Files
backnotprop__plannotator/packages/editor/App.tsx
T

6678 lines
294 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Eager renderer registration (side-effect imports, evaluated before every
// other module below). These keep Plannotator's first paint, identity minting
// and failure surface byte-identical now that @plannotator/ui loads KaTeX, the
// username dictionary and the Mermaid runtime lazily for hosts: math is typeset
// on the first commit, names come from the full dictionary, and Mermaid stays
// in this app's entry chunk (the review editor never renders Mermaid and does
// not import that entry). Guarded by tests/entry-assets.test.ts; do not drop
// or reorder any of these lines.
import '@plannotator/ui/utils/math-eager';
import '@plannotator/ui/utils/identity-tater';
import '@plannotator/ui/utils/mermaid-eager';
import React, { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from 'react';
import { toast, Toaster } from 'sonner';
import { type Origin, getAgentName } from '@plannotator/shared/agents';
import { shouldStripFrontmatter } from '@plannotator/shared/annotatable';
import { setExtraMarkdownExtensions } from '@plannotator/ui/utils/markdownExtensions';
import { annotateFileFeedback, annotateMessageFeedback, wrapFeedbackForClipboard, type AnnotateFeedbackTemplates } from '@plannotator/shared/feedback-templates';
import { parseMarkdownToBlocks, exportAnnotations, exportLinkedDocAnnotations, exportEditorAnnotations, exportCodeFileAnnotations, exportMessageAnnotations, extractFrontmatter, wrapFeedbackForAgent, Frontmatter, type LinkedDocAnnotationEntry, type MessageAnnotationEntry } from '@plannotator/ui/utils/parser';
import { primeSkillCatalog, primeSkillContentsForExport } from '@plannotator/ui/utils/skillCatalog';
import { Viewer, ViewerHandle } from '@plannotator/ui/components/Viewer';
import { HtmlViewer } from '@plannotator/ui/components/html-viewer';
import { MarkdownEditor, type MarkdownEditorHandle } from '@plannotator/ui/components/MarkdownEditor';
import { AnnotationPanel } from '@plannotator/ui/components/AnnotationPanel';
import { DocumentAIChatPanel } from '@plannotator/ui/components/ai/DocumentAIChatPanel';
import { SparklesIcon } from '@plannotator/ui/components/SparklesIcon';
import { ExportModal } from '@plannotator/ui/components/ExportModal';
import { ImportModal } from '@plannotator/ui/components/ImportModal';
import { ConfirmDialog } from '@plannotator/ui/components/ConfirmDialog';
import { Annotation, AnnotationType, Block, EditorMode, type CodeAnnotation, type InputMethod, type ImageAttachment, type ActionsLabelMode } from '@plannotator/ui/types';
import { ThemeProvider } from '@plannotator/ui/components/ThemeProvider';
import { Tooltip, TooltipProvider } from '@plannotator/ui/components/Tooltip';
import { AnnotationToolstrip } from '@plannotator/ui/components/AnnotationToolstrip';
import { StickyHeaderLane } from '@plannotator/ui/components/StickyHeaderLane';
import { TaterSpriteRunning } from '@plannotator/ui/components/TaterSpriteRunning';
import { TaterSpritePullup } from '@plannotator/ui/components/TaterSpritePullup';
import { useSharing } from '@plannotator/ui/hooks/useSharing';
import { getCallbackConfig, CallbackAction, executeCallback } from '@plannotator/ui/utils/callback';
import { useAgents } from '@plannotator/ui/hooks/useAgents';
import { useActiveSection } from '@plannotator/ui/hooks/useActiveSection';
import { storage } from '@plannotator/ui/utils/storage';
import { getIdentity } from '@plannotator/ui/utils/identity';
import { copyTextToClipboard } from '@plannotator/ui/utils/clipboard';
import { configStore, useConfigValue } from '@plannotator/ui/config';
import { CompletionOverlay } from '@plannotator/ui/components/CompletionOverlay';
import { useUpdateCheck } from '@plannotator/ui/hooks/useUpdateCheck';
import { LookAndFeelAnnouncementDialog } from '@plannotator/ui/components/LookAndFeelAnnouncementDialog';
import { getObsidianSettings, getEffectiveVaultPath, isObsidianConfigured, CUSTOM_PATH_SENTINEL } from '@plannotator/ui/utils/obsidian';
import { getBearSettings } from '@plannotator/ui/utils/bear';
import { getOctarineSettings, isOctarineConfigured } from '@plannotator/ui/utils/octarine';
import { getDefaultNotesApp } from '@plannotator/ui/utils/defaultNotesApp';
import { getAgentSwitchSettings, getEffectiveAgentName } from '@plannotator/ui/utils/agentSwitch';
import { getPlanSaveSettings } from '@plannotator/ui/utils/planSave';
import { type AIProviderOption } from '@plannotator/ui/utils/aiProvider';
import { useAIProviderConfig } from '@plannotator/ui/hooks/useAIProviderConfig';
import { useAIProviderActivation } from '@plannotator/ui/hooks/useAIProviderActivation';
import { markLookAndFeelChoiceResolved, needsLookAndFeelAnnouncement } from '@plannotator/ui/utils/lookAndFeelAnnouncement';
import { buildDefaultPrompt, useAIChat } from '@plannotator/ui/hooks/useAIChat';
import { getUIPreferences, type UIPreferences, type PlanWidth } from '@plannotator/ui/utils/uiPreferences';
import { getEditorMode, saveEditorMode } from '@plannotator/ui/utils/editorMode';
import { getInputMethod, refreshInputMethodStamp, saveInputMethod } from '@plannotator/ui/utils/inputMethod';
import { getHtmlChromeState, saveHtmlChromeState } from '@plannotator/ui/utils/htmlChrome';
import { useInputMethodSwitch } from '@plannotator/ui/hooks/useInputMethodSwitch';
import { usePrintMode } from '@plannotator/ui/hooks/usePrintMode';
import { requestVimDocumentFocus } from '@plannotator/ui/hooks/useVimDocumentFocus';
import { useResizablePanel } from '@plannotator/ui/hooks/useResizablePanel';
import { ResizeHandle } from '@plannotator/ui/components/ResizeHandle';
import { OverlayScrollArea } from '@plannotator/ui/components/OverlayScrollArea';
import {
getDocumentScrollViewport,
ScrollViewportProvider,
} from '@plannotator/ui/hooks/useScrollViewport';
import { useOverlayViewport } from '@plannotator/ui/hooks/useOverlayViewport';
import { useCompactTouchLayout, useIsMobile } from '@plannotator/ui/hooks/useIsMobile';
import { useViewportEnvironment } from '@plannotator/ui/hooks/useViewportEnvironment';
import {
getPermissionModeSettings,
needsPermissionModeSetup,
type PermissionMode,
} from '@plannotator/ui/utils/permissionMode';
import { PermissionModeSetup } from '@plannotator/ui/components/PermissionModeSetup';
import { ImageAnnotator } from '@plannotator/ui/components/ImageAnnotator';
import { deriveImageName } from '@plannotator/ui/components/AttachmentsButton';
import { useSidebar, type SidebarTab } from '@plannotator/ui/hooks/useSidebar';
import { usePlanDiff, type VersionInfo, type VersionEntry, type PlanDiffFetchers } from '@plannotator/ui/hooks/usePlanDiff';
import { useLinkedDoc, type LinkedDocSessionState } from '@plannotator/ui/hooks/useLinkedDoc';
import { useCodeFilePopout } from '@plannotator/ui/hooks/useCodeFilePopout';
import { useAnnotationDraft, type DraftEditedDocument, type DraftSavedFileChange } from '@plannotator/ui/hooks/useAnnotationDraft';
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 { useUndoHistory } from '@plannotator/ui/hooks/useUndoHistory';
import { buildPlanAgentInstructions } from '@plannotator/ui/utils/planAgentInstructions';
import { useFileBrowser } from '@plannotator/ui/hooks/useFileBrowser';
import { getFileEditStatus } from '@plannotator/ui/components/sidebar/FileBrowser';
import { isVaultBrowserEnabled } from '@plannotator/ui/utils/obsidian';
import { isFileBrowserEnabled, getFileBrowserSettings } from '@plannotator/ui/utils/fileBrowser';
import { generateId } from '@plannotator/ui/utils/generateId';
import { SidebarTabs } from '@plannotator/ui/components/sidebar/SidebarTabs';
import { SidebarContainer } from '@plannotator/ui/components/sidebar/SidebarContainer';
import type { ArchivedPlan } from '@plannotator/ui/components/sidebar/ArchiveBrowser';
import type { PickerMessage } from '@plannotator/ui/components/sidebar/MessagesBrowser';
import { PlanDiffViewer } from '@plannotator/ui/components/plan-diff/PlanDiffViewer';
import { CodeFilePopout, type CodeFileAnnotationInput } from '@plannotator/ui/components/CodeFilePopout';
import type { PlanDiffMode } from '@plannotator/ui/components/plan-diff/PlanDiffModeSwitcher';
import {
GoalSetupSurface,
type GoalSetupActionState,
type GoalSetupSurfaceHandle,
} from '@plannotator/ui/components/goal-setup/GoalSetupSurface';
import type { GoalSetupBundle } from '@plannotator/shared/goal-setup';
import type { AIContext } from '@plannotator/ai';
import type { CommentAskAIContext } from '@plannotator/ui/components/CommentPopover';
import {
hasSourceSaveConflictSnapshot,
isSourceSaveFilePath,
type SourceSaveCapability,
type SourceSaveResponse,
} from '@plannotator/shared/source-save';
import type { AgentTerminalCapability } from '@plannotator/shared/agent-terminal';
import { observeActionsLabelMode } from './actionsLabelMode';
// Demo content toggle. Default: the original Real-time Collaboration plan.
// Opt-in diff-engine stress test: `VITE_DIFF_DEMO=1 bun run dev:hook` swaps
// in the 20-case Auth Service Refactor test plan. dev-mock-api.ts reads the
// same env var on the server side so V2/V3 stay paired.
import { DEMO_PLAN_CONTENT as DEFAULT_DEMO_PLAN_CONTENT } from './demoPlan';
import { DIFF_DEMO_PLAN_CONTENT } from './demoPlanDiffDemo';
import { canUseAnnotateWideMode, resolveFocusShortcutAction, resolveWideModeExitLayout, type WideModeLayoutSnapshot, type WideModeType } from '@plannotator/ui/utils/wideMode';
import { modKey } from '@plannotator/ui/utils/platform';
import {
annotateSidebarShortcuts,
useAnnotateSidebarShortcuts,
useAnnotationModeShortcuts,
useDocumentViewShortcuts,
useDoubleTapShortcuts,
useHtmlAnnotateShortcuts,
useHistoryShortcuts,
} from '@plannotator/ui/shortcuts';
import {
applyCollectionMutation,
hasActiveHistoryOverlay,
isHumanHistoryMutation,
isNativeHistoryOwner,
syncHistoryHighlight,
type CollectionMutation,
type HistoryDirection,
} from '@plannotator/ui/utils/undoHistory';
const USE_DIFF_DEMO =
import.meta.env.VITE_DIFF_DEMO === '1' ||
import.meta.env.VITE_DIFF_DEMO === 'true';
const DEMO_PLAN_CONTENT = USE_DIFF_DEMO
? DIFF_DEMO_PLAN_CONTENT
: DEFAULT_DEMO_PLAN_CONTENT;
import {
useCheckboxOverrides,
type CheckboxOverrideSnapshot,
type CheckboxToggleMutation,
} from './hooks/useCheckboxOverrides';
import {
usePlanDiffNavigationAutoExit,
usePlanDiffViewAutoExit,
} from './hooks/usePlanDiffViewAutoExit';
import { AppHeader } from './components/AppHeader';
import { useHtmlRefresh, type HtmlRefreshedDocument } from './hooks/useHtmlRefresh';
import { AgentNudgeBanner } from './components/AgentNudgeBanner';
import { useDocumentWebMcp } from './webmcp/useDocumentWebMcp';
import { useWebMcpActivity } from '@plannotator/ui/webmcp';
import type { CompactPlanAction } from '@plannotator/ui/components/PlanHeaderMenu';
import { FolderAnnotationEmptyState } from './components/FolderAnnotationEmptyState';
import { CompactAnnotationControls } from './components/CompactAnnotationControls';
import { CompactEditControls } from './components/CompactEditControls';
import { CompactPlanStage } from './components/CompactPlanStage';
import {
CompactPlanCompletion,
CompactPlanReview,
type CompactPlanReviewAction,
} from './components/CompactPlanReview';
import {
COMPACT_PLAN_ARTIFACT,
openCompactPlanNavigator,
shouldPresentDesktopPlanPanel,
toggleCompactPlanNavigator,
type CompactPlanSurface,
} from './compactPlanSurface';
import {
AnnotateAgentTerminalPanel,
type AnnotateAgentTerminalPanelHandle,
} from './components/AnnotateAgentTerminalPanel';
import {
saveAnnotateAgentTerminalSide,
type AnnotateAgentTerminalSide,
} from '@plannotator/ui/utils/annotateAgentTerminal';
import {
AGENT_TERMINAL_LG_BREAKPOINT,
getAgentTerminalLayout,
} from './agentTerminalLayout';
import {
buildAgentTerminalDeliveryRecord,
buildTerminalAskPrompt,
isMatchingAgentTerminalDelivery,
shouldSendAgentTerminalFeedback,
type AgentTerminalDeliveryRecord,
type AnnotateFeedbackTarget,
} from './agentTerminalIntegration';
import {
buildPlanEditPanelItem,
buildDirectEditsSection,
buildSavedFileChangePanelItems,
buildSavedFileChangesSection,
computeEditStats,
normalizeEditedMarkdown,
} from './directEdits';
import {
buildAnnotateApprovalBody,
buildCompleteAnnotateFeedback,
} from './annotateSubmission';
import { buildDecisionSpec, type DecisionActionId, type DecisionMenuItem } from '@plannotator/ui/utils/decisionSpec';
import { DecisionNoteDialog, type DecisionHandler } from '@plannotator/ui/components/DecisionControl';
import {
compactPrimaryIdForDecision,
compactRowIdForDecisionItem,
resolveAnnotateDecisionAction,
} from './annotateDecision';
import {
openAnnotateClientLeaseStream,
shouldConnectAnnotateClientLease,
type AnnotateClientLeaseConfig,
} from './annotateClientLease';
import {
editableDocumentKey,
useEditableDocuments,
type EnabledSourceSaveCapability,
type SavedFileChangeDraftData,
} from './editableDocuments';
import {
validateSavedFileChanges,
} from './savedFileChangeValidation';
import { fetchSourceDocumentSnapshot, probeSourceSave } from './sourceDocumentClient';
import { reconcileSourceDocuments, type SourceDocumentReconcileEvent } from './sourceDocumentReconciliation';
import {
buildSourceWatchSubscription,
normalizeBrowserPath,
pathIsInsideDir,
} from './sourceDocumentPaths';
import { pickRestoredSingleFileDraftToDisplay } from './draftRestoreSelection';
type NoteAutoSaveResults = {
obsidian?: boolean;
bear?: boolean;
octarine?: boolean;
};
type MessageAnnotationState = {
messageId: string;
text: string;
timestamp?: string;
linkedDocSession: LinkedDocSessionState;
codeAnnotations: CodeAnnotation[];
selectedCodeAnnotationId: string | null;
};
const countLinkedDocSessionAnnotations = (session: LinkedDocSessionState): number => {
let total =
session.root.annotations.length +
session.root.globalAttachments.length;
for (const doc of session.docs.values()) {
total += doc.annotations.length + doc.globalAttachments.length;
}
return total;
};
const countMessageAnnotations = (state: MessageAnnotationState): number =>
countLinkedDocSessionAnnotations(state.linkedDocSession) +
state.codeAnnotations.length;
const createEmptyMessageState = (message: PickerMessage): MessageAnnotationState => ({
messageId: message.messageId,
text: message.text,
timestamp: message.timestamp,
linkedDocSession: {
root: {
markdown: message.text,
renderAs: 'markdown',
rawHtml: '',
shareHtml: '',
annotations: [],
selectedAnnotationId: null,
globalAttachments: [],
},
docs: new Map(),
},
codeAnnotations: [],
selectedCodeAnnotationId: null,
});
const normalizeMessageState = (
state: MessageAnnotationState,
message: PickerMessage,
): MessageAnnotationState => ({
...state,
text: message.text,
timestamp: message.timestamp,
linkedDocSession: {
root: {
...state.linkedDocSession.root,
// The root document for a message is immutable and comes from the picker.
// Keep it as the source of truth so transient UI state cannot cache an
// empty markdown value for a message.
markdown: message.text,
renderAs: state.linkedDocSession.root.renderAs ?? 'markdown',
rawHtml: state.linkedDocSession.root.rawHtml ?? '',
shareHtml: state.linkedDocSession.root.shareHtml ?? '',
},
docs: new Map(state.linkedDocSession.docs),
},
});
const buildMessageAnnotationCounts = (
states: Map<string, MessageAnnotationState>
): Map<string, number> => {
const counts = new Map<string, number>();
for (const [messageId, state] of states) {
const count = countMessageAnnotations(state);
if (count > 0) counts.set(messageId, count);
}
return counts;
};
const draftBannerMessage = (banner: { count: number; timeAgo: string; hasEdits: boolean }): string => {
const parts = [
banner.count > 0 ? `${banner.count} annotation${banner.count !== 1 ? 's' : ''}` : '',
banner.hasEdits ? 'unsent direct edits' : '',
].filter(Boolean);
return `Found ${parts.join(' and ')} from ${banner.timeAgo}. Would you like to restore them?`;
};
const feedbackLossDescription = (annotationCount: number, hasDirectEdits: boolean): string => {
const parts = [
annotationCount > 0 ? `${annotationCount} annotation${annotationCount !== 1 ? 's' : ''}` : '',
hasDirectEdits ? 'direct edits' : '',
].filter(Boolean);
return parts.length > 0 ? parts.join(' and ') : 'feedback';
};
type SourceFileEditWarningAction = 'send-feedback' | 'approve' | 'close';
type CompactPlanTransientSurface = Extract<
CompactPlanSurface,
{ readonly type: 'annotations' | 'ai' | 'review' }
>['type'];
interface HistorySelection {
annotationId: string | null;
codeAnnotationId: string | null;
}
type DocumentHistoryAction =
| {
kind: 'annotation';
mutation: CollectionMutation<Annotation>;
beforeSelection: HistorySelection;
afterSelection: HistorySelection;
}
| {
kind: 'code-annotation';
mutation: CollectionMutation<CodeAnnotation>;
beforeSelection: HistorySelection;
afterSelection: HistorySelection;
}
| {
kind: 'checkbox';
mutation: CheckboxToggleMutation;
beforeSelection: HistorySelection;
afterSelection: HistorySelection;
};
const itemId = (item: { id: string }): string => item.id;
function annotationOwnsHighlight(annotation: Annotation): boolean {
return !annotation.diffContext
&& annotation.type !== AnnotationType.GLOBAL_COMMENT
&& !annotation.id.startsWith('ann-checkbox-');
}
/** Hint shown following the cursor while hovering a sidebar/panel resize handle. */
const RESIZE_HANDLE_TOOLTIP = 'Click to close · Drag to resize';
const App: React.FC = () => {
useViewportEnvironment();
const [markdown, setMarkdown] = useState(DEMO_PLAN_CONTENT);
const [annotations, setAnnotations] = useState<Annotation[]>([]);
const annotationsRef = useRef<Annotation[]>(annotations);
useEffect(() => {
annotationsRef.current = annotations;
}, [annotations]);
const [codeAnnotations, setCodeAnnotations] = useState<CodeAnnotation[]>([]);
const codeAnnotationsRef = useRef(codeAnnotations);
codeAnnotationsRef.current = codeAnnotations;
const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null);
const [selectedCodeAnnotationId, setSelectedCodeAnnotationId] = useState<string | null>(null);
const selectionRef = useRef<HistorySelection>({ annotationId: null, codeAnnotationId: null });
selectionRef.current = { annotationId: selectedAnnotationId, codeAnnotationId: selectedCodeAnnotationId };
const restoreCheckboxOverridesRef = useRef<(snapshot: CheckboxOverrideSnapshot) => void>(() => {});
const checkboxSelectionBeforeRef = useRef<HistorySelection | null>(null);
const editableDocuments = useEditableDocuments();
const activeEditableDocument = editableDocuments.activeDocument;
const displayedMarkdown = activeEditableDocument?.currentText ?? markdown;
const [sourceFilePath, setSourceFilePath] = useState<string | undefined>();
// Mirrors linkedDocHook.filepath (declared later) so the parse memos below
// can key frontmatter behavior off the ACTIVE document's path. Kept in sync
// by an effect after the hook is created.
const [linkedDocParsePath, setLinkedDocParsePath] = useState<string | null>(null);
const activeParseDocPath = linkedDocParsePath ?? sourceFilePath;
// Frontmatter stripping is a markdown convention — for non-markdown
// annotatable sources (.yaml/.txt/…) a leading `--- … ---` pair is real
// content (multi-document YAML), so it must survive parsing.
const parseFrontmatter = shouldStripFrontmatter(activeParseDocPath);
const parseFrontmatterRef = useRef(parseFrontmatter);
useEffect(() => {
parseFrontmatterRef.current = parseFrontmatter;
}, [parseFrontmatter]);
const frontmatter = useMemo(
() => (parseFrontmatter ? extractFrontmatter(displayedMarkdown).frontmatter : null),
[displayedMarkdown, parseFrontmatter],
);
const blocks = useMemo(
() => parseMarkdownToBlocks(displayedMarkdown, { frontmatter: parseFrontmatter }),
[displayedMarkdown, parseFrontmatter],
);
const [showExport, setShowExport] = useState(false);
const [showImport, setShowImport] = useState(false);
const [showFeedbackPrompt, setShowFeedbackPrompt] = useState(false);
const [showClaudeCodeWarning, setShowClaudeCodeWarning] = useState(false);
const [showExitWarning, setShowExitWarning] = useState(false);
const [showSourceFileEditWarning, setShowSourceFileEditWarning] = useState(false);
const [sourceFileEditWarningAction, setSourceFileEditWarningAction] = useState<SourceFileEditWarningAction>('send-feedback');
const sourceFileEditWarningContinuationRef = useRef<(() => void | Promise<void>) | null>(null);
const [showAgentWarning, setShowAgentWarning] = useState(false);
// The decision-control note flow (#1436 mechanism): the note is committed
// into `annotations` as a GLOBAL_COMMENT and submitted one render later,
// because the payload builders close over `allAnnotations`. The route is
// captured at menu-choice time — re-deriving it after the commit would see
// the note itself and misroute a gate "Approve with a note" to feedback.
// L3: cleared only on submission SUCCESS — a failed POST keeps the captured
// route/framing armed so a retry cannot silently reframe the decision.
// `dispatched` marks the one automatic submit after the commit lands;
// after a failure, retries go through the primary.
const [pendingDecisionSubmit, setPendingDecisionSubmit] = useState<{
noteId: string;
route: 'feedback' | 'approve';
approvalFraming: boolean;
dispatched: boolean;
} | null>(null);
// Compact/touch decision surfaces: composer items open DecisionNoteDialog,
// confirm items open one ConfirmDialog (the desktop popover lives inside
// DecisionControl; compact has no popover to morph). L2: only the item ID
// is state — the dialog contents resolve from the LIVE spec at render, so
// a spec update while a dialog is up can never show or confirm stale copy.
const [compactDecisionComposer, setCompactDecisionComposer] = useState<DecisionMenuItem['id'] | null>(null);
const [compactDecisionConfirm, setCompactDecisionConfirm] = useState<DecisionMenuItem['id'] | null>(null);
// The keydown effects mount above the decision callbacks; call through a
// render-assigned ref (same pattern as headerHandlersRef) so keyboard and
// header share literally one submitPrimaryDecision.
const submitPrimaryDecisionRef = useRef<() => void>(() => {});
const [agentWarningMessage, setAgentWarningMessage] = useState('');
const [isPanelOpen, setIsPanelOpen] = useState(() => window.innerWidth >= 768);
const [rightSidebarTab, setRightSidebarTab] = useState<'annotations' | 'ai'>('annotations');
const [mobileSettingsOpen, setMobileSettingsOpen] = useState(false);
const [editorMode, setEditorMode] = useState<EditorMode>(getEditorMode);
const [inputMethod, setInputMethod] = useState<InputMethod>(getInputMethod);
const [compactInputMethod, setCompactInputMethod] = useState<InputMethod>(getInputMethod);
const [taterMode, setTaterMode] = useState(() => {
const stored = storage.getItem('plannotator-tater-mode');
return stored === 'true';
});
const gridEnabled = useConfigValue('gridEnabled');
const vimModeEnabled = useConfigValue('vimModeEnabled');
const vimHudEnabled = useConfigValue('vimHudEnabled');
const vimHudKeyPanelEnabled = useConfigValue('vimHudKeyPanelEnabled');
const handleVimHudKeyPanelChange = useCallback((enabled: boolean) => {
configStore.set('vimHudKeyPanelEnabled', enabled);
requestVimDocumentFocus();
}, []);
const [uiPrefs, setUiPrefs] = useState(() => getUIPreferences());
// Plan-area width (inside the OverlayScrollArea, after sidebar/panel
// shrinkage) drives the action button label compactness. ResizeObserver
// fires every frame during a resize drag, so we store only the BUCKET
// ('full' | 'short' | 'icon') in state — App.tsx then re-renders at
// most twice across an entire drag (once per threshold crossing) instead
// of on every pixel, which would chug the whole tree.
//
// full → "Global comment" / "Copy plan" — fits when planArea >= 800
// short → "Comment" / "Copy" — fits when planArea >= 680
// icon → labels hidden — fallback below that
const planAreaRef = useRef<HTMLDivElement>(null);
const [actionsLabelMode, setActionsLabelMode] = useState<ActionsLabelMode>('full');
const [isApiMode, setIsApiMode] = useState(false);
// Warm the skill-reference catalog once per API session so export enrichment
// covers comments whose composer never opened (draft restore, panel edits).
useEffect(() => {
if (isApiMode) primeSkillCatalog();
}, [isApiMode]);
const [origin, setOrigin] = useState<Origin | null>(null);
const [gitUser, setGitUser] = useState<string | undefined>();
const [isWSL, setIsWSL] = useState(false);
const updateInfo = useUpdateCheck();
const updateToastShown = useRef(false);
useEffect(() => {
if (window.location.hash) return;
if (updateInfo?.updateAvailable && !updateInfo.dismissed && !updateToastShown.current) {
updateToastShown.current = true;
const t = setTimeout(() => {
toast('A new version of Plannotator is available', {
description: 'Open the Options menu to update.',
duration: 4000,
classNames: { toast: '!w-auto', description: '!text-foreground/70' },
});
}, 1500);
return () => clearTimeout(t);
}
}, [updateInfo?.updateAvailable, updateInfo?.dismissed]);
// Markdown edit mode (prototype): CM6 live-preview editor over the raw plan
// text. originalMarkdownRef is the as-submitted baseline for the edit diff —
// set once at plan load, never by linked-doc navigation or edit commits.
const [isEditingMarkdown, setIsEditingMarkdown] = useState(false);
const isEditingMarkdownRef = useRef(isEditingMarkdown);
useEffect(() => {
isEditingMarkdownRef.current = isEditingMarkdown;
}, [isEditingMarkdown]);
const [editStats, setEditStats] = useState<{ added: number; removed: number } | null>(null);
// Bumped on every edit commit so the Viewer remounts: web-highlighter mutates
// the Viewer DOM, and reconciling changed blocks against the old subtree throws.
const [editGeneration, setEditGeneration] = useState(0);
// True while the open editor buffer differs from what it mounted with.
const [editorDirty, setEditorDirty] = useState(false);
// True while the open editor buffer differs from the as-submitted baseline.
const [editorDiffersFromBaseline, setEditorDiffersFromBaseline] = useState(false);
const [agentFeedbackRevision, setAgentFeedbackRevision] = useState(0);
// Two-step guard for the "Cancel" (discard edits + exit) action.
const [confirmCancelEdits, setConfirmCancelEdits] = useState(false);
const originalMarkdownRef = useRef<string | null>(null);
// Last COMMITTED editor text (null = no edits). The Direct Edits diff reads
// this — never the shared `markdown` state, which linked-doc navigation,
// message switching, and checkbox toggles repurpose.
const editedMarkdownRef = useRef<string | null>(null);
// What the current edit session mounted with, for live dirty tracking.
const editSessionBaseRef = useRef<string>('');
const markdownEditorHandleRef = useRef<MarkdownEditorHandle | null>(null);
const suspendedRootEditableKeyRef = useRef<string | null>(null);
const [globalAttachments, setGlobalAttachments] = useState<ImageAttachment[]>([]);
const [annotateMode, setAnnotateMode] = useState(false);
const [gate, setGate] = useState(false);
const [approvalNotesSupported, setApprovalNotesSupported] = useState(false);
const [clientLease, setClientLease] = useState<AnnotateClientLeaseConfig | null>(null);
const [annotateSource, setAnnotateSource] = useState<'file' | 'message' | 'folder' | null>(null);
const [recentMessages, setRecentMessages] = useState<PickerMessage[]>([]);
const [selectedMessageId, setSelectedMessageId] = useState<string | null>(null);
const messageStateCacheRef = useRef<Map<string, MessageAnnotationState>>(new Map());
const [cachedMessageAnnotationCounts, setCachedMessageAnnotationCounts] = useState<Map<string, number>>(new Map());
const [goalSetupBundle, setGoalSetupBundle] = useState<GoalSetupBundle | null>(null);
const goalSetupSurfaceRef = useRef<GoalSetupSurfaceHandle>(null);
const [goalSetupAction, setGoalSetupAction] = useState<GoalSetupActionState>({
canSubmit: false,
isSubmitting: false,
submitted: false,
submitLabel: 'Submit',
});
const [sourceInfo, setSourceInfo] = useState<string | undefined>();
// Server-resolved annotate copy-wrapper templates (config-aware) so
// clipboard Copy matches Send Feedback instead of the plan-deny wrap (#1107).
const [feedbackTemplates, setFeedbackTemplates] = useState<AnnotateFeedbackTemplates | null>(null);
const [sourceConverted, setSourceConverted] = useState(false);
const [renderAs, setRenderAs] = useState<'markdown' | 'html'>('markdown');
// HTML plans render edge-to-edge (full-viewport) instead of in the centered,
// card-chromed markdown column. Branch the document-area containers on this.
const isHtmlSurface = renderAs === 'html';
const [rawHtml, setRawHtml] = useState('');
const [htmlDiffHtml, setHtmlDiffHtml] = useState<string | null>(null);
const [shareHtml, setShareHtml] = useState('');
// Live app annotation (mode "annotate-app"): the HtmlViewer navigates the
// loopback proxy origin instead of rendering srcdoc HTML. Pinpoint-only,
// vim/edit/diff/share hidden, annotations stamped with the page they were
// made on.
const [liveApp, setLiveApp] = useState<{ appUrl: string; origin: string; token: string } | null>(null);
const [livePageUrl, setLivePageUrl] = useState('');
// Interact/Annotate mode for HTML and live-app surfaces. Armed = the bridge
// captures clicks for pinpoint annotation; disarmed (Interact) = clicks are
// fully native while committed markers stay visible/clickable and text
// drag-selection commenting stays live. Session-only, never persisted.
// BOTH surface kinds start armed; Esc (or the header pen) drops to
// Interact.
const [htmlAnnotateArmed, setHtmlAnnotateArmed] = useState(true);
const handleHtmlAnnotateToggle = useCallback(() => setHtmlAnnotateArmed((v) => !v), []);
const handleHtmlAnnotateExit = useCallback(() => setHtmlAnnotateArmed(false), []);
// Session-level force-markdown preference (`--markdown`). When set, folder/linked HTML
// files are converted instead of rendered raw — threaded into /api/doc as &convert=1.
const [convertHtml, setConvertHtml] = useState(false);
// Gate for the chrome-persistence writer: only start saving once the persisted
// state has been applied, so a pre-restore render can't clobber the cookie.
const htmlChromeRestoredRef = useRef(false);
// The restore's own commit still renders pre-restore values; the writer
// consumes this flag to skip that exact run (see the save effect).
const skipNextHtmlChromeSaveRef = useRef(false);
// Header "Hide tools": removes ALL floating chrome over the page (sidebar
// tongue tabs + comment/attachments cluster) from the DOM. The header
// button itself is the way back, so hidden state can never strand.
const [htmlToolsHidden, setHtmlToolsHidden] = useState(false);
const [imageBaseDir, setImageBaseDir] = useState<string | undefined>(undefined);
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isExiting, setIsExiting] = useState(false);
const [submitted, setSubmitted] = useState<'approved' | 'denied' | 'exited' | null>(null);
const [pendingPasteImage, setPendingPasteImage] = useState<{ file: File; blobUrl: string; initialName: string } | null>(null);
const [showPermissionModeSetup, setShowPermissionModeSetup] = useState(false);
const [permissionMode, setPermissionMode] = useState<PermissionMode>('bypassPermissions');
const [sharingEnabled, setSharingEnabled] = useState(true);
const [shareBaseUrl, setShareBaseUrl] = useState<string | undefined>(undefined);
const [pasteApiUrl, setPasteApiUrl] = useState<string | undefined>(undefined);
const [repoInfo, setRepoInfo] = useState<{ display: string; branch?: string; host?: string } | null>(null);
const [projectRoot, setProjectRoot] = useState<string | null>(null);
const [agentTerminalCapability, setAgentTerminalCapability] = useState<AgentTerminalCapability | null>(null);
const [isAgentTerminalOpen, setIsAgentTerminalOpen] = useState(false);
// Durable placement preference (server config > cookie > 'left'). Read through
// ConfigStore rather than component state so the Settings dialog's copy of the
// Position control and the terminal's own popover stay in step.
const agentTerminalSide = useConfigValue('agentTerminalSide');
const [isAgentTerminalRunning, setIsAgentTerminalRunning] = useState(false);
const [isAgentTerminalReady, setIsAgentTerminalReady] = useState(false);
const [agentTerminalSessionId, setAgentTerminalSessionId] = useState<number | null>(null);
const [agentTerminalDelivery, setAgentTerminalDeliveryState] = useState<AgentTerminalDeliveryRecord | null>(null);
const agentTerminalDeliveryRef = useRef<AgentTerminalDeliveryRecord | null>(null);
const agentTerminalSessionSeqRef = useRef(0);
const agentTerminalRef = useRef<AnnotateAgentTerminalPanelHandle>(null);
const [wideModeType, setWideModeType] = useState<WideModeType | null>(null);
const wideModeSnapshotRef = useRef<WideModeLayoutSnapshot | null>(null);
const initialSidebarPreferenceAppliedRef = useRef(false);
const lastAppliedTocEnabledRef = useRef(uiPrefs.tocEnabled);
const goalSetupMode = goalSetupBundle !== null;
useEffect(() => {
document.title = repoInfo ? `${repoInfo.display} · Plannotator` : "Plannotator";
}, [repoInfo]);
const [initialExportTab, setInitialExportTab] = useState<'share' | 'annotations' | 'notes'>();
const [isPlanDiffActive, setIsPlanDiffActive] = useState(false);
const [planDiffMode, setPlanDiffMode] = useState<PlanDiffMode>('clean');
const [previousPlan, setPreviousPlan] = useState<string | null>(null);
const [versionInfo, setVersionInfo] = useState<VersionInfo | null>(null);
const [aiSessionEnabled, setAISessionEnabled] = useState(false);
const [aiAvailable, setAiAvailable] = useState(false);
const [aiProviders, setAiProviders] = useState<Array<{ id: string; name: string; capabilities?: Record<string, boolean>; models?: Array<{ id: string; label: string; default?: boolean }> }>>([]);
const [aiDefaultProvider, setAiDefaultProvider] = useState<string | null>(null);
const { aiConfig, applyConfigChange } = useAIProviderConfig({
providers: aiProviders,
defaultProvider: aiDefaultProvider,
available: aiAvailable,
origin,
});
// Explicit provider activation: runs deferred (Codex) model discovery on a
// user gesture and merges the refreshed metadata, so the model picker and
// reasoning-effort control populate past the static fallback. Never called
// on load — that would reintroduce the eager `codex app-server` spawn.
const activateAIProvider = useAIProviderActivation({
onCapabilities: (providers, defaultProvider) => {
setAiProviders(providers);
setAiDefaultProvider(defaultProvider);
},
});
const [showLookAndFeelAnnouncement, setShowLookAndFeelAnnouncement] = useState(needsLookAndFeelAnnouncement);
const isMobile = useIsMobile();
const isBelowAgentTerminalBreakpoint = useIsMobile(AGENT_TERMINAL_LG_BREAKPOINT);
const isCompactTouchLayout = useCompactTouchLayout();
const usesDocumentScroll = isCompactTouchLayout;
const effectiveEditorMode: EditorMode = isCompactTouchLayout ? 'selection' : editorMode;
const effectiveInputMethod = isCompactTouchLayout ? compactInputMethod : inputMethod;
const [compactPlanSurface, setCompactPlanSurface] = useState<CompactPlanSurface>(COMPACT_PLAN_ARTIFACT);
const compactPlanSurfaceTriggerRef = useRef<HTMLElement | null>(null);
const [compactNavigatorTab, setCompactNavigatorTab] = useState<SidebarTab>('toc');
const [compactPendingFilePath, setCompactPendingFilePath] = useState<string | null>(null);
const compactPendingFileRef = useRef<string | null>(null);
const isCompactNavigatorOpen = isCompactTouchLayout && compactPlanSurface.type === 'navigator';
const isCompactFilesSurfaceOpen =
isCompactNavigatorOpen && compactPlanSurface.type === 'navigator' && compactPlanSurface.tab === 'files';
const isCompactContentsSurfaceOpen =
isCompactNavigatorOpen && compactPlanSurface.type === 'navigator' && compactPlanSurface.tab === 'toc';
const isCompactAnnotationsOpen = isCompactTouchLayout && compactPlanSurface.type === 'annotations';
const isCompactAIOpen = isCompactTouchLayout && compactPlanSurface.type === 'ai';
const isCompactReviewOpen = isCompactTouchLayout && compactPlanSurface.type === 'review';
const effectivePanelOpen = shouldPresentDesktopPlanPanel(isCompactTouchLayout, isPanelOpen);
// Resolved high, not at render time, because `isRightPanelVisible` is what
// decides whether the right-hand annotations/AI surface is actually on screen
// — and consumers of that fact (notably the Ask AI model-discovery effect)
// read it well before the JSX. Computing it late let those consumers fall
// back to `effectivePanelOpen`, which stays true under a right-docked
// terminal and so reported an invisible surface as open.
const showAgentTerminalControls =
annotateMode &&
annotateSource !== 'message' &&
agentTerminalCapability !== null &&
!goalSetupMode;
const {
shouldRender: shouldRenderAgentTerminal,
isVisible: isAgentTerminalVisible,
isLeftVisible: isLeftAgentTerminalVisible,
showOnLeft: showAgentTerminalOnLeft,
showOnRight: showAgentTerminalOnRight,
isRightPanelVisible,
dockClassName: agentTerminalDockClassName,
placement: agentTerminalPlacement,
} = getAgentTerminalLayout({
showControls: showAgentTerminalControls,
isOpen: isAgentTerminalOpen,
isRunning: isAgentTerminalRunning,
isWideMode: wideModeType !== null,
isBelowBreakpoint: isBelowAgentTerminalBreakpoint,
side: agentTerminalSide,
isRightPanelOpen: effectivePanelOpen,
});
// Compact interactions never write into the remembered desktop rail/panel
// state. Crossing back to a fine-pointer workspace simply removes the
// transient foreground surface and reveals the incumbent desktop layout.
useEffect(() => {
if (isCompactTouchLayout) return;
setCompactPlanSurface(COMPACT_PLAN_ARTIFACT);
compactPlanSurfaceTriggerRef.current = null;
compactPendingFileRef.current = null;
setCompactPendingFilePath(null);
// Keep the session-only compact method ready to inherit the latest
// explicit desktop choice without letting compact changes write it back.
setCompactInputMethod(inputMethod);
}, [inputMethod, isCompactTouchLayout]);
const viewerRef = useRef<ViewerHandle>(null);
const historyContext = [
annotateSource ?? 'plan',
selectedMessageId ?? 'message',
linkedDocParsePath ?? sourceFilePath ?? 'root',
livePageUrl || 'page',
].join(':');
const applyDocumentHistory = useCallback((action: DocumentHistoryAction, direction: HistoryDirection) => {
if (action.kind === 'checkbox') {
const snapshot = direction === 'undo'
? action.mutation.beforeOverrides
: action.mutation.afterOverrides;
const checkboxAnnotations = direction === 'undo'
? action.mutation.beforeAnnotations
: action.mutation.afterAnnotations;
restoreCheckboxOverridesRef.current(snapshot);
setAnnotations((current) => {
const withoutBlockAnnotations = current.filter((annotation) =>
annotation.blockId !== action.mutation.blockId
|| !annotation.id.startsWith('ann-checkbox-')
);
const next = checkboxAnnotations.reduce(
(items, entry) => applyCollectionMutation(
items,
{ kind: 'add', item: entry.annotation, index: entry.index },
'redo',
itemId,
),
withoutBlockAnnotations,
);
annotationsRef.current = next;
return next;
});
const selection = direction === 'undo' ? action.beforeSelection : action.afterSelection;
setSelectedAnnotationId(selection.annotationId);
setSelectedCodeAnnotationId(selection.codeAnnotationId);
selectionRef.current = selection;
return;
}
const selection = direction === 'undo' ? action.beforeSelection : action.afterSelection;
if (action.kind === 'code-annotation') {
setCodeAnnotations((current) => {
const next = applyCollectionMutation(current, action.mutation, direction, itemId);
codeAnnotationsRef.current = next;
return next;
});
} else {
setAnnotations((current) => {
const next = applyCollectionMutation(current, action.mutation, direction, itemId);
annotationsRef.current = next;
return next;
});
const annotation = action.mutation.kind === 'edit'
? (direction === 'undo' ? action.mutation.before : action.mutation.after)
: action.mutation.item;
const shouldPaint = annotationOwnsHighlight(annotation)
&& ((action.mutation.kind === 'add' && direction === 'redo')
|| (action.mutation.kind === 'delete' && direction === 'undo')
|| action.mutation.kind === 'edit');
if (annotationOwnsHighlight(annotation)) {
syncHistoryHighlight(viewerRef.current, annotation, shouldPaint);
}
}
setSelectedAnnotationId(selection.annotationId);
setSelectedCodeAnnotationId(selection.codeAnnotationId);
selectionRef.current = selection;
}, []);
const annotationHistory = useUndoHistory<DocumentHistoryAction>({
context: historyContext,
apply: applyDocumentHistory,
});
useEffect(() => {
if (submitted) annotationHistory.clear();
}, [annotationHistory, submitted]);
// Desktop uses the main document element as its native scroll viewport.
// Compact coarse-pointer browsers use the page scroller so Mobile Safari
// receives the document scroll gesture it requires to collapse its chrome.
const {
viewport: scrollViewport,
onViewportReady: handleViewportReady,
} = useOverlayViewport();
const mainViewportRef = useRef<HTMLElement | null>(null);
const handleDocumentViewportReady = useCallback((next: HTMLElement | null) => {
mainViewportRef.current = next;
handleViewportReady(next && usesDocumentScroll
? getDocumentScrollViewport()
: next);
}, [handleViewportReady, usesDocumentScroll]);
useEffect(() => {
if (!mainViewportRef.current) return;
handleViewportReady(usesDocumentScroll
? getDocumentScrollViewport()
: mainViewportRef.current);
}, [handleViewportReady, usesDocumentScroll]);
usePrintMode();
// Sidebar (shared TOC + Version Browser)
const sidebar = useSidebar(false);
// Resizable panels
const panelResize = useResizablePanel({
storageKey: 'plannotator-panel-width',
// Drag the right panel skinny → snap it shut (matches the contents sidebar).
onSnapClose: () => setIsPanelOpen(false),
// Single click on the handle (no drag) collapses it.
onClick: () => setIsPanelOpen(false),
// Render-free drag: write the live width to a :root var the panel reads,
// so dragging never re-renders this (heavy) App.
apply: (w) => document.documentElement.style.setProperty('--rpanel-w', `${w}px`),
});
const tocResize = useResizablePanel({
storageKey: 'plannotator-toc-width',
defaultWidth: 240, minWidth: 160, maxWidth: 400, side: 'left',
// Drag the contents panel skinny → snap it shut (prototype behavior).
onSnapClose: sidebar.close,
// Single click on the handle (no drag) collapses it.
onClick: sidebar.close,
// Render-free drag: write the live width to a :root var the panel reads.
apply: (w) => document.documentElement.style.setProperty('--toc-w', `${w}px`),
});
const agentTerminalResize = useResizablePanel({
storageKey: 'plannotator-agent-terminal-width',
defaultWidth: 360,
minWidth: 280,
maxWidth: 640,
// The handle follows the edge the panel actually docks against, which for a
// 'hidden' preference opened for the session is the left fallback.
side: agentTerminalPlacement,
onSnapClose: () => hideAgentTerminal(),
// Single click on the handle (no drag) collapses it.
onClick: () => hideAgentTerminal(),
apply: (w) => document.documentElement.style.setProperty('--agent-terminal-w', `${w}px`),
});
const isResizing = panelResize.isDragging || tocResize.isDragging || agentTerminalResize.isDragging;
// Whether the document has any TOC-eligible headings (level <= 3, matching
// buildTocHierarchy). Drives the empty-doc auto-close behavior below — must
// be declared before the effects that reference it (TDZ in dep arrays).
const hasTocEntries = useMemo(
() => blocks.some(b => b.type === 'heading' && (b.level ?? 0) <= 3),
[blocks]
);
const exitWideMode = useCallback((options?: {
restore?: boolean;
sidebarTab?: SidebarTab;
panelOpen?: boolean;
}) => {
if (wideModeType === null) {
if (options?.sidebarTab) sidebar.open(options.sidebarTab);
if (options?.panelOpen === true) setIsPanelOpen(true);
else if (options?.panelOpen === false) setIsPanelOpen(false);
return;
}
const snapshot = wideModeSnapshotRef.current;
const layout = resolveWideModeExitLayout(snapshot, options);
setWideModeType(null);
wideModeSnapshotRef.current = null;
if (layout.sidebarOpen && layout.sidebarTab) {
sidebar.open(layout.sidebarTab);
} else {
sidebar.close();
}
if (layout.panelOpen !== undefined) {
setIsPanelOpen(layout.panelOpen);
}
}, [wideModeType, sidebar.close, sidebar.open]);
const openSidebarTab = useCallback((tab: SidebarTab) => {
if (isCompactTouchLayout) {
setCompactNavigatorTab(tab);
setCompactPlanSurface(openCompactPlanNavigator(tab));
return;
}
if (wideModeType !== null) {
exitWideMode({ restore: false, sidebarTab: tab, panelOpen: false });
return;
}
sidebar.open(tab);
}, [exitWideMode, isCompactTouchLayout, wideModeType, sidebar.open]);
const toggleSidebarTab = useCallback((tab: SidebarTab) => {
if (isCompactTouchLayout) {
setCompactNavigatorTab(tab);
setCompactPlanSurface((surface) => toggleCompactPlanNavigator(surface, tab));
return;
}
if (wideModeType !== null) {
exitWideMode({ restore: false, sidebarTab: tab, panelOpen: false });
return;
}
sidebar.toggleTab(tab);
}, [exitWideMode, isCompactTouchLayout, wideModeType, sidebar.toggleTab]);
const closeCompactNavigator = useCallback((restoreFocus = true) => {
setCompactPlanSurface(COMPACT_PLAN_ARTIFACT);
if (!restoreFocus) return;
window.setTimeout(() => {
document
.getElementById('pn-compact-plan-navigator-trigger')
?.focus({ preventScroll: true });
}, 0);
}, []);
const openCompactPlanSurface = useCallback((type: CompactPlanTransientSurface) => {
const activeElement = document.activeElement;
compactPlanSurfaceTriggerRef.current = activeElement instanceof HTMLElement ? activeElement : null;
setCompactPlanSurface({ type });
}, []);
const switchCompactPlanSurface = useCallback((type: CompactPlanTransientSurface) => {
setCompactPlanSurface({ type });
}, []);
const closeCompactPlanSurface = useCallback((restoreFocus = true) => {
const trigger = compactPlanSurfaceTriggerRef.current;
compactPlanSurfaceTriggerRef.current = null;
setCompactPlanSurface(COMPACT_PLAN_ARTIFACT);
if (!restoreFocus) return;
window.setTimeout(() => {
const fallback = document.getElementById('pn-compact-plan-options-trigger');
const focusTarget = trigger?.isConnected ? trigger : fallback;
focusTarget?.focus({ preventScroll: true });
}, 0);
}, []);
const hideAgentTerminal = useCallback(() => {
setIsAgentTerminalOpen(false);
}, []);
/**
* RIGHT-SLOT INVARIANT (see also getAgentTerminalLayout in
* ./agentTerminalLayout, and the panel render site below).
*
* A right-docked Agent TUI and the annotations/AI panel compete for the same
* slot, and the coordination between them is deliberately ASYMMETRIC:
*
* - Panel wins over terminal, destructively. Asking for annotations or Ask
* AI is a request for that specific surface, so the terminal gives up the
* slot: `isAgentTerminalOpen` goes false. Nothing is lost — a running
* agent stays mounted off-layout, so reopening returns to the same
* session rather than a fresh PTY.
* - Terminal wins over panel, non-destructively. Opening the terminal only
* suppresses the panel visually (`isRightPanelVisible`); `isPanelOpen`
* and the selected tab are left alone, so dismissing the terminal
* restores exactly the surface the user had.
*
* Making this symmetric (closing the panel outright when the terminal opens)
* was considered and rejected: the terminal is frequently a short detour
* from an annotation pass, and clearing the panel would make every detour
* cost the user their place. The asymmetry is the UX, not an oversight.
*/
const replaceRightAgentTerminalWithPanel = useCallback((tab: 'annotations' | 'ai') => {
hideAgentTerminal();
setRightSidebarTab(tab);
setIsPanelOpen(true);
}, [hideAgentTerminal]);
const handleAnnotationPanelToggle = useCallback(() => {
if (isCompactTouchLayout) {
openCompactPlanSurface('annotations');
return;
}
if (wideModeType !== null) {
exitWideMode({ restore: false, panelOpen: true });
setRightSidebarTab('annotations');
return;
}
// Right-slot invariant: only a VISIBLE right-docked terminal is holding the
// slot. A collapsed-but-running one is off-layout and must not be evicted.
if (agentTerminalPlacement === 'right' && isAgentTerminalVisible) {
replaceRightAgentTerminalWithPanel('annotations');
return;
}
setRightSidebarTab('annotations');
setIsPanelOpen(prev => rightSidebarTab === 'annotations' ? !prev : true);
}, [agentTerminalPlacement, exitWideMode, isAgentTerminalVisible, isCompactTouchLayout, openCompactPlanSurface, replaceRightAgentTerminalWithPanel, rightSidebarTab, wideModeType]);
const dismissLookAndFeelAnnouncement = useCallback(() => {
// Persist even when the user accepts the displayed default without first
// clicking its already-selected card, then record the explicit decision.
configStore.set('gridEnabled', gridEnabled);
markLookAndFeelChoiceResolved();
setShowLookAndFeelAnnouncement(false);
}, [gridEnabled]);
const handleAIChatToggle = useCallback(() => {
if (isCompactTouchLayout) {
openCompactPlanSurface('ai');
return;
}
if (wideModeType !== null) {
exitWideMode({ restore: false, panelOpen: true });
setRightSidebarTab('ai');
return;
}
// Right-slot invariant: see replaceRightAgentTerminalWithPanel above.
if (agentTerminalPlacement === 'right' && isAgentTerminalVisible) {
replaceRightAgentTerminalWithPanel('ai');
return;
}
setRightSidebarTab('ai');
setIsPanelOpen(prev => rightSidebarTab === 'ai' ? !prev : true);
}, [agentTerminalPlacement, exitWideMode, isAgentTerminalVisible, isCompactTouchLayout, openCompactPlanSurface, replaceRightAgentTerminalWithPanel, rightSidebarTab, wideModeType]);
/**
* Record the durable placement. Writing through ConfigStore is the whole
* update: `agentTerminalSide` is a useConfigValue subscriber, so the terminal
* popover and the Settings dialog observe the same value.
*/
const handleAgentTerminalSideChange = useCallback((side: AnnotateAgentTerminalSide) => {
saveAnnotateAgentTerminalSide(side);
}, []);
const setAgentTerminalDelivery = useCallback((delivery: AgentTerminalDeliveryRecord | null) => {
agentTerminalDeliveryRef.current = delivery;
setAgentTerminalDeliveryState(delivery);
}, []);
const closeAgentTerminal = useCallback(() => {
if (agentTerminalRef.current) {
agentTerminalRef.current.stop();
return;
}
setIsAgentTerminalRunning(false);
setIsAgentTerminalReady(false);
setAgentTerminalSessionId(null);
setAgentTerminalDelivery(null);
hideAgentTerminal();
}, [hideAgentTerminal, setAgentTerminalDelivery]);
const handleAgentTerminalReadyChange = useCallback((ready: boolean) => {
setIsAgentTerminalReady(ready);
setAgentTerminalDelivery(null);
if (!ready) {
setAgentTerminalSessionId(null);
return;
}
agentTerminalSessionSeqRef.current += 1;
setAgentTerminalSessionId(agentTerminalSessionSeqRef.current);
}, [setAgentTerminalDelivery]);
/**
* Explicit intent to see the terminal now: the rail toggle, Shift Shift, or
* a message routed to the agent. Deliberately does NOT rewrite a 'hidden'
* preference — asking for the panel once is not the same as asking for it
* every session, so the open is session-scoped and the preference survives.
*/
const openAgentTerminal = useCallback(() => {
if (wideModeType !== null) {
exitWideMode({ restore: false, panelOpen: false });
}
setIsAgentTerminalOpen(true);
}, [exitWideMode, wideModeType]);
const toggleAgentTerminal = useCallback(() => {
if (isAgentTerminalOpen) {
hideAgentTerminal();
return;
}
openAgentTerminal();
}, [hideAgentTerminal, isAgentTerminalOpen, openAgentTerminal]);
useEffect(() => {
if (annotateMode && annotateSource !== 'message' && agentTerminalCapability) return;
closeAgentTerminal();
}, [agentTerminalCapability, annotateMode, annotateSource, closeAgentTerminal]);
// Choosing "Hidden" closes the terminal, from either surface that offers the
// Position control (the terminal's own popover, which then disappears, and
// the Settings dialog, which is how you get it back). Keyed on the preference
// alone, so a later explicit open in the same session is not undone: the
// effect does not re-run until the preference changes again.
useEffect(() => {
if (agentTerminalSide !== 'hidden') return;
closeAgentTerminal();
}, [agentTerminalSide, closeAgentTerminal]);
// Sync sidebar open state when the "Auto-open Sidebar" preference changes in
// Settings. Deliberately does NOT react to the document or render mode —
// switching files (e.g. in annotate-folder) leaves the sidebar exactly as the
// user left it.
useEffect(() => {
if (wideModeType !== null) return;
if (lastAppliedTocEnabledRef.current === uiPrefs.tocEnabled) return;
lastAppliedTocEnabledRef.current = uiPrefs.tocEnabled;
if (uiPrefs.tocEnabled && hasTocEntries) sidebar.open('toc');
else if (!uiPrefs.tocEnabled) sidebar.close();
}, [wideModeType, sidebar.close, sidebar.open, uiPrefs.tocEnabled, hasTocEntries]);
// Auto-close the sidebar when blocks parse with no TOC entries. Fires
// only on blocks/hasTocEntries change (not on sidebar state) so a user
// who manually re-opens the empty sidebar is left alone — until the
// document changes again (e.g. picking a new file in annotate-folder).
useEffect(() => {
if (blocks.length === 0) return;
if (hasTocEntries) return;
if (sidebar.activeTab === 'toc' && sidebar.isOpen) {
sidebar.close();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [blocks, hasTocEntries]);
// Clear diff view on Escape key. defaultPrevented respects the
// one-Escape-one-rung contract: an Escape consumed by a popover
// (useDismissablePopover) or another owned surface must not also exit
// the diff view.
useEffect(() => {
if (!isPlanDiffActive) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !e.defaultPrevented) {
setIsPlanDiffActive(false);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isPlanDiffActive]);
const linkedDocSidebar = useMemo(() => ({
...sidebar,
// useLinkedDoc opens the relevant desktop rail after activating a file.
// Compact navigation is a foreground task instead: selecting a destination
// closes it, and the later async document activation must not resurrect it.
open: (tab?: SidebarTab) => {
if (isCompactTouchLayout) return;
openSidebarTab(tab ?? 'toc');
},
toggleTab: toggleSidebarTab,
}), [
isCompactTouchLayout,
openSidebarTab,
sidebar.activeTab,
sidebar.close,
sidebar.isOpen,
toggleSidebarTab,
]);
const snapshotActiveEditableDocument = useCallback(() => {
if (!activeEditableDocument) return;
if (isEditingMarkdown) {
const live = markdownEditorHandleRef.current?.getMarkdown();
if (live != null) editableDocuments.updateActiveText(live, { forceNotify: true });
return;
}
editableDocuments.updateActiveText(displayedMarkdown, { forceNotify: true });
}, [activeEditableDocument, displayedMarkdown, editableDocuments, isEditingMarkdown]);
const getLinkedDocumentMarkdown = useCallback((filepath: string, fallback?: string) => {
return editableDocuments.getCurrentText(`file:${filepath}`) ?? fallback;
}, [editableDocuments]);
const restoreLinkedDocumentEditableKey = useCallback(() => {
const restoreKey = suspendedRootEditableKeyRef.current;
suspendedRootEditableKeyRef.current = null;
editableDocuments.setActiveKey(restoreKey);
}, [editableDocuments]);
const handleLinkedDocumentLoaded = useCallback((doc: { markdown?: string; filepath?: string; renderAs?: 'markdown' | 'html'; sourceSave?: SourceSaveCapability }) => {
if (annotateSource !== 'folder') {
if (activeEditableDocument?.sourceSave?.enabled) {
suspendedRootEditableKeyRef.current = activeEditableDocument.key;
editableDocuments.setActiveKey(null);
}
return undefined;
}
if (doc.renderAs === 'html' || !doc.filepath || doc.markdown == null) {
editableDocuments.setActiveKey(null);
return undefined;
}
const sourceSave = doc.sourceSave ?? null;
const key = editableDocumentKey(sourceSave, `file:${doc.filepath}`);
editableDocuments.openDocument({ key, text: doc.markdown, sourceSave });
const currentText = editableDocuments.getCurrentText(key) ?? doc.markdown;
const record = editableDocuments.getDocument(key);
if (isEditingMarkdown) {
editSessionBaseRef.current = currentText;
setEditorDirty(false);
setEditorDiffersFromBaseline(record ? currentText !== record.diskBaseline : false);
}
return currentText;
}, [activeEditableDocument, annotateSource, editableDocuments, isEditingMarkdown]);
const handleLinkedDocumentActivated = useCallback(() => {
if (!compactPendingFileRef.current) return;
compactPendingFileRef.current = null;
setCompactPendingFilePath(null);
setCompactPlanSurface((surface) =>
surface.type === 'navigator' ? COMPACT_PLAN_ARTIFACT : surface,
);
}, []);
const handleBeforeDocumentNavigation = useCallback(() => {
annotationHistory.clear();
snapshotActiveEditableDocument();
}, [annotationHistory, snapshotActiveEditableDocument]);
// Linked document navigation
const linkedDocHook = useLinkedDoc({
markdown, annotations, selectedAnnotationId, globalAttachments,
setMarkdown, setAnnotations, setSelectedAnnotationId, setGlobalAttachments,
renderAs, rawHtml, shareHtml, setRenderAs, setRawHtml, setShareHtml,
viewerRef, sidebar: linkedDocSidebar, sourceFilePath, sourceConverted,
onBeforeNavigate: handleBeforeDocumentNavigation,
onDocumentLoaded: handleLinkedDocumentLoaded,
onDocumentActivated: handleLinkedDocumentActivated,
getDocumentMarkdown: getLinkedDocumentMarkdown,
onAfterBack: restoreLinkedDocumentEditableKey,
});
// Active document's version-diff baseline: the root document's own
// previousPlan/versionInfo (set once from /api/plan) when no linked/folder
// doc is open, or the active document's own baseline when one is —
// captured from its /api/doc response and cached across navigation by
// useLinkedDoc. /api/doc only ever populates these for eligible folder
// files, so any other linked doc naturally resolves to null/null here,
// same as the (now-removed) blanket "linkedDocHook.isActive ? null : ..."
// suppression used to force.
const activeDiffPreviousPlan = linkedDocHook.isActive ? linkedDocHook.diffPreviousPlan : previousPlan;
const activeDiffVersionInfo = linkedDocHook.isActive ? linkedDocHook.diffVersionInfo : versionInfo;
const activeDocFilepath = linkedDocHook.isActive ? linkedDocHook.filepath : null;
const activeHtmlPath = linkedDocHook.filepath ?? sourceFilePath ?? null;
// Per-document version fetchers: only needed while a document with its own
// diff baseline is active (folder annotate) — usePlanDiff's bare-endpoint
// defaults already cover the root document.
const activeDocDiffFetchers = useMemo<PlanDiffFetchers | undefined>(() => {
if (!activeDocFilepath) return undefined;
const filepath = activeDocFilepath;
return {
fetchVersion: async (version: number) => {
const res = await fetch(`/api/plan/version?v=${version}&path=${encodeURIComponent(filepath)}`);
if (!res.ok) throw new Error(`Failed to load version ${version}.`);
return (await res.json()) as { plan: string; version: number };
},
fetchVersions: async () => {
const res = await fetch(`/api/plan/versions?path=${encodeURIComponent(filepath)}`);
if (!res.ok) throw new Error('Failed to load versions.');
return (await res.json()) as { project: string; slug: string; versions: VersionEntry[] };
},
};
}, [activeDocFilepath]);
// Plan diff computation. On the HTML surface the diff is rendered as the real
// page with inline highlights (htmlDiffHtml) instead of the markdown block diff,
// so suppress the markdown diff path there (markdown is empty for HTML).
// `activeDocFilepath` as the docKey resets the diff-base state whenever the
// active document changes, so a newly opened document starts from ITS OWN
// baseline instead of inheriting whatever the previous document had.
const planDiff = usePlanDiff(
markdown,
isHtmlSurface ? null : activeDiffPreviousPlan,
isHtmlSurface ? null : activeDiffVersionInfo,
activeDocDiffFetchers,
activeDocFilepath,
);
// Exit diff view when the active document switches to one with no diff
// baseline (e.g. a history-less folder file) — otherwise the stale active
// flag hides the annotation toolstrip until Escape. Gated off HTML surfaces,
// whose diff view is driven by htmlDiffHtml (usePlanDiff is fed nulls there,
// so hasPreviousVersion is always false). See usePlanDiffViewAutoExit.
const exitPlanDiffView = useCallback(() => setIsPlanDiffActive(false), []);
usePlanDiffViewAutoExit(
isPlanDiffActive && !isHtmlSurface,
planDiff.hasPreviousVersion,
exitPlanDiffView,
);
usePlanDiffNavigationAutoExit(
sidebar.activeTab === 'toc' || isCompactContentsSurfaceOpen,
exitPlanDiffView,
);
const warnFinishEditingFirst = useCallback((target: 'versions' | 'diff') => {
toast('Finish editing first', {
description: target === 'versions'
? 'Use "Done editing" before changing the comparison version.'
: 'Use "Done editing" before opening the version diff.',
});
}, []);
const handleSelectBaseVersion = useCallback((version: number) => {
if (isEditingMarkdown) {
warnFinishEditingFirst('versions');
return Promise.resolve();
}
return planDiff.selectBaseVersion(version);
}, [isEditingMarkdown, planDiff.selectBaseVersion, warnFinishEditingFirst]);
const handleActivatePlanDiff = useCallback(() => {
if (isEditingMarkdown) {
warnFinishEditingFirst('diff');
return;
}
setIsPlanDiffActive(true);
}, [isEditingMarkdown, warnFinishEditingFirst]);
// Keep the early parse-path mirror in sync with the active linked doc so
// the blocks/frontmatter memos (declared before this hook) parse with the
// right frontmatter rule for the file on screen.
useEffect(() => {
setLinkedDocParsePath(linkedDocHook.filepath ?? null);
}, [linkedDocHook.filepath]);
// Active document's directory — feeds both click-time popout fetches and
// the validator hook so they resolve against the same base. Drifting
// these would silently re-introduce the demote-correct-link bug.
const activeDocBaseDir = useMemo(
() => linkedDocHook.filepath
? linkedDocHook.filepath.replace(/\/[^/]+$/, '')
: imageBaseDir?.includes('/') ? imageBaseDir : undefined,
[linkedDocHook.filepath, imageBaseDir],
);
// Code file popout (read-only syntax-highlighted overlay)
const codeFilePopout = useCodeFilePopout({
buildUrl: useCallback((codePath: string) => {
return activeDocBaseDir
? `/api/doc?path=${encodeURIComponent(codePath)}&base=${encodeURIComponent(activeDocBaseDir)}`
: `/api/doc?path=${encodeURIComponent(codePath)}`;
}, [activeDocBaseDir]),
});
// Archive browser
const archive = useArchive({
markdown, viewerRef, linkedDocHook,
setMarkdown, setAnnotations, setSelectedAnnotationId, setSubmitted,
});
const documentReadOnly = archive.archiveMode;
useEffect(() => {
annotationHistory.clear();
}, [annotationHistory, archive.archiveMode]);
// A Refresh lands the bytes and, for the root document, the version diff
// the server recomputed against them (previousPlan/versionInfo still name
// the saved baseline). The view returns to normal mode with the "Show
// changes" toggle available whenever a diff came back; a refresh of a
// linked doc keeps the root's version fields untouched, as before.
const applyRefreshedHtml = useCallback((refreshed: HtmlRefreshedDocument) => {
annotationHistory.clear();
setRawHtml(refreshed.rawHtml);
setShareHtml('');
setIsPlanDiffActive(false);
if (linkedDocHook.isActive) {
setHtmlDiffHtml(null);
return;
}
setHtmlDiffHtml(refreshed.diffHtml ?? null);
setPreviousPlan(refreshed.previousPlan ?? null);
setVersionInfo(refreshed.versionInfo ?? null);
}, [annotationHistory, linkedDocHook.isActive]);
// Annotations a Refresh could no longer anchor: the panel shows an
// "Unanchored" chip on them. Set from the refresh's restore report only,
// so the chip is exactly the toast's list; a document change clears it.
const [htmlUnanchoredIds, setHtmlUnanchoredIds] = useState<ReadonlySet<string>>(() => new Set());
const handleHtmlRefreshUnanchored = useCallback((ids: string[]) => {
setHtmlUnanchoredIds(new Set(ids));
}, []);
useEffect(() => {
setHtmlUnanchoredIds((prev) => (prev.size === 0 ? prev : new Set()));
}, [activeHtmlPath]);
const htmlRefresh = useHtmlRefresh({
enabled: isApiMode && annotateMode && isHtmlSurface && !liveApp && !documentReadOnly,
activePath: activeHtmlPath,
onSnapshot: applyRefreshedHtml,
onUnanchored: handleHtmlRefreshUnanchored,
});
const htmlShareContext = useMemo(
() => ({ activePath: activeHtmlPath, reloadGeneration: htmlRefresh.reloadGeneration }),
[activeHtmlPath, htmlRefresh.reloadGeneration],
);
const latestHtmlShareContextRef = useRef(htmlShareContext);
latestHtmlShareContextRef.current = htmlShareContext;
const canUseWideMode = useMemo(() => canUseAnnotateWideMode({
archiveMode: archive.archiveMode,
isPlanDiffActive,
}), [archive.archiveMode, isPlanDiffActive]);
const enterViewMode = useCallback((type: WideModeType) => {
if (!canUseWideMode) return;
if (wideModeType === null) {
wideModeSnapshotRef.current = {
sidebarIsOpen: sidebar.isOpen,
sidebarTab: sidebar.activeTab,
panelOpen: isPanelOpen,
};
}
if (isAgentTerminalOpen) hideAgentTerminal();
setWideModeType(type);
sidebar.close();
setIsPanelOpen(false);
}, [canUseWideMode, hideAgentTerminal, isAgentTerminalOpen, isPanelOpen, wideModeType, sidebar.activeTab, sidebar.close, sidebar.isOpen]);
const toggleViewMode = useCallback((type: WideModeType) => {
if (wideModeType === type) {
exitWideMode();
} else {
enterViewMode(type);
}
}, [enterViewMode, exitWideMode, wideModeType]);
useEffect(() => {
if (!canUseWideMode && wideModeType !== null) {
exitWideMode();
}
}, [canUseWideMode, exitWideMode, wideModeType]);
// Markdown file browser (also handles vault dirs via isVault flag)
const fileBrowser = useFileBrowser();
const vaultPath = useMemo(() => {
if (!isVaultBrowserEnabled()) return '';
return getEffectiveVaultPath(getObsidianSettings());
}, [uiPrefs]);
const showFilesTab = useMemo(
() => !!projectRoot || isFileBrowserEnabled() || isVaultBrowserEnabled(),
[projectRoot, uiPrefs]
);
// Shared gate for the chrome-level keyboard commands (sidebars, focus mode):
// never while a dialog, an overlay, a submission, or a text field owns the
// keystroke. Annotate-only commands layer their own conditions on top.
const canHandleDocumentChromeShortcut = useCallback((event: KeyboardEvent) => {
if (archive.archiveMode || goalSetupMode) return false;
if (event.defaultPrevented) return false;
if (document.querySelector('[data-plannotator-confirm-dialog="true"]')) return false;
if (showExport || showImport || showFeedbackPrompt || showClaudeCodeWarning ||
showSourceFileEditWarning ||
showExitWarning || showAgentWarning || showPermissionModeSetup || pendingPasteImage) return false;
if (submitted || isSubmitting || isExiting || isEditingMarkdown) return false;
const target = event.target as HTMLElement | null;
const tag = target?.tagName;
return tag !== 'INPUT' && tag !== 'TEXTAREA' && !target?.isContentEditable;
}, [
archive.archiveMode,
goalSetupMode,
showExport,
showImport,
showFeedbackPrompt,
showClaudeCodeWarning,
showSourceFileEditWarning,
showExitWarning,
showAgentWarning,
showPermissionModeSetup,
pendingPasteImage,
submitted,
isSubmitting,
isExiting,
isEditingMarkdown,
]);
const canHandleAnnotateSidebarShortcut = useCallback(
(event: KeyboardEvent) => annotateMode && canHandleDocumentChromeShortcut(event),
[annotateMode, canHandleDocumentChromeShortcut],
);
const canHandleAnnotationHistoryShortcut = useCallback((event: KeyboardEvent) => {
if (event.defaultPrevented || documentReadOnly || submitted || isSubmitting || isExiting) return false;
if (isEditingMarkdown || pendingPasteImage || isNativeHistoryOwner(event)) return false;
return !hasActiveHistoryOverlay(document);
}, [documentReadOnly, isEditingMarkdown, isExiting, isSubmitting, pendingPasteImage, submitted]);
useHistoryShortcuts({
handlers: {
undo: {
when: (event) => canHandleAnnotationHistoryShortcut(event) && annotationHistory.canUndo,
handle: () => { annotationHistory.undo(); },
},
redo: {
when: (event) => canHandleAnnotationHistoryShortcut(event) && annotationHistory.canRedo,
handle: () => { annotationHistory.redo(); },
},
},
});
// Focus mode from the keyboard. Mirrors the document card's `Focus` control —
// including its availability — so the shortcut can never park the layout in a
// state with no visible way back. HTML surfaces cannot ENTER focus mode (they
// own their own persisted chrome and never render that control), but exit stays
// available everywhere: a linked-doc navigation can flip the surface to HTML
// while focus mode is active, and without the exit path that layout is stuck.
const handleToggleFocusMode = useCallback(() => {
const action = resolveFocusShortcutAction({
canUseWideMode: canUseWideMode && !isHtmlSurface,
wideModeType,
});
if (action === 'enter-focus') enterViewMode('focus');
else if (action === 'exit') exitWideMode();
}, [canUseWideMode, enterViewMode, exitWideMode, isHtmlSurface, wideModeType]);
useDocumentViewShortcuts({
handlers: {
toggleFocusMode: {
when: (event) =>
canHandleDocumentChromeShortcut(event)
&& ((canUseWideMode && !isHtmlSurface) || wideModeType !== null),
handle: handleToggleFocusMode,
},
},
});
useAnnotateSidebarShortcuts({
handlers: {
toggleContents: {
when: canHandleAnnotateSidebarShortcut,
handle: () => toggleSidebarTab('toc'),
},
toggleFiles: {
when: (event) => canHandleAnnotateSidebarShortcut(event) && showFilesTab && !archive.archiveMode,
handle: () => toggleSidebarTab('files'),
},
},
});
useDoubleTapShortcuts({
scope: annotateSidebarShortcuts,
handlers: {
toggleAgentTui: {
when: (event) =>
canHandleAnnotateSidebarShortcut(event) &&
annotateSource !== 'message' &&
agentTerminalCapability !== null,
handle: () => toggleAgentTerminal(),
},
},
});
const fileBrowserDirs = useMemo(() => {
const projectDirs = projectRoot ? [projectRoot] : [];
const userDirs = isFileBrowserEnabled()
? getFileBrowserSettings().directories
: [];
return [...new Set([...projectDirs, ...userDirs])];
}, [projectRoot, uiPrefs]);
// Clear active file when file browser is disabled
useEffect(() => {
if (!showFilesTab) fileBrowser.setActiveFile(null);
}, [showFilesTab]);
// When vault is disabled, prune any stale vault dirs immediately
useEffect(() => {
if (!vaultPath) fileBrowser.clearVaultDirs();
}, [vaultPath]);
useEffect(() => {
if ((sidebar.activeTab === 'files' || isCompactFilesSurfaceOpen) && showFilesTab) {
// Load regular dirs
if (fileBrowserDirs.length > 0) {
const regularLoaded = fileBrowser.dirs.filter(d => !d.isVault).map(d => d.path);
const needsRegular = fileBrowserDirs.some(d => !regularLoaded.includes(d))
|| regularLoaded.some(d => !fileBrowserDirs.includes(d));
if (needsRegular) fileBrowser.fetchAll(fileBrowserDirs);
}
// Load vault dir; addVaultDir atomically replaces any existing vault entry so
// switching vault paths never accumulates stale sections
if (vaultPath && !fileBrowser.dirs.find(d => d.isVault && d.path === vaultPath && !d.error)) {
fileBrowser.addVaultDir(vaultPath);
}
}
}, [fileBrowserDirs, isCompactFilesSurfaceOpen, showFilesTab, sidebar.activeTab, vaultPath]);
const buildCurrentMessageState = React.useCallback((): MessageAnnotationState | null => {
if (annotateSource !== 'message' || !selectedMessageId) return null;
const msg = recentMessages.find((m) => m.messageId === selectedMessageId);
if (!msg) return null;
const snapshot = linkedDocHook.snapshotSession();
return normalizeMessageState({
messageId: msg.messageId,
text: msg.text,
timestamp: msg.timestamp,
linkedDocSession: snapshot,
codeAnnotations: [...codeAnnotations],
selectedCodeAnnotationId,
}, msg);
}, [
annotateSource,
selectedMessageId,
recentMessages,
linkedDocHook.snapshotSession,
codeAnnotations,
selectedCodeAnnotationId,
]);
const getMessageStatesWithCurrent = React.useCallback((): Map<string, MessageAnnotationState> => {
const states = new Map(messageStateCacheRef.current);
const current = buildCurrentMessageState();
if (current) states.set(current.messageId, current);
return states;
}, [buildCurrentMessageState]);
const saveCurrentMessageState = React.useCallback((): Map<string, MessageAnnotationState> => {
const states = getMessageStatesWithCurrent();
messageStateCacheRef.current = states;
setCachedMessageAnnotationCounts(buildMessageAnnotationCounts(states));
return states;
}, [getMessageStatesWithCurrent]);
const buildMessageAnnotationEntries = React.useCallback((): MessageAnnotationEntry[] => {
if (annotateSource !== 'message' || recentMessages.length === 0) return [];
// Must be a PURE read: this runs on the render path via
// currentFeedbackPayload (useMemo) -> getCurrentFeedbackPayload.
// saveCurrentMessageState() writes React state
// (setCachedMessageAnnotationCounts), which during render is an infinite
// re-render loop in multi-message mode (#949). getMessageStatesWithCurrent
// returns the same merged data without the setState side effect; the cache
// persistence happens in event handlers (handleSelectMessage) instead.
const states = getMessageStatesWithCurrent();
return recentMessages.map((msg) => {
const state = states.get(msg.messageId) ?? createEmptyMessageState(msg);
const linkedDocs: Map<string, LinkedDocAnnotationEntry> = new Map();
for (const [filepath, doc] of state.linkedDocSession.docs) {
linkedDocs.set(filepath, {
...doc,
blocks: doc.markdown
? parseMarkdownToBlocks(doc.markdown, { frontmatter: shouldStripFrontmatter(filepath) })
: undefined,
});
}
return {
messageId: msg.messageId,
text: msg.text,
timestamp: msg.timestamp,
annotations: state.linkedDocSession.root.annotations,
globalAttachments: state.linkedDocSession.root.globalAttachments,
blocks: parseMarkdownToBlocks(state.linkedDocSession.root.markdown),
linkedDocs,
codeAnnotations: state.codeAnnotations,
};
});
}, [annotateSource, recentMessages, getMessageStatesWithCurrent]);
const activeMessageAnnotationCounts = React.useMemo(() => {
const counts = new Map(cachedMessageAnnotationCounts);
const current = buildCurrentMessageState();
if (current) {
const count = countMessageAnnotations(current);
if (count > 0) counts.set(current.messageId, count);
else counts.delete(current.messageId);
}
return counts;
}, [cachedMessageAnnotationCounts, buildCurrentMessageState]);
const messageFeedbackAnnotationCount = React.useMemo(
() => Array.from(activeMessageAnnotationCounts.values()).reduce((sum, count) => sum + count, 0),
[activeMessageAnnotationCounts]
);
const annotatedMessageIds = React.useMemo(
() => Array.from(activeMessageAnnotationCounts.keys()),
[activeMessageAnnotationCounts]
);
// File browser file selection: open via linked doc system
// For vault dirs (isVault), use the Obsidian doc endpoint; otherwise use generic /api/doc
const handleSelectMessage = React.useCallback((messageId: string) => {
const msg = recentMessages.find((m) => m.messageId === messageId);
if (!msg || messageId === selectedMessageId) return;
annotationHistory.clear();
const states = saveCurrentMessageState();
const targetState = normalizeMessageState(
states.get(messageId) ?? createEmptyMessageState(msg),
msg,
);
setSelectedMessageId(messageId);
linkedDocHook.restoreSession(targetState.linkedDocSession);
setCodeAnnotations([...targetState.codeAnnotations]);
setSelectedCodeAnnotationId(targetState.selectedCodeAnnotationId);
}, [
recentMessages,
selectedMessageId,
saveCurrentMessageState,
linkedDocHook.restoreSession,
annotationHistory,
]);
const handleFileBrowserSelect = React.useCallback(async (absolutePath: string, dirPath: string): Promise<void> => {
const normalizedAbsolutePath = normalizeBrowserPath(absolutePath);
const dirState = fileBrowser.dirs.find(d => d.path === dirPath);
const normalizedDirPath = normalizeBrowserPath(dirPath);
const dirPrefix = normalizedDirPath === "/" || /^[A-Za-z]:\/$/.test(normalizedDirPath)
? normalizedDirPath
: `${normalizedDirPath}/`;
const relativePath = normalizedAbsolutePath === normalizedDirPath
? ""
: normalizedAbsolutePath.startsWith(dirPrefix)
? normalizedAbsolutePath.slice(dirPrefix.length)
: undefined;
const editableStatus = getFileEditStatus(
absolutePath,
editableDocuments.fileEditStatuses,
relativePath,
dirState?.workspaceStatus,
);
const editableKey = editableStatus?.key ?? `file:${absolutePath}`;
const editableRecord = editableDocuments.getDocument(editableKey);
if (editableRecord?.missingOnDisk && editableRecord.sourceSave?.enabled) {
linkedDocHook.openLoaded({
filepath: editableRecord.path ?? absolutePath,
markdown: editableRecord.currentText,
renderAs: 'markdown',
sourceSave: editableRecord.sourceSave,
}, 'files', { notifyDocumentLoaded: false });
editableDocuments.setActiveKey(editableKey);
if (isEditingMarkdown) {
editSessionBaseRef.current = editableRecord.currentText;
setEditorDirty(false);
setEditorDiffersFromBaseline(editableRecord.currentText !== editableRecord.diskBaseline);
setEditStats(
editableRecord.currentText !== editableRecord.diskBaseline
? computeEditStats(editableRecord.diskBaseline, editableRecord.currentText)
: null,
);
}
fileBrowser.setActiveFile(absolutePath);
return;
}
const buildUrl = dirState?.isVault
? (path: string) => `/api/reference/obsidian/doc?vaultPath=${encodeURIComponent(dirPath)}&path=${encodeURIComponent(path)}`
// `doc=1`: file-browser selections always want annotatable document
// rendering — without it, extensions that overlap the code-file set
// (.yaml, .json, .toml, …) would come back as code-file popout payloads.
: (path: string) => `/api/doc?path=${encodeURIComponent(path)}&base=${encodeURIComponent(dirPath)}&doc=1${convertHtml ? '&convert=1' : ''}`;
fileBrowser.setActiveFile(absolutePath);
await linkedDocHook.open(absolutePath, buildUrl, 'files');
}, [editableDocuments, linkedDocHook, fileBrowser, convertHtml, isEditingMarkdown]);
// Route linked doc opens through the correct endpoint based on current context
const handleOpenLinkedDoc = React.useCallback((docPath: string) => {
const activeDirState = fileBrowser.dirs.find(d => d.path === fileBrowser.activeDirPath);
if (activeDirState?.isVault && fileBrowser.activeDirPath) {
linkedDocHook.open(docPath, (path) =>
`/api/reference/obsidian/doc?vaultPath=${encodeURIComponent(fileBrowser.activeDirPath!)}&path=${encodeURIComponent(path)}`
);
} else if (fileBrowser.activeFile && fileBrowser.activeDirPath) {
// When viewing a file browser doc, resolve links relative to current file's directory
const baseDir = linkedDocHook.filepath?.replace(/\/[^/]+$/, '') || fileBrowser.activeDirPath;
linkedDocHook.open(docPath, (path) =>
`/api/doc?path=${encodeURIComponent(path)}&base=${encodeURIComponent(baseDir)}${convertHtml ? '&convert=1' : ''}`
);
} else {
// Pass the current file's directory as base for relative path resolution
const baseDir = linkedDocHook.filepath
? linkedDocHook.filepath.replace(/\/[^/]+$/, '')
: imageBaseDir?.includes('/') ? imageBaseDir : undefined;
if (baseDir) {
linkedDocHook.open(docPath, (path) =>
`/api/doc?path=${encodeURIComponent(path)}&base=${encodeURIComponent(baseDir)}${convertHtml ? '&convert=1' : ''}`
);
} else {
linkedDocHook.open(docPath);
}
}
}, [fileBrowser.dirs, fileBrowser.activeDirPath, fileBrowser.activeFile, linkedDocHook, imageBaseDir, convertHtml]);
// Wrap linked doc back to also clear file browser active file
const handleLinkedDocBack = React.useCallback(() => {
linkedDocHook.back();
if (isEditingMarkdown) {
setIsEditingMarkdown(false);
setEditorDirty(false);
setEditorDiffersFromBaseline(false);
}
fileBrowser.setActiveFile(null);
archive.clearSelection();
}, [linkedDocHook, isEditingMarkdown, fileBrowser, archive]);
// Derive annotation counts per file from linked doc cache (includes active doc's live state)
const allAnnotationCounts = useMemo(() => {
const counts = new Map<string, number>();
for (const [fp, cached] of linkedDocHook.getDocAnnotations()) {
const count = cached.annotations.length + cached.globalAttachments.length;
if (count > 0) counts.set(fp, count);
}
return counts;
}, [linkedDocHook.getDocAnnotations, annotations, globalAttachments]);
// FileBrowser counts: all files under any loaded dir (regular + vault)
const fileAnnotationCounts = useMemo(() => {
const allDirPaths = fileBrowser.dirs.map(d => d.path);
if (allDirPaths.length === 0) return allAnnotationCounts;
const counts = new Map<string, number>();
for (const [fp, count] of allAnnotationCounts) {
if (allDirPaths.some(dir => pathIsInsideDir(fp, dir))) {
counts.set(fp, count);
}
}
return counts;
}, [allAnnotationCounts, fileBrowser.dirs]);
const hasFileAnnotations = fileAnnotationCounts.size > 0;
// Annotations in other files (not the current view) — for the right panel "+N" indicator
const otherFileAnnotations = useMemo(() => {
const currentFile = linkedDocHook.filepath;
let count = 0;
let files = 0;
for (const [fp, n] of allAnnotationCounts) {
if (fp !== currentFile) {
count += n;
files++;
}
}
return count > 0 ? { count, files } : undefined;
}, [allAnnotationCounts, linkedDocHook.filepath]);
// Flash highlight for annotated files in the sidebar
const [highlightedFiles, setHighlightedFiles] = useState<Set<string> | undefined>();
const flashTimerRef = React.useRef<ReturnType<typeof setTimeout>>();
const handleFlashAnnotatedFiles = React.useCallback(() => {
const filePaths = new Set(allAnnotationCounts.keys());
if (filePaths.size === 0) return;
// Open sidebar to the files tab so the flash is visible
if (isCompactTouchLayout
? !isCompactFilesSurfaceOpen
: (!sidebar.isOpen || sidebar.activeTab !== 'files')) {
openSidebarTab('files');
}
// Cancel any pending clear from a previous flash
if (flashTimerRef.current) clearTimeout(flashTimerRef.current);
// Clear first so re-triggering restarts the CSS animation
setHighlightedFiles(undefined);
requestAnimationFrame(() => {
setHighlightedFiles(filePaths);
flashTimerRef.current = setTimeout(() => setHighlightedFiles(undefined), 1200);
});
}, [allAnnotationCounts, isCompactFilesSurfaceOpen, isCompactTouchLayout, openSidebarTab, sidebar]);
// Context-aware back label for linked doc navigation
const backLabel = annotateSource === 'folder' ? 'file list'
: annotateSource === 'file' ? 'file'
: annotateSource === 'message' ? 'message'
: 'plan';
// Viewer identity must change when the rendered document changes: web-highlighter
// mutates the Viewer DOM, so reconciling new content against the old subtree throws
// removeChild errors — a changed key remounts it cleanly instead. StickyHeaderLane
// observes a node inside Viewer, so it re-anchors off the same token.
const viewerContentKey = linkedDocHook.isActive
? `doc:${linkedDocHook.filepath}`
: annotateSource === 'message' && selectedMessageId
? `msg:${selectedMessageId}`
: `plan:${editGeneration}`;
// Track active section for TOC highlighting
const headingCount = useMemo(() => blocks.filter(b => b.type === 'heading').length, [blocks]);
const activeSection = useActiveSection(planAreaRef, headingCount, scrollViewport);
const { editorAnnotations, deleteEditorAnnotation } = useEditorAnnotations();
const { externalAnnotations, updateExternalAnnotation, deleteExternalAnnotation } = useExternalAnnotations<Annotation>({
enabled: isApiMode && !goalSetupMode && !documentReadOnly,
});
// Drive DOM highlights for SSE-delivered external annotations. Disabled
// while a linked doc overlay is open (Viewer DOM is hidden) and while the
// plan diff view is active (diff view has its own annotation surface).
const { reset: resetExternalHighlights } = useExternalAnnotationHighlights({
viewerRef,
externalAnnotations,
enabled: isApiMode && !goalSetupMode && !linkedDocHook.isActive && !isPlanDiffActive && !isEditingMarkdown,
planKey: markdown,
});
// Merge local + SSE annotations, deduping draft-restored externals against
// live SSE versions. Prefer the SSE version when both exist (same source,
// type, and originalText). This avoids the timing issues of an effect-based
// cleanup — draft-restored externals persist until SSE actually re-delivers them.
const allAnnotations = useMemo(() => {
if (externalAnnotations.length === 0) return annotations;
const local = annotations.filter(a => {
if (!a.source) return true;
return !externalAnnotations.some(ext =>
ext.source === a.source &&
ext.type === a.type &&
ext.originalText === a.originalText
);
});
return [...local, ...externalAnnotations];
}, [annotations, externalAnnotations]);
// Plan diff state — memoize filtered annotation lists to avoid new references per render
const diffAnnotations = useMemo(() => allAnnotations.filter(a => !!a.diffContext), [allAnnotations]);
const viewerAnnotations = useMemo(() => allAnnotations.filter(a => !a.diffContext), [allAnnotations]);
// Any-annotations flag used by Close/Approve/Send guards. Consolidates the
// four-term check that was inlined across the annotate-mode header + keyboard paths.
const messageMultiSelectMode = annotateSource === 'message' && recentMessages.length > 1;
const hasAnyAnnotations = useMemo(
() => messageMultiSelectMode
? messageFeedbackAnnotationCount > 0 || editorAnnotations.length > 0
: allAnnotations.length > 0
|| codeAnnotations.length > 0
|| editorAnnotations.length > 0
|| linkedDocHook.docAnnotationCount > 0
|| globalAttachments.length > 0,
[
messageMultiSelectMode,
messageFeedbackAnnotationCount,
allAnnotations.length,
codeAnnotations.length,
editorAnnotations.length,
linkedDocHook.docAnnotationCount,
globalAttachments.length,
],
);
const feedbackAnnotationCount = messageMultiSelectMode
? messageFeedbackAnnotationCount + editorAnnotations.length
: allAnnotations.length +
codeAnnotations.length +
editorAnnotations.length +
linkedDocHook.docAnnotationCount +
globalAttachments.length;
// Lazily fetch the SKILL.md contents of referenced HUMAN-ONLY skills so the
// exported feedback can inject their instructions (a human referencing a
// human-only skill IS the human invocation). Runs whenever comment state
// changes — covering typed comments, panel edits, draft restore, and
// external annotations — and bumps a generation so memoized exports
// recompute once content lands. A submit that races the fetch degrades to
// the name + directory fallback inside skillReferenceExportBlock.
const [skillContentGeneration, setSkillContentGeneration] = useState(0);
useEffect(() => {
if (!isApiMode) return;
// Only reviewer-written comments prime skill contents. Annotations with a
// `source` arrived through the unauthenticated external-annotations API
// and can never cause injection (see skillReferenceExportBlock), so their
// references must not trigger content fetches either.
const texts: Array<string | undefined> = [];
for (const a of allAnnotations) if (!a.source) texts.push(a.text);
for (const a of codeAnnotations) if (!a.source) texts.push(a.text);
for (const entry of linkedDocHook.getDocAnnotations().values()) {
for (const a of entry.annotations) if (!a.source) texts.push(a.text);
}
if (messageMultiSelectMode) {
for (const state of getMessageStatesWithCurrent().values()) {
for (const a of state.linkedDocSession.root.annotations) if (!a.source) texts.push(a.text);
for (const doc of state.linkedDocSession.docs.values()) {
for (const a of doc.annotations) if (!a.source) texts.push(a.text);
}
for (const a of state.codeAnnotations) if (!a.source) texts.push(a.text);
}
}
let cancelled = false;
primeSkillContentsForExport(texts).then((changed) => {
if (changed && !cancelled) setSkillContentGeneration((g) => g + 1);
});
return () => {
cancelled = true;
};
}, [
isApiMode,
allAnnotations,
codeAnnotations,
linkedDocHook.docAnnotationCount,
linkedDocHook.getDocAnnotations,
messageMultiSelectMode,
getMessageStatesWithCurrent,
activeMessageAnnotationCounts,
]);
const annotationsOutput = useMemo(() => {
const docAnnotations = linkedDocHook.getDocAnnotations();
const hasDocAnnotations = Array.from(docAnnotations.values()).some(
(d) => d.annotations.length > 0 || d.globalAttachments.length > 0
);
const hasPlanAnnotations = allAnnotations.length > 0 || globalAttachments.length > 0;
const hasEditorAnnotations = editorAnnotations.length > 0;
const hasCodeAnnotations = codeAnnotations.length > 0;
if (!hasPlanAnnotations && !hasDocAnnotations && !hasEditorAnnotations && !hasCodeAnnotations) {
return 'User reviewed the document and has no feedback.';
}
const activeConverted = linkedDocHook.isActive
? (docAnnotations.get(linkedDocHook.filepath ?? '')?.isConverted ?? false)
: sourceConverted;
let output = hasPlanAnnotations
? exportAnnotations(
blocks,
allAnnotations,
globalAttachments,
annotateSource === 'message' ? 'Message Feedback' : annotateSource === 'folder' ? 'Folder Feedback' : annotateSource === 'file' ? 'File Feedback' : 'Plan Feedback',
annotateSource ?? 'plan',
{ sourceConverted: activeConverted },
)
: '';
if (hasDocAnnotations) {
const enriched: Map<string, LinkedDocAnnotationEntry> = new Map(docAnnotations);
for (const [filepath, entry] of enriched) {
if (entry.markdown) {
enriched.set(filepath, {
...entry,
blocks: parseMarkdownToBlocks(entry.markdown, { frontmatter: shouldStripFrontmatter(filepath) }),
});
}
}
output += exportLinkedDocAnnotations(enriched);
}
if (hasEditorAnnotations) {
output += exportEditorAnnotations(editorAnnotations);
}
if (hasCodeAnnotations) {
output += exportCodeFileAnnotations(codeAnnotations);
}
return output;
// skillContentGeneration re-runs this once lazily fetched human-only skill
// contents land in the export registry (module state the exporters read).
}, [blocks, allAnnotations, globalAttachments, linkedDocHook.getDocAnnotations, editorAnnotations, codeAnnotations, sourceConverted, annotateSource, linkedDocHook.isActive, linkedDocHook.filepath, skillContentGeneration]);
// Code-file comments are intentionally not serialized into share URLs in v1.
// Hide share entry points once they exist so we do not silently drop feedback.
const canShareCurrentSession = sharingEnabled && codeAnnotations.length === 0;
const resolveRawHtmlForShare = useCallback(async (): Promise<string | null> => {
if (renderAs !== 'html' || !rawHtml) return null;
if (shareHtml) return shareHtml;
if (!isApiMode) return rawHtml;
const params = new URLSearchParams();
const { activePath } = htmlShareContext;
if (activePath) params.set('path', activePath);
const query = params.toString();
const res = await fetch(`/api/share-html${query ? `?${query}` : ''}`);
const data = (await res.json().catch(() => ({}))) as { shareHtml?: unknown; error?: string };
if (!res.ok || data.error || typeof data.shareHtml !== 'string') {
throw new Error(data.error || 'Failed to prepare HTML for sharing');
}
if (latestHtmlShareContextRef.current !== htmlShareContext) {
throw new Error('HTML changed while preparing the share link');
}
setShareHtml(data.shareHtml);
return data.shareHtml;
}, [htmlShareContext, isApiMode, rawHtml, renderAs, shareHtml]);
// URL-based sharing
const {
isSharedSession,
isLoadingShared,
shareUrl,
shareUrlSize,
shortShareUrl,
isGeneratingShortUrl,
shortUrlError,
pendingSharedAnnotations,
sharedGlobalAttachments,
clearPendingSharedAnnotations,
generateShortUrl,
importFromShareUrl,
shareLoadError,
clearShareLoadError,
} = useSharing(
markdown,
allAnnotations,
globalAttachments,
setMarkdown,
setAnnotations,
setGlobalAttachments,
() => {
// When loaded from share, mark as loaded
setIsLoading(false);
},
shareBaseUrl,
pasteApiUrl,
renderAs === 'html' ? rawHtml : undefined,
resolveRawHtmlForShare,
setRawHtml,
setShareHtml,
setRenderAs,
htmlRefresh.reloadGeneration,
);
useEffect(() => {
if (initialSidebarPreferenceAppliedRef.current) return;
if (isLoading || isLoadingShared) return;
if (wideModeType !== null) return;
initialSidebarPreferenceAppliedRef.current = true;
if (archive.archiveMode || goalSetupMode || annotateSource === 'folder') return;
// HTML chrome is owned by the surface-transition effect below, which also
// covers linked .html docs opened from a markdown session.
if (renderAs === 'html') return;
if (uiPrefs.tocEnabled && hasTocEntries) {
sidebar.open('toc');
}
}, [
annotateSource,
archive.archiveMode,
goalSetupMode,
hasTocEntries,
isLoading,
isLoadingShared,
renderAs,
sidebar.close,
sidebar.open,
uiPrefs.tocEnabled,
wideModeType,
]);
// Restore-on-entry: every time the session transitions ONTO an HTML surface
// (a root raw-HTML session, or a linked .html doc opened from markdown),
// apply the sidebar/panel/toolsHidden state the user last left an HTML
// session with (first-ever run: both closed, tools visible). A restored
// toolsHidden:true always has a way back on every layout: the desktop
// header eye toggle, and the compact Options menu "Show tools" action
// (compactDocumentActions). Re-restoring on each entry is also what keeps
// a markdown surface's sidebar state from leaking into the HTML cookie on
// the way back.
const prevHtmlChromeSurfaceRef = useRef(false);
useEffect(() => {
if (isLoading || isLoadingShared) return;
if (wideModeType !== null) return;
const wasHtml = prevHtmlChromeSurfaceRef.current;
prevHtmlChromeSurfaceRef.current = isHtmlSurface;
if (!isHtmlSurface || wasHtml) return;
if (archive.archiveMode || goalSetupMode || annotateSource === 'folder') return;
const chrome = getHtmlChromeState();
skipNextHtmlChromeSaveRef.current = true;
if (chrome.sidebarOpen) sidebar.open();
else sidebar.close();
setIsPanelOpen(chrome.panelOpen);
setHtmlToolsHidden(chrome.toolsHidden);
htmlChromeRestoredRef.current = true;
}, [
annotateSource,
archive.archiveMode,
goalSetupMode,
isHtmlSurface,
isLoading,
isLoadingShared,
sidebar.close,
sidebar.open,
wideModeType,
]);
// Persist the chrome the user leaves an HTML session in (sidebar + panel
// open state), so the next raw-HTML session opens exactly as they left this
// one. Gated on the restore having run — a pre-restore render must not save
// the transient defaults over the user's remembered state — and on being ON
// the HTML surface, so a linked markdown doc's sidebar use never writes here.
useEffect(() => {
if (!isHtmlSurface || !htmlChromeRestoredRef.current) return;
// The restore effect flips htmlChromeRestoredRef synchronously, but its
// state updates land a commit LATER — a save in the restore commit itself
// would still see pre-restore values and clobber the remembered state
// (self-corrected next flush, but a page ending in between would freeze
// the inverted value). Skip exactly that one run. If the restore changed
// any state, the changed deps re-run this effect and save then; if it
// changed nothing, the cookie already holds exactly those values.
if (skipNextHtmlChromeSaveRef.current) {
skipNextHtmlChromeSaveRef.current = false;
return;
}
saveHtmlChromeState({ sidebarOpen: sidebar.isOpen, panelOpen: isPanelOpen, toolsHidden: htmlToolsHidden });
}, [isHtmlSurface, sidebar.isOpen, isPanelOpen, htmlToolsHidden]);
const ensureShareLink = useCallback(async (): Promise<string | null> => {
const existing = shortShareUrl || shareUrl;
if (existing) return existing;
if (!canShareCurrentSession) return null;
return await generateShortUrl();
}, [canShareCurrentSession, generateShortUrl, shareUrl, shortShareUrl]);
// useLayoutEffect + synchronous getBoundingClientRect so the initial
// bucket is set before the browser paints. Otherwise narrow viewports
// get a one-frame flash of "Global comment"/"Copy plan" labels before
// the ResizeObserver callback collapses them.
useLayoutEffect(() => {
if (isLoading && !isSharedSession) return;
const el = planAreaRef.current;
if (!el) return;
return observeActionsLabelMode(el, (next) => {
setActionsLabelMode((prev) => (prev === next ? prev : next));
});
}, [isLoading, isSharedSession]);
// The user's current direct-edit text: the open editor buffer, else the
// last commit; null when there is none or it matches the baseline. Never
// the shared `markdown` state, which linked docs, message switching, and
// checkbox toggles legitimately mutate. Feeds both the draft auto-save and
// the Direct Edits feedback section.
const getEditedMarkdown = useCallback((): string | null => {
const activeDocument = editableDocuments.getActiveDocumentLive();
if (activeDocument?.sourceSave?.enabled) {
const live = isEditingMarkdown ? markdownEditorHandleRef.current?.getMarkdown() : null;
return normalizeEditedMarkdown(activeDocument.diskBaseline, live ?? activeDocument.currentText);
}
const base = originalMarkdownRef.current;
if (base === null) return null;
const live = isEditingMarkdown ? markdownEditorHandleRef.current?.getMarkdown() : null;
return normalizeEditedMarkdown(base, live ?? editedMarkdownRef.current);
}, [editableDocuments, isEditingMarkdown]);
const getDraftEditedMarkdown = useCallback((): string | null => {
if (editableDocuments.getActiveDocumentLive()?.sourceSave?.enabled) return null;
return getEditedMarkdown();
}, [editableDocuments, getEditedMarkdown]);
// Auto-save annotation drafts
const { draftBanner, restoreDraft, scheduleDraftSave, scheduleDraftSaveAfterSubmitFailure, getDraftGeneration, dismissDraft } = useAnnotationDraft({
annotations: allAnnotations,
codeAnnotations,
globalAttachments,
getEditedMarkdown: getDraftEditedMarkdown,
getEditedDocuments: editableDocuments.getDraftDocuments,
getSavedFileChanges: editableDocuments.getDraftSavedFileChanges,
isApiMode: isApiMode && !goalSetupMode && !documentReadOnly,
isSharedSession,
// isSubmitting counts: a save firing while approve/deny is in flight can
// land after the server's draft delete and ghost a "Draft Recovered"
// banner into the next session for this plan. Saving resumes if it fails.
submitted: !!submitted || isSubmitting,
});
// Fetch available agents for OpenCode (for validation on approve)
const { agents: availableAgents, validateAgent, getAgentWarning } = useAgents(origin);
// Apply shared annotations to DOM after they're loaded
useEffect(() => {
if (pendingSharedAnnotations && pendingSharedAnnotations.length > 0) {
annotationHistory.clear();
// Small delay to ensure DOM is rendered
const timer = setTimeout(() => {
// Clear existing highlights first (important when loading new share URL)
viewerRef.current?.clearAllHighlights();
viewerRef.current?.applySharedAnnotations(pendingSharedAnnotations.filter(a => !a.diffContext));
clearPendingSharedAnnotations();
// `clearAllHighlights` wiped live external SSE highlights too;
// tell the external-highlight bookkeeper to re-apply them.
resetExternalHighlights();
}, 100);
return () => clearTimeout(timer);
}
}, [annotationHistory, pendingSharedAnnotations, clearPendingSharedAnnotations, resetExternalHighlights]);
// Markdown edit mode: single consolidated gate. The editor only ever opens on
// the main plan/file markdown — never on HTML surfaces, archive/goal-setup
// views, linked docs, messages, folder pickers, diff view, or shared sessions.
const canEditMarkdown =
renderAs !== 'html' &&
// editStats non-null keeps the toggle available after committing an
// emptied document, so the user can re-enter and undo. Source-backed files
// are editable even when they start empty.
(activeEditableDocument?.sourceSave?.enabled || displayedMarkdown !== '' || editStats !== null) &&
!archive.archiveMode &&
!goalSetupMode &&
(!linkedDocHook.isActive || (annotateSource === 'folder' && activeEditableDocument?.sourceSave?.enabled)) &&
!isPlanDiffActive &&
!isSharedSession &&
annotateSource !== 'message' &&
!submitted;
// Swap the document to `next` and re-resolve annotation block anchors against
// the new parse so exported line labels don't point at stale content.
// Annotations whose text no longer exists get blockId '' — exportAnnotations
// omits the line label instead of emitting a wrong one. Returns the remapped
// objects so callers repaint THOSE, not the pre-remap ones (whose stale
// startMeta/endMeta would let fromStore() silently highlight wrong content).
// `list` defaults to current state; draft restore passes the restored set,
// which isn't in state yet when the remap runs.
const applyEditedDocument = useCallback((next: string, list?: Annotation[]): Annotation[] => {
annotationHistory.clear();
const sourceAnnotations = list ?? annotationsRef.current;
// Match the display parse (blocks memo) — the active document's
// frontmatter rule must apply here too or the remapped blockIds drift.
const newBlocks = parseMarkdownToBlocks(next, { frontmatter: parseFrontmatterRef.current });
const remapped = sourceAnnotations.map((a) => {
if (a.diffContext || a.type === AnnotationType.GLOBAL_COMMENT || a.id.startsWith('ann-checkbox-')) return a;
const blk = newBlocks.find((b) => b.content.includes(a.originalText));
if ((blk?.id ?? '') === a.blockId) return a;
// Block moved: also strip startMeta/endMeta — fromStore() anchors by
// positional parent index without validating text. Text-search is safe.
return { ...a, blockId: blk?.id ?? '', startMeta: undefined, endMeta: undefined };
});
setMarkdown(next);
setEditGeneration((g) => g + 1);
annotationsRef.current = remapped;
setAnnotations(remapped);
return remapped;
}, [annotationHistory]);
// The Viewer is remounted after every edit-mode exit (it was unmounted while
// editing), so highlight DOM is rebuilt from scratch. Re-anchor via the same
// text-search restore used by draft/share/linked-doc flows, then report
// annotations whose text vanished. resetExternalHighlights repaints live SSE
// annotation highlights the same way the share-import path does.
const repaintHighlights = useCallback((list: Annotation[]) => {
resetExternalHighlights();
const planAnnotations = list.filter(
(a) => !a.diffContext && a.type !== AnnotationType.GLOBAL_COMMENT && !a.id.startsWith('ann-checkbox-')
);
if (planAnnotations.length === 0) return;
setTimeout(() => {
viewerRef.current?.applySharedAnnotations(planAnnotations);
// web-highlighter restores use data-highlight-id; manual code-block
// wraps use data-bind-id. Either counts as present.
const missing = planAnnotations.filter(
(a) => !document.querySelector(`[data-bind-id="${a.id}"], [data-highlight-id="${a.id}"]`)
);
if (missing.length > 0) {
toast(`${missing.length} annotation${missing.length === 1 ? '' : 's'} no longer match the text`, {
description: 'The highlighted text was edited. They remain listed in the panel.',
duration: 5000,
});
}
}, 120);
}, [resetExternalHighlights]);
// Commits the open editor buffer: updates markdown state, records the edit
// for the Direct Edits diff, re-anchors annotations, repaints highlights.
const commitMarkdownEdits = useCallback(() => {
if (!isEditingMarkdown) return;
const edited = markdownEditorHandleRef.current?.getMarkdown();
setIsEditingMarkdown(false);
setEditorDirty(false);
setEditorDiffersFromBaseline(false);
const base = originalMarkdownRef.current;
if (edited != null) {
if (activeEditableDocument?.sourceSave?.enabled) {
editableDocuments.updateActiveText(edited, { forceNotify: true });
const sourceEdited = normalizeEditedMarkdown(activeEditableDocument.diskBaseline, edited);
editedMarkdownRef.current = null;
setEditStats(sourceEdited !== null ? computeEditStats(activeEditableDocument.diskBaseline, sourceEdited) : null);
if (sourceEdited !== null && window.innerWidth >= 768) {
setRightSidebarTab('annotations');
setIsPanelOpen(true);
}
} else {
const normalizedEdited = normalizeEditedMarkdown(base, edited);
editedMarkdownRef.current = normalizedEdited;
setEditStats(base !== null && normalizedEdited !== null ? computeEditStats(base, normalizedEdited) : null);
// Surface the Direct Edits card so the user sees where their changes went.
if (base !== null && normalizedEdited !== null && window.innerWidth >= 768) {
setRightSidebarTab('annotations');
setIsPanelOpen(true);
}
}
}
const renderedBaseline = activeEditableDocument?.sourceSave?.enabled ? markdown : displayedMarkdown;
const remapped = edited != null && edited !== renderedBaseline ? applyEditedDocument(edited) : annotations;
repaintHighlights(remapped);
scheduleDraftSave();
}, [activeEditableDocument, displayedMarkdown, editableDocuments, isEditingMarkdown, annotations, markdown, applyEditedDocument, repaintHighlights, scheduleDraftSave]);
// Discards direct edits for one document. Source-backed folder edits are
// file-scoped; normal plan-review edits still have a single document.
const handleDiscardEdits = useCallback((sourceKey?: string) => {
const targetKey = sourceKey ?? activeEditableDocument?.key;
const targetIsActive = !!targetKey && editableDocuments.getActiveKey() === targetKey;
const targetRecord = targetKey ? editableDocuments.getDocument(targetKey) : null;
if (sourceKey && !targetRecord?.sourceSave?.enabled) return;
if (targetKey && targetRecord?.sourceSave?.enabled) {
const discarded = editableDocuments.discardDocument(targetKey);
if (!discarded) return;
if (!targetIsActive) {
scheduleDraftSave();
return;
}
setIsEditingMarkdown(false);
setEditorDirty(false);
setEditorDiffersFromBaseline(false);
editedMarkdownRef.current = null;
setEditStats(null);
if (discarded.missingOnDisk) {
if (linkedDocHook.isActive) {
linkedDocHook.back();
fileBrowser.setActiveFile(null);
} else {
const remapped = displayedMarkdown !== ''
? applyEditedDocument('')
: annotations;
repaintHighlights(remapped);
originalMarkdownRef.current = '';
}
scheduleDraftSave();
return;
}
const remapped = displayedMarkdown !== discarded.diskBaseline
? applyEditedDocument(discarded.diskBaseline)
: annotations;
repaintHighlights(remapped);
scheduleDraftSave();
return;
}
const base = originalMarkdownRef.current;
if (base === null) return;
setIsEditingMarkdown(false);
setEditorDirty(false);
setEditorDiffersFromBaseline(false);
editedMarkdownRef.current = null;
setEditStats(null);
const remapped = markdown !== base ? applyEditedDocument(base) : annotations;
repaintHighlights(remapped);
scheduleDraftSave();
}, [activeEditableDocument, editableDocuments, displayedMarkdown, markdown, annotations, applyEditedDocument, repaintHighlights, linkedDocHook, fileBrowser, scheduleDraftSave]);
// Restores a recovered draft: annotations always; direct edits when present
// and the baseline exists. Edits flow through the same helpers
// commitMarkdownEdits uses, with the RESTORED annotations remapped against
// the edited document (they aren't in state yet when the remap runs).
const resolveSavedFileChangeSource = useCallback((
change: SavedFileChangeDraftData,
) => {
return probeSourceSave(change.path);
}, []);
const validateDraftSavedFileChanges = useCallback(async (
changes: SavedFileChangeDraftData[],
): Promise<{ kept: SavedFileChangeDraftData[]; changedOrMissing: SavedFileChangeDraftData[]; unverified: SavedFileChangeDraftData[] }> => {
if (changes.length === 0) return { kept: [], changedOrMissing: [], unverified: [] };
const result = await validateSavedFileChanges(changes, resolveSavedFileChangeSource);
const changedOrMissing = result.dropped
.filter((entry) => entry.reason === 'changed' || entry.reason === 'missing')
.map((entry) => entry.change);
if (changedOrMissing.length > 0) {
toast('Some saved edit context was not restored', {
description: 'Those files changed or disappeared after Plannotator saved them.',
duration: 5000,
});
}
if (result.unverified.length > 0) {
toast('Some saved edit context could not be verified', {
description: 'Plannotator kept it for now and will check again before sending feedback.',
duration: 5000,
});
}
return {
kept: [...result.valid, ...result.unverified],
changedOrMissing,
unverified: result.unverified,
};
}, [resolveSavedFileChangeSource]);
const handleRestoreDraft = React.useCallback(async () => {
annotationHistory.clear();
const {
annotations: restored,
codeAnnotations: restoredCode,
globalAttachments: restoredGlobal,
editedMarkdown,
editedDocuments,
savedFileChanges,
} = restoreDraft();
if (restoredCode.length > 0) setCodeAnnotations(restoredCode);
if (restoredGlobal.length > 0) setGlobalAttachments(restoredGlobal);
const nestedSavedFileChanges = editedDocuments
.map((doc) => doc.savedChange)
.filter((change): change is SavedFileChangeDraftData => !!change);
const savedChangeCandidates = new Map<string, SavedFileChangeDraftData>();
for (const change of [...savedFileChanges, ...nestedSavedFileChanges]) {
savedChangeCandidates.set(change.key, change);
}
const validatedSaved = await validateDraftSavedFileChanges([...savedChangeCandidates.values()]);
const validSavedChangeByKey = new Map(validatedSaved.kept.map((change) => [change.key, change]));
const editedDocumentKeys = new Set(editedDocuments.map((doc) => doc.key));
const cleanSavedFileChanges = validatedSaved.kept.filter((change) => !editedDocumentKeys.has(change.key));
const editedDocumentsForRestore: DraftEditedDocument[] = editedDocuments.map((doc) =>
doc.savedChange
? { ...doc, savedChange: validSavedChangeByKey.get(doc.savedChange.key) }
: doc
);
if (cleanSavedFileChanges.length > 0) {
editableDocuments.restoreSavedFileChanges(cleanSavedFileChanges);
if (window.innerWidth >= 768) {
setRightSidebarTab('annotations');
setIsPanelOpen(true);
}
}
if (editedDocumentsForRestore.length > 0) {
if (isEditingMarkdown) {
toast('Draft file edits were not restored', {
description: 'You already have edits in this session — those take precedence.',
duration: 5000,
});
} else {
const restoredDocumentKeys = editableDocuments.restoreDraftDocuments(editedDocumentsForRestore);
if (restoredDocumentKeys.length < editedDocumentsForRestore.length) {
toast('Some draft file edits were not restored', {
description: 'You already have edits in this session — those take precedence.',
duration: 5000,
});
}
const restoredSingleFileDraft = pickRestoredSingleFileDraftToDisplay(
editedDocumentsForRestore,
restoredDocumentKeys,
editableDocuments.getActiveKey(),
);
if (restoredSingleFileDraft) {
editableDocuments.setActiveKey(restoredSingleFileDraft.key);
const restoredDocument = editableDocuments.getDocument(restoredSingleFileDraft.key);
if (restoredDocument?.sourceSave?.enabled) {
const remapped = applyEditedDocument(restoredDocument.currentText, restored);
repaintHighlights(remapped);
if (restoredDocument.currentText !== restoredDocument.diskBaseline) {
setEditStats(computeEditStats(restoredDocument.diskBaseline, restoredDocument.currentText));
if (window.innerWidth >= 768) {
setRightSidebarTab('annotations');
setIsPanelOpen(true);
}
}
scheduleDraftSave();
return;
}
}
const activeRestoredDocument = editableDocuments.getActiveDocumentLive();
const activeDraft = activeRestoredDocument?.sourceSave?.enabled && restoredDocumentKeys.includes(activeRestoredDocument.key)
? editedDocumentsForRestore.find((doc) => doc.key === activeRestoredDocument.key)
: undefined;
if (activeDraft && activeRestoredDocument) {
const remapped = applyEditedDocument(activeRestoredDocument.currentText, restored);
repaintHighlights(remapped);
if (activeRestoredDocument.currentText !== activeRestoredDocument.diskBaseline) {
setEditStats(computeEditStats(activeRestoredDocument.diskBaseline, activeRestoredDocument.currentText));
if (window.innerWidth >= 768) {
setRightSidebarTab('annotations');
setIsPanelOpen(true);
}
}
scheduleDraftSave();
return;
}
}
}
// CRLF normalize is insurance against a hand-edited draft file — a \r
// here would fabricate a whole-document diff against the LF baseline.
const base = originalMarkdownRef.current;
const edited = editedMarkdown !== null ? editedMarkdown.replace(/\r\n?/g, '\n') : null;
// editStats/isEditingMarkdown guards are defensive: the restore dialog is
// modal on load, so live edits can't exist yet — but if they ever do,
// the user's current work wins over the draft.
if (edited !== null && base !== null && edited !== base && editStats === null && !isEditingMarkdown) {
editedMarkdownRef.current = edited;
setEditorDiffersFromBaseline(false);
setEditStats(computeEditStats(base, edited));
if (window.innerWidth >= 768) {
setRightSidebarTab('annotations');
setIsPanelOpen(true);
}
const remapped = applyEditedDocument(edited, restored);
repaintHighlights(remapped);
scheduleDraftSave();
return;
}
if (edited !== null && (editStats !== null || isEditingMarkdown)) {
// Skipped, not silently dropped: the user started editing before the
// (late) draft banner was answered. Their live work wins.
toast('Draft edits were not restored', {
description: 'You already have edits in this session — those take precedence.',
duration: 5000,
});
}
if (restored.length > 0) {
setAnnotations(restored);
// Apply highlights to DOM after a tick
setTimeout(() => {
viewerRef.current?.applySharedAnnotations(restored.filter(a => !a.diffContext));
}, 100);
}
scheduleDraftSave();
}, [annotationHistory, restoreDraft, validateDraftSavedFileChanges, editStats, isEditingMarkdown, editableDocuments, activeEditableDocument, markdown, applyEditedDocument, repaintHighlights, scheduleDraftSave]);
const handleEditToggle = useCallback(() => {
if (isEditingMarkdown) {
commitMarkdownEdits();
return;
}
// Normalize CRLF before it becomes a baseline (e.g. share-imported content) —
// CM6 emits \n-joined text, and a CRLF baseline would fabricate a full diff.
const normalized = displayedMarkdown.includes('\r') ? displayedMarkdown.replace(/\r\n?/g, '\n') : displayedMarkdown;
if (normalized !== displayedMarkdown) {
if (activeEditableDocument?.sourceSave?.enabled) {
editableDocuments.updateActiveText(normalized, { forceNotify: true });
} else {
setMarkdown(normalized);
}
}
// Safety net for paths that loaded content without setting the baseline.
if (originalMarkdownRef.current === null) originalMarkdownRef.current = normalized;
const base = originalMarkdownRef.current;
editSessionBaseRef.current = normalized;
if (activeEditableDocument?.sourceSave?.enabled) {
editableDocuments.beginEdit(normalized);
}
setEditorDirty(false);
setEditorDiffersFromBaseline(
activeEditableDocument?.sourceSave?.enabled
? normalized !== activeEditableDocument.diskBaseline
: base !== null && normalized !== base
);
setIsEditingMarkdown(true);
}, [activeEditableDocument, displayedMarkdown, editableDocuments, isEditingMarkdown, commitMarkdownEdits]);
// Live dirty tracking for the open editor session. String compare per
// keystroke is fine at plan sizes; setState bails out on unchanged values.
const handleEditorChange = useCallback((md: string) => {
setEditorDirty(md !== editSessionBaseRef.current);
if (activeEditableDocument?.sourceSave?.enabled) {
editableDocuments.updateActiveText(md);
setEditorDiffersFromBaseline(md !== activeEditableDocument.diskBaseline);
} else {
const base = originalMarkdownRef.current;
setEditorDiffersFromBaseline(base !== null && md !== base);
}
// Mid-edit keystrokes persist too — a crash loses at most the debounce
// window. The hook reads the live buffer via getDraftEditedMarkdown.
if (agentTerminalDeliveryRef.current) {
setAgentFeedbackRevision((version) => version + 1);
}
scheduleDraftSave();
}, [activeEditableDocument, editableDocuments, scheduleDraftSave]);
const unsavedEditableDocuments = useMemo(
() => editableDocuments.getUnsavedDocuments(),
[editableDocuments, editableDocuments.version],
);
const savedFileChanges = useMemo(
() => editableDocuments.getSavedFileChanges(),
[editableDocuments, editableDocuments.version],
);
const openSourceDocuments = useMemo(
() => editableDocuments.getSourceDocuments(),
[editableDocuments, editableDocuments.version],
);
const savedFileChangesForValidation = useMemo(() => {
const sourceByKey = new Map(openSourceDocuments.map((doc) => [doc.key, doc.sourceSave]));
return savedFileChanges
.map((change): SavedFileChangeDraftData | null => {
const sourceSave = sourceByKey.get(change.key);
return sourceSave ? { ...change, sourceSave } : null;
})
.filter((change): change is SavedFileChangeDraftData => change !== null);
}, [openSourceDocuments, savedFileChanges]);
const activeSourceSave = activeEditableDocument?.sourceSave?.enabled
? activeEditableDocument.sourceSave
: null;
// Save-button display is driven by the editableDocuments state machine — one
// source of truth for dirty/saving/saved, rather than a parallel flag.
const activeSaveStatus = activeEditableDocument?.saveStatus;
const hasUnsavedDiskChanges =
activeSaveStatus === 'dirty' || activeSaveStatus === 'conflict' || activeSaveStatus === 'error' || activeSaveStatus === 'missing';
// Emphasize the Save control (dot + primary text) whenever there is work to
// persist or a save is in flight — one predicate drives both so they can't diverge.
const emphasizeSave = hasUnsavedDiskChanges || activeSaveStatus === 'saving';
// A rejected save (disk conflict or write error) — surfaced as a destructive
// dot/label so it reads as "save failed, retry" rather than ordinary unsaved.
const saveFailed = activeSaveStatus === 'conflict' || activeSaveStatus === 'error';
const activeSourceBufferDirty =
activeEditableDocument?.sourceSave?.enabled === true &&
activeEditableDocument.currentText !== activeEditableDocument.diskBaseline;
const canOverwriteDiskConflict =
activeEditableDocument?.sourceSave?.enabled === true &&
!!activeEditableDocument.diskConflict &&
activeEditableDocument.currentText !== activeEditableDocument.diskConflict.text;
// Editing exit control: a source-backed session with unsaved edits gets a
// two-step "Cancel" (discard + exit). Plan mode and clean source sessions keep
// the plain "Done" (commit edits + exit), so plan-mode keep behavior is unchanged.
const cancelMode = isEditingMarkdown && !!activeSourceSave && (
activeSourceBufferDirty ||
activeSaveStatus === 'conflict' ||
activeSaveStatus === 'error'
);
const handleEditExitClick = useCallback(() => {
if (!isEditingMarkdown) { handleEditToggle(); return; } // enter edit mode
if (cancelMode) { // discard flow (two-step)
if (confirmCancelEdits) { setConfirmCancelEdits(false); handleDiscardEdits(); }
else setConfirmCancelEdits(true);
return;
}
handleEditToggle(); // commit edits + exit
}, [isEditingMarkdown, cancelMode, confirmCancelEdits, handleEditToggle, handleDiscardEdits]);
// Drop the discard confirmation once it no longer applies — exited the editor,
// or the doc went clean (e.g. the user saved).
useEffect(() => {
if (!cancelMode && confirmCancelEdits) setConfirmCancelEdits(false);
}, [cancelMode, confirmCancelEdits]);
// Each file owns its edit state: switching the active file (folder mode keeps
// the editor open across files) starts the discard confirmation fresh, so an
// armed "Discard?" on one file can never drop another file's edits on first click.
useEffect(() => {
setConfirmCancelEdits(false);
}, [activeEditableDocument?.key]);
const hasUnsavedSourceFileBuffers = unsavedEditableDocuments.length > 0;
// True when the feedback payload carries unsaved direct edits. Source-backed
// file buffers are ordinary dirty editor state; they only become review
// context once saved to disk and tracked through savedFileChanges.
const hasDirectEdits =
!activeSourceSave &&
!hasUnsavedSourceFileBuffers &&
(isEditingMarkdown ? editorDiffersFromBaseline : editedMarkdownRef.current !== null);
const hasSavedFileChanges = savedFileChanges.length > 0;
const hasFeedbackContent = hasAnyAnnotations || hasDirectEdits || hasSavedFileChanges;
const feedbackLoss = feedbackLossDescription(feedbackAnnotationCount, hasDirectEdits);
const hasUnsentFeedback = feedbackAnnotationCount > 0 || hasDirectEdits;
const hasOnlySavedFileChanges = hasSavedFileChanges && !hasUnsentFeedback;
const savedFileChangesLabel = savedFileChanges.length === 1 ? 'saved file change' : 'saved file changes';
const savedFileChangesVerb = savedFileChanges.length === 1 ? 'is' : 'are';
const savedFileChangesPronoun = savedFileChanges.length === 1 ? 'it' : 'them';
const savedFileChangesOnDiskMessage = <>Your {savedFileChangesLabel} {savedFileChangesVerb} already on disk.</>;
const savedFileAwarenessOnlyMessage = <>{savedFileChangesOnDiskMessage} The agent won't be told about {savedFileChangesPronoun}.</>;
const savedFileAwarenessMixedMessage = hasSavedFileChanges
? <> Your {savedFileChangesLabel} will stay on disk, but the agent won't be told about {savedFileChangesPronoun}.</>
: null;
// Pinned "Direct edits" card data for the annotation sidebar. Source-backed
// documents show saved-to-disk changes only; dirty buffers stay in the editor
// and file tree until the user explicitly saves.
const directEditsPanelInfo = useMemo(() => {
if (savedFileChanges.length > 0) {
return buildSavedFileChangePanelItems(savedFileChanges);
}
if (activeEditableDocument?.sourceSave?.enabled) return null;
if (!editStats) return null;
const base = originalMarkdownRef.current;
const edited = editedMarkdownRef.current;
if (base === null || edited === null) return null;
return [buildPlanEditPanelItem(base, edited)];
}, [activeEditableDocument, editStats, savedFileChanges]);
// "Direct Edits" feedback section: unified diff of user edits vs the
// as-submitted baseline. getEditedMarkdown owns the read discipline.
const buildEditsSection = useCallback((): string => {
if (activeSourceSave || hasUnsavedSourceFileBuffers) return '';
const base = originalMarkdownRef.current;
return buildDirectEditsSection(base, getEditedMarkdown(), sourceConverted);
}, [activeSourceSave, getEditedMarkdown, hasUnsavedSourceFileBuffers, sourceConverted]);
const buildSavedChangesSection = useCallback((changes = savedFileChanges): string => {
return buildSavedFileChangesSection(
changes.map((change) => ({
path: change.path,
basename: change.basename,
beforeText: change.beforeText,
afterText: change.afterText,
})),
);
}, [savedFileChanges]);
const getCurrentFeedbackPayload = useCallback((
checkedSavedFileChanges = savedFileChanges,
options?: {
/** Discard flow: every annotation source is dropped, so the builder
* emits the legacy zero payload (plus any direct-edit sections). */
discardAnnotations?: boolean;
/** Positive-finish framing for the non-gated note (spec §3.1). */
approvalFraming?: boolean;
},
): string => {
const discard = options?.discardAnnotations === true;
const linkedDocuments = linkedDocHook.getDocAnnotations();
const activeConverted = linkedDocHook.isActive
? (linkedDocuments.get(linkedDocHook.filepath ?? '')?.isConverted ?? false)
: sourceConverted;
return buildCompleteAnnotateFeedback({
blocks,
annotations: discard ? [] : allAnnotations,
globalAttachments: discard ? [] : globalAttachments,
linkedDocuments: discard ? new Map() : linkedDocuments,
editorAnnotations: discard ? [] : editorAnnotations,
codeAnnotations: discard ? [] : codeAnnotations,
title: annotateSource === 'message'
? 'Message Feedback'
: annotateSource === 'folder'
? 'Folder Feedback'
: annotateSource === 'file'
? 'File Feedback'
: 'Plan Feedback',
subject: annotateSource ?? 'plan',
sourceConverted: activeConverted,
directEditsSection: buildEditsSection(),
savedFileChangesSection: buildSavedChangesSection(checkedSavedFileChanges),
...(messageMultiSelectMode && !discard
? { messageEntries: buildMessageAnnotationEntries() }
: {}),
...(options?.approvalFraming ? { approvalFraming: true } : {}),
});
}, [
allAnnotations,
annotateSource,
blocks,
buildEditsSection,
buildMessageAnnotationEntries,
buildSavedChangesSection,
codeAnnotations,
editorAnnotations,
globalAttachments,
linkedDocHook.filepath,
linkedDocHook.getDocAnnotations,
linkedDocHook.isActive,
messageMultiSelectMode,
savedFileChanges,
sourceConverted,
]);
const withDraftGeneration = useCallback((path: string): string => {
const separator = path.includes('?') ? '&' : '?';
return `${path}${separator}draftGeneration=${getDraftGeneration()}`;
}, [getDraftGeneration]);
const validateSavedFileChangesBeforeSubmit = useCallback(async (): Promise<SavedFileChangeDraftData[] | null> => {
if (savedFileChangesForValidation.length === 0) return [];
const result = await validateSavedFileChanges(savedFileChangesForValidation, resolveSavedFileChangeSource);
const stale = result.dropped.filter((entry) => entry.reason === 'changed' || entry.reason === 'missing');
if (stale.length > 0) {
editableDocuments.clearSavedFileChanges(stale.map((entry) => entry.change.key));
scheduleDraftSave();
toast.error('Saved edits changed on disk', {
description: 'Plannotator removed the stale edit context. Nothing was sent.',
});
return null;
}
if (result.unverified.length > 0) {
toast.error('Saved edits could not be verified', {
description: 'Check the file tree and try sending feedback again.',
});
return null;
}
return result.valid;
}, [editableDocuments, resolveSavedFileChangeSource, savedFileChangesForValidation, scheduleDraftSave]);
const sourceReconcileSeqRef = useRef<Map<string, number>>(new Map());
const reconcileOpenSourceDocuments = useCallback(async (changedDir?: string) => {
const activeKey = editableDocuments.getActiveKey();
if (isEditingMarkdownRef.current && activeKey) {
const live = markdownEditorHandleRef.current?.getMarkdown();
if (live != null) editableDocuments.updateActiveText(live, { forceNotify: true });
}
const handleReconcileEvent = (event: SourceDocumentReconcileEvent) => {
const { result } = event;
if (event.type === 'file-missing') {
if (!result.alreadyMissing && result.record.key === editableDocuments.getActiveKey()) {
setEditorDiffersFromBaseline(result.record.currentText !== result.record.diskBaseline);
if (isEditingMarkdownRef.current) {
setEditorDirty(result.record.currentText !== editSessionBaseRef.current);
setEditStats(
result.record.currentText !== result.record.diskBaseline
? computeEditStats(result.record.diskBaseline, result.record.currentText)
: null,
);
}
toast('File no longer exists on disk', {
description: `Save ${result.record.basename} to recreate it.`,
duration: 5000,
});
}
return;
}
if (event.type === 'clean-updated') {
if (result.record.key === editableDocuments.getActiveKey()) {
const remapped = applyEditedDocument(result.record.currentText);
repaintHighlights(remapped);
editSessionBaseRef.current = result.record.currentText;
setEditorDirty(false);
setEditorDiffersFromBaseline(false);
setEditStats(null);
}
if (result.clearedSavedChange) {
toast('File updated from disk', {
description: `${result.record.basename} changed outside Plannotator, so its old Edits card was cleared.`,
});
}
} else if (event.type === 'conflict') {
if (result.record.key === editableDocuments.getActiveKey()) {
setEditorDirty(true);
setEditorDiffersFromBaseline(true);
setEditStats(computeEditStats(result.record.diskBaseline, result.record.currentText));
toast.error('File changed on disk', {
description: 'Choose whether to overwrite disk or reload the file.',
});
}
}
};
const changed = await reconcileSourceDocuments({
changedDir,
documents: editableDocuments.getSourceDocuments(),
sequenceByKey: sourceReconcileSeqRef.current,
getDocument: editableDocuments.getDocument,
fetchSnapshot: fetchSourceDocumentSnapshot,
markFileMissing: editableDocuments.markFileMissing,
reconcileDiskSnapshot: editableDocuments.reconcileDiskSnapshot,
onEvent: handleReconcileEvent,
});
if (changed) scheduleDraftSave();
}, [applyEditedDocument, editableDocuments, repaintHighlights, scheduleDraftSave]);
const reconcileOpenSourceDocumentsRef = useRef(reconcileOpenSourceDocuments);
useEffect(() => {
reconcileOpenSourceDocumentsRef.current = reconcileOpenSourceDocuments;
}, [reconcileOpenSourceDocuments]);
const sourceWatchSubscription = useMemo(
() => buildSourceWatchSubscription(openSourceDocuments.map((doc) => doc.sourceSave.path)),
[openSourceDocuments],
);
useEffect(() => {
if (!sourceWatchSubscription.key || typeof EventSource === 'undefined') return;
const dirs = sourceWatchSubscription.dirs;
const timers = new Map<string, ReturnType<typeof setTimeout>>();
const source = new EventSource(`/api/reference/files/stream?${sourceWatchSubscription.query}`);
const schedule = (dir?: string) => {
const key = dir ?? '*';
const existing = timers.get(key);
if (existing) clearTimeout(existing);
timers.set(key, setTimeout(() => {
timers.delete(key);
void reconcileOpenSourceDocumentsRef.current(dir);
}, 120));
};
source.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as { type?: string; dirPath?: string };
const dir = typeof data.dirPath === 'string' && dirs.includes(data.dirPath) ? data.dirPath : undefined;
if (data.type === 'ready') {
schedule(dir);
return;
}
if (data.type !== 'changed') return;
schedule(dir);
} catch {
return;
}
};
return () => {
for (const timer of timers.values()) clearTimeout(timer);
source.close();
};
}, [sourceWatchSubscription.key]);
const handleTaterModeChange = useCallback((enabled: boolean) => {
setTaterMode(enabled);
storage.setItem('plannotator-tater-mode', String(enabled));
}, []);
const handleEditorModeChange = (mode: EditorMode) => {
setEditorMode(mode);
saveEditorMode(mode);
};
const handleInputMethodChange = (method: InputMethod) => {
// HTML and live-app surfaces pin the viewer to pinpoint (drag-selection
// commenting is simultaneously live there, so there is nothing to
// switch): the toolstrip is not rendered and the Alt shortcut must not
// flip state the surface ignores or write the html cookie.
if (liveApp || isHtmlSurface) return;
if (isCompactTouchLayout) {
setCompactInputMethod(method);
return;
}
setInputMethod(method);
// Surface-scoped persistence: an explicit choice made on the HTML surface
// sticks for HTML sessions only; markdown keeps its own preference.
saveInputMethod(method, isHtmlSurface ? 'html' : 'markdown');
};
// Raw-HTML surfaces resolve their own input-method preference (default:
// Pinpoint — see utils/inputMethod.ts for the persistence decision). Applied
// whenever the surface flips (session load or linked-doc navigation), so a
// markdown-era "drag" cookie never suppresses the HTML default.
const prevSurfaceRef = useRef(isHtmlSurface);
useEffect(() => {
if (prevSurfaceRef.current === isHtmlSurface) return;
prevSurfaceRef.current = isHtmlSurface;
const method = getInputMethod(isHtmlSurface ? 'html' : 'markdown');
setInputMethod(method);
setCompactInputMethod(method);
}, [isHtmlSurface]);
// Alt/Option key: hold to temporarily switch, double-tap to toggle
useInputMethodSwitch(effectiveInputMethod, handleInputMethodChange);
// Gates both the toolstrip's own render and its shortcuts, so a mode can never
// change with no visible pill to report it. HTML/live surfaces have no
// toolstrip at all: they are comment-only with pinpoint + drag both live,
// so there is no input method or annotation mode left to switch.
const toolstripVisible = useMemo(
() =>
!goalSetupMode && !isPlanDiffActive && !archive.archiveMode && !isEditingMarkdown && !isHtmlSurface
&& (!isCompactTouchLayout || !(annotateSource === 'folder' && !markdown && !linkedDocHook.isActive)),
[
annotateSource,
archive.archiveMode,
goalSetupMode,
isHtmlSurface,
isCompactTouchLayout,
isEditingMarkdown,
isPlanDiffActive,
linkedDocHook.isActive,
markdown,
],
);
const canHandleAnnotationModeShortcut = useCallback(
(event: KeyboardEvent) => toolstripVisible && canHandleDocumentChromeShortcut(event),
[canHandleDocumentChromeShortcut, toolstripVisible],
);
// Interact/Annotate toggle (Mod+Shift+A) — HTML and live-app surfaces only.
// The bridge mirrors the same chord inside the iframe and forwards it, so
// this parent-side registration covers focus living in the editor chrome.
useHtmlAnnotateShortcuts({
handlers: {
toggleAnnotateMode: {
when: (event) => isHtmlSurface && !documentReadOnly && canHandleDocumentChromeShortcut(event),
handle: handleHtmlAnnotateToggle,
},
},
});
useAnnotationModeShortcuts({
handlers: {
selectMarkupMode: { when: canHandleAnnotationModeShortcut, handle: () => handleEditorModeChange('selection') },
selectCommentMode: { when: canHandleAnnotationModeShortcut, handle: () => handleEditorModeChange('comment') },
selectRedlineMode: { when: canHandleAnnotationModeShortcut, handle: () => handleEditorModeChange('redline') },
selectQuickLabelMode: { when: canHandleAnnotationModeShortcut, handle: () => handleEditorModeChange('quickLabel') },
},
});
// Check if we're in API mode (served from Bun hook server)
// Skip if we loaded from a shared URL
useEffect(() => {
if (isLoadingShared) return; // Wait for share check to complete
if (isSharedSession) return; // Already loaded from share
fetch('/api/plan')
.then(res => {
if (!res.ok) throw new Error('Not in API mode');
return res.json();
})
.then((data: { plan: string; origin?: Origin; mode?: 'annotate' | 'annotate-last' | 'annotate-folder' | 'annotate-app' | 'archive' | 'goal-setup'; goalSetup?: GoalSetupBundle; filePath?: string; appUrl?: string; targetUrl?: string; liveToken?: string; sourceInfo?: string; sourceConverted?: boolean; sourceSave?: SourceSaveCapability; gate?: boolean; approvalNotesSupported?: boolean; clientLease?: AnnotateClientLeaseConfig; renderAs?: 'html' | 'markdown'; rawHtml?: string; shareHtml?: string; diffHtml?: string; convertHtml?: boolean; sharingEnabled?: boolean; shareBaseUrl?: string; pasteApiUrl?: string; repoInfo?: { display: string; branch?: string; host?: string }; previousPlan?: string | null; versionInfo?: { version: number; totalVersions: number; project: string }; archivePlans?: ArchivedPlan[]; projectRoot?: string; isWSL?: boolean; markdownExtensions?: string[]; serverConfig?: { displayName?: string; gitUser?: string }; recentMessages?: PickerMessage[]; agentTerminal?: AgentTerminalCapability; feedbackTemplates?: AnnotateFeedbackTemplates }) => {
// Initialize config store with server-provided values (config file > cookie > default)
configStore.init(data.serverConfig);
// Extra extensions the user registered as markdown (#1307) — the
// renderer needs them to treat links to sibling `.livemd`-style docs
// as openable local documents rather than external links.
setExtraMarkdownExtensions(data.markdownExtensions);
// Session-level force-markdown preference (--markdown); threaded into folder/linked
// /api/doc requests so on-demand HTML files convert too.
setConvertHtml(data.convertHtml ?? false);
setAISessionEnabled(data.mode !== 'archive' && data.mode !== 'goal-setup');
// gitUser drives the "Use git name" button in Settings; stays undefined (button hidden) when unavailable
setGitUser(data.serverConfig?.gitUser);
if (data.mode === 'goal-setup' && data.goalSetup) {
setGoalSetupBundle(data.goalSetup);
setMarkdown('');
setSharingEnabled(false);
} else if (data.mode === 'archive') {
// Archive mode: show first archived plan or clear demo content
setMarkdown(data.plan || '');
if (data.archivePlans) archive.init(data.archivePlans);
archive.fetchPlans();
setSharingEnabled(false);
sidebar.open('archive');
} else if (data.mode === 'annotate-app' && data.appUrl && data.liveToken) {
// Live app annotation: full-viewport live surface on the loopback
// proxy origin. No rawHtml, no version fields, no sharing.
setRenderAs('html');
setMarkdown('');
// Live sessions open ARMED like every HTML surface (htmlAnnotateArmed
// defaults true): pinpoint is the default, Esc drops to Interact.
setLiveApp({
appUrl: data.appUrl,
origin: new URL(data.appUrl).origin,
token: data.liveToken,
});
} else if (data.renderAs === 'html' && data.rawHtml) {
setRenderAs('html');
setRawHtml(data.rawHtml);
setShareHtml(data.shareHtml ?? '');
setHtmlDiffHtml(data.diffHtml ?? null);
setMarkdown('');
} else if (data.mode === 'annotate-folder') {
// Folder annotation mode: clear demo content, let user pick a file
setMarkdown('');
} else if (typeof data.plan === 'string') {
// CM6 joins lines with \n; CRLF input would make an untouched
// edit round-trip fabricate a whole-document diff. Normalize once.
const normalizedPlan = data.plan.replace(/\r\n?/g, '\n');
setMarkdown(normalizedPlan);
originalMarkdownRef.current = normalizedPlan;
if (data.mode === 'annotate' && data.sourceSave?.enabled) {
const key = editableDocumentKey(data.sourceSave, `file:${data.sourceSave.path}`);
editableDocuments.openDocument({ key, text: normalizedPlan, sourceSave: data.sourceSave });
}
}
setIsApiMode(true);
if (data.mode === 'annotate' || data.mode === 'annotate-last' || data.mode === 'annotate-folder' || data.mode === 'annotate-app') {
setAnnotateMode(true);
setGate(data.gate ?? false);
setApprovalNotesSupported(data.approvalNotesSupported ?? false);
setClientLease(data.clientLease ?? null);
}
if (data.mode === 'annotate-folder') {
sidebar.open('files');
}
if (data.mode === 'annotate' || data.mode === 'annotate-last' || data.mode === 'annotate-folder' || data.mode === 'annotate-app') {
setAnnotateSource(data.mode === 'annotate-last' ? 'message' : data.mode === 'annotate-folder' ? 'folder' : 'file');
}
if (data.mode === 'annotate-last' && data.recentMessages && data.recentMessages.length > 0) {
messageStateCacheRef.current = new Map();
setCachedMessageAnnotationCounts(new Map());
setRecentMessages(data.recentMessages);
setSelectedMessageId(data.recentMessages[0].messageId);
} else {
messageStateCacheRef.current = new Map();
setCachedMessageAnnotationCounts(new Map());
setRecentMessages([]);
setSelectedMessageId(null);
}
setSourceInfo(data.sourceInfo ?? undefined);
setFeedbackTemplates(data.feedbackTemplates ?? null);
setSourceConverted(!!data.sourceConverted);
if (data.filePath) {
setImageBaseDir(data.mode === 'annotate-folder' ? data.filePath : data.filePath.replace(/\/[^/]+$/, ''));
if (data.mode === 'annotate') {
setSourceFilePath(data.filePath);
}
}
if (data.sharingEnabled !== undefined) {
setSharingEnabled(data.sharingEnabled);
}
if (data.shareBaseUrl) {
setShareBaseUrl(data.shareBaseUrl);
}
if (data.pasteApiUrl) {
setPasteApiUrl(data.pasteApiUrl);
}
if (data.repoInfo) {
setRepoInfo(data.repoInfo);
}
if (data.projectRoot) {
setProjectRoot(data.projectRoot);
}
setAgentTerminalCapability(data.agentTerminal ?? null);
// Capture plan version history data
if (data.previousPlan !== undefined) {
setPreviousPlan(data.previousPlan);
}
if (data.versionInfo) {
setVersionInfo(data.versionInfo);
}
if (data.origin) {
setOrigin(data.origin);
// For Claude Code, check if user needs to configure permission mode.
// Plan review only: the setting decides what happens after a plan is
// APPROVED, which is meaningless in annotate / annotate-last /
// annotate-folder / archive / goal-setup sessions. Plan review is the
// absence of a mode field (the plan server sends `mode` only for
// archive; annotate and goal-setup always name themselves).
if (data.origin === 'claude-code' && data.mode === undefined && needsPermissionModeSetup()) {
setShowPermissionModeSetup(true);
}
// Load saved permission mode preference
setPermissionMode(getPermissionModeSettings().mode);
}
if (data.isWSL) {
setIsWSL(true);
}
})
.catch(() => {
// Not in API mode - use default content
setIsApiMode(false);
setAISessionEnabled(false);
setAgentTerminalCapability(null);
// Demo mode still exercises edit mode; baseline is the demo plan.
originalMarkdownRef.current = DEMO_PLAN_CONTENT;
})
.finally(() => setIsLoading(false));
}, [isLoadingShared, isSharedSession]);
// Client-lease: while a local direct structured annotate gate is open, keep
// exactly one EventSource open so the server can detect this tab going away
// and auto-dismiss the gate after its grace period instead of hanging the
// CLI/hook caller forever. Grace only starts once the transport reports a
// disconnect; abrupt/half-open connection loss is best-effort and not
// bounded by the grace period. Only ever a presence signal — no message
// payload is read from the stream.
useEffect(() => {
if (typeof EventSource === 'undefined') return;
if (!shouldConnectAnnotateClientLease({ annotateMode, isSharedSession, submitted, clientLease })) return;
const stream = openAnnotateClientLeaseStream(EventSource);
return () => stream.close();
}, [annotateMode, isSharedSession, submitted, clientLease]);
useEffect(() => {
if (!aiSessionEnabled || !isApiMode || isSharedSession) {
setAiAvailable(false);
setAiProviders([]);
return;
}
let cancelled = false;
fetch('/api/ai/capabilities')
.then(res => res.ok ? res.json() : null)
.then(data => {
if (cancelled) return;
if (data?.available) {
const providers = data.providers ?? [];
setAiAvailable(true);
setAiProviders(providers);
// Provider/model is resolved by useAIProviderConfig's effect once these
// states land — just record the server default for it to use.
setAiDefaultProvider(data.defaultProvider ?? null);
} else {
setAiAvailable(false);
setAiProviders([]);
}
})
.catch(() => {
if (!cancelled) {
setAiAvailable(false);
setAiProviders([]);
}
});
return () => { cancelled = true; };
}, [aiSessionEnabled, isApiMode, isSharedSession, origin]);
// Auto-save to notes apps on plan arrival (each gated by its autoSave toggle)
const autoSaveAttempted = useRef(false);
const autoSaveResultsRef = useRef<NoteAutoSaveResults>({});
const autoSavePromiseRef = useRef<Promise<NoteAutoSaveResults> | null>(null);
useEffect(() => {
autoSaveAttempted.current = false;
autoSaveResultsRef.current = {};
autoSavePromiseRef.current = null;
// eslint-disable-next-line react-hooks/exhaustive-deps -- runs once on mount;
// markdown changes from edit commits, linked docs, or discard must NOT reset
// the arrival auto-save (Bear creates a new note each time).
}, []);
useEffect(() => {
if (!isApiMode || !markdown || isSharedSession || annotateMode || archive.archiveMode) return;
if (autoSaveAttempted.current) return;
const body: { obsidian?: object; bear?: object; octarine?: object } = {};
const targets: string[] = [];
const obsSettings = getObsidianSettings();
if (obsSettings.autoSave && obsSettings.enabled) {
const vaultPath = getEffectiveVaultPath(obsSettings);
if (vaultPath) {
body.obsidian = {
vaultPath,
folder: obsSettings.folder || 'plannotator',
plan: markdown,
...(obsSettings.filenameFormat && { filenameFormat: obsSettings.filenameFormat }),
...(obsSettings.filenameSeparator && obsSettings.filenameSeparator !== 'space' && { filenameSeparator: obsSettings.filenameSeparator }),
};
targets.push('Obsidian');
}
}
const bearSettings = getBearSettings();
if (bearSettings.autoSave && bearSettings.enabled) {
body.bear = {
plan: markdown,
customTags: bearSettings.customTags,
tagPosition: bearSettings.tagPosition,
};
targets.push('Bear');
}
const octSettings = getOctarineSettings();
if (octSettings.autoSave && isOctarineConfigured()) {
body.octarine = {
plan: markdown,
workspace: octSettings.workspace,
folder: octSettings.folder || 'plannotator',
};
targets.push('Octarine');
}
if (targets.length === 0) return;
autoSaveAttempted.current = true;
const autoSavePromise = fetch('/api/save-notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
.then(res => res.json())
.then(data => {
const results: NoteAutoSaveResults = {
...(body.obsidian ? { obsidian: Boolean(data.results?.obsidian?.success) } : {}),
...(body.bear ? { bear: Boolean(data.results?.bear?.success) } : {}),
...(body.octarine ? { octarine: Boolean(data.results?.octarine?.success) } : {}),
};
autoSaveResultsRef.current = results;
const failed = targets.filter(t => !data.results?.[t.toLowerCase()]?.success);
if (failed.length === 0) {
toast.success(`Auto-saved to ${targets.join(' & ')}`);
} else {
toast.error(`Auto-save failed for ${failed.join(' & ')}`);
}
return results;
})
.catch(() => {
autoSaveResultsRef.current = {};
toast.error('Auto-save failed');
return {};
});
autoSavePromiseRef.current = autoSavePromise;
}, [isApiMode, markdown, isSharedSession, annotateMode]);
// Global paste listener for image attachments
useEffect(() => {
if (documentReadOnly) return;
const handlePaste = (e: ClipboardEvent) => {
const items = e.clipboardData?.items;
if (!items) return;
for (const item of items) {
if (item.type.startsWith('image/')) {
e.preventDefault();
const file = item.getAsFile();
if (file) {
// Derive name before showing annotator so user sees it immediately
const initialName = deriveImageName(file.name, globalAttachments.map(g => g.name));
const blobUrl = URL.createObjectURL(file);
setPendingPasteImage({ file, blobUrl, initialName });
}
break;
}
}
};
document.addEventListener('paste', handlePaste);
return () => document.removeEventListener('paste', handlePaste);
}, [documentReadOnly, globalAttachments]);
// Handle paste annotator accept — name comes from ImageAnnotator
const handlePasteAnnotatorAccept = async (blob: Blob, hasDrawings: boolean, name: string) => {
if (documentReadOnly || !pendingPasteImage) return;
try {
const formData = new FormData();
const fileToUpload = hasDrawings
? new File([blob], 'annotated.png', { type: 'image/png' })
: pendingPasteImage.file;
formData.append('file', fileToUpload);
const res = await fetch('/api/upload', { method: 'POST', body: formData });
if (res.ok) {
const data = await res.json();
setGlobalAttachments(prev => [...prev, { path: data.path, name }]);
}
} catch {
// Upload failed silently
} finally {
URL.revokeObjectURL(pendingPasteImage.blobUrl);
setPendingPasteImage(null);
}
};
const handlePasteAnnotatorClose = () => {
if (pendingPasteImage) {
URL.revokeObjectURL(pendingPasteImage.blobUrl);
setPendingPasteImage(null);
}
};
const sendToAgentTerminal = useCallback((message: string) => {
const sent = agentTerminalRef.current?.sendMessage(message) ?? false;
if (!sent) return false;
openAgentTerminal();
return true;
}, [openAgentTerminal]);
const getAnnotateFeedbackTarget = useCallback((): AnnotateFeedbackTarget => {
if (linkedDocHook.isActive && linkedDocHook.filepath) {
return { fileHeader: 'File', filePath: linkedDocHook.filepath };
}
if (sourceFilePath) {
return { fileHeader: 'File', filePath: sourceFilePath };
}
if (fileBrowser.activeFile) {
return { fileHeader: 'File', filePath: fileBrowser.activeFile };
}
if (annotateSource === 'folder') {
return { fileHeader: 'Folder', filePath: fileBrowser.activeDirPath ?? projectRoot ?? 'selected folder' };
}
return { fileHeader: 'File', filePath: 'current file' };
}, [
annotateSource,
fileBrowser.activeDirPath,
fileBrowser.activeFile,
linkedDocHook.filepath,
linkedDocHook.isActive,
projectRoot,
sourceFilePath,
]);
const buildAnnotateAgentFeedback = useCallback((feedback: string) => {
if (annotateSource === 'message') {
return annotateMessageFeedback(feedback);
}
return annotateFileFeedback(feedback, getAnnotateFeedbackTarget());
}, [annotateSource, getAnnotateFeedbackTarget]);
// Clipboard copy wrapper (#1107): plan review keeps the deliberately forceful
// plan-deny framing; annotate sessions wrap with the server-resolved template
// (the same one Send Feedback gets, including custom prompts.annotate.*
// config), falling back to the built-in annotate defaults when the server
// didn't ship one. Shared/static and archive sessions never set annotateMode
// and keep today's behavior.
const wrapCopiedFeedback = useCallback((feedback: string) => {
if (annotateMode) {
if (annotateSource === 'message') {
return wrapFeedbackForClipboard(feedback, {
mode: 'annotate-message',
template: feedbackTemplates?.messageFeedback,
});
}
const target = getAnnotateFeedbackTarget();
return wrapFeedbackForClipboard(feedback, {
mode: 'annotate-file',
template: feedbackTemplates?.fileFeedback,
filePath: target.filePath,
fileHeader: target.fileHeader,
});
}
return wrapFeedbackForAgent(feedback);
}, [annotateMode, annotateSource, feedbackTemplates, getAnnotateFeedbackTarget]);
const currentFeedbackPayload = useMemo(() => getCurrentFeedbackPayload(), [
agentFeedbackRevision,
editableDocuments.version,
editorDiffersFromBaseline,
getCurrentFeedbackPayload,
savedFileChanges,
]);
const currentAgentFeedbackTarget = useMemo(
() => getAnnotateFeedbackTarget(),
[getAnnotateFeedbackTarget],
);
const currentAgentFeedbackDelivery = useMemo(() => {
if (agentTerminalSessionId === null) return null;
return buildAgentTerminalDeliveryRecord({
terminalSessionId: agentTerminalSessionId,
feedback: currentFeedbackPayload,
targetPath: annotateSource === 'message' ? null : currentAgentFeedbackTarget.filePath,
});
}, [
agentTerminalSessionId,
annotateSource,
currentFeedbackPayload,
currentAgentFeedbackTarget.filePath,
]);
const isCurrentFeedbackDeliveredToAgent = isMatchingAgentTerminalDelivery(
agentTerminalDelivery,
currentAgentFeedbackDelivery,
);
const showAgentTerminalDeliveryStatus =
annotateMode &&
agentTerminalDelivery !== null &&
isCurrentFeedbackDeliveredToAgent;
const hasFeedbackToSend =
hasFeedbackContent &&
!isCurrentFeedbackDeliveredToAgent;
// API mode handlers
const handleApprove = async () => {
setIsSubmitting(true);
try {
// Integrations must describe the same document the feedback diff does —
// mid-edit submits read the live editor buffer, not stale markdown state.
const currentMarkdown = isEditingMarkdown
? markdownEditorHandleRef.current?.getMarkdown() ?? displayedMarkdown
: displayedMarkdown;
const obsidianSettings = getObsidianSettings();
const bearSettings = getBearSettings();
const octarineSettings = getOctarineSettings();
const planSaveSettings = getPlanSaveSettings();
const autoSaveResults = bearSettings.autoSave && autoSavePromiseRef.current
? await autoSavePromiseRef.current
: autoSaveResultsRef.current;
// Build request body - include integrations if enabled
const body: { draftGeneration: number; obsidian?: object; bear?: object; octarine?: object; feedback?: string; agentSwitch?: string; planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string } = {
draftGeneration: getDraftGeneration(),
};
// Include permission mode for Claude Code
if (origin === 'claude-code') {
body.permissionMode = permissionMode;
}
const effectiveAgent = getEffectiveAgentName(getAgentSwitchSettings('plan'));
if (effectiveAgent) {
body.agentSwitch = effectiveAgent;
}
// Include plan save settings
body.planSave = {
enabled: planSaveSettings.enabled,
...(planSaveSettings.customPath && { customPath: planSaveSettings.customPath }),
};
const effectiveVaultPath = getEffectiveVaultPath(obsidianSettings);
if (obsidianSettings.enabled && effectiveVaultPath) {
body.obsidian = {
vaultPath: effectiveVaultPath,
folder: obsidianSettings.folder || 'plannotator',
plan: currentMarkdown,
...(obsidianSettings.filenameFormat && { filenameFormat: obsidianSettings.filenameFormat }),
...(obsidianSettings.filenameSeparator && obsidianSettings.filenameSeparator !== 'space' && { filenameSeparator: obsidianSettings.filenameSeparator }),
};
}
// Bear creates a new note each time, so don't send it again on approve
// if the arrival auto-save already succeeded.
if (bearSettings.enabled && !(bearSettings.autoSave && autoSaveResults.bear)) {
body.bear = {
plan: currentMarkdown,
customTags: bearSettings.customTags,
tagPosition: bearSettings.tagPosition,
};
}
if (isOctarineConfigured()) {
body.octarine = {
plan: currentMarkdown,
workspace: octarineSettings.workspace,
folder: octarineSettings.folder || 'plannotator',
};
}
// Include annotations as feedback if any exist (for OpenCode "approve with notes").
// Direct edits count as feedback too — without the editsSection check here,
// an edit-only approval would silently drop the user's changes.
const hasDocAnnotations = Array.from(linkedDocHook.getDocAnnotations().values()).some(
(d) => d.annotations.length > 0 || d.globalAttachments.length > 0
);
const checkedSavedFileChanges = await validateSavedFileChangesBeforeSubmit();
if (checkedSavedFileChanges === null) {
setIsSubmitting(false);
return;
}
const editsSection = buildEditsSection();
const savedChangesSection = buildSavedChangesSection(checkedSavedFileChanges);
if (allAnnotations.length > 0 || codeAnnotations.length > 0 || globalAttachments.length > 0 || hasDocAnnotations || editorAnnotations.length > 0 || editsSection || savedChangesSection) {
body.feedback = getCurrentFeedbackPayload(checkedSavedFileChanges);
}
await fetch('/api/approve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
setSubmitted('approved');
} catch {
setIsSubmitting(false);
}
};
const handleDeny = async () => {
setIsSubmitting(true);
try {
const checkedSavedFileChanges = await validateSavedFileChangesBeforeSubmit();
if (checkedSavedFileChanges === null) {
setIsSubmitting(false);
return;
}
const planSaveSettings = getPlanSaveSettings();
await fetch('/api/deny', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
draftGeneration: getDraftGeneration(),
feedback: getCurrentFeedbackPayload(checkedSavedFileChanges),
planSave: {
enabled: planSaveSettings.enabled,
...(planSaveSettings.customPath && { customPath: planSaveSettings.customPath }),
},
})
});
setSubmitted('denied');
} catch {
setIsSubmitting(false);
}
};
// Annotate mode handler — sends feedback to the running terminal agent when
// available, otherwise through the original server feedback channel.
// Returns whether the submission settled (delivered or posted): the pending
// note-decision machinery (L3) keeps its captured route armed on failure.
// Which message(s) a submission is about. Send Feedback and Approve with
// Notes must resolve this identically — otherwise notes delivered on the
// approve path anchor to the last message instead of the picked one.
const getFeedbackMessageScope = (): {
selectedMessageId?: string;
feedbackScope?: 'messages';
} => {
const scopedSelectedMessageId = messageMultiSelectMode
? annotatedMessageIds.length === 1 ? annotatedMessageIds[0] : undefined
: selectedMessageId ?? undefined;
return {
...(scopedSelectedMessageId ? { selectedMessageId: scopedSelectedMessageId } : {}),
...(messageMultiSelectMode && annotatedMessageIds.length > 1 ? { feedbackScope: 'messages' as const } : {}),
};
};
const handleAnnotateFeedback = async (options?: {
/** Discard-and-finish (post-confirm): annotations dropped, the payload is
* the legacy "reviewed, no feedback" record. */
discardAnnotations?: boolean;
/** "Done with a note…" — approval framing on the one feedback string. */
approvalFraming?: boolean;
}): Promise<boolean> => {
setIsSubmitting(true);
try {
snapshotActiveEditableDocument();
const checkedSavedFileChanges = await validateSavedFileChangesBeforeSubmit();
if (checkedSavedFileChanges === null) {
setIsSubmitting(false);
return false;
}
const discard = options?.discardAnnotations === true;
const feedback = getCurrentFeedbackPayload(checkedSavedFileChanges, options);
const agentFeedbackDelivery = agentTerminalSessionId === null
? null
: buildAgentTerminalDeliveryRecord({
terminalSessionId: agentTerminalSessionId,
feedback,
targetPath: annotateSource === 'message' ? null : getAnnotateFeedbackTarget().filePath,
});
if (isAgentTerminalReady) {
if (!shouldSendAgentTerminalFeedback(agentTerminalDeliveryRef.current, agentFeedbackDelivery)) {
dismissDraft();
setIsSubmitting(false);
return true;
}
const agentFeedback = buildAnnotateAgentFeedback(feedback);
if (agentFeedbackDelivery && sendToAgentTerminal(agentFeedback)) {
setAgentTerminalDelivery(agentFeedbackDelivery);
dismissDraft();
annotationHistory.clear();
setIsSubmitting(false);
return true;
}
handleAgentTerminalReadyChange(false);
toast.error('Agent terminal is not ready. Sending through the original session.');
}
const res = await fetch('/api/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
draftGeneration: getDraftGeneration(),
feedback,
annotations: discard ? [] : allAnnotations,
codeAnnotations: discard ? [] : codeAnnotations,
...getFeedbackMessageScope(),
}),
});
if (!res.ok) throw new Error('Failed to send feedback');
dismissDraft();
setSubmitted('denied'); // reuse 'denied' state for "feedback sent" overlay
return true;
} catch {
setIsSubmitting(false);
scheduleDraftSaveAfterSubmitFailure();
return false;
}
};
// Annotate gate-mode handler — capable transports preserve complete feedback.
const handleAnnotateApprove = async (options?: {
/** "Approve, discard n annotations…" (post-confirm): the whole feedback
* payload is dropped — text AND annotation arrays — so a capable
* transport cannot deliver what the reviewer chose to discard. */
discardAnnotations?: boolean;
}): Promise<boolean> => {
setIsSubmitting(true);
try {
snapshotActiveEditableDocument();
const checkedSavedFileChanges = await validateSavedFileChangesBeforeSubmit();
if (checkedSavedFileChanges === null) {
setIsSubmitting(false);
return false;
}
const discard = options?.discardAnnotations === true;
// hasFeedbackToSend (not hasFeedbackContent) so notes already delivered
// via the agent terminal are not re-sent on approve.
const feedback = !discard && hasFeedbackToSend
? getCurrentFeedbackPayload(checkedSavedFileChanges)
: '';
const res = await fetch('/api/approve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildAnnotateApprovalBody({
supported: approvalNotesSupported,
draftGeneration: getDraftGeneration(),
feedback,
annotations: discard ? [] : allAnnotations,
codeAnnotations: discard ? [] : codeAnnotations,
...getFeedbackMessageScope(),
})),
});
if (!res.ok) throw new Error('Failed to approve');
dismissDraft();
setSubmitted('approved');
return true;
} catch {
setIsSubmitting(false);
scheduleDraftSaveAfterSubmitFailure();
return false;
}
};
// Exit annotation session without sending feedback
const handleAnnotateExit = useCallback(async () => {
setIsExiting(true);
try {
const res = await fetch(withDraftGeneration('/api/exit'), { method: 'POST' });
if (res.ok) {
setSubmitted('exited');
} else {
throw new Error('Failed to exit');
}
} catch {
setIsExiting(false);
}
}, [withDraftGeneration]);
const handleGoalSetupSubmit = useCallback(() => {
goalSetupSurfaceRef.current?.submit();
}, []);
const handleGoalSetupExit = useCallback(async () => {
setIsExiting(true);
try {
const res = await fetch('/api/exit', { method: 'POST' });
if (res.ok) {
setSubmitted('exited');
} else {
throw new Error('Failed to exit');
}
} catch {
setIsExiting(false);
}
}, []);
const confirmUnsavedSourceFileEdits = useCallback((
action: SourceFileEditWarningAction,
continueAction: () => void | Promise<void>,
) => {
sourceFileEditWarningContinuationRef.current = continueAction;
setSourceFileEditWarningAction(action);
setShowSourceFileEditWarning(true);
}, []);
const maybeConfirmUnsavedSourceFileEdits = useCallback((
action: SourceFileEditWarningAction,
continueAction: () => void | Promise<void>,
): boolean => {
if (!hasUnsavedSourceFileBuffers) return false;
confirmUnsavedSourceFileEdits(action, continueAction);
return true;
}, [confirmUnsavedSourceFileEdits, hasUnsavedSourceFileBuffers]);
const closeSourceFileEditWarning = useCallback(() => {
sourceFileEditWarningContinuationRef.current = null;
setShowSourceFileEditWarning(false);
}, []);
const confirmSourceFileEditWarning = useCallback(() => {
const continuation = sourceFileEditWarningContinuationRef.current;
sourceFileEditWarningContinuationRef.current = null;
setShowSourceFileEditWarning(false);
void continuation?.();
}, []);
// Global keyboard shortcuts (Cmd/Ctrl+Enter to submit)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Only handle Cmd/Ctrl+Enter
if (e.key !== 'Enter' || !(e.metaKey || e.ctrlKey)) return;
const target = e.target as HTMLElement | null;
const tag = target?.tagName;
const isTextField = tag === 'INPUT' || tag === 'TEXTAREA' || Boolean(target?.isContentEditable);
// Let active confirmation dialogs own Cmd/Ctrl+Enter and Escape.
if (document.querySelector('[data-plannotator-confirm-dialog="true"]')) return;
// Don't intercept if any modal is open
if (showExport || showImport || showFeedbackPrompt || showClaudeCodeWarning ||
showSourceFileEditWarning ||
showExitWarning || showAgentWarning || showPermissionModeSetup || pendingPasteImage) return;
// Don't intercept if already submitted, submitting, or exiting
if (submitted || isSubmitting || isExiting || goalSetupAction.isSubmitting) return;
// Don't intercept in demo/share mode (no API)
if (!isApiMode) return;
// Standalone archive is navigable but has no review decision to submit.
if (documentReadOnly) return;
// While the markdown editor is open, submit shortcuts belong to editing,
// not the review session.
if (isEditingMarkdown) return;
// Folder files are the active review target; normal linked docs are side
// references and should not submit the root plan.
if (linkedDocHook.isActive && annotateSource !== 'folder') return;
if (goalSetupMode) {
if (document.querySelector('[data-comment-popover="true"]')) return;
if (isTextField && !target?.closest('.goal-shell')) return;
e.preventDefault();
if (goalSetupAction.canSubmit) goalSetupSurfaceRef.current?.submit();
return;
}
// Don't intercept if typing in an input/textarea outside goal setup.
if (isTextField) return;
e.preventDefault();
// Annotate mode: Mod+Enter always equals the visible header primary —
// one submitPrimaryDecision for keyboard, header, and compact (spec §4).
if (annotateMode) {
submitPrimaryDecisionRef.current();
return;
}
// No feedback → Approve, otherwise → Send Feedback
if (!hasFeedbackToSend) {
const approve = () => {
// Check if agent exists for OpenCode users
if (origin === 'opencode') {
const warning = getAgentWarning();
if (warning) {
setAgentWarningMessage(warning);
setShowAgentWarning(true);
return;
}
}
handleApprove();
};
if (maybeConfirmUnsavedSourceFileEdits('approve', approve)) return;
approve();
} else {
// Direct edits route through deny too: on Claude Code, deny is the only
// channel whose output carries feedback to the agent.
if (maybeConfirmUnsavedSourceFileEdits('send-feedback', () => handleDeny())) return;
handleDeny();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [
showExport, showImport, showFeedbackPrompt, showClaudeCodeWarning, showSourceFileEditWarning, showExitWarning, showAgentWarning,
showPermissionModeSetup, pendingPasteImage,
submitted, isSubmitting, isExiting, goalSetupAction.isSubmitting, isApiMode, documentReadOnly, isEditingMarkdown, linkedDocHook.isActive, annotations.length, codeAnnotations.length, externalAnnotations.length, annotateMode,
hasFeedbackToSend, goalSetupMode, goalSetupAction.canSubmit, isAgentTerminalReady,
annotateSource, origin, getAgentWarning,
maybeConfirmUnsavedSourceFileEdits,
]);
const handleAddAnnotation = (ann: Annotation) => {
if (documentReadOnly) return;
// Live app sessions stamp every page-located annotation with the page it
// was made on (restore filters per page; export groups by page). Global
// comments have no page location and stay unstamped.
const stamped =
liveApp && livePageUrl && ann.type !== AnnotationType.GLOBAL_COMMENT
? { ...ann, pageUrl: livePageUrl }
: ann;
const beforeSelection = selectionRef.current;
const index = annotationsRef.current.length;
annotationsRef.current = [...annotationsRef.current, stamped];
setAnnotations(annotationsRef.current);
setSelectedAnnotationId(stamped.id);
setSelectedCodeAnnotationId(null);
selectionRef.current = { annotationId: stamped.id, codeAnnotationId: null };
if (isHumanHistoryMutation(stamped)) {
annotationHistory.record({
kind: 'annotation',
mutation: { kind: 'add', item: stamped, index },
beforeSelection,
afterSelection: selectionRef.current,
});
}
// Annotation activity keeps the HTML-surface preferences alive: re-stamp
// the input method and chrome records so they only expire for users who
// have not annotated HTML within the staleness TTL (see preferenceTtl.ts).
if (isHtmlSurface) {
refreshInputMethodStamp(inputMethod);
if (htmlChromeRestoredRef.current) {
saveHtmlChromeState({ sidebarOpen: sidebar.isOpen, panelOpen: isPanelOpen, toolsHidden: htmlToolsHidden });
}
}
};
// Keep selection behavior explicit across mobile/wide-mode transitions.
const handleSelectAnnotation = React.useCallback((id: string | null) => {
setSelectedAnnotationId(id);
if (id) setSelectedCodeAnnotationId(null);
selectionRef.current = {
annotationId: id,
codeAnnotationId: id ? null : selectionRef.current.codeAnnotationId,
};
if (id && isMobile && !isCompactTouchLayout && wideModeType === null) setIsPanelOpen(true);
}, [isCompactTouchLayout, isMobile, wideModeType]);
const handleAddCodeAnnotation = React.useCallback((input: CodeFileAnnotationInput) => {
if (documentReadOnly) return;
const annotation: CodeAnnotation = {
id: generateId('code-ann'),
type: 'comment',
scope: 'line',
filePath: input.filePath,
lineStart: input.lineStart,
lineEnd: input.lineEnd,
side: 'new',
text: input.text,
images: input.images,
originalCode: input.originalCode,
createdAt: Date.now(),
author: configStore.get('displayName') || undefined,
};
const beforeSelection = selectionRef.current;
const index = codeAnnotationsRef.current.length;
codeAnnotationsRef.current = [...codeAnnotationsRef.current, annotation];
setCodeAnnotations(codeAnnotationsRef.current);
setSelectedAnnotationId(null);
setSelectedCodeAnnotationId(annotation.id);
selectionRef.current = { annotationId: null, codeAnnotationId: annotation.id };
annotationHistory.record({
kind: 'code-annotation',
mutation: { kind: 'add', item: annotation, index },
beforeSelection,
afterSelection: selectionRef.current,
});
}, [annotationHistory, documentReadOnly]);
// The code popout is full-viewport modal — the annotation panel is behind it.
// This handler only fires when the popout is closed (sidebar visible), so
// reopening the file via codeFilePopout.open() is the correct behavior.
const handleSelectCodeAnnotation = React.useCallback((id: string) => {
const annotation = codeAnnotations.find(a => a.id === id);
if (!annotation) return;
setSelectedAnnotationId(null);
setSelectedCodeAnnotationId(id);
selectionRef.current = { annotationId: null, codeAnnotationId: id };
codeFilePopout.open(annotation.filePath);
if (isMobile && !isCompactTouchLayout && wideModeType === null) setIsPanelOpen(true);
}, [codeAnnotations, codeFilePopout.open, isCompactTouchLayout, isMobile, wideModeType]);
const handleDeleteCodeAnnotation = React.useCallback((id: string) => {
if (documentReadOnly) return;
const index = codeAnnotationsRef.current.findIndex((annotation) => annotation.id === id);
const annotation = codeAnnotationsRef.current[index];
if (!annotation) return;
const beforeSelection = selectionRef.current;
codeAnnotationsRef.current = codeAnnotationsRef.current.filter((item) => item.id !== id);
setCodeAnnotations(codeAnnotationsRef.current);
if (beforeSelection.codeAnnotationId === id) setSelectedCodeAnnotationId(null);
const afterSelection = beforeSelection.codeAnnotationId === id
? { ...beforeSelection, codeAnnotationId: null }
: beforeSelection;
selectionRef.current = afterSelection;
if (isHumanHistoryMutation(annotation)) {
annotationHistory.record({
kind: 'code-annotation',
mutation: { kind: 'delete', item: annotation, index },
beforeSelection,
afterSelection,
});
}
}, [annotationHistory, documentReadOnly]);
const handleEditCodeAnnotation = React.useCallback((id: string, updates: Partial<CodeAnnotation>) => {
if (documentReadOnly) return;
const before = codeAnnotationsRef.current.find((annotation) => annotation.id === id);
if (!before) return;
const after = { ...before, ...updates };
codeAnnotationsRef.current = codeAnnotationsRef.current.map((annotation) => annotation.id === id ? after : annotation);
setCodeAnnotations(codeAnnotationsRef.current);
if (isHumanHistoryMutation(before)) {
annotationHistory.record({
kind: 'code-annotation',
mutation: { kind: 'edit', before, after },
beforeSelection: selectionRef.current,
afterSelection: selectionRef.current,
});
}
}, [annotationHistory, documentReadOnly]);
// Core annotation removal — highlight cleanup + state filter + selection clear
const removeAnnotation = (id: string) => {
if (documentReadOnly) return;
viewerRef.current?.removeHighlight(id);
annotationsRef.current = annotationsRef.current.filter((annotation) => annotation.id !== id);
setAnnotations(annotationsRef.current);
if (selectionRef.current.annotationId === id) {
setSelectedAnnotationId(null);
selectionRef.current = { ...selectionRef.current, annotationId: null };
}
};
// Interactive checkbox toggling with annotation tracking
const checkbox = useCheckboxOverrides({
blocks,
annotations,
addAnnotation: (annotation) => {
checkboxSelectionBeforeRef.current ??= selectionRef.current;
const index = annotationsRef.current.length;
annotationsRef.current = [...annotationsRef.current, annotation];
setAnnotations(annotationsRef.current);
setSelectedAnnotationId(annotation.id);
setSelectedCodeAnnotationId(null);
selectionRef.current = { annotationId: annotation.id, codeAnnotationId: null };
return index;
},
removeAnnotation: (id) => {
checkboxSelectionBeforeRef.current ??= selectionRef.current;
removeAnnotation(id);
},
onToggleMutation: (mutation) => {
const beforeSelection = checkboxSelectionBeforeRef.current ?? selectionRef.current;
annotationHistory.record({
kind: 'checkbox',
mutation,
beforeSelection,
afterSelection: selectionRef.current,
});
checkboxSelectionBeforeRef.current = null;
},
});
restoreCheckboxOverridesRef.current = checkbox.restoreOverrides;
const deleteAnnotation = (id: string, history: 'record' | 'silent') => {
if (documentReadOnly) return;
const ann = allAnnotations.find(a => a.id === id);
if (ann?.source) annotationHistory.clear();
// External annotations (live in SSE hook) route to the SSE hook, not local state.
// Check membership by ID — source alone is insufficient because share-imported
// and draft-restored annotations also carry source but live in local state.
if (ann?.source && externalAnnotations.some(e => e.id === id)) {
deleteExternalAnnotation(id);
if (selectionRef.current.annotationId === id) {
selectionRef.current = { ...selectionRef.current, annotationId: null };
setSelectedAnnotationId(null);
}
return;
}
// Checkbox deletion is one composite action: visual state and generated
// annotation must travel together through history.
if (id.startsWith('ann-checkbox-')) {
if (ann) {
const beforeSelection = selectionRef.current;
const beforeOverrides = [...checkbox.overrides.entries()] as CheckboxOverrideSnapshot;
const annotationIndex = annotationsRef.current.findIndex((item) => item.id === id);
checkbox.revertOverride(ann.blockId);
removeAnnotation(id);
if (history === 'record') annotationHistory.record({
kind: 'checkbox',
mutation: {
blockId: ann.blockId,
beforeOverrides,
afterOverrides: beforeOverrides.filter(([blockId]) => blockId !== ann.blockId),
beforeAnnotations: [{ annotation: ann, index: annotationIndex }],
afterAnnotations: [],
},
beforeSelection,
afterSelection: selectionRef.current,
});
return;
}
removeAnnotation(id);
return;
}
const index = annotationsRef.current.findIndex((annotation) => annotation.id === id);
if (!ann || index < 0) {
removeAnnotation(id);
return;
}
const beforeSelection = selectionRef.current;
removeAnnotation(id);
if (history === 'record' && isHumanHistoryMutation(ann)) {
annotationHistory.record({
kind: 'annotation',
mutation: { kind: 'delete', item: ann, index },
beforeSelection,
afterSelection: selectionRef.current,
});
}
};
const handleDeleteAnnotation = (id: string) => deleteAnnotation(id, 'record');
const deleteAnnotationSilently = (id: string) => deleteAnnotation(id, 'silent');
const editAnnotation = (
id: string,
updates: Partial<Annotation>,
history: 'record' | 'silent',
) => {
if (documentReadOnly) return;
const ann = allAnnotations.find(a => a.id === id);
if (ann?.source) annotationHistory.clear();
if (ann?.source && externalAnnotations.some(e => e.id === id)) {
updateExternalAnnotation(id, updates);
return;
}
if (!ann) return;
const after = { ...ann, ...updates };
annotationsRef.current = annotationsRef.current.map((annotation) => annotation.id === id ? after : annotation);
setAnnotations(annotationsRef.current);
if (history === 'record' && isHumanHistoryMutation(ann)) {
annotationHistory.record({
kind: 'annotation',
mutation: { kind: 'edit', before: ann, after },
beforeSelection: selectionRef.current,
afterSelection: selectionRef.current,
});
}
};
const handleEditAnnotation = (id: string, updates: Partial<Annotation>) =>
editAnnotation(id, updates, 'record');
const editAnnotationSilently = (id: string, updates: Partial<Annotation>) =>
editAnnotation(id, updates, 'silent');
// WebMCP (browser-agent tools). The hook detects `document.modelContext`
// once and does nothing in a browser without it; the banner state below
// is only ever set by the agent's `nudge_user` tool.
const [agentNudge, setAgentNudge] = useState<{ key: number; message: string } | null>(null);
const showAgentNudge = useCallback((message: string) => {
setAgentNudge({ key: Date.now(), message });
}, []);
const webmcpActivity = useWebMcpActivity();
const webmcp = useDocumentWebMcp({
isApiMode,
isSharedSession,
goalSetupMode,
annotateMode,
annotateSource,
liveApp,
livePageUrl,
archiveMode: archive.archiveMode,
gate,
submitted,
renderAs,
rawHtml,
displayedMarkdown,
blocks,
allAnnotations,
isEditingMarkdown,
editorDiffersFromBaseline,
sourceStale: !!activeEditableDocument?.missingOnDisk || !!activeEditableDocument?.diskConflict,
sourceFilePath,
sourceInfo,
versionInfo,
linkedDoc: {
isActive: linkedDocHook.isActive,
filepath: linkedDocHook.filepath,
error: linkedDocHook.error,
getDocAnnotations: linkedDocHook.getDocAnnotations,
open: (path: string) => linkedDocHook.open(path),
},
fileBrowserDirs: fileBrowser.dirs,
fileBrowserActiveFile: fileBrowser.activeFile,
openFolderFile: handleFileBrowserSelect,
viewerRef,
scrollViewport,
addAnnotation: (annotation) => {
annotationHistory.clear();
handleAddAnnotation(annotation);
},
editAnnotation: (id, patch) => {
annotationHistory.clear();
editAnnotationSilently(id, patch);
},
deleteAnnotation: (id) => {
annotationHistory.clear();
deleteAnnotationSilently(id);
},
selectAnnotation: handleSelectAnnotation,
showBanner: showAgentNudge,
});
const agentHasComments = allAnnotations.some((a) => a.source === 'browser-agent');
const handleIdentityChange = useCallback((oldIdentity: string, newIdentity: string) => {
if (documentReadOnly) return;
annotationHistory.clear();
setAnnotations(prev => prev.map(ann =>
ann.author === oldIdentity ? { ...ann, author: newIdentity } : ann
));
setCodeAnnotations(prev => prev.map(ann =>
ann.author === oldIdentity ? { ...ann, author: newIdentity } : ann
));
}, [annotationHistory, documentReadOnly]);
const handleAddGlobalAttachment = (image: ImageAttachment) => {
if (documentReadOnly) return;
setGlobalAttachments(prev => [...prev, image]);
};
const handleRemoveGlobalAttachment = (path: string) => {
if (documentReadOnly) return;
setGlobalAttachments(prev => prev.filter(p => p.path !== path));
};
const handleTocNavigate = (blockId: string) => {
// Navigation handled by TableOfContents component
// This is just a placeholder for future custom logic
};
const aiAnnotationsContext = useMemo(
() => hasAnyAnnotations ? annotationsOutput : undefined,
[annotationsOutput, hasAnyAnnotations],
);
const aiDocumentPath = linkedDocHook.isActive
? linkedDocHook.filepath ?? 'linked document'
: sourceFilePath ?? (annotateSource === 'message' ? 'agent message' : annotateSource === 'folder' ? 'folder document' : 'plan');
const aiSourceInfo = linkedDocHook.isActive ? linkedDocHook.filepath ?? undefined : sourceInfo;
const aiSourceConverted = linkedDocHook.isActive
? (linkedDocHook.getDocAnnotations().get(linkedDocHook.filepath ?? '')?.isConverted ?? false)
: sourceConverted;
// renderAs now tracks the active file (plan, linked doc, or folder file), so the AI
// sees the current surface's mode — raw HTML for an .html file, markdown otherwise.
const aiRenderAs = renderAs;
const aiDocumentMode = annotateMode || linkedDocHook.isActive;
const hasAIDocumentContext =
!aiDocumentMode ||
annotateSource !== 'folder' ||
linkedDocHook.isActive ||
!!sourceFilePath;
const aiContext = useMemo<AIContext | null>(() => {
if (!aiSessionEnabled || archive.archiveMode || goalSetupMode) return null;
if (aiDocumentMode && !hasAIDocumentContext) return null;
if (aiDocumentMode) {
return {
mode: 'annotate',
annotate: {
content: aiRenderAs === 'html' && rawHtml ? rawHtml : displayedMarkdown,
filePath: aiDocumentPath,
sourceInfo: aiSourceInfo,
sourceConverted: aiSourceConverted,
renderAs: aiRenderAs,
annotations: aiAnnotationsContext,
},
};
}
return {
mode: 'plan-review',
plan: {
plan: markdown,
previousPlan: previousPlan ?? undefined,
version: versionInfo?.version,
totalVersions: versionInfo?.totalVersions,
project: versionInfo?.project,
annotations: aiAnnotationsContext,
},
};
}, [
aiAnnotationsContext,
aiDocumentPath,
aiRenderAs,
aiSessionEnabled,
aiSourceConverted,
aiSourceInfo,
aiDocumentMode,
hasAIDocumentContext,
archive.archiveMode,
displayedMarkdown,
goalSetupMode,
markdown,
previousPlan,
rawHtml,
renderAs,
versionInfo,
]);
const aiChat = useAIChat({
context: aiContext,
providerId: aiConfig.providerId,
model: aiConfig.model,
reasoningEffort: aiConfig.reasoningEffort,
threadTitle: aiDocumentMode ? 'Document chat' : 'Plan chat',
});
const {
messages: aiMessages,
isCreatingSession: aiIsCreatingSession,
isStreaming: aiIsStreaming,
permissionRequests: aiPermissionRequests,
respondToPermission: respondToAIPermission,
ask: askAI,
abort: abortAI,
resetSession: resetAISession,
resetThread: resetAIThread,
sessionId: aiSessionId,
} = aiChat;
const canUseAI = aiAvailable && aiContext !== null;
const canUseAskAI = canUseAI || isAgentTerminalReady;
const canUseDocumentAskAI = canUseAskAI;
const visibleAIMessages = isAgentTerminalReady ? [] : aiMessages;
const visibleAIProviders = useMemo<AIProviderOption[]>(
() => isAgentTerminalReady ? [{ id: 'agent-terminal', name: 'Agent terminal' }] : aiProviders,
[aiProviders, isAgentTerminalReady],
);
const visibleAIConfig = isAgentTerminalReady
? { providerId: 'agent-terminal', model: null, reasoningEffort: null }
: aiConfig;
const terminalAskReadableFilePath = useMemo(() => {
if (linkedDocHook.isActive && linkedDocHook.filepath) return linkedDocHook.filepath;
if (sourceFilePath) return sourceFilePath;
if (fileBrowser.activeFile) return fileBrowser.activeFile;
return null;
}, [fileBrowser.activeFile, linkedDocHook.filepath, linkedDocHook.isActive, sourceFilePath]);
const buildAgentAskPrompt = useCallback((question: string, context?: CommentAskAIContext) => {
const scope = context ? {
kind: context.kind,
label: context.label,
text: context.text,
sourcePath: context.sourcePath ?? aiDocumentPath,
} : undefined;
const scopedQuestion = buildDefaultPrompt({
prompt: question,
scope,
});
return buildTerminalAskPrompt({
scopedQuestion,
documentPath: aiDocumentPath,
annotationsContext: aiAnnotationsContext,
readableFilePath: terminalAskReadableFilePath,
inlineDocument: terminalAskReadableFilePath
? null
: {
label: aiRenderAs === 'html' ? 'Current document HTML' : 'Current document text',
content: aiRenderAs === 'html' && rawHtml ? rawHtml : displayedMarkdown,
},
});
}, [aiAnnotationsContext, aiDocumentPath, aiRenderAs, displayedMarkdown, rawHtml, terminalAskReadableFilePath]);
const aiDocumentKey = aiContext
? `${aiDocumentMode ? 'document' : 'plan'}:${aiRenderAs}:${aiDocumentPath}:${versionInfo?.version ?? 'current'}`
: 'none';
const previousAIDocumentKeyRef = useRef<string | null>(null);
useEffect(() => {
if (!aiSessionEnabled) return;
if (previousAIDocumentKeyRef.current && previousAIDocumentKeyRef.current !== aiDocumentKey) {
resetAIThread();
}
previousAIDocumentKeyRef.current = aiDocumentKey;
}, [aiDocumentKey, aiSessionEnabled, resetAIThread]);
// Provider/model/effort selection logic lives in the shared hook above (incl.
// per-model reasoning effort); the app only composes the session reset (the
// hook can't own it — see the cycle note in useAIProviderConfig).
const handleAIConfigChange = useCallback((config: { providerId?: string | null; model?: string | null; reasoningEffort?: string | null }) => {
// Switching the picker to a provider is an explicit gesture — activate it
// so its deferred model discovery (Codex) refreshes the advertised list.
if (config.providerId) activateAIProvider(config.providerId);
applyConfigChange(config);
resetAISession();
}, [activateAIProvider, applyConfigChange, resetAISession]);
// Opening the Ask AI surface with a provider selected is the other explicit
// gesture that should surface the provider's real model list.
// isRightPanelVisible, not effectivePanelOpen: a right-docked Agent TUI
// suppresses the panel without closing it, and kicking off provider model
// discovery for a surface nobody can see is exactly the eager work this
// gesture-gated effect exists to avoid.
const aiSurfaceOpen = isCompactTouchLayout
? compactPlanSurface.type === 'ai'
: isRightPanelVisible && rightSidebarTab === 'ai';
useEffect(() => {
if (!aiAvailable || !aiSurfaceOpen) return;
activateAIProvider(aiConfig.providerId);
}, [aiAvailable, aiSurfaceOpen, aiConfig.providerId, activateAIProvider]);
const openAIChat = useCallback(() => {
if (isCompactTouchLayout) {
openCompactPlanSurface('ai');
return;
}
if (wideModeType !== null) {
exitWideMode({ restore: false, panelOpen: true });
}
setRightSidebarTab('ai');
setIsPanelOpen(true);
}, [exitWideMode, isCompactTouchLayout, openCompactPlanSurface, wideModeType]);
const handleAskAI = useCallback((question: string, context?: CommentAskAIContext): boolean => {
if (isAgentTerminalReady) {
if (sendToAgentTerminal(buildAgentAskPrompt(question, context))) {
return true;
}
handleAgentTerminalReadyChange(false);
if (!canUseAI) {
toast.error('Agent terminal is not ready');
return false;
}
}
if (!canUseAI) {
toast.error('Ask AI is unavailable');
return false;
}
openAIChat();
askAI({
prompt: question,
scope: context ? {
kind: context.kind,
label: context.label,
text: context.text,
sourcePath: context.sourcePath ?? aiDocumentPath,
} : undefined,
contextUpdate: aiSessionId ? aiAnnotationsContext : undefined,
});
return true;
}, [
aiAnnotationsContext,
aiDocumentPath,
aiSessionId,
askAI,
buildAgentAskPrompt,
canUseAI,
handleAgentTerminalReadyChange,
isAgentTerminalReady,
openAIChat,
sendToAgentTerminal,
]);
const handleAskGeneralAI = useCallback((question: string) => {
handleAskAI(question, { kind: 'general', label: aiDocumentMode ? 'Document' : 'Plan', sourcePath: aiDocumentPath });
}, [aiDocumentMode, aiDocumentPath, handleAskAI]);
// Bot callback config — read once from URL search params (?cb=&ct=)
// TODO: bot callbacks post shareUrl which doesn't include code-file annotations.
// If a user adds code comments and hits the callback button, those comments are silently dropped.
// Fix: either disable callbacks when codeAnnotations exist, or include annotationsOutput in the payload.
const callbackConfig = React.useMemo(() => getCallbackConfig(), []);
const callCallback = React.useCallback(async (action: CallbackAction) => {
if (!callbackConfig || isSubmitting) return;
setIsSubmitting(true);
try {
const callbackShareUrl = await ensureShareLink();
if (!callbackShareUrl) {
toast.error('Failed to create share link');
return;
}
const result = await executeCallback(action, callbackConfig, callbackShareUrl);
if (result) {
if (result.type === 'success') {
toast.success(result.message);
setSubmitted(action === CallbackAction.Approve ? 'approved' : 'denied');
} else {
toast.error(result.message);
}
}
} finally {
setIsSubmitting(false);
}
}, [callbackConfig, ensureShareLink, isSubmitting]);
const handleCallbackApprove = React.useCallback(() => callCallback(CallbackAction.Approve), [callCallback]);
const handleCallbackFeedback = React.useCallback(() => callCallback(CallbackAction.Feedback), [callCallback]);
// Quick-save handlers for export dropdown and keyboard shortcut
const handleDownloadAnnotations = () => {
const output = getCurrentFeedbackPayload();
const blob = new Blob([output], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'annotations.md';
a.click();
URL.revokeObjectURL(url);
toast.success('Downloaded annotations');
};
const handleQuickSaveToNotes = async (target: 'obsidian' | 'bear' | 'octarine') => {
if (documentReadOnly) return;
const body: { obsidian?: object; bear?: object; octarine?: object } = {};
// Mid-edit saves describe the live buffer, matching handleApprove.
const quickSaveMarkdown = isEditingMarkdown
? markdownEditorHandleRef.current?.getMarkdown() ?? displayedMarkdown
: displayedMarkdown;
if (target === 'obsidian') {
const s = getObsidianSettings();
const vaultPath = getEffectiveVaultPath(s);
if (vaultPath) {
body.obsidian = {
vaultPath,
folder: s.folder || 'plannotator',
plan: quickSaveMarkdown,
...(s.filenameFormat && { filenameFormat: s.filenameFormat }),
...(s.filenameSeparator && s.filenameSeparator !== 'space' && { filenameSeparator: s.filenameSeparator }),
};
}
}
if (target === 'bear') {
const bs = getBearSettings();
body.bear = {
plan: quickSaveMarkdown,
customTags: bs.customTags,
tagPosition: bs.tagPosition,
};
}
if (target === 'octarine') {
const os = getOctarineSettings();
body.octarine = {
plan: quickSaveMarkdown,
workspace: os.workspace,
folder: os.folder || 'plannotator',
};
}
const targetName = target === 'obsidian' ? 'Obsidian' : target === 'bear' ? 'Bear' : 'Octarine';
try {
const res = await fetch('/api/save-notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
const result = data.results?.[target];
if (result?.success) {
toast.success(`Saved to ${targetName}`);
} else {
toast.error(result?.error || 'Save failed');
}
} catch {
toast.error('Save failed');
}
};
const handleSaveEditedSourceFile = useCallback(async (options?: { overwriteDiskConflict?: boolean }): Promise<boolean> => {
const activeDocument = editableDocuments.getActiveDocumentLive();
const activeSourceSave = activeDocument?.sourceSave;
if (!activeDocument || !activeSourceSave?.enabled) {
toast.error('This document cannot be saved to a file');
return true;
}
const edited = isEditingMarkdown
? markdownEditorHandleRef.current?.getMarkdown()
: activeDocument.currentText;
if (edited == null) {
toast.error('Editor is not ready');
return true;
}
if (activeDocument.diskConflict && !options?.overwriteDiskConflict) {
toast.error('Resolve the disk conflict first', {
description: 'Choose Overwrite disk or Reload from disk.',
});
return true;
}
const saveBaseSource = options?.overwriteDiskConflict && activeDocument.diskConflict
? activeDocument.diskConflict.sourceSave
: activeSourceSave;
const savedChangeBaseText = options?.overwriteDiskConflict
? activeDocument.diskConflict?.text
: undefined;
const savedChangeBaseHash = options?.overwriteDiskConflict
? activeDocument.diskConflict?.sourceSave.hash
: undefined;
editableDocuments.updateActiveText(edited);
editableDocuments.markSaving(activeDocument.key);
try {
const res = await fetch('/api/source/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
path: saveBaseSource.scope === 'folder-file' ? saveBaseSource.path : undefined,
text: edited,
baseHash: saveBaseSource.hash,
baseMtimeMs: saveBaseSource.mtimeMs,
baseEol: saveBaseSource.eol,
allowMissingBase: true,
}),
});
const data = (await res.json()) as SourceSaveResponse;
if (!res.ok || !data.ok) {
const message = !data.ok ? data.message : 'Save failed';
if (!data.ok && data.code === 'conflict') {
const hasConflictSnapshot = hasSourceSaveConflictSnapshot(data);
if (hasConflictSnapshot) {
const conflictSourceSave: EnabledSourceSaveCapability = {
...saveBaseSource,
hash: data.currentHash,
mtimeMs: data.currentMtimeMs,
size: data.currentSize,
eol: data.currentEol,
};
const result = editableDocuments.reconcileDiskSnapshot({
key: activeDocument.key,
text: data.currentText,
sourceSave: conflictSourceSave,
});
if (result.type === 'conflict' && editableDocuments.getActiveKey() === activeDocument.key) {
setEditorDirty(true);
setEditorDiffersFromBaseline(true);
setEditStats(computeEditStats(result.record.diskBaseline, result.record.currentText));
scheduleDraftSave();
toast.error('File changed on disk', {
description: 'Choose whether to overwrite disk or reload the file.',
});
} else if (result.type === 'conflict') {
scheduleDraftSave();
toast.error('File changed on disk', {
description: 'Choose whether to overwrite disk or reload the file.',
});
} else if (result.type === 'clean-updated') {
if (editableDocuments.getActiveKey() === activeDocument.key) {
const remapped = applyEditedDocument(result.record.currentText);
repaintHighlights(remapped);
editSessionBaseRef.current = result.record.currentText;
setEditorDirty(false);
setEditorDiffersFromBaseline(false);
setEditStats(null);
}
scheduleDraftSave();
toast('File updated from disk', {
description: `${result.record.basename} changed outside Plannotator, so it was reloaded instead of saved.`,
});
} else if (!editableDocuments.getDocument(activeDocument.key)?.diskConflict) {
editableDocuments.markError(activeDocument.key, message);
toast.error('File changed on disk', {
description: 'Plannotator could not load the latest disk version. Try saving again.',
});
}
} else {
editableDocuments.markError(activeDocument.key, message);
toast.error('File changed on disk', {
description: 'Plannotator could not load the latest disk version. Try saving again.',
});
}
} else {
editableDocuments.markError(activeDocument.key, message);
toast.error(message);
}
return true;
}
const nextSourceSave = {
...saveBaseSource,
hash: data.hash,
mtimeMs: data.mtimeMs,
size: data.size,
eol: data.eol,
};
editableDocuments.markSaved({
key: activeDocument.key,
text: edited,
sourceSave: nextSourceSave,
savedChangeBaseText,
savedChangeBaseHash,
});
const normalizedEdited = edited.replace(/\r\n?/g, '\n');
const savedChangedFromOpen = normalizedEdited !== activeDocument.sessionOpenText;
editedMarkdownRef.current = null;
if (editableDocuments.getActiveKey() === activeDocument.key) {
const live = isEditingMarkdown ? markdownEditorHandleRef.current?.getMarkdown() : null;
const normalizedLive = live?.replace(/\r\n?/g, '\n');
editSessionBaseRef.current = normalizedEdited;
const currentText = normalizedLive ?? editableDocuments.getDocument(activeDocument.key)?.currentText ?? normalizedEdited;
if (currentText === normalizedEdited) {
setEditorDirty(false);
setEditorDiffersFromBaseline(false);
setEditStats(null);
} else {
editableDocuments.updateActiveText(currentText, { forceNotify: true });
setEditorDirty(true);
setEditorDiffersFromBaseline(true);
setEditStats(computeEditStats(normalizedEdited, currentText));
}
}
if (savedChangedFromOpen && window.innerWidth >= 768) {
setRightSidebarTab('annotations');
setIsPanelOpen(true);
}
scheduleDraftSave();
toast.success(`Saved ${activeSourceSave.basename}`);
return true;
} catch {
editableDocuments.markError(activeDocument.key, 'Save failed');
toast.error('Save failed');
return true;
}
}, [applyEditedDocument, editableDocuments, isEditingMarkdown, repaintHighlights, scheduleDraftSave]);
const handleOverwriteDiskConflict = useCallback(() => {
void handleSaveEditedSourceFile({ overwriteDiskConflict: true });
}, [handleSaveEditedSourceFile]);
const handleReloadDiskConflict = useCallback(() => {
const activeDocument = editableDocuments.getActiveDocumentLive();
if (!activeDocument?.diskConflict) return;
const reloaded = editableDocuments.reloadDiskConflict(activeDocument.key);
if (!reloaded) return;
const remapped = applyEditedDocument(reloaded.currentText);
repaintHighlights(remapped);
editSessionBaseRef.current = reloaded.currentText;
setEditorDirty(false);
setEditorDiffersFromBaseline(false);
setEditStats(null);
scheduleDraftSave();
toast.success(`Reloaded ${reloaded.basename} from disk`);
}, [applyEditedDocument, editableDocuments, repaintHighlights, scheduleDraftSave]);
// 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);
if (await copyTextToClipboard(payload)) {
toast.success('Agent instructions copied');
} else {
toast.error('Failed to copy');
}
};
const handleCopyShareLink = async () => {
const url = await ensureShareLink();
if (!url) {
setInitialExportTab('share');
setShowExport(true);
toast.error('Failed to create share link');
return;
}
if (await copyTextToClipboard(url)) {
toast.success('Share link copied');
} else {
toast.error('Failed to copy');
}
};
// Cmd/Ctrl+S keyboard shortcut — while editing, save the active source file;
// otherwise keep the existing default notes/export behavior.
useEffect(() => {
const handleSaveShortcut = (e: KeyboardEvent) => {
if (e.key !== 's' || !(e.metaKey || e.ctrlKey)) return;
if (documentReadOnly) return;
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
if (showExport || showFeedbackPrompt || showClaudeCodeWarning ||
showSourceFileEditWarning ||
showExitWarning || showAgentWarning || showPermissionModeSetup || pendingPasteImage) return;
if (submitted || !isApiMode) return;
if (isEditingMarkdown && editableDocuments.getActiveDocumentLive()?.sourceSave?.enabled) {
e.preventDefault();
void handleSaveEditedSourceFile();
return;
}
e.preventDefault();
const defaultApp = getDefaultNotesApp();
const obsOk = isObsidianConfigured();
const bearOk = getBearSettings().enabled;
const octOk = isOctarineConfigured();
if (defaultApp === 'download') {
handleDownloadAnnotations();
} else if (defaultApp === 'obsidian' && obsOk) {
handleQuickSaveToNotes('obsidian');
} else if (defaultApp === 'bear' && bearOk) {
handleQuickSaveToNotes('bear');
} else if (defaultApp === 'octarine' && octOk) {
handleQuickSaveToNotes('octarine');
} else {
setInitialExportTab('notes');
setShowExport(true);
}
};
window.addEventListener('keydown', handleSaveShortcut);
return () => window.removeEventListener('keydown', handleSaveShortcut);
}, [
showExport, showFeedbackPrompt, showClaudeCodeWarning, showSourceFileEditWarning, showExitWarning, showAgentWarning,
showPermissionModeSetup, pendingPasteImage,
submitted, isApiMode, documentReadOnly, isEditingMarkdown, handleSaveEditedSourceFile, displayedMarkdown, annotationsOutput,
]);
// Cmd/Ctrl+P keyboard shortcut — print plan
useEffect(() => {
const handlePrintShortcut = (e: KeyboardEvent) => {
if (e.key !== 'p' || !(e.metaKey || e.ctrlKey)) return;
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
if (showExport || showFeedbackPrompt || showClaudeCodeWarning ||
showSourceFileEditWarning ||
showExitWarning || showAgentWarning || showPermissionModeSetup || pendingPasteImage) return;
if (submitted) return;
e.preventDefault();
window.print();
};
window.addEventListener('keydown', handlePrintShortcut);
return () => window.removeEventListener('keydown', handlePrintShortcut);
}, [
showExport, showFeedbackPrompt, showClaudeCodeWarning, showSourceFileEditWarning, showExitWarning, showAgentWarning,
showPermissionModeSetup, pendingPasteImage, submitted,
]);
const agentName = useMemo(() => getAgentName(origin), [origin]);
// Header handlers ref — stores latest handler references so the stable
// callbacks below always call the current version without needing useCallback
// dep arrays for every handler. This lets React.memo on AppHeader work.
const headerHandlersRef = useRef({
handleApprove,
handleDeny,
handleAnnotateApprove,
handleAnnotateFeedback,
handleAnnotateExit,
handleQuickSaveToNotes,
handleDownloadAnnotations,
handleCopyAgentInstructions,
handleCopyShareLink,
getAgentWarning,
getDocAnnotations: linkedDocHook.getDocAnnotations,
});
headerHandlersRef.current = {
handleApprove,
handleDeny,
handleAnnotateApprove,
handleAnnotateFeedback,
handleAnnotateExit,
handleQuickSaveToNotes,
handleDownloadAnnotations,
handleCopyAgentInstructions,
handleCopyShareLink,
getAgentWarning,
getDocAnnotations: linkedDocHook.getDocAnnotations,
};
const handleHeaderAnnotateExit = useCallback(() => {
const close = () => {
if (hasFeedbackToSend) {
setShowExitWarning(true);
} else {
headerHandlersRef.current.handleAnnotateExit();
}
};
if (maybeConfirmUnsavedSourceFileEdits('close', close)) return;
close();
}, [hasFeedbackToSend, maybeConfirmUnsavedSourceFileEdits]);
const handleHeaderFeedback = useCallback(() => {
const sendFeedback = () => {
const h = headerHandlersRef.current;
// Direct edits count as feedback — deny is the only Claude Code channel
// whose output carries feedback to the agent.
if (!hasFeedbackToSend) {
setShowFeedbackPrompt(true);
} else {
h.handleDeny();
}
};
if (maybeConfirmUnsavedSourceFileEdits('send-feedback', sendFeedback)) return;
sendFeedback();
}, [hasFeedbackToSend, maybeConfirmUnsavedSourceFileEdits]);
const handleHeaderApprove = useCallback(() => {
const approve = () => {
const h = headerHandlersRef.current;
if (origin === 'claude-code' && hasFeedbackToSend) {
setShowClaudeCodeWarning(true);
return;
}
if (origin === 'opencode') {
const warning = h.getAgentWarning();
if (warning) {
setAgentWarningMessage(warning);
setShowAgentWarning(true);
return;
}
}
h.handleApprove();
};
if (maybeConfirmUnsavedSourceFileEdits('approve', approve)) return;
approve();
}, [hasFeedbackToSend, maybeConfirmUnsavedSourceFileEdits, origin]);
// --- The unified annotate decision control (spec §3.1/§4) ----------------
// One primary, one callback: the header's left segment, the global
// Mod+Enter handler (via submitPrimaryDecisionRef), and the compact primary
// row all call this. The zero-state Done submit is the SAME /api/feedback
// POST the keyboard-only silent submit made (byte-identical payload —
// spec §5.3); gate mode's empty primary is Approve on /api/approve.
// Runs one captured note decision on its captured route/framing. Cleared
// only on success (L3); the in-flight ref guards a double dispatch while a
// POST is outstanding.
const pendingDispatchInFlightRef = useRef(false);
const dispatchPendingDecision = useCallback((pending: {
route: 'feedback' | 'approve';
approvalFraming: boolean;
}) => {
if (pendingDispatchInFlightRef.current) return;
const { route, approvalFraming } = pending;
const run = async () => {
pendingDispatchInFlightRef.current = true;
try {
const ok = route === 'approve'
? await headerHandlersRef.current.handleAnnotateApprove()
: await headerHandlersRef.current.handleAnnotateFeedback(
approvalFraming ? { approvalFraming: true } : undefined,
);
if (ok) setPendingDecisionSubmit(null);
} finally {
pendingDispatchInFlightRef.current = false;
}
};
if (maybeConfirmUnsavedSourceFileEdits(route === 'approve' ? 'approve' : 'send-feedback', run)) return;
void run();
}, [maybeConfirmUnsavedSourceFileEdits]);
const submitPrimaryDecision = useCallback(() => {
if (isSubmitting || isExiting) return; // double-submit guard while in flight
if (pendingDecisionSubmit) {
// L3: a failed note submit stays armed with its captured route/framing;
// the next primary invocation retries THAT decision, never the bare
// primary (which would silently reframe "Done with a note" as a change
// request once the note raised hasFeedbackToSend).
dispatchPendingDecision(pendingDecisionSubmit);
return;
}
if (gate && !hasFeedbackToSend) {
const approve = () => headerHandlersRef.current.handleAnnotateApprove();
if (maybeConfirmUnsavedSourceFileEdits('approve', approve)) return;
approve();
return;
}
const sendFeedback = () => headerHandlersRef.current.handleAnnotateFeedback();
if (maybeConfirmUnsavedSourceFileEdits('send-feedback', sendFeedback)) return;
sendFeedback();
}, [
dispatchPendingDecision,
gate,
hasFeedbackToSend,
isExiting,
isSubmitting,
maybeConfirmUnsavedSourceFileEdits,
pendingDecisionSubmit,
]);
submitPrimaryDecisionRef.current = submitPrimaryDecision;
// Note → GLOBAL_COMMENT at submit time (#1436): it rides exportAnnotations
// and the /api/feedback annotations array exactly like a composer-made
// global comment — zero server change on either runtime. Deliberately NOT
// annotationHistory.record: the note lives for one submit, and undoing it
// after the send would restore nothing the agent has not been told.
const commitSubmitNote = useCallback((text: string): string | null => {
const trimmed = text.trim();
if (!trimmed) return null;
const note: Annotation = {
id: `global-note-${crypto.randomUUID()}`,
blockId: '',
startOffset: 0,
endOffset: 0,
type: AnnotationType.GLOBAL_COMMENT,
text: trimmed,
originalText: '',
createdA: Date.now(),
author: getIdentity(),
};
annotationsRef.current = [...annotationsRef.current, note];
setAnnotations(annotationsRef.current);
return note.id;
}, []);
const queueNoteDecision = useCallback((
text: string | undefined,
route: 'feedback' | 'approve',
approvalFraming: boolean,
) => {
if (isSubmitting || isExiting) return;
const noteId = commitSubmitNote(text ?? '');
if (!noteId) return; // the control never submits an empty note
setPendingDecisionSubmit({ noteId, route, approvalFraming, dispatched: false });
}, [commitSubmitNote, isExiting, isSubmitting]);
// The commit above is a state write, so the payload builders (which close
// over `allAnnotations`) only see the note on the NEXT render. Submit from
// an effect once the note is actually in state rather than guessing. One
// automatic dispatch per arming; after a failure the armed decision waits
// for the next primary invocation (L3).
useEffect(() => {
const pending = pendingDecisionSubmit;
if (!pending) return;
if (!annotations.some((a) => a.id === pending.noteId)) {
// The note left state (panel delete, draft restore, document switch):
// the captured decision lost its note — disarm rather than replaying
// its framing over someone else's payload.
setPendingDecisionSubmit(null);
return;
}
if (pending.dispatched) return;
setPendingDecisionSubmit({ ...pending, dispatched: true });
dispatchPendingDecision(pending);
}, [annotations, dispatchPendingDecision, pendingDecisionSubmit]);
const runAnnotateDecisionAction = useCallback((id: DecisionActionId, note?: string) => {
const action = resolveAnnotateDecisionAction(id, { gate });
switch (action.kind) {
case 'primary':
submitPrimaryDecision();
return;
case 'note':
queueNoteDecision(note, action.route, action.approvalFraming);
return;
case 'approve-with-notes': {
const approve = () => headerHandlersRef.current.handleAnnotateApprove();
if (maybeConfirmUnsavedSourceFileEdits('approve', approve)) return;
approve();
return;
}
case 'discard': {
// The DecisionControl / compact ConfirmDialog has already confirmed.
// Same in-flight guard as the primary path: a confirm left open
// across an in-flight decision POST must not produce a second one.
if (submitted || isSubmitting || isExiting) return;
if (action.route === 'approve') {
const approve = () =>
headerHandlersRef.current.handleAnnotateApprove({ discardAnnotations: true });
if (maybeConfirmUnsavedSourceFileEdits('approve', approve)) return;
approve();
return;
}
const send = () => headerHandlersRef.current.handleAnnotateFeedback({
discardAnnotations: true,
approvalFraming: true,
});
if (maybeConfirmUnsavedSourceFileEdits('send-feedback', send)) return;
send();
}
}
}, [gate, isExiting, isSubmitting, maybeConfirmUnsavedSourceFileEdits, queueNoteDecision, submitPrimaryDecision, submitted]);
const annotateDecisionSpec = useMemo(() => buildDecisionSpec({
app: 'annotate',
gate,
count: feedbackAnnotationCount,
hasFeedback: hasFeedbackToSend,
approvalNotesSupported,
// M1 ruling: agent-terminal delivered feedback flips the state to empty,
// but Done still posts the full payload — the spec adjusts its copy.
feedbackDelivered: isCurrentFeedbackDeliveredToAgent,
}), [
approvalNotesSupported,
feedbackAnnotationCount,
gate,
hasFeedbackToSend,
isCurrentFeedbackDeliveredToAgent,
]);
const annotateDecisionHandlers = useMemo<Record<DecisionActionId, DecisionHandler>>(() => ({
'primary': () => runAnnotateDecisionAction('primary'),
'note-with-approval': (note) => runAnnotateDecisionAction('note-with-approval', note),
'request-changes': (note) => runAnnotateDecisionAction('request-changes', note),
'note-with-feedback': (note) => runAnnotateDecisionAction('note-with-feedback', note),
'approve-with-notes': () => runAnnotateDecisionAction('approve-with-notes'),
'discard-and-finish': () => runAnnotateDecisionAction('discard-and-finish'),
}), [runAnnotateDecisionAction]);
// Per-surface Close titles (spec §3.1 / prototype :521-522).
const annotateCloseTitle = annotateSource === 'message'
? 'Dismiss without telling the agent'
: 'Close session without sending';
const annotateDecision = useMemo(() => ({
spec: annotateDecisionSpec,
handlers: annotateDecisionHandlers,
closeTitle: annotateCloseTitle,
// Framed surfaces: clicks inside the iframe never reach the parent
// document, so iframe focus dismisses the popover instead (spec §2.4).
dismissOnIframeFocus: isHtmlSurface,
}), [annotateCloseTitle, annotateDecisionHandlers, annotateDecisionSpec, isHtmlSurface]);
const annotateCompactPrimaryId = compactPrimaryIdForDecision(annotateDecisionSpec.primary);
// L2: the compact dialogs render from the LIVE spec; if the item behind an
// open dialog left the spec (annotation deleted, state flipped), the dialog
// closes instead of acting on a stale capture.
const compactComposerItem = compactDecisionComposer !== null
? annotateDecisionSpec.items.find(
(item) => item.id === compactDecisionComposer && item.composer,
) ?? null
: null;
const compactConfirmItem = compactDecisionConfirm !== null
? annotateDecisionSpec.items.find(
(item) => item.id === compactDecisionConfirm && item.confirm,
) ?? null
: null;
useEffect(() => {
if (compactDecisionComposer !== null && !compactComposerItem) setCompactDecisionComposer(null);
if (compactDecisionConfirm !== null && !compactConfirmItem) setCompactDecisionConfirm(null);
}, [compactComposerItem, compactConfirmItem, compactDecisionComposer, compactDecisionConfirm]);
const handleHeaderDownloadAnnotations = useCallback(() => headerHandlersRef.current.handleDownloadAnnotations(), []);
const handleHeaderCopyAgentInstructions = useCallback(() => headerHandlersRef.current.handleCopyAgentInstructions(), []);
const handleHeaderCopyShareLink = useCallback(() => headerHandlersRef.current.handleCopyShareLink(), []);
const handleOpenSettings = useCallback(() => setMobileSettingsOpen(true), []);
const handleCloseSettings = useCallback(() => setMobileSettingsOpen(false), []);
const handleOpenExport = useCallback(() => { setInitialExportTab(undefined); setShowExport(true); }, []);
const handlePrint = useCallback(() => window.print(), []);
const handleOpenImport = useCallback(() => setShowImport(true), []);
const handleSaveToObsidian = useCallback(() => headerHandlersRef.current.handleQuickSaveToNotes('obsidian'), []);
const handleSaveToOctarine = useCallback(() => headerHandlersRef.current.handleQuickSaveToNotes('octarine'), []);
const handleSaveToBear = useCallback(() => headerHandlersRef.current.handleQuickSaveToNotes('bear'), []);
const compactDocumentTitle = useMemo(() => {
const path = linkedDocHook.filepath ?? sourceFilePath ?? fileBrowser.activeFile;
if (path) return path.replace(/\\/g, '/').split('/').pop() || path;
if (archive.currentInfo?.title) return archive.currentInfo.title;
if (annotateSource === 'message') return 'Message';
if (annotateSource === 'folder') return 'Choose a file';
return 'Plan';
}, [annotateSource, archive.currentInfo?.title, fileBrowser.activeFile, linkedDocHook.filepath, sourceFilePath]);
const callbackShareUrlReady = callbackConfig
? Boolean(shareUrl || shortShareUrl || (renderAs === 'html' && (shareHtml || rawHtml)))
: true;
const compactActionBusy = isSubmitting || isExiting || goalSetupAction.isSubmitting;
const compactReviewActions: CompactPlanReviewAction[] = !isCompactTouchLayout
? []
: callbackConfig && !isApiMode && isSharedSession
? [
{
id: 'feedback',
label: 'Send feedback to bot',
onSelect: handleCallbackFeedback,
disabled: compactActionBusy || !callbackShareUrlReady,
},
{
id: 'approve',
label: 'Approve design',
onSelect: handleCallbackApprove,
disabled: compactActionBusy || !callbackShareUrlReady,
},
]
: isApiMode && (!linkedDocHook.isActive || annotateMode) && !archive.archiveMode && !goalSetupMode
? [
...(annotateMode
? [
// Spec-driven decision rows: a visible send action exists in
// EVERY compact state (touch has no Mod+Enter — spec §3.1;
// the missing positive outcome at zero was the defect).
{
id: 'exit' as const,
label: 'Close session',
onSelect: handleHeaderAnnotateExit,
disabled: compactActionBusy,
},
{
id: annotateCompactPrimaryId,
label: annotateDecisionSpec.primary.mobileLabel ?? annotateDecisionSpec.primary.label,
subtitle: feedbackAnnotationCount > 0
? `${feedbackAnnotationCount} annotation${feedbackAnnotationCount === 1 ? '' : 's'}`
: hasFeedbackToSend
? 'Edited document'
: undefined,
onSelect: submitPrimaryDecision,
disabled: compactActionBusy,
},
...annotateDecisionSpec.items.map((item) => ({
id: compactRowIdForDecisionItem(item.id),
label: item.label,
subtitle: item.subtitle,
onSelect: () => {
if (item.composer) {
setCompactDecisionComposer(item.id);
return;
}
if (item.confirm) {
setCompactDecisionConfirm(item.id);
return;
}
runAnnotateDecisionAction(item.id);
},
disabled: compactActionBusy,
})),
]
: [{
id: 'feedback' as const,
label: 'Send feedback',
subtitle: hasFeedbackToSend
? `${feedbackAnnotationCount} annotation${feedbackAnnotationCount === 1 ? '' : 's'}`
: 'Add general feedback',
onSelect: handleHeaderFeedback,
disabled: compactActionBusy,
}]),
...(!annotateMode
? [{
id: 'approve' as const,
label: 'Approve',
subtitle: hasFeedbackToSend ? 'Feedback remains unsent' : undefined,
onSelect: handleHeaderApprove,
disabled: compactActionBusy,
}]
: []),
]
: [];
const compactModeActions: CompactPlanAction[] = !isCompactTouchLayout
? []
: isApiMode && !linkedDocHook.isActive && archive.archiveMode
? [
{ id: 'copy', label: 'Copy plan', onSelect: archive.copy },
{ id: 'done', label: 'Done', onSelect: archive.done },
]
: isApiMode && !linkedDocHook.isActive && goalSetupMode
? [
{
id: 'exit',
label: 'Close goal setup',
onSelect: handleGoalSetupExit,
disabled: compactActionBusy,
},
{
id: 'approve',
label: goalSetupAction.submitLabel,
onSelect: handleGoalSetupSubmit,
disabled: !goalSetupAction.canSubmit || compactActionBusy,
},
]
: [];
const hasReviewDocumentChanges = hasDirectEdits || hasSavedFileChanges;
const compactCanApprove = compactReviewActions.some((action) => action.id === 'approve');
const compactFeedbackSummary = showAgentTerminalDeliveryStatus
? 'Feedback was sent to the agent. You can keep reviewing or close the session.'
: feedbackAnnotationCount > 0 && hasReviewDocumentChanges
? `${feedbackAnnotationCount} annotation${feedbackAnnotationCount === 1 ? '' : 's'} and document edits are ready.`
: feedbackAnnotationCount > 0
? `${feedbackAnnotationCount} annotation${feedbackAnnotationCount === 1 ? '' : 's'} ${feedbackAnnotationCount === 1 ? 'is' : 'are'} ready.`
: hasReviewDocumentChanges
? 'Document edits are ready to send with your review.'
: compactCanApprove
? annotateMode && !gate
? 'No feedback added. You can finish or keep reviewing.'
: 'No feedback added. You can approve or keep reviewing.'
: 'No feedback added. You can keep reviewing or close the session.';
const compactPrimaryReviewActionId: CompactPlanReviewAction['id'] | undefined =
annotateMode && compactReviewActions.length > 0
// The compact primary row IS the header primary (spec §3.1).
? annotateCompactPrimaryId
: compactReviewActions.some((action) => action.id === 'feedback') &&
(hasFeedbackToSend || !compactCanApprove)
? 'feedback'
: compactReviewActions.find((action) => action.id === 'approve')?.id
?? compactReviewActions.find((action) => action.id !== 'exit')?.id
?? compactReviewActions[0]?.id;
const compactSessionActions: CompactPlanAction[] = !isCompactTouchLayout
? []
: [
...(!goalSetupMode
? [{
id: 'annotations' as const,
label: 'Annotations',
subtitle: feedbackAnnotationCount > 0
? `${feedbackAnnotationCount} item${feedbackAnnotationCount === 1 ? '' : 's'}`
: undefined,
onSelect: () => openCompactPlanSurface('annotations'),
}]
: []),
...(!goalSetupMode && canUseAskAI
? [{
id: 'ai' as const,
label: 'Ask AI',
subtitle: visibleAIMessages.length > 0
? `${visibleAIMessages.length} message${visibleAIMessages.length === 1 ? '' : 's'}`
: undefined,
onSelect: () => openCompactPlanSurface('ai'),
}]
: []),
...(compactReviewActions.length > 0
? [{
id: 'review' as const,
label: 'Review and finish',
subtitle: hasFeedbackToSend
? 'Feedback ready'
: compactCanApprove
? 'Approve or send feedback'
: 'Close when finished',
onSelect: () => openCompactPlanSurface('review'),
}]
: compactModeActions),
];
const compactDocumentActions: CompactPlanAction[] = !isCompactTouchLayout
? []
: [
...(canEditMarkdown && !isEditingMarkdown && !isPlanDiffActive && !archive.archiveMode && !isHtmlSurface
? [{
id: 'edit' as const,
label: 'Edit document',
subtitle: activeSourceSave ? `Edit ${activeSourceSave.basename}` : 'Edit the plan text directly',
onSelect: handleEditExitClick,
}]
: []),
// HTML/live surfaces on the compact touch shell: the desktop pen and
// eye toggles are header-only and hidden here, and Mod+Shift+A is
// keyboard-only, so without these menu actions a touch user has NO
// way to disarm annotate mode (every tap annotates, the page beneath
// is unreachable) or to bring hidden tools back.
...(isHtmlSurface && !documentReadOnly
? [{
id: 'annotate' as const,
label: htmlAnnotateArmed ? 'Interact with page' : 'Annotate page',
subtitle: htmlAnnotateArmed
? 'Taps annotate. Switch to use the page itself.'
: 'Taps use the page. Switch to add annotations.',
onSelect: handleHtmlAnnotateToggle,
}]
: []),
...(isHtmlSurface
? [{
id: 'tools' as const,
label: htmlToolsHidden ? 'Show tools' : 'Hide tools',
subtitle: htmlToolsHidden
? 'Bring the annotation chrome back over the page'
: 'Remove all floating chrome from over the page',
onSelect: () => setHtmlToolsHidden((v) => !v),
}]
: []),
// The desktop header's Refresh is header-only too; local HTML files
// (never URL or live-app sessions) get the same action here.
...(isHtmlSurface && htmlRefresh.canRefresh
? [{
id: 'refresh' as const,
label: 'Refresh from disk',
subtitle: htmlRefresh.isRefreshing
? 'Refreshing the HTML file'
: 'Reload the HTML file and keep the annotations that still match',
onSelect: () => { void htmlRefresh.refresh(); },
disabled: htmlRefresh.isRefreshing,
}]
: []),
];
const planMaxWidth = useMemo(() => {
const widths: Record<PlanWidth, number> = { compact: 832, default: 1040, wide: 1280 };
return widths[uiPrefs.planWidth] ?? 832;
}, [uiPrefs.planWidth]);
const annotateReaderMaxWidth = canUseWideMode && wideModeType === 'wide' ? null : planMaxWidth;
const agentTerminalPanel = shouldRenderAgentTerminal && agentTerminalCapability ? (
<div
key="agent-terminal"
className={agentTerminalDockClassName}
aria-hidden={!isAgentTerminalVisible}
inert={!isAgentTerminalVisible}
>
<AnnotateAgentTerminalPanel
ref={agentTerminalRef}
capability={agentTerminalCapability}
width={`var(--agent-terminal-w, ${agentTerminalResize.width}px)`}
side={agentTerminalSide}
placement={agentTerminalPlacement}
onSideChange={handleAgentTerminalSideChange}
onSessionActiveChange={setIsAgentTerminalRunning}
onSessionReadyChange={handleAgentTerminalReadyChange}
onClose={hideAgentTerminal}
/>
{isAgentTerminalVisible && (
<ResizeHandle
{...agentTerminalResize.handleProps}
className="hidden lg:block z-[55]"
side={agentTerminalPlacement}
hideHoverTrack
tooltip={RESIZE_HANDLE_TOOLTIP}
onCollapse={hideAgentTerminal}
/>
)}
</div>
) : null;
// Only greet in a normal authoring context — not on a read-only shared session
// (a viewer would also be able to flip the owner's gridEnabled), nor over the
// goal-setup / permission-mode flows. Deferred (not marked seen) until then.
const shouldShowLookAndFeelAnnouncement =
showLookAndFeelAnnouncement &&
!isSharedSession &&
!goalSetupMode &&
!showPermissionModeSetup;
const compactNavigatorTabs: SidebarTab[] = [
...(hasTocEntries ? ['toc' as const] : []),
...(!isHtmlSurface && activeDiffVersionInfo !== null && activeDiffVersionInfo.totalVersions > 1
? ['versions' as const]
: []),
...(annotateSource === 'message' && recentMessages.length > 1 ? ['messages' as const] : []),
...(showFilesTab && !archive.archiveMode ? ['files' as const] : []),
...(isApiMode && !annotateMode && !goalSetupMode ? ['archive' as const] : []),
];
const compactNavigatorAvailable = !goalSetupMode && compactNavigatorTabs.length > 0;
const effectiveCompactNavigatorTab = compactNavigatorTabs.includes(compactNavigatorTab)
? compactNavigatorTab
: (compactNavigatorTabs[0] ?? 'toc');
useEffect(() => {
if (isCompactNavigatorOpen && !compactNavigatorAvailable) {
closeCompactNavigator(false);
}
}, [closeCompactNavigator, compactNavigatorAvailable, isCompactNavigatorOpen]);
const handleNavigatorTabChange = (tab: SidebarTab) => {
if (isCompactTouchLayout) openSidebarTab(tab);
else toggleSidebarTab(tab);
if (tab === 'archive' && !archive.archiveMode) archive.fetchPlans();
};
const handleNavigatorFileSelect = async (...args: Parameters<typeof handleFileBrowserSelect>) => {
// Plan/review linked-doc browsing still swaps the root document under the
// editor. Folder mode snapshots the active file first.
if (isEditingMarkdown && annotateSource !== 'folder') {
toast('Finish editing first', { description: 'Use "Done editing" before opening files.' });
return;
}
// Wider annotatable types are view-only. Switching to one mid-edit would
// silently downgrade "Done editing" to feedback-only edits.
if (isEditingMarkdown && !isSourceSaveFilePath(args[0])) {
toast('Finish editing first', { description: 'Use "Done editing" before opening non-editable files.' });
return;
}
if (!isCompactTouchLayout) {
void handleFileBrowserSelect(...args);
return;
}
if (compactPendingFileRef.current) return;
const destination = args[0];
compactPendingFileRef.current = destination;
setCompactPendingFilePath(destination);
try {
await handleFileBrowserSelect(...args);
} catch {
// The shared fetch path reports expected failures through its error
// state. Treat an unexpected exception as the same retryable outcome.
}
if (compactPendingFileRef.current === destination) {
compactPendingFileRef.current = null;
setCompactPendingFilePath(null);
toast('Couldnt open file', { description: 'The file navigator is still open so you can try again.' });
}
};
const handleNavigatorArchiveSelect = (...args: Parameters<typeof archive.select>) => {
if (isEditingMarkdown) {
toast('Finish editing first', { description: 'Use "Done editing" before browsing archived plans.' });
return;
}
archive.select(...args);
if (isCompactTouchLayout) closeCompactNavigator();
};
const handleNavigatorMessageSelect = (messageId: string) => {
handleSelectMessage(messageId);
if (isCompactTouchLayout) closeCompactNavigator();
};
const handleNavigatorDiffActivate = () => {
handleActivatePlanDiff();
if (isCompactTouchLayout && !isEditingMarkdown) closeCompactNavigator();
};
const renderPlanSidebar = (presentation: 'desktop' | 'overlay') => {
const compact = presentation === 'overlay';
return (
<SidebarContainer
presentation={presentation}
activeTab={compact ? effectiveCompactNavigatorTab : sidebar.activeTab}
onTabChange={handleNavigatorTabChange}
onClose={compact ? closeCompactNavigator : sidebar.close}
width={compact ? '100%' : `var(--toc-w, ${tocResize.width}px)`}
showAgentTerminalButton={!compact && showAgentTerminalControls}
isAgentTerminalOpen={isAgentTerminalOpen}
isAgentTerminalRunning={isAgentTerminalRunning}
onToggleAgentTerminal={toggleAgentTerminal}
showContentsTab={!compact || hasTocEntries}
blocks={blocks}
annotations={annotations}
activeSection={activeSection}
onTocNavigate={(blockId) => {
handleTocNavigate(blockId);
if (compact) closeCompactNavigator();
}}
linkedDocFilepath={linkedDocHook.filepath}
onLinkedDocBack={linkedDocHook.isActive
? () => {
handleLinkedDocBack();
if (compact) closeCompactNavigator();
}
: undefined}
backLabel={backLabel}
showFilesTab={showFilesTab && !archive.archiveMode}
fileAnnotationCounts={fileAnnotationCounts}
highlightedFiles={highlightedFiles}
fileEditStatuses={editableDocuments.fileEditStatuses}
fileBrowser={fileBrowser}
onFilesSelectFile={handleNavigatorFileSelect}
onFilesFetchAll={() => fileBrowser.fetchAll(fileBrowserDirs)}
onFilesRetryVaultDir={(vaultPath) => fileBrowser.addVaultDir(vaultPath)}
pendingFileLabel={compact && compactPendingFilePath
? compactPendingFilePath.replace(/\\/g, '/').split('/').pop() || compactPendingFilePath
: null}
hasFileAnnotations={hasFileAnnotations}
showVersionsTab={!isHtmlSurface && activeDiffVersionInfo !== null && activeDiffVersionInfo.totalVersions > 1}
versionInfo={activeDiffVersionInfo}
versions={planDiff.versions}
selectedBaseVersion={planDiff.diffBaseVersion}
onSelectBaseVersion={handleSelectBaseVersion}
isPlanDiffActive={isPlanDiffActive}
hasPreviousVersion={planDiff.hasPreviousVersion}
onActivatePlanDiff={handleNavigatorDiffActivate}
isLoadingVersions={planDiff.isLoadingVersions}
isSelectingVersion={planDiff.isSelectingVersion}
fetchingVersion={planDiff.fetchingVersion}
onFetchVersions={planDiff.fetchVersions}
showArchiveTab={isApiMode && !annotateMode && !goalSetupMode}
archivePlans={archive.plans}
selectedArchiveFile={archive.selectedFile}
onArchiveSelect={handleNavigatorArchiveSelect}
isLoadingArchive={archive.isLoading}
showMessagesTab={annotateSource === 'message' && recentMessages.length > 1}
messages={recentMessages}
selectedMessageId={selectedMessageId}
onSelectMessage={handleNavigatorMessageSelect}
messageAnnotationCounts={activeMessageAnnotationCounts}
/>
);
};
const renderAnnotationPanel = (presentation: 'panel' | 'embedded', isOpen = true) => (
<AnnotationPanel
isOpen={isOpen}
presentation={presentation}
blocks={blocks}
annotations={allAnnotations}
selectedId={selectedAnnotationId ?? selectedCodeAnnotationId}
onSelect={handleSelectAnnotation}
onDelete={handleDeleteAnnotation}
onEdit={handleEditAnnotation}
codeAnnotations={codeAnnotations}
onSelectCodeAnnotation={handleSelectCodeAnnotation}
onDeleteCodeAnnotation={handleDeleteCodeAnnotation}
onEditCodeAnnotation={handleEditCodeAnnotation}
sharingEnabled={canShareCurrentSession}
width={presentation === 'panel' ? `var(--rpanel-w, ${panelResize.width}px)` : undefined}
editorAnnotations={editorAnnotations}
onDeleteEditorAnnotation={deleteEditorAnnotation}
unanchoredIds={isHtmlSurface && htmlUnanchoredIds.size > 0 ? htmlUnanchoredIds : undefined}
onClose={presentation === 'panel' ? () => setIsPanelOpen(false) : closeCompactPlanSurface}
onQuickCopy={async () => {
const output = getCurrentFeedbackPayload();
return copyTextToClipboard(wrapCopiedFeedback(output));
}}
onShare={canShareCurrentSession ? () => {
if (presentation === 'panel') setIsPanelOpen(false);
else closeCompactPlanSurface(false);
setInitialExportTab('share');
setShowExport(true);
} : undefined}
otherFileAnnotations={otherFileAnnotations}
directEdits={directEditsPanelInfo?.map((item) => ({
...item,
onDiscard: item.id === 'plan' ? () => handleDiscardEdits() : undefined,
})) ?? null}
onOtherFileAnnotationsClick={handleFlashAnnotatedFiles}
readOnly={documentReadOnly}
/>
);
const renderDocumentAIChat = () => (
<DocumentAIChatPanel
messages={visibleAIMessages}
isCreatingSession={isAgentTerminalReady ? false : aiIsCreatingSession}
isStreaming={isAgentTerminalReady ? false : aiIsStreaming}
onAskGeneral={handleAskGeneralAI}
onStop={isAgentTerminalReady ? undefined : abortAI}
permissionRequests={isAgentTerminalReady ? [] : aiPermissionRequests}
onRespondToPermission={isAgentTerminalReady ? undefined : respondToAIPermission}
aiProviders={visibleAIProviders}
aiConfig={visibleAIConfig}
onAIConfigChange={isAgentTerminalReady ? undefined : handleAIConfigChange}
/>
);
const showCompactPlanCompletion =
isCompactTouchLayout &&
compactPlanSurface.type === 'artifact' &&
compactReviewActions.length > 0 &&
!isEditingMarkdown &&
!isPlanDiffActive &&
!goalSetupMode &&
!isHtmlSurface &&
!(annotateSource === 'folder' && !markdown && !linkedDocHook.isActive);
// Mobile Safari paints the browser-controls backdrop from the document/app
// canvas, not from the nested document scroller. Keep that canvas continuous
// with the active surface so a card-backed plan does not end in a dark band.
const browserCanvas = isHtmlSurface || gridEnabled ? 'background' : 'card';
if (isLoading && !isSharedSession) {
return (
<ThemeProvider defaultTheme="dark" manageFavicon>
<div className="pn-app-viewport bg-background" />
</ThemeProvider>
);
}
return (
<ThemeProvider defaultTheme="dark" manageFavicon>
<TooltipProvider delayDuration={900} skipDelayDuration={200} disableHoverableContent>
<div
data-print-region="root"
data-pn-browser-canvas={browserCanvas}
data-pn-compact-touch-layout={usesDocumentScroll ? 'true' : undefined}
data-pn-document-scroll={usesDocumentScroll ? 'true' : undefined}
className={`pn-app-viewport flex flex-col ${usesDocumentScroll ? 'overflow-visible' : 'overflow-hidden'} ${browserCanvas === 'card' ? 'bg-card' : 'bg-background'}`}
>
<AppHeader
sticky={!usesDocumentScroll}
htmlSurface={isHtmlSurface}
htmlAnnotateArmed={htmlAnnotateArmed}
onToggleHtmlAnnotate={isHtmlSurface && !documentReadOnly ? handleHtmlAnnotateToggle : undefined}
htmlToolsHidden={htmlToolsHidden}
onToggleHtmlTools={isHtmlSurface ? () => setHtmlToolsHidden((v) => !v) : undefined}
canRefreshHtml={htmlRefresh.canRefresh}
isRefreshingHtml={htmlRefresh.isRefreshing}
onRefreshHtml={htmlRefresh.refresh}
compactTouchLayout={isCompactTouchLayout}
compactNavigatorAvailable={compactNavigatorAvailable}
compactNavigatorOpen={isCompactNavigatorOpen}
onCompactNavigatorToggle={() => toggleSidebarTab(effectiveCompactNavigatorTab)}
compactDocumentTitle={compactDocumentTitle}
compactSessionActions={compactSessionActions}
compactDocumentActions={compactDocumentActions}
isApiMode={isApiMode}
annotateMode={annotateMode}
archiveMode={archive.archiveMode}
goalSetupMode={goalSetupMode}
goalSetupCanSubmit={goalSetupAction.canSubmit}
goalSetupIsSubmitting={goalSetupAction.isSubmitting}
goalSetupSubmitLabel={goalSetupAction.submitLabel}
isSharedSession={isSharedSession}
origin={origin}
isSubmitting={isSubmitting}
isExiting={isExiting}
isPanelOpen={isRightPanelVisible && rightSidebarTab === 'annotations'}
aiAvailable={canUseAskAI}
isAIChatOpen={isRightPanelVisible && rightSidebarTab === 'ai'}
aiHasMessages={visibleAIMessages.length > 0}
annotationCount={feedbackAnnotationCount}
linkedDocIsActive={linkedDocHook.isActive}
callbackShareUrlReady={callbackShareUrlReady}
canShareCurrentSession={canShareCurrentSession}
agentName={agentName}
availableAgents={availableAgents}
showAnnotationsWarning={hasFeedbackToSend}
annotateDecision={annotateMode ? annotateDecision : undefined}
callbackConfig={callbackConfig}
taterMode={taterMode}
mobileSettingsOpen={mobileSettingsOpen}
gitUser={gitUser}
agentTerminalAvailable={showAgentTerminalControls}
webmcpAvailable={webmcp.available}
agentConnected={webmcpActivity.calls > 0}
onCallbackFeedback={handleCallbackFeedback}
onCallbackApprove={handleCallbackApprove}
onAnnotateExit={handleHeaderAnnotateExit}
onGoalSetupExit={handleGoalSetupExit}
onGoalSetupSubmit={handleGoalSetupSubmit}
onFeedback={handleHeaderFeedback}
onApprove={handleHeaderApprove}
onAnnotationPanelToggle={handleAnnotationPanelToggle}
onAIChatToggle={handleAIChatToggle}
onArchiveCopy={archive.copy}
onArchiveDone={archive.done}
onTaterModeChange={handleTaterModeChange}
onIdentityChange={handleIdentityChange}
onUIPreferencesChange={setUiPrefs}
onOpenSettings={handleOpenSettings}
onCloseSettings={handleCloseSettings}
onOpenExport={handleOpenExport}
onCopyAgentInstructions={handleHeaderCopyAgentInstructions}
onDownloadAnnotations={handleHeaderDownloadAnnotations}
onPrint={handlePrint}
onCopyShareLink={handleHeaderCopyShareLink}
onOpenImport={handleOpenImport}
onSaveToObsidian={handleSaveToObsidian}
onSaveToBear={handleSaveToBear}
onSaveToOctarine={handleSaveToOctarine}
appVersion={typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0'}
updateInfo={updateInfo}
isWSL={isWSL}
agentInstructionsEnabled={isApiMode && !archive.archiveMode && !annotateMode && !goalSetupMode}
obsidianConfigured={isObsidianConfigured()}
bearConfigured={getBearSettings().enabled}
octarineConfigured={isOctarineConfigured()}
/>
{/* The provider is render-transparent (context only, no DOM), so it can
open here without changing the shell's element structure or order.
It has to: the compact navigator renders the SAME TableOfContents as
the desktop rail, and a TOC outside this provider resolves a null
viewport, which makes every "jump to heading" tap a silent no-op. */}
<ScrollViewportProvider viewport={scrollViewport}>
{isCompactNavigatorOpen && compactNavigatorAvailable && renderPlanSidebar('overlay')}
{isCompactAnnotationsOpen && (
<CompactPlanStage
id="pn-compact-plan-annotations"
title="Annotations"
subtitle={compactDocumentTitle}
count={feedbackAnnotationCount}
onClose={closeCompactPlanSurface}
>
<div className="mx-auto flex min-h-0 w-full max-w-3xl flex-1">
{renderAnnotationPanel('embedded')}
</div>
</CompactPlanStage>
)}
{isCompactAIOpen && canUseAskAI && (
<CompactPlanStage
id="pn-compact-plan-ai"
title="Ask AI"
subtitle={compactDocumentTitle}
count={visibleAIMessages.length}
onClose={closeCompactPlanSurface}
>
<div className="mx-auto flex min-h-0 w-full max-w-3xl flex-1">
{renderDocumentAIChat()}
</div>
</CompactPlanStage>
)}
{isCompactReviewOpen && compactReviewActions.length > 0 && (
<CompactPlanStage
id="pn-compact-plan-review"
title="Review"
subtitle={compactDocumentTitle}
onClose={closeCompactPlanSurface}
>
<CompactPlanReview
feedbackSummary={compactFeedbackSummary}
actions={compactReviewActions}
primaryActionId={compactPrimaryReviewActionId}
onOpenAnnotations={() => switchCompactPlanSurface('annotations')}
onOpenAI={canUseAskAI ? () => switchCompactPlanSurface('ai') : undefined}
/>
</CompactPlanStage>
)}
{/* Linked document error banner */}
{linkedDocHook.error && (
<div className="bg-destructive/10 border-b border-destructive/20 px-4 py-2 flex items-center gap-2 flex-shrink-0">
<span className="text-xs text-destructive">{linkedDocHook.error}</span>
<button
onClick={linkedDocHook.dismissError}
className="ml-auto text-xs text-destructive/60 hover:text-destructive"
>
dismiss
</button>
</div>
)}
{activeEditableDocument?.diskConflict && (
<div className="bg-warning/10 border-b border-warning/25 px-4 py-2 flex items-center gap-3 flex-shrink-0">
<span className="min-w-0 flex-1 text-xs text-warning-foreground">
{activeEditableDocument.basename} changed on disk{isEditingMarkdown ? ' while you were editing' : ''}.
</span>
{canOverwriteDiskConflict && (
<button
type="button"
onClick={handleOverwriteDiskConflict}
className="text-xs font-medium text-primary hover:text-primary/80"
>
Overwrite disk
</button>
)}
<button
type="button"
onClick={handleReloadDiskConflict}
className="text-xs font-medium text-muted-foreground hover:text-foreground"
>
Reload from disk
</button>
</div>
)}
{activeEditableDocument?.missingOnDisk && !activeEditableDocument.diskConflict && (
<div className="bg-warning/10 border-b border-warning/25 px-4 py-2 flex items-center gap-3 flex-shrink-0">
<span className="min-w-0 flex-1 text-xs text-warning-foreground">
{activeEditableDocument.basename} no longer exists on disk. Save to recreate it.
</span>
<button
type="button"
onClick={() => { void handleSaveEditedSourceFile(); }}
disabled={activeSaveStatus === 'saving'}
className="text-xs font-medium text-primary hover:text-primary/80 disabled:cursor-not-allowed disabled:opacity-50"
>
Save
</button>
</div>
)}
{showAgentTerminalDeliveryStatus && (
<div className="border-b border-primary/20 bg-primary/5 px-4 py-2 text-xs text-muted-foreground flex-shrink-0">
<span className="font-medium text-foreground">Sent to agent.</span>{" "}
Keep this window open while it runs. Close Plannotator when you're done.
</div>
)}
{/* Main Content */}
<div data-print-region="content" className={`flex-1 flex ${usesDocumentScroll ? 'overflow-visible' : 'overflow-hidden'} relative z-0 ${isResizing ? 'select-none' : ''}`}>
{/* Tater sprites — inside content wrapper so z-0 stacking context applies */}
{taterMode && <TaterSpriteRunning />}
{showAgentTerminalOnLeft && agentTerminalPanel}
{/* Left Sidebar: collapsed tab flags (when sidebar is closed) */}
{!isCompactTouchLayout && wideModeType === null && !sidebar.isOpen && !goalSetupMode && !isLeftAgentTerminalVisible && !(isHtmlSurface && htmlToolsHidden) && (
<SidebarTabs
activeTab={sidebar.activeTab}
onToggleTab={toggleSidebarTab}
hasDiff={planDiff.hasPreviousVersion}
showVersionsTab={!isHtmlSurface && activeDiffVersionInfo !== null && activeDiffVersionInfo.totalVersions > 1}
showFilesTab={showFilesTab && !archive.archiveMode}
showMessagesTab={annotateSource === 'message' && recentMessages.length > 1}
showAgentTerminalTab={showAgentTerminalControls}
isAgentTerminalOpen={isAgentTerminalOpen}
isAgentTerminalRunning={isAgentTerminalRunning}
onToggleAgentTerminal={toggleAgentTerminal}
hasMessageAnnotations={activeMessageAnnotationCounts.size > 0}
hasFileAnnotations={hasFileAnnotations}
className="hidden lg:flex absolute left-0 top-0 z-20"
/>
)}
{/* Left Sidebar: open state (TOC or Version Browser) */}
{!isCompactTouchLayout && sidebar.isOpen && !goalSetupMode && (
<div className="contents group/sidebar">
{renderPlanSidebar('desktop')}
<ResizeHandle {...tocResize.handleProps} className="hidden lg:block z-[55]" side="left" hideHoverTrack tooltip={RESIZE_HANDLE_TOOLTIP} onCollapse={sidebar.close} />
</div>
)}
{/* Document Area */}
<OverlayScrollArea
element="main"
className={`flex-1 min-w-0 ${isHtmlSurface ? 'bg-background' : `${gridEnabled ? "bg-grid " : "bg-card "}${!goalSetupMode && !sidebar.isOpen && !isLeftAgentTerminalVisible && wideModeType === null ? 'lg:pl-[30px]' : ''}`}`}
data-print-region="document"
overflowX={usesDocumentScroll ? 'visible' : 'hidden'}
overflowY={usesDocumentScroll ? 'visible' : 'auto'}
onViewportReady={handleDocumentViewportReady}
>
<ConfirmDialog
isOpen={!!draftBanner}
onClose={dismissDraft}
onConfirm={handleRestoreDraft}
title="Draft Recovered"
message={draftBanner ? draftBannerMessage(draftBanner) : ''}
confirmText="Restore"
cancelText="Dismiss"
showCancel
/>
<div ref={planAreaRef} className={`${isHtmlSurface ? 'h-full flex flex-col' : 'min-h-full flex flex-col items-center px-2 py-3 md:px-10 md:py-8 xl:px-16'} relative z-10`}>
{/* Sticky header lane — ghost bar that pins the toolstrip +
badges at top: 12px once the user scrolls. Invisible at top
of doc; original toolstrip/badges remain the source of
truth there. Hidden in plan diff or archive mode, or when
sticky actions are disabled. remountToken re-anchors the
ResizeObserver when Viewer swaps content (linked docs or
message switches). */}
{!usesDocumentScroll && !goalSetupMode && !isPlanDiffActive && !isHtmlSurface && !archive.archiveMode && !isEditingMarkdown && uiPrefs.stickyActionsEnabled && (
<StickyHeaderLane
inputMethod={inputMethod}
onInputMethodChange={handleInputMethodChange}
mode={editorMode}
onModeChange={handleEditorModeChange}
taterMode={taterMode}
repoInfo={repoInfo}
planDiffStats={planDiff.diffStats}
isPlanDiffActive={isPlanDiffActive}
hasPreviousVersion={planDiff.hasPreviousVersion}
onPlanDiffToggle={() => setIsPlanDiffActive(!isPlanDiffActive)}
planDiffBaselineLabel={annotateMode ? 'since last review' : undefined}
planDiffBaselineTooltip={annotateMode ? 'Changes since you last reviewed this file' : undefined}
archiveInfo={archive.currentInfo}
maxWidth={annotateReaderMaxWidth}
remountToken={viewerContentKey}
/>
)}
{/* Annotation Toolstrip — the mode switcher (selection/redline input +
comment/markup mode). Markdown surfaces only: HTML/live surfaces
are comment-only with pinpoint + drag both live, so no floating
toolstrip ever overlays the rendered page. Hidden during plan
diff and archive browsing. */}
{toolstripVisible && (
<div
data-print-hide
className="w-full mb-3 md:mb-4 flex items-center justify-start"
style={annotateReaderMaxWidth == null ? undefined : { maxWidth: annotateReaderMaxWidth }}
>
{isCompactTouchLayout ? (
<CompactAnnotationControls
inputMethod={effectiveInputMethod}
onInputMethodChange={handleInputMethodChange}
/>
) : (
<AnnotationToolstrip
inputMethod={inputMethod}
onInputMethodChange={handleInputMethodChange}
mode={editorMode}
onModeChange={handleEditorModeChange}
taterMode={taterMode}
showHelpLink
/>
)}
</div>
)}
{/* Plan Diff View — rendered when diff data exists, hidden when inactive */}
{goalSetupBundle && (
<div className="w-full flex justify-center">
<GoalSetupSurface
ref={goalSetupSurfaceRef}
bundle={goalSetupBundle}
maxWidth={planMaxWidth}
onActionStateChange={setGoalSetupAction}
onSubmitted={() => setSubmitted('approved')}
/>
</div>
)}
{planDiff.diffBlocks && planDiff.diffStats && !goalSetupMode && (
<div className="w-full flex justify-center" style={{ display: isPlanDiffActive ? undefined : 'none' }}>
<PlanDiffViewer
diffBlocks={planDiff.diffBlocks}
diffStats={planDiff.diffStats}
diffMode={planDiffMode}
onDiffModeChange={setPlanDiffMode}
onPlanDiffToggle={() => setIsPlanDiffActive(false)}
repoInfo={repoInfo}
baseVersionLabel={planDiff.diffBaseVersion != null ? `v${planDiff.diffBaseVersion}` : undefined}
baseVersion={planDiff.diffBaseVersion ?? undefined}
maxWidth={planMaxWidth}
annotations={diffAnnotations}
onAddAnnotation={handleAddAnnotation}
onSelectAnnotation={handleSelectAnnotation}
selectedAnnotationId={selectedAnnotationId}
mode={effectiveEditorMode}
/>
</div>
)}
{/* Folder annotation empty state — shown before user picks a file */}
{annotateSource === 'folder' && !markdown && !linkedDocHook.isActive && !goalSetupMode && (
<FolderAnnotationEmptyState
compactTouchLayout={isCompactTouchLayout}
onChooseFile={() => openSidebarTab('files')}
/>
)}
{/* Normal Plan View — always mounted, hidden during diff mode */}
<div className={`w-full relative ${isHtmlSurface ? 'flex-1 flex flex-col' : `${isCompactTouchLayout && isEditingMarkdown ? 'flex flex-col items-center' : 'flex justify-center'}${isEditingMarkdown ? ' flex-1 min-h-0' : ''}`}`} style={{ display: goalSetupMode || (isPlanDiffActive && planDiff.diffBlocks) || (annotateSource === 'folder' && !markdown && !linkedDocHook.isActive) ? 'none' : undefined }}>
{!isCompactTouchLayout && (canUseWideMode || canEditMarkdown) && !isPlanDiffActive && !archive.archiveMode && !isHtmlSurface && (
<div
data-print-hide
className="absolute -top-5 left-0 right-0 mx-auto w-full flex justify-end pointer-events-none"
style={annotateReaderMaxWidth === null ? undefined : { maxWidth: annotateReaderMaxWidth ?? 832 }}
>
<div className={`pointer-events-auto flex items-center gap-1.5 text-[11px] tracking-wide ${taterMode ? 'mr-[60px]' : 'mr-[4px]'}`}>
{canUseWideMode && (['wide', 'focus'] as const).map((type, i) => (
<React.Fragment key={type}>
{i > 0 && <span aria-hidden className="text-muted-foreground/30 select-none">|</span>}
<Tooltip
side="top"
align="end"
content={type === 'wide' ? 'Hide panels and expand document width' : `Hide panels, keep document width (${modKey}+.)`}
>
<button
type="button"
onClick={() => toggleViewMode(type)}
aria-pressed={wideModeType === type}
className={`cursor-pointer rounded-sm transition-colors duration-150 outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background active:opacity-80 ${
wideModeType === type
? 'text-foreground'
: 'text-muted-foreground/50 hover:text-muted-foreground'
}`}
>
{type.charAt(0).toUpperCase() + type.slice(1)}
</button>
</Tooltip>
</React.Fragment>
))}
{canEditMarkdown && (
<>
{canUseWideMode && <span aria-hidden className="text-muted-foreground/30 select-none">|</span>}
{isEditingMarkdown && activeSourceSave && (
<>
<Tooltip
side="top"
align="end"
content={`Save changes to ${activeSourceSave.basename}`}
>
<button
type="button"
onClick={() => { void handleSaveEditedSourceFile(); }}
disabled={activeSaveStatus === 'saving'}
className={`flex items-center gap-1 cursor-pointer rounded-sm transition-colors duration-150 outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background active:opacity-80 disabled:cursor-not-allowed disabled:opacity-50 ${
saveFailed
? 'text-destructive'
: emphasizeSave
? 'text-primary'
: 'text-muted-foreground/50 hover:text-muted-foreground'
}`}
>
{/* Invisible widest label reserves the width so Save/Saving/Saved
swap without nudging neighbors (font-agnostic, no fixed px). */}
<span className="grid justify-items-start">
<span aria-hidden className="invisible col-start-1 row-start-1">Saving</span>
<span className="col-start-1 row-start-1">
{activeSaveStatus === 'saving'
? 'Saving'
: hasUnsavedDiskChanges
? 'Save'
: 'Saved'}
</span>
</span>
{/* Dot slot is always present — only its color changes — so the
button never reflows when edits appear/clear. */}
<span
aria-hidden
className={`h-1.5 w-1.5 shrink-0 rounded-full transition-colors duration-150 ${
saveFailed ? 'bg-destructive' : emphasizeSave ? 'bg-primary' : 'bg-transparent'
}`}
/>
</button>
</Tooltip>
<span aria-hidden className="text-muted-foreground/30 select-none">|</span>
</>
)}
<Tooltip
side="top"
align="end"
content={
!isEditingMarkdown
? 'Edit the document text directly'
: cancelMode
? 'Discard your edits and stop editing'
: 'Commit your edits and return to annotating'
}
>
<button
type="button"
onClick={handleEditExitClick}
aria-pressed={isEditingMarkdown}
className={`cursor-pointer rounded-sm transition-colors duration-150 outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background active:opacity-80 ${
cancelMode
? (confirmCancelEdits
? 'text-destructive'
: 'text-muted-foreground/70 hover:text-foreground')
: isEditingMarkdown
? 'text-primary'
: 'text-muted-foreground/50 hover:text-muted-foreground'
}`}
>
{!isEditingMarkdown
? 'Edit'
: cancelMode
? (confirmCancelEdits ? 'Discard?' : 'Cancel')
: 'Done'}
</button>
</Tooltip>
</>
)}
</div>
</div>
)}
{isCompactTouchLayout && isEditingMarkdown && !isHtmlSurface && (
<CompactEditControls
documentTitle={compactDocumentTitle}
sourceBacked={!!activeSourceSave}
saveStatus={activeSaveStatus}
cancelMode={cancelMode}
confirmDiscard={confirmCancelEdits}
onSave={() => { void handleSaveEditedSourceFile(); }}
onExit={handleEditExitClick}
/>
)}
{renderAs === 'html' ? (
<HtmlViewer
key={`${liveApp ? 'live-app' : linkedDocHook.isActive ? `doc:${linkedDocHook.filepath}` : 'plan'}${isPlanDiffActive && htmlDiffHtml ? ':diff' : ''}:reload-${htmlRefresh.reloadGeneration}`}
ref={viewerRef}
rawHtml={isPlanDiffActive && htmlDiffHtml ? htmlDiffHtml : rawHtml}
src={liveApp?.appUrl}
liveSession={liveApp ? { origin: liveApp.origin, token: liveApp.token } : undefined}
currentPageUrl={liveApp ? livePageUrl : undefined}
onPageChange={liveApp ? setLivePageUrl : undefined}
annotations={viewerAnnotations}
onAddAnnotation={handleAddAnnotation}
onSelectAnnotation={handleSelectAnnotation}
selectedAnnotationId={selectedAnnotationId}
mode={effectiveEditorMode}
// HTML/live surfaces are always pinpoint: armed = click
// pins an element AND drag selects text (both live at
// once); Interact (Esc) keeps clicks native while drag
// commenting stays available. No input-method switch.
inputMethod="pinpoint"
annotateModeActive={htmlAnnotateArmed}
onAnnotateModeExit={documentReadOnly ? undefined : handleHtmlAnnotateExit}
onAnnotateModeToggle={documentReadOnly ? undefined : handleHtmlAnnotateToggle}
vimModeEnabled={liveApp ? false : vimModeEnabled && htmlAnnotateArmed}
vimHudEnabled={!liveApp && vimModeEnabled && htmlAnnotateArmed && vimHudEnabled}
vimHudKeyPanelEnabled={vimHudKeyPanelEnabled}
onVimHudKeyPanelChange={handleVimHudKeyPanelChange}
globalAttachments={globalAttachments}
onAddGlobalAttachment={handleAddGlobalAttachment}
onRemoveGlobalAttachment={handleRemoveGlobalAttachment}
maxWidth={isHtmlSurface ? null : annotateReaderMaxWidth}
fullViewport={isHtmlSurface}
// Applied on every layout: desktop has the header eye
// toggle, and the compact touch shell has the Options
// menu "Show tools" action (compactDocumentActions), so
// a restored toolsHidden:true always has a way back.
hideControls={isHtmlSurface && htmlToolsHidden}
diffAvailable={!liveApp && !!htmlDiffHtml}
diffActive={!liveApp && isPlanDiffActive && !!htmlDiffHtml}
onToggleDiff={() => setIsPlanDiffActive((v) => !v)}
onAskAI={canUseDocumentAskAI ? handleAskAI : undefined}
onUnanchoredChange={htmlRefresh.reportAnnotationRestore}
readOnly={documentReadOnly}
/>
) : isEditingMarkdown ? (
<MarkdownEditor
markdown={displayedMarkdown}
documentId={`edit:${activeEditableDocument?.key ?? 'root'}:${editGeneration}`}
editorHandleRef={markdownEditorHandleRef}
onMarkdownChange={handleEditorChange}
maxWidth={annotateReaderMaxWidth}
gridEnabled={gridEnabled}
/>
) : (
<Viewer
key={viewerContentKey}
ref={viewerRef}
blocks={blocks}
markdown={displayedMarkdown}
frontmatter={frontmatter}
annotations={viewerAnnotations}
onAddAnnotation={handleAddAnnotation}
onSelectAnnotation={handleSelectAnnotation}
selectedAnnotationId={selectedAnnotationId}
mode={effectiveEditorMode}
inputMethod={effectiveInputMethod}
vimModeEnabled={vimModeEnabled}
vimHudEnabled={vimModeEnabled && vimHudEnabled}
vimHudKeyPanelEnabled={vimHudKeyPanelEnabled}
onVimHudKeyPanelChange={handleVimHudKeyPanelChange}
taterMode={taterMode}
gridEnabled={gridEnabled}
globalAttachments={globalAttachments}
onAddGlobalAttachment={handleAddGlobalAttachment}
onRemoveGlobalAttachment={handleRemoveGlobalAttachment}
repoInfo={repoInfo}
stickyActions={uiPrefs.stickyActionsEnabled && !usesDocumentScroll}
planDiffStats={planDiff.diffStats}
isPlanDiffActive={isPlanDiffActive}
onPlanDiffToggle={() => setIsPlanDiffActive(!isPlanDiffActive)}
hasPreviousVersion={planDiff.hasPreviousVersion}
planDiffBaselineLabel={annotateMode ? 'since last review' : undefined}
planDiffBaselineTooltip={annotateMode ? 'Changes since you last reviewed this file' : undefined}
showDemoBadge={!isApiMode && !isLoadingShared && !isSharedSession}
maxWidth={annotateReaderMaxWidth}
onOpenLinkedDoc={handleOpenLinkedDoc}
onOpenCodeFile={codeFilePopout.open}
linkedDocInfo={
linkedDocHook.isActive
? {
filepath: linkedDocHook.filepath!,
onBack: handleLinkedDocBack,
label: annotateSource === 'folder'
? undefined
: fileBrowser.dirs.find(d => d.path === fileBrowser.activeDirPath)?.isVault
? 'Vault File'
: fileBrowser.activeFile ? 'File' : undefined,
backLabel,
variant: annotateSource === 'folder' ? 'folder-file' : 'breadcrumb',
}
: null
}
imageBaseDir={imageBaseDir}
codePathBaseDir={activeDocBaseDir}
copyLabel={annotateSource === 'message' ? 'Copy message' : annotateSource === 'file' || annotateSource === 'folder' ? 'Copy file' : undefined}
archiveInfo={archive.currentInfo}
sourceInfo={sourceInfo}
openInAppPath={annotateMode ? (linkedDocHook.isActive ? (linkedDocHook.filepath ?? null) : sourceFilePath) : null}
messagePickerInfo={
annotateSource === 'message' && recentMessages.length > 1
? {
// selectedMessageId is always one of recentMessages (set on init,
// only changed via handleSelectMessage), so findIndex is >= 0.
current: recentMessages.findIndex((m) => m.messageId === selectedMessageId) + 1,
total: recentMessages.length,
onOpen: () => openSidebarTab('messages'),
}
: undefined
}
onToggleCheckbox={checkbox.toggle}
checkboxOverrides={checkbox.overrides}
actionsLabelMode={actionsLabelMode}
onAskAI={canUseDocumentAskAI ? handleAskAI : undefined}
readOnly={documentReadOnly}
/>
)}
</div>
{showCompactPlanCompletion && (
<CompactPlanCompletion
feedbackSummary={compactFeedbackSummary}
maxWidth={planMaxWidth}
onOpenReview={() => openCompactPlanSurface('review')}
/>
)}
</div>
</OverlayScrollArea>
{showAgentTerminalOnRight && agentTerminalPanel}
{/* Right panel region — `group/sidebar` so the collapse button reveals when
hovering the whole panel, not just the thin handle. The handle and the
panel(s) are separate sibling conditionals, so they need a shared hover
ancestor (`contents` = no layout box). */}
<div className="contents group/sidebar">
{/* Resize Handle */}
{isRightPanelVisible && wideModeType === null && !goalSetupMode && (rightSidebarTab === 'annotations' || canUseAskAI) && <ResizeHandle {...panelResize.handleProps} className="hidden md:block z-[55]" side="right" hideHoverTrack tooltip={RESIZE_HANDLE_TOOLTIP} onCollapse={() => setIsPanelOpen(false)} />}
{/* Annotation Panel */}
{renderAnnotationPanel(
'panel',
isRightPanelVisible && rightSidebarTab === 'annotations' && wideModeType === null && !goalSetupMode,
)}
{isRightPanelVisible && rightSidebarTab === 'ai' && wideModeType === null && !goalSetupMode && canUseAskAI && (
<aside
data-annotation-panel="true"
className={`border-l border-border/50 bg-card flex flex-col flex-shrink-0 ${
isMobile ? 'fixed top-12 bottom-0 right-0 z-[60] w-full max-w-sm shadow-2xl bg-card' : ''
}`}
style={isMobile ? undefined : { width: `var(--rpanel-w, ${panelResize.width ?? 288}px)` }}
>
<div className="border-b border-border/50">
<div className="flex h-10 items-center justify-between px-3">
<div className="flex items-center gap-2 min-w-0">
<SparklesIcon className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
<h2 className="text-xs font-medium text-foreground">
AI
</h2>
{visibleAIMessages.length > 0 && (
<span className="flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-primary/10 px-1 font-mono text-[10px] font-medium tabular-nums text-primary">
{visibleAIMessages.length}
</span>
)}
</div>
{isMobile && (
<button
onClick={() => setIsPanelOpen(false)}
className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-foreground md:hidden"
title="Close panel"
aria-label="Close AI panel"
>
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
{renderDocumentAIChat()}
</aside>
)}
</div>
</div>
</ScrollViewportProvider>
{/* Code File Popout */}
{codeFilePopout.popoutProps && (
<CodeFilePopout
{...codeFilePopout.popoutProps}
annotations={codeAnnotations.filter((ann) => ann.filePath === codeFilePopout.popoutProps?.filepath)}
selectedAnnotationId={selectedCodeAnnotationId}
onAddAnnotation={documentReadOnly ? undefined : handleAddCodeAnnotation}
onEditAnnotation={documentReadOnly ? undefined : handleEditCodeAnnotation}
onDeleteAnnotation={documentReadOnly ? undefined : handleDeleteCodeAnnotation}
onSelectAnnotation={(id) => {
setSelectedAnnotationId(null);
setSelectedCodeAnnotationId(id);
}}
/>
)}
{/* Export Modal */}
<ExportModal
isOpen={showExport}
onClose={() => { setShowExport(false); setInitialExportTab(undefined); }}
shareUrl={shareUrl}
shareUrlSize={shareUrlSize}
shortShareUrl={shortShareUrl}
isGeneratingShortUrl={isGeneratingShortUrl}
shortUrlError={shortUrlError}
onGenerateShortUrl={generateShortUrl}
annotationsOutput={
// Computed only while the modal is open: composeFeedback runs a
// unified diff when edits exist — not per-render work.
showExport
? getCurrentFeedbackPayload()
: ''
}
annotationCount={allAnnotations.length + codeAnnotations.length}
taterSprite={taterMode ? <TaterSpritePullup /> : undefined}
sharingEnabled={canShareCurrentSession}
markdown={markdown}
isApiMode={isApiMode && !documentReadOnly}
initialTab={initialExportTab}
wrapCopiedAnnotations={wrapCopiedFeedback}
/>
{/* Import Modal */}
<ImportModal
isOpen={showImport}
onClose={() => setShowImport(false)}
onImport={importFromShareUrl}
shareBaseUrl={shareBaseUrl}
/>
{/* Feedback prompt dialog */}
<ConfirmDialog
isOpen={showFeedbackPrompt}
onClose={() => setShowFeedbackPrompt(false)}
title="Add Feedback First"
message={
canEditMarkdown
? `To provide feedback, add annotations or direct edits. ${agentName} will use your feedback to revise the ${annotateMode ? 'document' : 'plan'}.`
: `To provide feedback, select text and add annotations. ${agentName} will use your annotations to revise the ${annotateMode ? 'document' : 'plan'}.`
}
variant="info"
/>
{/* Unsaved source-file edit warning dialog */}
<ConfirmDialog
isOpen={showSourceFileEditWarning}
onClose={closeSourceFileEditWarning}
onConfirm={confirmSourceFileEditWarning}
title={sourceFileEditWarningAction === 'close' ? 'Unsaved File Edits' : "File Edits Won't Be Sent"}
message={
sourceFileEditWarningAction === 'close'
? <>You have unsaved file edits. They are not saved to disk and will be lost if you close this session.</>
: <>You have unsaved file edits. They are not saved to disk, and {agentName} won't get them if you {sourceFileEditWarningAction === 'approve' ? 'approve' : 'send feedback'}.</>
}
subMessage="Save or discard the file edits first if you want Plannotator to keep them."
confirmText={
sourceFileEditWarningAction === 'approve'
? 'Approve Anyway'
: sourceFileEditWarningAction === 'close'
? 'Close Anyway'
: 'Send Anyway'
}
cancelText="Cancel"
variant="warning"
showCancel
/>
{/* Claude Code feedback warning dialog */}
<ConfirmDialog
isOpen={showClaudeCodeWarning}
onClose={() => setShowClaudeCodeWarning(false)}
onConfirm={() => {
setShowClaudeCodeWarning(false);
handleApprove();
}}
title="Feedback Won't Be Sent"
message={
hasOnlySavedFileChanges
? <>{agentName} doesn't yet support feedback on approval. {savedFileAwarenessOnlyMessage}</>
: <>{agentName} doesn't yet support feedback on approval. Your {feedbackLoss} will be lost.{savedFileAwarenessMixedMessage}</>
}
subMessage={
<>
To send feedback, use <strong>Send Feedback</strong> instead.
<br /><br />
Want this feature? Upvote these issues:
<br />
<a href="https://github.com/anthropics/claude-code/issues/16001" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">#16001</a>
{' · '}
<a href="https://github.com/anthropics/claude-code/issues/15755" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">#15755</a>
</>
}
confirmText="Approve Anyway"
cancelText="Cancel"
variant="warning"
showCancel
/>
{/* Unsent feedback warning dialog — the ghost X still warns when
content would be lost. The approve flavour is gone: approving away
feedback is now the explicit discard menu item with its own
confirm inside the decision control. */}
<ConfirmDialog
isOpen={showExitWarning}
onClose={() => setShowExitWarning(false)}
onConfirm={() => {
setShowExitWarning(false);
handleAnnotateExit();
}}
title="Feedback Won't Be Sent"
message={
hasOnlySavedFileChanges
? <>{savedFileChangesOnDiskMessage} The agent will not get that context if you close.</>
: <>You have {feedbackLoss} that will be lost if you close.{savedFileAwarenessMixedMessage}</>
}
subMessage={hasOnlySavedFileChanges ? 'To tell the agent what changed, use Send Feedback instead.' : 'To send this feedback, use Send Feedback instead.'}
confirmText="Close Anyway"
cancelText="Cancel"
variant="warning"
showCancel
/>
{/* Compact/touch decision surfaces: the note composer is a dialog
(never a textarea inside the scrolling header menu popup), the
discard confirm is the same ConfirmDialog the desktop control
raises. Desktop popover state lives inside DecisionControl. */}
{compactComposerItem?.composer && (
<DecisionNoteDialog
isOpen
onClose={() => setCompactDecisionComposer(null)}
composer={compactComposerItem.composer}
subtitle={compactComposerItem.subtitle}
disabled={isSubmitting || isExiting}
onSubmit={(note) => {
const item = compactComposerItem;
setCompactDecisionComposer(null);
runAnnotateDecisionAction(item.id, note);
}}
/>
)}
{compactConfirmItem?.confirm && (
<ConfirmDialog
isOpen
onClose={() => setCompactDecisionConfirm(null)}
onConfirm={() => {
const item = compactConfirmItem;
setCompactDecisionConfirm(null);
runAnnotateDecisionAction(item.id);
}}
title={compactConfirmItem.confirm.title}
message={compactConfirmItem.confirm.message}
confirmText={compactConfirmItem.confirm.confirmText}
cancelText="Cancel"
variant="warning"
showCancel
/>
)}
{/* OpenCode agent not found warning dialog */}
<ConfirmDialog
isOpen={showAgentWarning}
onClose={() => setShowAgentWarning(false)}
onConfirm={() => {
setShowAgentWarning(false);
handleApprove();
}}
title="Agent Not Found"
message={agentWarningMessage}
subMessage={
<>
You can change the agent in <strong>Settings</strong>, or approve anyway and OpenCode will use the default agent.
</>
}
confirmText="Approve Anyway"
cancelText="Cancel"
variant="warning"
showCancel
/>
{/* Shared URL load failure warning */}
<ConfirmDialog
isOpen={!!shareLoadError && !isApiMode}
onClose={clearShareLoadError}
title="Shared Plan Could Not Be Loaded"
message={shareLoadError}
subMessage="You are viewing a demo plan. This is sample content it is not your data or anyone else's."
variant="warning"
/>
<Toaster
position="top-right"
offset={64}
toastOptions={{
style: {
'--normal-bg': 'var(--card)',
'--normal-border': 'var(--border)',
'--normal-text': 'var(--foreground)',
'--success-bg': 'oklch(from var(--success) l c h / 0.15)',
'--success-border': 'oklch(from var(--success) l c h / 0.3)',
'--success-text': 'var(--success)',
'--error-bg': 'oklch(from var(--destructive) l c h / 0.15)',
'--error-border': 'oklch(from var(--destructive) l c h / 0.3)',
'--error-text': 'var(--destructive)',
} as React.CSSProperties,
}}
/>
{/* Browser-agent nudge (WebMCP nudge_user): one transient banner,
rendered only while a message exists. */}
{agentNudge && (
<AgentNudgeBanner
key={agentNudge.key}
message={agentNudge.message}
onDismiss={() => setAgentNudge(null)}
onShowComments={agentHasComments && !isPanelOpen ? () => { setIsPanelOpen(true); setAgentNudge(null); } : undefined}
/>
)}
{/* Completion overlay - shown after approve/deny */}
<CompletionOverlay
submitted={submitted}
title={
archive.archiveMode ? 'Archive Closed'
: submitted === 'exited' ? 'Session Closed'
: goalSetupMode ? 'Answers Submitted'
: submitted === 'approved'
? (annotateMode ? 'Approved' : 'Plan Approved')
: annotateMode ? 'Feedback Sent'
: 'Feedback Sent'
}
subtitle={
submitted === 'exited'
? 'Annotation session closed without feedback.'
: archive.archiveMode
? 'You can reopen with plannotator archive.'
: goalSetupMode
? `${agentName} will use your answers to continue.`
: submitted === 'approved'
? (annotateMode
? `${agentName} will proceed.`
: `${agentName} will proceed with the implementation.`)
: annotateMode
? `${agentName} will address your feedback on the ${annotateSource === 'message' ? 'message' : annotateSource === 'folder' ? 'files' : 'file'}.`
: `${agentName} will revise the plan based on your feedback.`
}
agentLabel={agentName}
/>
<LookAndFeelAnnouncementDialog
isOpen={shouldShowLookAndFeelAnnouncement}
gridEnabled={gridEnabled}
onToggleGrid={(v) => configStore.set('gridEnabled', v)}
onDismiss={dismissLookAndFeelAnnouncement}
/>
{/* Image Annotator for pasted images */}
<ImageAnnotator
isOpen={!!pendingPasteImage}
imageSrc={pendingPasteImage?.blobUrl ?? ''}
initialName={pendingPasteImage?.initialName}
onAccept={handlePasteAnnotatorAccept}
onClose={handlePasteAnnotatorClose}
/>
{/* Permission Mode Setup (Claude Code first-time) */}
<PermissionModeSetup
isOpen={showPermissionModeSetup}
onComplete={(mode) => {
setPermissionMode(mode);
setShowPermissionModeSetup(false);
}}
/>
</div>
</TooltipProvider>
</ThemeProvider>
);
};
export default App;