From f4493fc6ff34b9ec17e00d010a88971a88210dac Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 11 May 2026 08:55:18 -0400 Subject: [PATCH] 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 --- AGENTS.md | 2 + bun.lock | 3 + package.json | 3 +- packages/editor/App.tsx | 456 ++++++++++------------- packages/editor/components/AppHeader.tsx | 296 +++++++++++++++ packages/editor/index.css | 1 + packages/ui/components/ThemeProvider.tsx | 32 +- packages/ui/theme.css | 9 + 8 files changed, 527 insertions(+), 275 deletions(-) create mode 100644 packages/editor/components/AppHeader.tsx diff --git a/AGENTS.md b/AGENTS.md index 0dc3cbcb..908c7901 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/bun.lock b/bun.lock index bba646b1..8fd3e21c 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index 82772f32..ccb287e6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 610cbd8e..e7d0af67 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -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([]); const [selectedAnnotationId, setSelectedAnnotationId] = useState(null); const [selectedCodeAnnotationId, setSelectedCodeAnnotationId] = useState(null); - const [blocks, setBlocks] = useState([]); - const [frontmatter, setFrontmatter] = useState(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(null); const [isPlanDiffActive, setIsPlanDiffActive] = useState(false); const [planDiffMode, setPlanDiffMode] = useState('clean'); const [previousPlan, setPreviousPlan] = useState(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({}); @@ -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 = { compact: 832, default: 1040, wide: 1280 }; return widths[uiPrefs.planWidth] ?? 832; @@ -1529,225 +1608,57 @@ const App: React.FC = () => {
- {/* Minimal Header */} -
- - -
- {/* Bot callback buttons — only shown when ?cb=&ct= params are present */} - {callbackConfig && !isApiMode && isSharedSession && ( - <> -
- - - - )} - - {isApiMode && !linkedDocHook.isActive && archive.archiveMode && ( - <> - - - - )} - - {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). - <> - { - if (hasAnyAnnotations) { - setExitWarningAction('close'); - setShowExitWarning(true); - } else { - handleAnnotateExit(); - } - }} - disabled={isSubmitting || isExiting} - isLoading={isExiting} - /> - {hasAnyAnnotations && ( - - )} - - ) : ( - // Plan mode: Send Feedback - { - 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 ? ( - { - const warning = getAgentWarning(); - if (warning) { - setAgentWarningMessage(warning); - setShowAgentWarning(true); - return; - } - handleApprove(); - }} - agents={availableAgents} - disabled={isSubmitting} - isLoading={isSubmitting} - /> - ) : ( -
- { - 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) && ( -
-
-
- {agentName} doesn't support feedback on approval. Your annotations won't be seen. -
- )} -
- ) - )} - -
- - )} - - {/* Annotations panel toggle — top-level header button */} - - - {/* Settings dialog (controlled, button hidden — opened from PlanHeaderMenu) */} -
- setMobileSettingsOpen(false)} - gitUser={gitUser} - /> -
- - { - 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()} - /> -
-
+ 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 && ( -
- {noteSaveToast.message} -
- )} + {/* Completion overlay - shown after approve/deny */} 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(({ + 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 ( +
+ + +
+ {/* Bot callback buttons — only shown when ?cb=&ct= params are present */} + {callbackConfig && !isApiMode && isSharedSession && ( + <> +
+ + + + )} + + {isApiMode && !linkedDocIsActive && archiveMode && ( + <> + + + + )} + + {isApiMode && (!linkedDocIsActive || annotateMode) && !archiveMode && ( + <> + {annotateMode ? ( + <> + + {hasAnyAnnotations && ( + + )} + + ) : ( + + )} + + {(!annotateMode || gate) && ( + origin === 'opencode' && !annotateMode && availableAgents.length > 0 ? ( + + ) : ( +
+ + {!annotateMode && (origin === 'claude-code' || origin === 'gemini-cli') && showAnnotationsWarning && ( +
+
+
+ {agentName} doesn't support feedback on approval. Your annotations won't be seen. +
+ )} +
+ ) + )} + +
+ + )} + + {/* Annotations panel toggle */} + + + {/* Settings dialog (controlled, button hidden — opened from PlanHeaderMenu) */} +
+ +
+ + +
+
+ ); +}); + +const AppHeaderLogo = () => ( + +); diff --git a/packages/editor/index.css b/packages/editor/index.css index 2e13ada5..fc236c04 100644 --- a/packages/editor/index.css +++ b/packages/editor/index.css @@ -4,6 +4,7 @@ @source "../ui/components/**/*.tsx"; @source "../ui/hooks/**/*.ts"; @source "./*.tsx"; +@source "./components/**/*.tsx"; @import "../ui/theme.css"; diff --git a/packages/ui/components/ThemeProvider.tsx b/packages/ui/components/ThemeProvider.tsx index d6f9fa42..6fc8c096 100644 --- a/packages/ui/components/ThemeProvider.tsx +++ b/packages/ui/components/ThemeProvider.tsx @@ -40,6 +40,23 @@ function resolveThemeClasses(themeId: string, effectiveMode: 'dark' | 'light'): return `theme-${themeId}${applyLight ? ' light' : ''}`; } +/** Sync theme classes on 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(() => { diff --git a/packages/ui/theme.css b/packages/ui/theme.css index b81231ac..8a157923 100644 --- a/packages/ui/theme.css +++ b/packages/ui/theme.css @@ -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. */