Optimize plan editor rendering (#696)

- Replace blocks/frontmatter useState + useEffect with useMemo (eliminates mount cascade)
- Migrate toast notifications from React state to Sonner (App no longer re-renders for toasts)
- Extract AppHeader component with React.memo (header no longer re-renders on unrelated state changes)
- Fix color transition flash on initial load (suppress global * transition until mount settles)
- Add Tailwind @source path for new editor components directory
This commit is contained in:
Michael Ramos
2026-05-11 08:55:18 -04:00
committed by GitHub
parent 579127e2c9
commit f4493fc6ff
8 changed files with 527 additions and 275 deletions
+2
View File
@@ -492,6 +492,8 @@ bun run package:vscode # Package .vsix for marketplace
bun run build # Build hook + opencode (main targets)
```
**Important: Tailwind `@source` paths.** When creating new directories that contain `.tsx` files with Tailwind classes, add a matching `@source` entry to the app's `index.css`. Tailwind only generates CSS for classes it finds in scanned files — missing paths means classes appear in the DOM but have no effect.
**Important: Build order matters.** The hook build (`build:hook`) copies pre-built HTML from `apps/review/dist/`. If you change UI code in `packages/ui/`, `packages/editor/`, or `packages/review-editor/`, you **must** rebuild the review app first, then the hook:
```bash
+3
View File
@@ -13,6 +13,7 @@
"dockview-react": "^5.2.0",
"dompurify": "^3.3.3",
"marked": "^17.0.6",
"sonner": "^2.0.7",
},
"devDependencies": {
"@types/dompurify": "^3.2.0",
@@ -2386,6 +2387,8 @@
"socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="],
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
+2 -1
View File
@@ -43,7 +43,8 @@
"diff": "^8.0.4",
"dockview-react": "^5.2.0",
"dompurify": "^3.3.3",
"marked": "^17.0.6"
"marked": "^17.0.6",
"sonner": "^2.0.7"
},
"devDependencies": {
"@types/dompurify": "^3.2.0",
+187 -269
View File
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from 'react';
import { toast, Toaster } from 'sonner';
import { type Origin, getAgentName } from '@plannotator/shared/agents';
import { parseMarkdownToBlocks, exportAnnotations, exportLinkedDocAnnotations, exportEditorAnnotations, exportCodeFileAnnotations, extractFrontmatter, wrapFeedbackForAgent, Frontmatter, type LinkedDocAnnotationEntry } from '@plannotator/ui/utils/parser';
import { Viewer, ViewerHandle } from '@plannotator/ui/components/Viewer';
@@ -13,11 +14,8 @@ import { AnnotationToolstrip } from '@plannotator/ui/components/AnnotationToolst
import { StickyHeaderLane } from '@plannotator/ui/components/StickyHeaderLane';
import { TaterSpriteRunning } from '@plannotator/ui/components/TaterSpriteRunning';
import { TaterSpritePullup } from '@plannotator/ui/components/TaterSpritePullup';
import { Settings } from '@plannotator/ui/components/Settings';
import { FeedbackButton, ApproveButton, ExitButton } from '@plannotator/ui/components/ToolbarButtons';
import { ApproveDropdown } from '@plannotator/ui/components/ApproveDropdown';
import { useSharing } from '@plannotator/ui/hooks/useSharing';
import { getCallbackConfig, CallbackAction, executeCallback, type ToastPayload } from '@plannotator/ui/utils/callback';
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';
@@ -41,7 +39,6 @@ import { OverlayScrollArea } from '@plannotator/ui/components/OverlayScrollArea'
import { ScrollViewportContext } from '@plannotator/ui/hooks/useScrollViewport';
import { useOverlayViewport } from '@plannotator/ui/hooks/useOverlayViewport';
import { useIsMobile } from '@plannotator/ui/hooks/useIsMobile';
import { PlanHeaderMenu } from '@plannotator/ui/components/PlanHeaderMenu';
import {
getPermissionModeSettings,
needsPermissionModeSetup,
@@ -84,6 +81,7 @@ const DEMO_PLAN_CONTENT = USE_DIFF_DEMO
? DIFF_DEMO_PLAN_CONTENT
: DEFAULT_DEMO_PLAN_CONTENT;
import { useCheckboxOverrides } from './hooks/useCheckboxOverrides';
import { AppHeader } from './components/AppHeader';
type NoteAutoSaveResults = {
obsidian?: boolean;
@@ -97,8 +95,8 @@ const App: React.FC = () => {
const [codeAnnotations, setCodeAnnotations] = useState<CodeAnnotation[]>([]);
const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null);
const [selectedCodeAnnotationId, setSelectedCodeAnnotationId] = useState<string | null>(null);
const [blocks, setBlocks] = useState<Block[]>([]);
const [frontmatter, setFrontmatter] = useState<Frontmatter | null>(null);
const frontmatter = useMemo(() => extractFrontmatter(markdown).frontmatter, [markdown]);
const blocks = useMemo(() => parseMarkdownToBlocks(markdown), [markdown]);
const [showExport, setShowExport] = useState(false);
const [showImport, setShowImport] = useState(false);
const [showFeedbackPrompt, setShowFeedbackPrompt] = useState(false);
@@ -180,7 +178,6 @@ const App: React.FC = () => {
}, [repoInfo]);
const [initialExportTab, setInitialExportTab] = useState<'share' | 'annotations' | 'notes'>();
const [noteSaveToast, setNoteSaveToast] = useState<ToastPayload>(null);
const [isPlanDiffActive, setIsPlanDiffActive] = useState(false);
const [planDiffMode, setPlanDiffMode] = useState<PlanDiffMode>('clean');
const [previousPlan, setPreviousPlan] = useState<string | null>(null);
@@ -682,10 +679,10 @@ const App: React.FC = () => {
}
}, [pendingSharedAnnotations, clearPendingSharedAnnotations, resetExternalHighlights]);
const handleTaterModeChange = (enabled: boolean) => {
const handleTaterModeChange = useCallback((enabled: boolean) => {
setTaterMode(enabled);
storage.setItem('plannotator-tater-mode', String(enabled));
};
}, []);
const handleEditorModeChange = (mode: EditorMode) => {
setEditorMode(mode);
@@ -790,12 +787,6 @@ const App: React.FC = () => {
.finally(() => setIsLoading(false));
}, [isLoadingShared, isSharedSession]);
useEffect(() => {
const { frontmatter: fm } = extractFrontmatter(markdown);
setFrontmatter(fm);
setBlocks(parseMarkdownToBlocks(markdown));
}, [markdown]);
// Auto-save to notes apps on plan arrival (each gated by its autoSave toggle)
const autoSaveAttempted = useRef(false);
const autoSaveResultsRef = useRef<NoteAutoSaveResults>({});
@@ -868,19 +859,18 @@ const App: React.FC = () => {
const failed = targets.filter(t => !data.results?.[t.toLowerCase()]?.success);
if (failed.length === 0) {
setNoteSaveToast({ type: 'success', message: `Auto-saved to ${targets.join(' & ')}` });
toast.success(`Auto-saved to ${targets.join(' & ')}`);
} else {
setNoteSaveToast({ type: 'error', message: `Auto-save failed for ${failed.join(' & ')}` });
toast.error(`Auto-save failed for ${failed.join(' & ')}`);
}
return results;
})
.catch(() => {
autoSaveResultsRef.current = {};
setNoteSaveToast({ type: 'error', message: 'Auto-save failed' });
toast.error('Auto-save failed');
return {};
})
.finally(() => setTimeout(() => setNoteSaveToast(null), 3000));
});
autoSavePromiseRef.current = autoSavePromise;
}, [isApiMode, markdown, isSharedSession, annotateMode]);
@@ -1257,14 +1247,14 @@ const App: React.FC = () => {
));
};
const handleIdentityChange = (oldIdentity: string, newIdentity: string) => {
const handleIdentityChange = useCallback((oldIdentity: string, newIdentity: string) => {
setAnnotations(prev => prev.map(ann =>
ann.author === oldIdentity ? { ...ann, author: newIdentity } : ann
));
setCodeAnnotations(prev => prev.map(ann =>
ann.author === oldIdentity ? { ...ann, author: newIdentity } : ann
));
};
}, []);
const handleAddGlobalAttachment = (image: ImageAttachment) => {
setGlobalAttachments(prev => [...prev, image]);
@@ -1343,12 +1333,13 @@ const App: React.FC = () => {
if (!callbackConfig || isSubmitting || !shareUrl) return;
setIsSubmitting(true);
try {
const toast = await executeCallback(action, callbackConfig, shareUrl);
if (toast) {
setNoteSaveToast(toast);
setTimeout(() => setNoteSaveToast(null), 4000);
if (toast.type === 'success') {
const result = await executeCallback(action, callbackConfig, shareUrl);
if (result) {
if (result.type === 'success') {
toast.success(result.message);
setSubmitted(action === CallbackAction.Approve ? 'approved' : 'denied');
} else {
toast.error(result.message);
}
}
} finally {
@@ -1368,8 +1359,7 @@ const App: React.FC = () => {
a.download = 'annotations.md';
a.click();
URL.revokeObjectURL(url);
setNoteSaveToast({ type: 'success', message: 'Downloaded annotations' });
setTimeout(() => setNoteSaveToast(null), 3000);
toast.success('Downloaded annotations');
};
const handleQuickSaveToNotes = async (target: 'obsidian' | 'bear' | 'octarine') => {
@@ -1415,14 +1405,13 @@ const App: React.FC = () => {
const data = await res.json();
const result = data.results?.[target];
if (result?.success) {
setNoteSaveToast({ type: 'success', message: `Saved to ${targetName}` });
toast.success(`Saved to ${targetName}`);
} else {
setNoteSaveToast({ type: 'error', message: result?.error || 'Save failed' });
toast.error(result?.error || 'Save failed');
}
} catch {
setNoteSaveToast({ type: 'error', message: 'Save failed' });
toast.error('Save failed');
}
setTimeout(() => setNoteSaveToast(null), 3000);
};
// Agent Instructions — copy a clipboard payload teaching external agents
@@ -1433,21 +1422,19 @@ const App: React.FC = () => {
const payload = buildPlanAgentInstructions(window.location.origin);
try {
await navigator.clipboard.writeText(payload);
setNoteSaveToast({ type: 'success', message: 'Agent instructions copied' });
toast.success('Agent instructions copied');
} catch {
setNoteSaveToast({ type: 'error', message: 'Failed to copy' });
toast.error('Failed to copy');
}
setTimeout(() => setNoteSaveToast(null), 3000);
};
const handleCopyShareLink = async () => {
try {
await navigator.clipboard.writeText(shareUrl);
setNoteSaveToast({ type: 'success', message: 'Share link copied' });
toast.success('Share link copied');
} catch {
setNoteSaveToast({ type: 'error', message: 'Failed to copy' });
toast.error('Failed to copy');
}
setTimeout(() => setNoteSaveToast(null), 3000);
};
// Cmd/Ctrl+S keyboard shortcut — save to default notes app
@@ -1518,6 +1505,98 @@ const App: React.FC = () => {
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(() => {
if (hasAnyAnnotations) {
setExitWarningAction('close');
setShowExitWarning(true);
} else {
headerHandlersRef.current.handleAnnotateExit();
}
}, [hasAnyAnnotations]);
const handleHeaderFeedback = useCallback(() => {
const h = headerHandlersRef.current;
const docAnnotations = h.getDocAnnotations();
const hasDocAnnotations = Array.from(docAnnotations.values()).some(
(d) => d.annotations.length > 0 || d.globalAttachments.length > 0
);
if (allAnnotations.length === 0 && codeAnnotations.length === 0 && editorAnnotations.length === 0 && !hasDocAnnotations) {
setShowFeedbackPrompt(true);
} else {
h.handleDeny();
}
}, [allAnnotations.length, codeAnnotations.length, editorAnnotations.length]);
const handleHeaderApprove = useCallback(() => {
const h = headerHandlersRef.current;
if (annotateMode) {
if (hasAnyAnnotations) {
setExitWarningAction('approve');
setShowExitWarning(true);
return;
}
h.handleAnnotateApprove();
return;
}
if (origin === 'claude-code' && (allAnnotations.length > 0 || codeAnnotations.length > 0)) {
setShowClaudeCodeWarning(true);
return;
}
if (origin === 'opencode') {
const warning = h.getAgentWarning();
if (warning) {
setAgentWarningMessage(warning);
setShowAgentWarning(true);
return;
}
}
h.handleApprove();
}, [annotateMode, hasAnyAnnotations, origin, allAnnotations.length, codeAnnotations.length]);
const handleHeaderAnnotateFeedback = useCallback(() => headerHandlersRef.current.handleAnnotateFeedback(), []);
const handleHeaderAnnotateApprove = useCallback(() => headerHandlersRef.current.handleAnnotateApprove(), []);
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 planMaxWidth = useMemo(() => {
const widths: Record<PlanWidth, number> = { compact: 832, default: 1040, wide: 1280 };
return widths[uiPrefs.planWidth] ?? 832;
@@ -1529,225 +1608,57 @@ const App: React.FC = () => {
<ThemeProvider defaultTheme="dark">
<TooltipProvider delayDuration={900} skipDelayDuration={200} disableHoverableContent>
<div data-print-region="root" className="h-screen flex flex-col bg-background overflow-hidden">
{/* Minimal Header */}
<header data-app-header="true" className="h-12 flex items-center justify-between px-2 md:px-4 border-b border-border/50 bg-card/50 backdrop-blur-xl sticky top-0 z-[50]">
<div className="flex items-center gap-2 md:gap-3">
<a
href="https://plannotator.ai"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 md:gap-2 hover:opacity-80 transition-opacity"
>
<span className="text-sm font-semibold tracking-tight">Plannotator</span>
</a>
</div>
<div className="flex items-center gap-1 md:gap-2">
{/* Bot callback buttons — only shown when ?cb=&ct= params are present */}
{callbackConfig && !isApiMode && isSharedSession && (
<>
<div className="w-px h-5 bg-border/50 mx-1 hidden md:block" />
<FeedbackButton
onClick={handleCallbackFeedback}
disabled={isSubmitting || !shareUrl}
isLoading={isSubmitting}
title="Send feedback to bot"
/>
<ApproveButton
onClick={handleCallbackApprove}
disabled={isSubmitting || !shareUrl}
isLoading={isSubmitting}
title="Approve design and notify bot"
/>
</>
)}
{isApiMode && !linkedDocHook.isActive && archive.archiveMode && (
<>
<button
onClick={archive.copy}
className="px-2.5 py-1 rounded-md text-xs font-medium transition-all bg-muted text-foreground hover:bg-muted/80 border border-border"
title="Copy plan content"
>
<span className="hidden md:inline">Copy</span>
<svg className="w-4 h-4 md:hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
<button
onClick={archive.done}
className="px-2.5 py-1 rounded-md text-xs font-medium transition-all bg-success text-success-foreground hover:opacity-90"
title="Close archive"
>
Done
</button>
</>
)}
{isApiMode && (!linkedDocHook.isActive || annotateMode) && !archive.archiveMode && (
<>
{annotateMode ? (
// Annotate mode: Close always visible, Send Annotations when annotations exist,
// Approve only when gate (review) mode is enabled (#570).
<>
<ExitButton
onClick={() => {
if (hasAnyAnnotations) {
setExitWarningAction('close');
setShowExitWarning(true);
} else {
handleAnnotateExit();
}
}}
disabled={isSubmitting || isExiting}
isLoading={isExiting}
/>
{hasAnyAnnotations && (
<FeedbackButton
onClick={handleAnnotateFeedback}
disabled={isSubmitting || isExiting}
isLoading={isSubmitting}
label="Send Annotations"
title="Send Annotations"
/>
)}
</>
) : (
// Plan mode: Send Feedback
<FeedbackButton
onClick={() => {
const docAnnotations = linkedDocHook.getDocAnnotations();
const hasDocAnnotations = Array.from(docAnnotations.values()).some(
(d) => d.annotations.length > 0 || d.globalAttachments.length > 0
);
if (allAnnotations.length === 0 && codeAnnotations.length === 0 && editorAnnotations.length === 0 && !hasDocAnnotations) {
setShowFeedbackPrompt(true);
} else {
handleDeny();
}
}}
disabled={isSubmitting}
isLoading={isSubmitting}
label="Send Feedback"
title="Send Feedback"
/>
)}
{(!annotateMode || gate) && (
origin === 'opencode' && !annotateMode && availableAgents.length > 0 ? (
<ApproveDropdown
onApprove={() => {
const warning = getAgentWarning();
if (warning) {
setAgentWarningMessage(warning);
setShowAgentWarning(true);
return;
}
handleApprove();
}}
agents={availableAgents}
disabled={isSubmitting}
isLoading={isSubmitting}
/>
) : (
<div className="relative group/approve">
<ApproveButton
onClick={() => {
if (annotateMode) {
if (hasAnyAnnotations) {
setExitWarningAction('approve');
setShowExitWarning(true);
return;
}
handleAnnotateApprove();
return;
}
if (origin === 'claude-code' && (allAnnotations.length > 0 || codeAnnotations.length > 0)) {
setShowClaudeCodeWarning(true);
return;
}
if (origin === 'opencode') {
const warning = getAgentWarning();
if (warning) {
setAgentWarningMessage(warning);
setShowAgentWarning(true);
return;
}
}
handleApprove();
}}
disabled={isSubmitting || (annotateMode && isExiting)}
isLoading={isSubmitting}
dimmed={!annotateMode && (origin === 'claude-code' || origin === 'gemini-cli') && (allAnnotations.length > 0 || codeAnnotations.length > 0)}
title={annotateMode ? 'Approve — no changes requested' : undefined}
/>
{!annotateMode && (origin === 'claude-code' || origin === 'gemini-cli') && (allAnnotations.length > 0 || codeAnnotations.length > 0) && (
<div className="absolute top-full right-0 mt-2 px-3 py-2 bg-popover border border-border rounded-lg shadow-xl text-xs text-foreground w-56 text-center opacity-0 invisible group-hover/approve:opacity-100 group-hover/approve:visible transition-all pointer-events-none z-50">
<div className="absolute bottom-full right-4 border-4 border-transparent border-b-border" />
<div className="absolute bottom-full right-4 mt-px border-4 border-transparent border-b-popover" />
{agentName} doesn't support feedback on approval. Your annotations won't be seen.
</div>
)}
</div>
)
)}
<div className="w-px h-5 bg-border/50 mx-1 hidden md:block" />
</>
)}
{/* Annotations panel toggle — top-level header button */}
<button
onClick={handleAnnotationPanelToggle}
className={`p-1.5 rounded-md text-xs font-medium transition-all ${
isPanelOpen
? 'bg-primary/15 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
}`}
title={isPanelOpen ? 'Hide annotations' : 'Show annotations'}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
</svg>
</button>
{/* Settings dialog (controlled, button hidden — opened from PlanHeaderMenu) */}
<div className="hidden">
<Settings
taterMode={taterMode}
onTaterModeChange={handleTaterModeChange}
onIdentityChange={handleIdentityChange}
origin={origin}
onUIPreferencesChange={setUiPrefs}
externalOpen={mobileSettingsOpen}
onExternalClose={() => setMobileSettingsOpen(false)}
gitUser={gitUser}
/>
</div>
<PlanHeaderMenu
appVersion={typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0'}
onOpenSettings={() => {
setMobileSettingsOpen(true);
}}
onOpenExport={() => { setInitialExportTab(undefined); setShowExport(true); }}
onCopyAgentInstructions={handleCopyAgentInstructions}
onDownloadAnnotations={handleDownloadAnnotations}
onPrint={() => window.print()}
onCopyShareLink={handleCopyShareLink}
onOpenImport={() => setShowImport(true)}
onSaveToObsidian={() => handleQuickSaveToNotes('obsidian')}
onSaveToBear={() => handleQuickSaveToNotes('bear')}
onSaveToOctarine={() => handleQuickSaveToNotes('octarine')}
sharingEnabled={canShareCurrentSession}
isApiMode={isApiMode}
agentInstructionsEnabled={isApiMode && !archive.archiveMode && !annotateMode}
obsidianConfigured={isObsidianConfigured()}
bearConfigured={getBearSettings().enabled}
octarineConfigured={isOctarineConfigured()}
/>
</div>
</header>
<AppHeader
isApiMode={isApiMode}
annotateMode={annotateMode}
archiveMode={archive.archiveMode}
gate={gate}
isSharedSession={isSharedSession}
origin={origin}
isSubmitting={isSubmitting}
isExiting={isExiting}
isPanelOpen={isPanelOpen}
hasAnyAnnotations={hasAnyAnnotations}
linkedDocIsActive={linkedDocHook.isActive}
callbackShareUrlReady={callbackConfig ? Boolean(shareUrl) : true}
canShareCurrentSession={canShareCurrentSession}
agentName={agentName}
availableAgents={availableAgents}
showAnnotationsWarning={allAnnotations.length > 0 || codeAnnotations.length > 0}
callbackConfig={callbackConfig}
taterMode={taterMode}
mobileSettingsOpen={mobileSettingsOpen}
gitUser={gitUser}
onCallbackFeedback={handleCallbackFeedback}
onCallbackApprove={handleCallbackApprove}
onAnnotateExit={handleHeaderAnnotateExit}
onAnnotateFeedback={handleHeaderAnnotateFeedback}
onAnnotateApprove={handleHeaderAnnotateApprove}
onFeedback={handleHeaderFeedback}
onApprove={handleHeaderApprove}
onAnnotationPanelToggle={handleAnnotationPanelToggle}
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'}
agentInstructionsEnabled={isApiMode && !archive.archiveMode && !annotateMode}
obsidianConfigured={isObsidianConfigured()}
bearConfigured={getBearSettings().enabled}
octarineConfigured={isOctarineConfigured()}
/>
{/* Linked document error banner */}
{linkedDocHook.error && (
@@ -2147,16 +2058,23 @@ const App: React.FC = () => {
variant="warning"
/>
{/* Save-to-notes toast */}
{noteSaveToast && (
<div className={`fixed top-16 right-4 z-50 px-3 py-2 rounded-lg text-xs font-medium shadow-lg transition-opacity ${
noteSaveToast.type === 'success'
? 'bg-success/15 text-success border border-success/30'
: 'bg-destructive/15 text-destructive border border-destructive/30'
}`}>
{noteSaveToast.message}
</div>
)}
<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,
}}
/>
{/* Completion overlay - shown after approve/deny */}
<CompletionOverlay
+296
View File
@@ -0,0 +1,296 @@
import React from 'react';
import type { Origin } from '@plannotator/shared/agents';
import type { Agent } from '@plannotator/ui/hooks/useAgents';
import { FeedbackButton, ApproveButton, ExitButton } from '@plannotator/ui/components/ToolbarButtons';
import { ApproveDropdown } from '@plannotator/ui/components/ApproveDropdown';
import { Settings } from '@plannotator/ui/components/Settings';
import { PlanHeaderMenu } from '@plannotator/ui/components/PlanHeaderMenu';
import type { CallbackConfig } from '@plannotator/ui/utils/callback';
import type { UIPreferences } from '@plannotator/ui/utils/uiPreferences';
interface AppHeaderProps {
// Mode flags (stable after mount)
isApiMode: boolean;
annotateMode: boolean;
archiveMode: boolean;
gate: boolean;
isSharedSession: boolean;
origin: Origin | null;
// Dynamic state
isSubmitting: boolean;
isExiting: boolean;
isPanelOpen: boolean;
hasAnyAnnotations: boolean;
linkedDocIsActive: boolean;
callbackShareUrlReady: boolean;
canShareCurrentSession: boolean;
agentName: string;
availableAgents: Agent[];
showAnnotationsWarning: boolean;
// Callback config (null when no bot callback)
callbackConfig: CallbackConfig | null;
// Settings props
taterMode: boolean;
mobileSettingsOpen: boolean;
gitUser: string | undefined;
// Handlers — App owns all decision logic, header just calls these
onCallbackFeedback: () => void;
onCallbackApprove: () => void;
onAnnotateExit: () => void;
onAnnotateFeedback: () => void;
onAnnotateApprove: () => void;
onFeedback: () => void;
onApprove: () => void;
onAnnotationPanelToggle: () => void;
onArchiveCopy: () => void;
onArchiveDone: () => void;
onTaterModeChange: (enabled: boolean) => void;
onIdentityChange: (oldId: string, newId: string) => void;
onUIPreferencesChange: (prefs: UIPreferences) => void;
onOpenSettings: () => void;
onCloseSettings: () => void;
onOpenExport: () => void;
onCopyAgentInstructions: () => void;
onDownloadAnnotations: () => void;
onPrint: () => void;
onCopyShareLink: () => void;
onOpenImport: () => void;
onSaveToObsidian: () => void;
onSaveToBear: () => void;
onSaveToOctarine: () => void;
// PlanHeaderMenu config
appVersion: string;
agentInstructionsEnabled: boolean;
obsidianConfigured: boolean;
bearConfigured: boolean;
octarineConfigured: boolean;
}
export const AppHeader = React.memo<AppHeaderProps>(({
isApiMode,
annotateMode,
archiveMode,
gate,
isSharedSession,
origin,
isSubmitting,
isExiting,
isPanelOpen,
hasAnyAnnotations,
linkedDocIsActive,
callbackShareUrlReady,
canShareCurrentSession,
agentName,
availableAgents,
showAnnotationsWarning,
callbackConfig,
taterMode,
mobileSettingsOpen,
gitUser,
onCallbackFeedback,
onCallbackApprove,
onAnnotateExit,
onAnnotateFeedback,
onAnnotateApprove,
onFeedback,
onApprove,
onAnnotationPanelToggle,
onArchiveCopy,
onArchiveDone,
onTaterModeChange,
onIdentityChange,
onUIPreferencesChange,
onOpenSettings,
onCloseSettings,
onOpenExport,
onCopyAgentInstructions,
onDownloadAnnotations,
onPrint,
onCopyShareLink,
onOpenImport,
onSaveToObsidian,
onSaveToBear,
onSaveToOctarine,
appVersion,
agentInstructionsEnabled,
obsidianConfigured,
bearConfigured,
octarineConfigured,
}) => {
return (
<header data-app-header="true" className="h-12 flex items-center justify-between px-2 md:px-4 border-b border-border/50 bg-card/50 backdrop-blur-xl sticky top-0 z-[50]">
<AppHeaderLogo />
<div className="flex items-center gap-1 md:gap-2">
{/* Bot callback buttons — only shown when ?cb=&ct= params are present */}
{callbackConfig && !isApiMode && isSharedSession && (
<>
<div className="w-px h-5 bg-border/50 mx-1 hidden md:block" />
<FeedbackButton
onClick={onCallbackFeedback}
disabled={isSubmitting || !callbackShareUrlReady}
isLoading={isSubmitting}
title="Send feedback to bot"
/>
<ApproveButton
onClick={onCallbackApprove}
disabled={isSubmitting || !callbackShareUrlReady}
isLoading={isSubmitting}
title="Approve design and notify bot"
/>
</>
)}
{isApiMode && !linkedDocIsActive && archiveMode && (
<>
<button
onClick={onArchiveCopy}
className="px-2.5 py-1 rounded-md text-xs font-medium transition-all bg-muted text-foreground hover:bg-muted/80 border border-border"
title="Copy plan content"
>
<span className="hidden md:inline">Copy</span>
<svg className="w-4 h-4 md:hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
<button
onClick={onArchiveDone}
className="px-2.5 py-1 rounded-md text-xs font-medium transition-all bg-success text-success-foreground hover:opacity-90"
title="Close archive"
>
Done
</button>
</>
)}
{isApiMode && (!linkedDocIsActive || annotateMode) && !archiveMode && (
<>
{annotateMode ? (
<>
<ExitButton
onClick={onAnnotateExit}
disabled={isSubmitting || isExiting}
isLoading={isExiting}
/>
{hasAnyAnnotations && (
<FeedbackButton
onClick={onAnnotateFeedback}
disabled={isSubmitting || isExiting}
isLoading={isSubmitting}
label="Send Annotations"
title="Send Annotations"
/>
)}
</>
) : (
<FeedbackButton
onClick={onFeedback}
disabled={isSubmitting}
isLoading={isSubmitting}
label="Send Feedback"
title="Send Feedback"
/>
)}
{(!annotateMode || gate) && (
origin === 'opencode' && !annotateMode && availableAgents.length > 0 ? (
<ApproveDropdown
onApprove={onApprove}
agents={availableAgents}
disabled={isSubmitting}
isLoading={isSubmitting}
/>
) : (
<div className="relative group/approve">
<ApproveButton
onClick={onApprove}
disabled={isSubmitting || (annotateMode && isExiting)}
isLoading={isSubmitting}
dimmed={!annotateMode && (origin === 'claude-code' || origin === 'gemini-cli') && showAnnotationsWarning}
title={annotateMode ? 'Approve — no changes requested' : undefined}
/>
{!annotateMode && (origin === 'claude-code' || origin === 'gemini-cli') && showAnnotationsWarning && (
<div className="absolute top-full right-0 mt-2 px-3 py-2 bg-popover border border-border rounded-lg shadow-xl text-xs text-foreground w-56 text-center opacity-0 invisible group-hover/approve:opacity-100 group-hover/approve:visible transition-all pointer-events-none z-50">
<div className="absolute bottom-full right-4 border-4 border-transparent border-b-border" />
<div className="absolute bottom-full right-4 mt-px border-4 border-transparent border-b-popover" />
{agentName} doesn't support feedback on approval. Your annotations won't be seen.
</div>
)}
</div>
)
)}
<div className="w-px h-5 bg-border/50 mx-1 hidden md:block" />
</>
)}
{/* Annotations panel toggle */}
<button
onClick={onAnnotationPanelToggle}
className={`p-1.5 rounded-md text-xs font-medium transition-all ${
isPanelOpen
? 'bg-primary/15 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
}`}
title={isPanelOpen ? 'Hide annotations' : 'Show annotations'}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
</svg>
</button>
{/* Settings dialog (controlled, button hidden — opened from PlanHeaderMenu) */}
<div className="hidden">
<Settings
taterMode={taterMode}
onTaterModeChange={onTaterModeChange}
onIdentityChange={onIdentityChange}
origin={origin}
onUIPreferencesChange={onUIPreferencesChange}
externalOpen={mobileSettingsOpen}
onExternalClose={onCloseSettings}
gitUser={gitUser}
/>
</div>
<PlanHeaderMenu
appVersion={appVersion}
onOpenSettings={onOpenSettings}
onOpenExport={onOpenExport}
onCopyAgentInstructions={onCopyAgentInstructions}
onDownloadAnnotations={onDownloadAnnotations}
onPrint={onPrint}
onCopyShareLink={onCopyShareLink}
onOpenImport={onOpenImport}
onSaveToObsidian={onSaveToObsidian}
onSaveToBear={onSaveToBear}
onSaveToOctarine={onSaveToOctarine}
sharingEnabled={canShareCurrentSession}
isApiMode={isApiMode}
agentInstructionsEnabled={agentInstructionsEnabled}
obsidianConfigured={obsidianConfigured}
bearConfigured={bearConfigured}
octarineConfigured={octarineConfigured}
/>
</div>
</header>
);
});
const AppHeaderLogo = () => (
<div className="flex items-center gap-2 md:gap-3">
<a
href="https://plannotator.ai"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 md:gap-2 hover:opacity-80 transition-opacity"
>
<span className="text-sm font-semibold tracking-tight">Plannotator</span>
</a>
</div>
);
+1
View File
@@ -4,6 +4,7 @@
@source "../ui/components/**/*.tsx";
@source "../ui/hooks/**/*.ts";
@source "./*.tsx";
@source "./components/**/*.tsx";
@import "../ui/theme.css";
+27 -5
View File
@@ -40,6 +40,23 @@ function resolveThemeClasses(themeId: string, effectiveMode: 'dark' | 'light'):
return `theme-${themeId}${applyLight ? ' light' : ''}`;
}
/** Sync theme classes on <html> without stripping non-theme classes (e.g. transitions-ready). */
function applyThemeClasses(themeId: string, effectiveMode: 'dark' | 'light'): void {
const el = document.documentElement;
const themeClass = `theme-${themeId}`;
const wantLight = resolveThemeClasses(themeId, effectiveMode).includes(' light');
if (el.classList.contains(themeClass) && el.classList.contains('light') === wantLight) return;
for (const cls of Array.from(el.classList)) {
if (cls.startsWith('theme-')) el.classList.remove(cls);
}
el.classList.remove('light');
el.classList.add(themeClass);
if (wantLight) el.classList.add('light');
}
/** Read system preference synchronously */
function getSystemIsLight(): boolean {
return typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: light)').matches;
@@ -77,17 +94,22 @@ export function ThemeProvider({
// flash of unstyled content. CSS tokens live under .theme-* selectors, so
// without this the first frame has no valid --background/--foreground.
if (typeof window !== 'undefined') {
const targetClass = resolveThemeClasses(colorTheme, resolvedMode);
if (window.document.documentElement.className !== targetClass) {
window.document.documentElement.className = targetClass;
}
applyThemeClasses(colorTheme, resolvedMode);
}
// Keep class in sync after state changes
useEffect(() => {
window.document.documentElement.className = resolveThemeClasses(colorTheme, resolvedMode);
applyThemeClasses(colorTheme, resolvedMode);
}, [resolvedMode, colorTheme]);
// Enable color transitions after mount settles — prevents the global *
// transition rule from firing during initial load.
useEffect(() => {
requestAnimationFrame(() => {
document.documentElement.classList.add('transitions-ready');
});
}, []);
// [P2 fix] Listen for system theme changes AND re-read current value when
// entering system mode (OS may have changed while pinned to explicit mode)
useEffect(() => {
+9
View File
@@ -191,6 +191,15 @@ body {
background: oklch(from var(--primary) l c h / 0.3);
}
/* Suppress all color transitions until the app has mounted and the theme
is stable. Without this, the global * transition below fires on initial
load — className reassignments during React mount trigger a 150ms
transition from browser-default black to themed white, causing a visible
flash. ThemeProvider adds .transitions-ready after mount. */
html:not(.transitions-ready) * {
transition-duration: 0s !important;
}
/* Smooth transitions for theme switching and hover states.
Only color properties — transform/opacity are handled per-component
to avoid interfering with scroll compositing. */