mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
- Add ESLint 9 configuration (eslint.config.mjs) with TypeScript and React hooks plugins
- Add npm run lint:eslint command for code quality checks
- Expand npm run lint to check all three TypeScript configs (renderer, main, cli)
- Update tsconfig.cli.json to include src/prompts and src/types directories
Fix 29 ESLint errors:
- Remove unused updateCliActivity import in batch-processor.ts
- Convert {false && <jsx>} patterns to comments in AutoRun components
- Wrap case block with const declarations in braces (AgentSelectionScreen)
- Fix unused expression pattern in PreparingPlanScreen
- Fix conditional hook calls in FilePreview, OfflineQueueBanner, RecentCommandChips, TerminalOutput
- Add windows-diagnostics.json to PackageContents interface
Update CLAUDE.md and CONTRIBUTING.md with new linting commands and documentation.
Claude ID: 029e8abe-5734-4967-9fb4-c85078c1973d
Maestro ID: 87ffa06e-0ecd-4eb8-b327-dad1ec24f7a9
1748 lines
65 KiB
TypeScript
1748 lines
65 KiB
TypeScript
import React, { useState, useRef, useEffect, useLayoutEffect, useCallback, memo, useMemo, forwardRef, useImperativeHandle } from 'react';
|
|
import ReactMarkdown from 'react-markdown';
|
|
import remarkGfm from 'remark-gfm';
|
|
import { Eye, Edit, Play, Square, HelpCircle, Loader2, Image, X, Search, ChevronDown, ChevronRight, FolderOpen, FileText, RefreshCw, Maximize2, AlertTriangle, SkipForward, XCircle } from 'lucide-react';
|
|
import { getEncoding } from 'js-tiktoken';
|
|
import type { BatchRunState, SessionState, Theme, Shortcut } from '../types';
|
|
import { AutoRunnerHelpModal } from './AutoRunnerHelpModal';
|
|
import { MermaidRenderer } from './MermaidRenderer';
|
|
import { AutoRunDocumentSelector, DocumentTaskCount } from './AutoRunDocumentSelector';
|
|
import { AutoRunLightbox } from './AutoRunLightbox';
|
|
import { AutoRunSearchBar } from './AutoRunSearchBar';
|
|
import { useTemplateAutocomplete } from '../hooks/useTemplateAutocomplete';
|
|
import { useAutoRunUndo } from '../hooks/useAutoRunUndo';
|
|
import { useAutoRunImageHandling, imageCache } from '../hooks/useAutoRunImageHandling';
|
|
import { TemplateAutocompleteDropdown } from './TemplateAutocompleteDropdown';
|
|
import { generateAutoRunProseStyles, createMarkdownComponents } from '../utils/markdownConfig';
|
|
import { formatShortcutKeys } from '../utils/shortcutFormatter';
|
|
|
|
// Memoize remarkPlugins array - it never changes
|
|
const REMARK_PLUGINS = [remarkGfm];
|
|
|
|
// Lazy-loaded tokenizer encoder (cl100k_base is used by Claude/GPT-4)
|
|
let encoderPromise: Promise<ReturnType<typeof getEncoding>> | null = null;
|
|
const getEncoder = () => {
|
|
if (!encoderPromise) {
|
|
encoderPromise = Promise.resolve(getEncoding('cl100k_base'));
|
|
}
|
|
return encoderPromise;
|
|
};
|
|
|
|
// Format token count with K/M suffix
|
|
const formatTokenCount = (count: number): string => {
|
|
if (count >= 1_000_000) {
|
|
return `${(count / 1_000_000).toFixed(1)}M`;
|
|
}
|
|
if (count >= 1_000) {
|
|
return `${(count / 1_000).toFixed(1)}K`;
|
|
}
|
|
return count.toLocaleString();
|
|
};
|
|
|
|
interface AutoRunProps {
|
|
theme: Theme;
|
|
sessionId: string; // Maestro session ID for per-session attachment storage
|
|
|
|
// Folder & document state
|
|
folderPath: string | null;
|
|
selectedFile: string | null;
|
|
documentList: string[]; // Filenames without .md
|
|
documentTree?: Array<{ name: string; type: 'file' | 'folder'; path: string; children?: unknown[] }>; // Tree structure for subfolders
|
|
|
|
// Content state
|
|
content: string;
|
|
onContentChange: (content: string) => void;
|
|
contentVersion?: number; // Incremented on external file changes to force-sync
|
|
|
|
// Optional external draft content management (for sharing between panel and expanded modal)
|
|
// When provided, the component uses these instead of internal localContent state
|
|
externalLocalContent?: string;
|
|
onExternalLocalContentChange?: (content: string) => void;
|
|
externalSavedContent?: string;
|
|
onExternalSavedContentChange?: (content: string) => void;
|
|
|
|
// Mode state
|
|
mode: 'edit' | 'preview';
|
|
onModeChange: (mode: 'edit' | 'preview') => void;
|
|
|
|
// Scroll/cursor state
|
|
initialCursorPosition?: number;
|
|
initialEditScrollPos?: number;
|
|
initialPreviewScrollPos?: number;
|
|
onStateChange?: (state: {
|
|
mode: 'edit' | 'preview';
|
|
cursorPosition: number;
|
|
editScrollPos: number;
|
|
previewScrollPos: number;
|
|
}) => void;
|
|
|
|
// Actions
|
|
onOpenSetup: () => void;
|
|
onRefresh: () => void;
|
|
onSelectDocument: (filename: string) => void;
|
|
onCreateDocument: (filename: string) => Promise<boolean>;
|
|
isLoadingDocuments?: boolean;
|
|
documentTaskCounts?: Map<string, DocumentTaskCount>; // Task counts per document path
|
|
|
|
// Batch processing props
|
|
batchRunState?: BatchRunState;
|
|
onOpenBatchRunner?: () => void;
|
|
onStopBatchRun?: () => void;
|
|
// Error handling callbacks (Phase 5.10)
|
|
onSkipCurrentDocument?: () => void;
|
|
onAbortBatchOnError?: () => void;
|
|
onResumeAfterError?: () => void;
|
|
|
|
// Session state for disabling Run when agent is busy
|
|
sessionState?: SessionState;
|
|
|
|
// Expand to modal callback
|
|
onExpand?: () => void;
|
|
|
|
// Shortcuts for displaying hotkey hints
|
|
shortcuts?: Record<string, Shortcut>;
|
|
|
|
// Hide top controls (when rendered in expanded modal with controls in header)
|
|
hideTopControls?: boolean;
|
|
}
|
|
|
|
export interface AutoRunHandle {
|
|
focus: () => void;
|
|
switchMode: (mode: 'edit' | 'preview') => void;
|
|
isDirty: () => boolean;
|
|
save: () => Promise<void>;
|
|
revert: () => void;
|
|
}
|
|
|
|
// Custom image component that loads images from the Auto Run folder or external URLs
|
|
function AttachmentImage({
|
|
src,
|
|
alt,
|
|
folderPath,
|
|
theme,
|
|
onImageClick
|
|
}: {
|
|
src?: string;
|
|
alt?: string;
|
|
folderPath: string | null;
|
|
theme: any;
|
|
onImageClick?: (filename: string) => void;
|
|
}) {
|
|
const [dataUrl, setDataUrl] = useState<string | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [filename, setFilename] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!src) {
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
// Decode URL-encoded paths (e.g., "images/Image%20Test.png" -> "images/Image Test.png")
|
|
const decodedSrc = decodeURIComponent(src);
|
|
|
|
// Check if this is a relative path (e.g., images/{docName}-{timestamp}.{ext})
|
|
if (decodedSrc.startsWith('images/') && folderPath) {
|
|
const fname = decodedSrc.split('/').pop() || decodedSrc;
|
|
setFilename(fname);
|
|
const cacheKey = `${folderPath}:${decodedSrc}`;
|
|
|
|
// Check cache first
|
|
if (imageCache.has(cacheKey)) {
|
|
setDataUrl(imageCache.get(cacheKey)!);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
// Load from folder using absolute path
|
|
const absolutePath = `${folderPath}/${decodedSrc}`;
|
|
window.maestro.fs.readFile(absolutePath)
|
|
.then((result) => {
|
|
if (result.startsWith('data:')) {
|
|
imageCache.set(cacheKey, result);
|
|
setDataUrl(result);
|
|
} else {
|
|
setError('Invalid image data');
|
|
}
|
|
setLoading(false);
|
|
})
|
|
.catch((err) => {
|
|
setError(`Failed to load image: ${err.message || 'Unknown error'}`);
|
|
setLoading(false);
|
|
});
|
|
} else if (src.startsWith('data:')) {
|
|
// Already a data URL
|
|
setDataUrl(src);
|
|
setFilename(null);
|
|
setLoading(false);
|
|
} else if (src.startsWith('http://') || src.startsWith('https://')) {
|
|
// External URL - just use it directly
|
|
setDataUrl(src);
|
|
setFilename(null);
|
|
setLoading(false);
|
|
} else if (src.startsWith('/')) {
|
|
// Absolute file path - load via IPC
|
|
setFilename(src.split('/').pop() || null);
|
|
window.maestro.fs.readFile(src)
|
|
.then((result) => {
|
|
if (result.startsWith('data:')) {
|
|
setDataUrl(result);
|
|
} else {
|
|
setError('Invalid image data');
|
|
}
|
|
setLoading(false);
|
|
})
|
|
.catch((err) => {
|
|
setError(`Failed to load image: ${err.message || 'Unknown error'}`);
|
|
setLoading(false);
|
|
});
|
|
} else {
|
|
// Other relative path - try to load as file from folderPath if available
|
|
setFilename(src.split('/').pop() || null);
|
|
const pathToLoad = folderPath ? `${folderPath}/${src}` : src;
|
|
window.maestro.fs.readFile(pathToLoad)
|
|
.then((result) => {
|
|
if (result.startsWith('data:')) {
|
|
setDataUrl(result);
|
|
} else {
|
|
setError('Invalid image data');
|
|
}
|
|
setLoading(false);
|
|
})
|
|
.catch((err) => {
|
|
setError(`Failed to load image: ${err.message || 'Unknown error'}`);
|
|
setLoading(false);
|
|
});
|
|
}
|
|
}, [src, folderPath]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div
|
|
className="inline-flex items-center gap-2 px-3 py-2 rounded"
|
|
style={{ backgroundColor: theme.colors.bgActivity }}
|
|
>
|
|
<Loader2 className="w-4 h-4 animate-spin" style={{ color: theme.colors.textDim }} />
|
|
<span className="text-xs" style={{ color: theme.colors.textDim }}>Loading image...</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div
|
|
className="inline-flex items-center gap-2 px-3 py-2 rounded"
|
|
style={{ backgroundColor: theme.colors.bgActivity, borderColor: theme.colors.error, border: '1px solid' }}
|
|
>
|
|
<Image className="w-4 h-4" style={{ color: theme.colors.error }} />
|
|
<span className="text-xs" style={{ color: theme.colors.error }}>{error}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!dataUrl) {
|
|
return null;
|
|
}
|
|
|
|
// For lightbox, pass the decoded path (which matches attachmentsList)
|
|
// rather than the URL-encoded src from markdown
|
|
const decodedSrcForClick = src ? decodeURIComponent(src) : '';
|
|
return (
|
|
<span
|
|
className="inline-block align-middle mx-1 my-1 cursor-pointer group relative"
|
|
onClick={() => onImageClick?.(decodedSrcForClick)}
|
|
title={filename ? `Click to enlarge: ${filename}` : 'Click to enlarge'}
|
|
>
|
|
<img
|
|
src={dataUrl}
|
|
alt={alt || ''}
|
|
className="rounded border hover:opacity-90 transition-all hover:shadow-lg"
|
|
style={{
|
|
maxHeight: '120px',
|
|
maxWidth: '200px',
|
|
objectFit: 'contain',
|
|
borderColor: theme.colors.border,
|
|
}}
|
|
/>
|
|
{/* Zoom hint overlay */}
|
|
<span
|
|
className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity rounded"
|
|
style={{ backgroundColor: 'rgba(0,0,0,0.4)' }}
|
|
>
|
|
<Search className="w-5 h-5 text-white" />
|
|
</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// Image preview thumbnail for staged images in edit mode
|
|
function ImagePreview({
|
|
src,
|
|
filename,
|
|
theme,
|
|
onRemove,
|
|
onImageClick
|
|
}: {
|
|
src: string;
|
|
filename: string;
|
|
theme: any;
|
|
onRemove: () => void;
|
|
onImageClick: (filename: string) => void;
|
|
}) {
|
|
return (
|
|
<div
|
|
className="relative inline-block group"
|
|
style={{ margin: '4px' }}
|
|
>
|
|
<img
|
|
src={src}
|
|
alt={filename}
|
|
className="w-20 h-20 object-cover rounded cursor-pointer hover:opacity-80 transition-opacity"
|
|
style={{ border: `1px solid ${theme.colors.border}` }}
|
|
onClick={() => onImageClick(filename)}
|
|
/>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onRemove();
|
|
}}
|
|
className="absolute -top-2 -right-2 w-5 h-5 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
|
style={{
|
|
backgroundColor: theme.colors.error,
|
|
color: 'white'
|
|
}}
|
|
title="Remove image"
|
|
>
|
|
<X className="w-3 h-3" />
|
|
</button>
|
|
<div
|
|
className="absolute bottom-0 left-0 right-0 px-1 py-0.5 text-[9px] truncate rounded-b"
|
|
style={{
|
|
backgroundColor: 'rgba(0,0,0,0.6)',
|
|
color: 'white'
|
|
}}
|
|
>
|
|
{filename}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Inner implementation component
|
|
const AutoRunInner = forwardRef<AutoRunHandle, AutoRunProps>(function AutoRunInner({
|
|
theme,
|
|
sessionId,
|
|
folderPath,
|
|
selectedFile,
|
|
documentList,
|
|
documentTree,
|
|
content,
|
|
onContentChange,
|
|
contentVersion = 0, // Used to force-sync on external file changes
|
|
externalLocalContent,
|
|
onExternalLocalContentChange,
|
|
externalSavedContent,
|
|
onExternalSavedContentChange,
|
|
mode: externalMode,
|
|
onModeChange,
|
|
initialCursorPosition = 0,
|
|
initialEditScrollPos = 0,
|
|
initialPreviewScrollPos = 0,
|
|
onStateChange,
|
|
onOpenSetup,
|
|
onRefresh,
|
|
onSelectDocument,
|
|
onCreateDocument,
|
|
isLoadingDocuments = false,
|
|
documentTaskCounts,
|
|
batchRunState,
|
|
onOpenBatchRunner,
|
|
onStopBatchRun,
|
|
// Error handling callbacks (Phase 5.10)
|
|
onSkipCurrentDocument,
|
|
onAbortBatchOnError,
|
|
onResumeAfterError,
|
|
sessionState,
|
|
onExpand,
|
|
shortcuts,
|
|
hideTopControls = false,
|
|
}, ref) {
|
|
// Only lock the editor when Auto Run is running WITHOUT a worktree (directly on main repo)
|
|
// AND only for documents that are part of the current Auto Run
|
|
// Documents not in the Auto Run can still be edited
|
|
const isLocked = (
|
|
batchRunState?.isRunning &&
|
|
!batchRunState?.worktreeActive &&
|
|
selectedFile !== null &&
|
|
batchRunState?.lockedDocuments?.includes(selectedFile)
|
|
) || false;
|
|
const isAgentBusy = sessionState === 'busy' || sessionState === 'connecting';
|
|
const isStopping = batchRunState?.isStopping || false;
|
|
// Error state (Phase 5.10)
|
|
const isErrorPaused = batchRunState?.errorPaused || false;
|
|
const batchError = batchRunState?.error;
|
|
const errorDocumentName = batchRunState?.errorDocumentIndex !== undefined
|
|
? batchRunState.documents[batchRunState.errorDocumentIndex]
|
|
: undefined;
|
|
|
|
// Use external mode if provided, otherwise use local state
|
|
const [localMode, setLocalMode] = useState<'edit' | 'preview'>(externalMode || 'edit');
|
|
const mode = externalMode || localMode;
|
|
const setMode = useCallback((newMode: 'edit' | 'preview') => {
|
|
if (onModeChange) {
|
|
onModeChange(newMode);
|
|
} else {
|
|
setLocalMode(newMode);
|
|
}
|
|
}, [onModeChange]);
|
|
|
|
// Use onContentChange if provided, otherwise no-op
|
|
const handleContentChange = onContentChange || (() => {});
|
|
|
|
// Local content state for responsive typing
|
|
// Always use internal state for immediate feedback, but sync with external state when provided
|
|
// On initial mount, prefer external state if provided (for restoring draft from shared state)
|
|
const [internalLocalContent, setInternalLocalContent] = useState(
|
|
externalLocalContent !== undefined ? externalLocalContent : content
|
|
);
|
|
|
|
// Use refs for external callbacks to ensure stable callback identity
|
|
const externalLocalContentChangeRef = useRef(onExternalLocalContentChange);
|
|
externalLocalContentChangeRef.current = onExternalLocalContentChange;
|
|
|
|
// Sync internal state FROM external state when external state changes
|
|
// This handles: opening expanded modal with existing draft, or panel receiving updates from modal
|
|
const prevExternalLocalContentRef = useRef(externalLocalContent);
|
|
useEffect(() => {
|
|
if (externalLocalContent !== undefined &&
|
|
externalLocalContent !== prevExternalLocalContentRef.current &&
|
|
externalLocalContent !== internalLocalContent) {
|
|
setInternalLocalContent(externalLocalContent);
|
|
}
|
|
prevExternalLocalContentRef.current = externalLocalContent;
|
|
}, [externalLocalContent, internalLocalContent]);
|
|
|
|
// Always use internal state for display (provides immediate feedback)
|
|
const localContent = internalLocalContent;
|
|
|
|
const setLocalContent = useCallback((newContent: string) => {
|
|
// Always update internal state for immediate feedback
|
|
setInternalLocalContent(newContent);
|
|
// Also propagate to external callback if provided (for sharing with expanded modal)
|
|
if (externalLocalContentChangeRef.current) {
|
|
externalLocalContentChangeRef.current(newContent);
|
|
}
|
|
}, []); // Empty deps - uses ref for external callback
|
|
|
|
// Track the saved content to detect dirty state (unsaved changes)
|
|
// On initial mount, prefer external state if provided
|
|
const [internalSavedContent, setInternalSavedContent] = useState(
|
|
externalSavedContent !== undefined ? externalSavedContent : content
|
|
);
|
|
|
|
// Use refs for external callbacks to ensure stable callback identity
|
|
const externalSavedContentChangeRef = useRef(onExternalSavedContentChange);
|
|
externalSavedContentChangeRef.current = onExternalSavedContentChange;
|
|
|
|
// Sync internal saved state FROM external state when external state changes
|
|
const prevExternalSavedContentRef = useRef(externalSavedContent);
|
|
useEffect(() => {
|
|
if (externalSavedContent !== undefined &&
|
|
externalSavedContent !== prevExternalSavedContentRef.current &&
|
|
externalSavedContent !== internalSavedContent) {
|
|
setInternalSavedContent(externalSavedContent);
|
|
}
|
|
prevExternalSavedContentRef.current = externalSavedContent;
|
|
}, [externalSavedContent, internalSavedContent]);
|
|
|
|
// Always use internal state for saved content comparison
|
|
const savedContent = internalSavedContent;
|
|
|
|
const setSavedContent = useCallback((newContent: string) => {
|
|
// Always update internal state
|
|
setInternalSavedContent(newContent);
|
|
// Also propagate to external callback if provided
|
|
if (externalSavedContentChangeRef.current) {
|
|
externalSavedContentChangeRef.current(newContent);
|
|
}
|
|
}, []); // Empty deps - uses ref for external callback
|
|
|
|
// Dirty state: true when localContent differs from savedContent
|
|
const isDirty = localContent !== savedContent;
|
|
|
|
// Track previous session/document to detect switches
|
|
const prevSessionIdRef = useRef(sessionId);
|
|
const prevSelectedFileRef = useRef(selectedFile);
|
|
const prevContentVersionRef = useRef(contentVersion);
|
|
|
|
// Sync local content when session/document changes or external file changes
|
|
useEffect(() => {
|
|
const sessionChanged = sessionId !== prevSessionIdRef.current;
|
|
const documentChanged = selectedFile !== prevSelectedFileRef.current;
|
|
const versionChanged = contentVersion !== prevContentVersionRef.current;
|
|
|
|
if (sessionChanged || documentChanged || versionChanged) {
|
|
// Reset to the new content from props (discard any unsaved changes)
|
|
setLocalContent(content);
|
|
setSavedContent(content);
|
|
prevSessionIdRef.current = sessionId;
|
|
prevSelectedFileRef.current = selectedFile;
|
|
prevContentVersionRef.current = contentVersion;
|
|
}
|
|
}, [sessionId, selectedFile, contentVersion, content, setLocalContent, setSavedContent]);
|
|
|
|
// Save function - writes to disk
|
|
// Note: We do NOT call handleContentChange here because it would update the
|
|
// activeSession's content, which may be a different session than the one we're
|
|
// editing (during rapid session switches). The file watcher will pick up the
|
|
// change and update the correct session's content.
|
|
const handleSave = useCallback(async () => {
|
|
if (!folderPath || !selectedFile || !isDirty) return;
|
|
|
|
try {
|
|
await window.maestro.autorun.writeDoc(folderPath, selectedFile + '.md', localContent);
|
|
setSavedContent(localContent);
|
|
} catch (err) {
|
|
console.error('Failed to save:', err);
|
|
}
|
|
}, [folderPath, selectedFile, localContent, isDirty, setSavedContent]);
|
|
|
|
// Revert function - discard changes
|
|
const handleRevert = useCallback(() => {
|
|
setLocalContent(savedContent);
|
|
}, [savedContent, setLocalContent]);
|
|
|
|
// Track mode before auto-run to restore when it ends
|
|
const modeBeforeAutoRunRef = useRef<'edit' | 'preview' | null>(null);
|
|
const [helpModalOpen, setHelpModalOpen] = useState(false);
|
|
// Token count state
|
|
const [tokenCount, setTokenCount] = useState<number | null>(null);
|
|
// Search state
|
|
const [searchOpen, setSearchOpen] = useState(false);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [currentMatchIndex, setCurrentMatchIndex] = useState(0);
|
|
const [totalMatches, setTotalMatches] = useState(0);
|
|
const matchElementsRef = useRef<HTMLElement[]>([]);
|
|
// Refresh animation state for empty state button
|
|
const [isRefreshingEmpty, setIsRefreshingEmpty] = useState(false);
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
const previewRef = useRef<HTMLDivElement>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
// Track scroll positions in refs to preserve across re-renders
|
|
const previewScrollPosRef = useRef(initialPreviewScrollPos);
|
|
const editScrollPosRef = useRef(initialEditScrollPos);
|
|
|
|
// Template variable autocomplete hook
|
|
const {
|
|
autocompleteState,
|
|
handleKeyDown: handleAutocompleteKeyDown,
|
|
handleChange: handleAutocompleteChange,
|
|
selectVariable,
|
|
closeAutocomplete,
|
|
autocompleteRef,
|
|
} = useTemplateAutocomplete({
|
|
textareaRef,
|
|
value: localContent,
|
|
onChange: setLocalContent,
|
|
});
|
|
|
|
// Undo/Redo functionality hook
|
|
const {
|
|
pushUndoState,
|
|
scheduleUndoSnapshot,
|
|
handleUndo,
|
|
handleRedo,
|
|
resetUndoHistory,
|
|
lastUndoSnapshotRef,
|
|
} = useAutoRunUndo({
|
|
selectedFile,
|
|
localContent,
|
|
setLocalContent,
|
|
textareaRef,
|
|
});
|
|
|
|
// Reset undo history when document changes (session or file change)
|
|
useEffect(() => {
|
|
// Reset undo history snapshot to the new content (so first edit creates a proper undo point)
|
|
resetUndoHistory(content);
|
|
}, [selectedFile, sessionId, content, resetUndoHistory]);
|
|
|
|
// Image handling hook (attachments, paste, upload, lightbox)
|
|
const {
|
|
attachmentsList,
|
|
attachmentPreviews,
|
|
attachmentsExpanded,
|
|
setAttachmentsExpanded,
|
|
lightboxFilename,
|
|
lightboxExternalUrl,
|
|
fileInputRef,
|
|
handlePaste,
|
|
handleFileSelect,
|
|
handleRemoveAttachment,
|
|
openLightboxByFilename,
|
|
closeLightbox,
|
|
handleLightboxNavigate,
|
|
handleLightboxDelete,
|
|
} = useAutoRunImageHandling({
|
|
folderPath,
|
|
selectedFile,
|
|
localContent,
|
|
setLocalContent,
|
|
handleContentChange,
|
|
isLocked,
|
|
textareaRef,
|
|
pushUndoState,
|
|
lastUndoSnapshotRef,
|
|
});
|
|
|
|
// Switch mode with scroll position synchronization
|
|
const switchMode = useCallback((newMode: 'edit' | 'preview') => {
|
|
if (newMode === mode) return;
|
|
|
|
// Calculate scroll percentage from current mode to apply to new mode
|
|
let scrollPercent = 0;
|
|
if (mode === 'edit' && textareaRef.current) {
|
|
const { scrollTop, scrollHeight, clientHeight } = textareaRef.current;
|
|
const maxScroll = scrollHeight - clientHeight;
|
|
scrollPercent = maxScroll > 0 ? scrollTop / maxScroll : 0;
|
|
} else if (mode === 'preview' && previewRef.current) {
|
|
const { scrollTop, scrollHeight, clientHeight } = previewRef.current;
|
|
const maxScroll = scrollHeight - clientHeight;
|
|
scrollPercent = maxScroll > 0 ? scrollTop / maxScroll : 0;
|
|
}
|
|
|
|
setMode(newMode);
|
|
|
|
// Apply scroll percentage to the new mode after it renders
|
|
requestAnimationFrame(() => {
|
|
if (newMode === 'preview' && previewRef.current) {
|
|
const { scrollHeight, clientHeight } = previewRef.current;
|
|
const maxScroll = scrollHeight - clientHeight;
|
|
const newScrollTop = Math.round(scrollPercent * maxScroll);
|
|
previewRef.current.scrollTop = newScrollTop;
|
|
previewScrollPosRef.current = newScrollTop;
|
|
} else if (newMode === 'edit' && textareaRef.current) {
|
|
const { scrollHeight, clientHeight } = textareaRef.current;
|
|
const maxScroll = scrollHeight - clientHeight;
|
|
const newScrollTop = Math.round(scrollPercent * maxScroll);
|
|
textareaRef.current.scrollTop = newScrollTop;
|
|
editScrollPosRef.current = newScrollTop;
|
|
}
|
|
});
|
|
|
|
if (onStateChange) {
|
|
onStateChange({
|
|
mode: newMode,
|
|
cursorPosition: textareaRef.current?.selectionStart || 0,
|
|
editScrollPos: textareaRef.current?.scrollTop || 0,
|
|
previewScrollPos: previewRef.current?.scrollTop || 0
|
|
});
|
|
}
|
|
}, [mode, onStateChange]);
|
|
|
|
// Toggle between edit and preview modes
|
|
const toggleMode = useCallback(() => {
|
|
switchMode(mode === 'edit' ? 'preview' : 'edit');
|
|
}, [mode, switchMode]);
|
|
|
|
// Expose methods to parent via ref
|
|
useImperativeHandle(ref, () => ({
|
|
focus: () => {
|
|
// Focus the appropriate element based on current mode
|
|
if (mode === 'edit' && textareaRef.current) {
|
|
textareaRef.current.focus();
|
|
} else if (mode === 'preview' && previewRef.current) {
|
|
previewRef.current.focus();
|
|
}
|
|
},
|
|
switchMode,
|
|
isDirty: () => isDirty,
|
|
save: handleSave,
|
|
revert: handleRevert,
|
|
}), [mode, switchMode, isDirty, handleSave, handleRevert]);
|
|
|
|
// Auto-switch to preview mode when auto-run starts, restore when it ends
|
|
useEffect(() => {
|
|
if (isLocked) {
|
|
// Auto-run started: save current mode and switch to preview
|
|
modeBeforeAutoRunRef.current = mode;
|
|
if (mode !== 'preview') {
|
|
setMode('preview');
|
|
}
|
|
} else if (modeBeforeAutoRunRef.current !== null) {
|
|
// Auto-run ended: restore previous mode
|
|
setMode(modeBeforeAutoRunRef.current);
|
|
modeBeforeAutoRunRef.current = null;
|
|
}
|
|
}, [isLocked]);
|
|
|
|
// Restore cursor and scroll positions when component mounts
|
|
useEffect(() => {
|
|
if (textareaRef.current && initialCursorPosition > 0) {
|
|
textareaRef.current.setSelectionRange(initialCursorPosition, initialCursorPosition);
|
|
textareaRef.current.scrollTop = initialEditScrollPos;
|
|
}
|
|
if (previewRef.current && initialPreviewScrollPos > 0) {
|
|
previewRef.current.scrollTop = initialPreviewScrollPos;
|
|
}
|
|
}, []);
|
|
|
|
// Restore scroll position after content changes cause ReactMarkdown to rebuild DOM
|
|
// useLayoutEffect runs synchronously after DOM mutations but before paint
|
|
useLayoutEffect(() => {
|
|
if (mode === 'preview' && previewRef.current && previewScrollPosRef.current > 0) {
|
|
// Use requestAnimationFrame to ensure DOM is fully updated
|
|
requestAnimationFrame(() => {
|
|
if (previewRef.current) {
|
|
previewRef.current.scrollTop = previewScrollPosRef.current;
|
|
}
|
|
});
|
|
}
|
|
}, [localContent, mode, searchOpen, searchQuery]);
|
|
|
|
// Auto-focus the active element after mode change
|
|
useEffect(() => {
|
|
if (mode === 'edit' && textareaRef.current) {
|
|
textareaRef.current.focus();
|
|
} else if (mode === 'preview' && previewRef.current) {
|
|
previewRef.current.focus();
|
|
}
|
|
}, [mode]);
|
|
|
|
// Handle document selection change - focus the appropriate element
|
|
// Note: Content syncing and editing state reset is handled by the main sync effect above
|
|
// This effect ONLY handles focusing on document change
|
|
const prevFocusSelectedFileRef = useRef(selectedFile);
|
|
useEffect(() => {
|
|
if (!selectedFile) return;
|
|
|
|
const isNewDocument = selectedFile !== prevFocusSelectedFileRef.current;
|
|
prevFocusSelectedFileRef.current = selectedFile;
|
|
|
|
if (isNewDocument) {
|
|
// Focus on document change
|
|
requestAnimationFrame(() => {
|
|
if (mode === 'edit' && textareaRef.current) {
|
|
textareaRef.current.focus();
|
|
} else if (mode === 'preview' && previewRef.current) {
|
|
previewRef.current.focus();
|
|
}
|
|
});
|
|
}
|
|
}, [selectedFile, mode]);
|
|
|
|
// Save cursor position and scroll position when they change
|
|
const handleCursorOrScrollChange = () => {
|
|
if (textareaRef.current) {
|
|
// Save to ref for persistence across re-renders
|
|
editScrollPosRef.current = textareaRef.current.scrollTop;
|
|
if (onStateChange) {
|
|
onStateChange({
|
|
mode,
|
|
cursorPosition: textareaRef.current.selectionStart,
|
|
editScrollPos: textareaRef.current.scrollTop,
|
|
previewScrollPos: previewRef.current?.scrollTop || 0
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
const handlePreviewScroll = () => {
|
|
if (previewRef.current) {
|
|
// Save to ref for persistence across re-renders
|
|
previewScrollPosRef.current = previewRef.current.scrollTop;
|
|
if (onStateChange) {
|
|
onStateChange({
|
|
mode,
|
|
cursorPosition: textareaRef.current?.selectionStart || 0,
|
|
editScrollPos: textareaRef.current?.scrollTop || 0,
|
|
previewScrollPos: previewRef.current.scrollTop
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
// Handle refresh for empty state with animation
|
|
const handleEmptyStateRefresh = useCallback(async () => {
|
|
setIsRefreshingEmpty(true);
|
|
try {
|
|
await onRefresh();
|
|
} finally {
|
|
// Keep spinner visible for at least 500ms for visual feedback
|
|
setTimeout(() => setIsRefreshingEmpty(false), 500);
|
|
}
|
|
}, [onRefresh]);
|
|
|
|
// Open search function
|
|
const openSearch = useCallback(() => {
|
|
setSearchOpen(true);
|
|
}, []);
|
|
|
|
// Close search function
|
|
const closeSearch = useCallback(() => {
|
|
setSearchOpen(false);
|
|
setSearchQuery('');
|
|
setCurrentMatchIndex(0);
|
|
setTotalMatches(0);
|
|
matchElementsRef.current = [];
|
|
// Refocus appropriate element
|
|
if (mode === 'edit' && textareaRef.current) {
|
|
textareaRef.current.focus();
|
|
} else if (mode === 'preview' && previewRef.current) {
|
|
previewRef.current.focus();
|
|
}
|
|
}, [mode]);
|
|
|
|
// Update match count when search query changes
|
|
useEffect(() => {
|
|
if (searchQuery.trim()) {
|
|
const escapedQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const regex = new RegExp(escapedQuery, 'gi');
|
|
const matches = localContent.match(regex);
|
|
const count = matches ? matches.length : 0;
|
|
setTotalMatches(count);
|
|
if (count > 0 && currentMatchIndex >= count) {
|
|
setCurrentMatchIndex(0);
|
|
}
|
|
} else {
|
|
setTotalMatches(0);
|
|
setCurrentMatchIndex(0);
|
|
}
|
|
}, [searchQuery, localContent]);
|
|
|
|
// Navigate to next search match
|
|
const goToNextMatch = useCallback(() => {
|
|
if (totalMatches === 0) return;
|
|
const nextIndex = (currentMatchIndex + 1) % totalMatches;
|
|
setCurrentMatchIndex(nextIndex);
|
|
}, [currentMatchIndex, totalMatches]);
|
|
|
|
// Navigate to previous search match
|
|
const goToPrevMatch = useCallback(() => {
|
|
if (totalMatches === 0) return;
|
|
const prevIndex = (currentMatchIndex - 1 + totalMatches) % totalMatches;
|
|
setCurrentMatchIndex(prevIndex);
|
|
}, [currentMatchIndex, totalMatches]);
|
|
|
|
// Track if the user manually navigated to a match (prev/next buttons or Enter key)
|
|
// vs just typing in the search box
|
|
const userNavigatedToMatchRef = useRef(false);
|
|
|
|
// Wrapped navigation handlers that set the flag
|
|
const goToNextMatchWithFlag = useCallback(() => {
|
|
userNavigatedToMatchRef.current = true;
|
|
goToNextMatch();
|
|
}, [goToNextMatch]);
|
|
|
|
const goToPrevMatchWithFlag = useCallback(() => {
|
|
userNavigatedToMatchRef.current = true;
|
|
goToPrevMatch();
|
|
}, [goToPrevMatch]);
|
|
|
|
// Scroll to current match in edit mode
|
|
// Only run when user explicitly navigated to a match (not on every keystroke)
|
|
useEffect(() => {
|
|
// Only scroll when user explicitly navigated (prev/next buttons or Enter key)
|
|
if (!userNavigatedToMatchRef.current) return;
|
|
if (!searchOpen || !searchQuery.trim() || totalMatches === 0) return;
|
|
if (mode !== 'edit' || !textareaRef.current) return;
|
|
|
|
// For edit mode, find the match position in the text and scroll
|
|
const escapedQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const regex = new RegExp(escapedQuery, 'gi');
|
|
let matchPosition = -1;
|
|
|
|
// Find the nth match position using matchAll
|
|
const matches = Array.from(localContent.matchAll(regex));
|
|
if (currentMatchIndex < matches.length) {
|
|
matchPosition = matches[currentMatchIndex].index!;
|
|
}
|
|
|
|
if (matchPosition >= 0 && textareaRef.current) {
|
|
const textarea = textareaRef.current;
|
|
|
|
// Create a temporary element to measure text height up to the match
|
|
const measureDiv = document.createElement('div');
|
|
const computedStyle = window.getComputedStyle(textarea);
|
|
measureDiv.style.font = computedStyle.font;
|
|
measureDiv.style.fontSize = computedStyle.fontSize;
|
|
measureDiv.style.lineHeight = computedStyle.lineHeight;
|
|
measureDiv.style.padding = computedStyle.padding;
|
|
measureDiv.style.border = computedStyle.border;
|
|
measureDiv.style.boxSizing = computedStyle.boxSizing;
|
|
measureDiv.style.height = 'auto';
|
|
measureDiv.style.position = 'absolute';
|
|
measureDiv.style.visibility = 'hidden';
|
|
measureDiv.style.whiteSpace = 'pre-wrap';
|
|
measureDiv.style.wordWrap = 'break-word';
|
|
measureDiv.style.width = `${textarea.clientWidth}px`;
|
|
measureDiv.style.overflow = 'hidden';
|
|
|
|
// Set content up to the match position to measure vertical offset
|
|
const textBeforeMatch = localContent.substring(0, matchPosition);
|
|
measureDiv.textContent = textBeforeMatch;
|
|
document.body.appendChild(measureDiv);
|
|
|
|
// The height of the measureDiv is the vertical position of the match
|
|
const matchVerticalPos = measureDiv.scrollHeight;
|
|
document.body.removeChild(measureDiv);
|
|
|
|
// Scroll to center the match in the viewport
|
|
const scrollTarget = Math.max(0, matchVerticalPos - textarea.clientHeight / 2);
|
|
textarea.scrollTop = scrollTarget;
|
|
|
|
// Focus textarea and select the match text
|
|
textarea.focus();
|
|
textarea.setSelectionRange(matchPosition, matchPosition + searchQuery.length);
|
|
userNavigatedToMatchRef.current = false;
|
|
}
|
|
}, [currentMatchIndex, searchOpen, searchQuery, totalMatches, mode, localContent]);
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
// Let template autocomplete handle keys first
|
|
if (handleAutocompleteKeyDown(e)) {
|
|
return;
|
|
}
|
|
|
|
// Insert actual tab character instead of moving focus
|
|
if (e.key === 'Tab') {
|
|
e.preventDefault();
|
|
const textarea = e.currentTarget;
|
|
const start = textarea.selectionStart;
|
|
const end = textarea.selectionEnd;
|
|
|
|
// Push undo state before modifying content
|
|
pushUndoState();
|
|
|
|
const newContent = localContent.substring(0, start) + '\t' + localContent.substring(end);
|
|
setLocalContent(newContent);
|
|
lastUndoSnapshotRef.current = newContent;
|
|
|
|
// Restore cursor position after the tab
|
|
requestAnimationFrame(() => {
|
|
textarea.selectionStart = start + 1;
|
|
textarea.selectionEnd = start + 1;
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Cmd+Z to undo, Cmd+Shift+Z to redo
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'z') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (e.shiftKey) {
|
|
handleRedo();
|
|
} else {
|
|
handleUndo();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Cmd+S to save
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (isDirty) {
|
|
handleSave();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Command-E to toggle between edit and preview (without Shift)
|
|
// Cmd+Shift+E is allowed to propagate to global handler for "Toggle Auto Run Expanded"
|
|
// Skip if edit mode is locked (during Auto Run) - matches button disabled state
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'e' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (!isLocked) {
|
|
toggleMode();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Command-F to open search in edit mode (without Shift)
|
|
// Cmd+Shift+F is allowed to propagate to the global handler for "Go to Files"
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'f' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openSearch();
|
|
return;
|
|
}
|
|
|
|
// Command-L to insert a markdown checkbox
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'l') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const textarea = e.currentTarget;
|
|
const cursorPos = textarea.selectionStart;
|
|
const textBeforeCursor = localContent.substring(0, cursorPos);
|
|
const textAfterCursor = localContent.substring(cursorPos);
|
|
|
|
// Push undo state before modifying content
|
|
pushUndoState();
|
|
|
|
// Check if we're at the start of a line or have text before
|
|
const lastNewline = textBeforeCursor.lastIndexOf('\n');
|
|
const lineStart = lastNewline === -1 ? 0 : lastNewline + 1;
|
|
const textOnCurrentLine = textBeforeCursor.substring(lineStart);
|
|
|
|
let newContent: string;
|
|
let newCursorPos: number;
|
|
|
|
if (textOnCurrentLine.length === 0) {
|
|
// At start of line, just insert checkbox
|
|
newContent = textBeforeCursor + '- [ ] ' + textAfterCursor;
|
|
newCursorPos = cursorPos + 6; // "- [ ] " is 6 chars
|
|
} else {
|
|
// In middle of line, insert newline then checkbox
|
|
newContent = textBeforeCursor + '\n- [ ] ' + textAfterCursor;
|
|
newCursorPos = cursorPos + 7; // "\n- [ ] " is 7 chars
|
|
}
|
|
|
|
setLocalContent(newContent);
|
|
// Update lastUndoSnapshot since we pushed state explicitly
|
|
lastUndoSnapshotRef.current = newContent;
|
|
setTimeout(() => {
|
|
if (textareaRef.current) {
|
|
textareaRef.current.setSelectionRange(newCursorPos, newCursorPos);
|
|
}
|
|
}, 0);
|
|
return;
|
|
}
|
|
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
const textarea = e.currentTarget;
|
|
const cursorPos = textarea.selectionStart;
|
|
const textBeforeCursor = localContent.substring(0, cursorPos);
|
|
const textAfterCursor = localContent.substring(cursorPos);
|
|
const currentLineStart = textBeforeCursor.lastIndexOf('\n') + 1;
|
|
const currentLine = textBeforeCursor.substring(currentLineStart);
|
|
|
|
// Check for list patterns
|
|
const unorderedListMatch = currentLine.match(/^(\s*)([-*])\s+/);
|
|
const orderedListMatch = currentLine.match(/^(\s*)(\d+)\.\s+/);
|
|
const taskListMatch = currentLine.match(/^(\s*)- \[([ x])\]\s+/);
|
|
|
|
if (taskListMatch) {
|
|
// Task list: continue with unchecked checkbox
|
|
const indent = taskListMatch[1];
|
|
e.preventDefault();
|
|
// Push undo state before modifying content
|
|
pushUndoState();
|
|
const newContent = textBeforeCursor + '\n' + indent + '- [ ] ' + textAfterCursor;
|
|
setLocalContent(newContent);
|
|
lastUndoSnapshotRef.current = newContent;
|
|
setTimeout(() => {
|
|
if (textareaRef.current) {
|
|
const newPos = cursorPos + indent.length + 7; // "\n" + indent + "- [ ] "
|
|
textareaRef.current.setSelectionRange(newPos, newPos);
|
|
}
|
|
}, 0);
|
|
} else if (unorderedListMatch) {
|
|
// Unordered list: continue with same marker
|
|
const indent = unorderedListMatch[1];
|
|
const marker = unorderedListMatch[2];
|
|
e.preventDefault();
|
|
// Push undo state before modifying content
|
|
pushUndoState();
|
|
const newContent = textBeforeCursor + '\n' + indent + marker + ' ' + textAfterCursor;
|
|
setLocalContent(newContent);
|
|
lastUndoSnapshotRef.current = newContent;
|
|
setTimeout(() => {
|
|
if (textareaRef.current) {
|
|
const newPos = cursorPos + indent.length + 3; // "\n" + indent + marker + " "
|
|
textareaRef.current.setSelectionRange(newPos, newPos);
|
|
}
|
|
}, 0);
|
|
} else if (orderedListMatch) {
|
|
// Ordered list: increment number
|
|
const indent = orderedListMatch[1];
|
|
const num = parseInt(orderedListMatch[2]);
|
|
e.preventDefault();
|
|
// Push undo state before modifying content
|
|
pushUndoState();
|
|
const newContent = textBeforeCursor + '\n' + indent + (num + 1) + '. ' + textAfterCursor;
|
|
setLocalContent(newContent);
|
|
lastUndoSnapshotRef.current = newContent;
|
|
setTimeout(() => {
|
|
if (textareaRef.current) {
|
|
const newPos = cursorPos + indent.length + (num + 1).toString().length + 3; // "\n" + indent + num + ". "
|
|
textareaRef.current.setSelectionRange(newPos, newPos);
|
|
}
|
|
}, 0);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Memoize prose CSS styles - only regenerate when theme changes
|
|
// Uses shared utility from markdownConfig.ts
|
|
const proseStyles = useMemo(() => generateAutoRunProseStyles(theme), [theme]);
|
|
|
|
// Parse task counts from markdown content
|
|
const taskCounts = useMemo(() => {
|
|
const completedRegex = /^[\s]*[-*]\s*\[x\]/gim;
|
|
const uncheckedRegex = /^[\s]*[-*]\s*\[\s\]/gim;
|
|
const completedMatches = localContent.match(completedRegex) || [];
|
|
const uncheckedMatches = localContent.match(uncheckedRegex) || [];
|
|
const completed = completedMatches.length;
|
|
const total = completed + uncheckedMatches.length;
|
|
return { completed, total };
|
|
}, [localContent]);
|
|
|
|
// Count tokens when content changes
|
|
useEffect(() => {
|
|
if (!localContent) {
|
|
setTokenCount(null);
|
|
return;
|
|
}
|
|
|
|
getEncoder()
|
|
.then(encoder => {
|
|
const tokens = encoder.encode(localContent);
|
|
setTokenCount(tokens.length);
|
|
})
|
|
.catch(err => {
|
|
console.error('Failed to count tokens:', err);
|
|
setTokenCount(null);
|
|
});
|
|
}, [localContent]);
|
|
|
|
// Callback for when a search match is rendered (used for scrolling to current match)
|
|
const handleMatchRendered = useCallback((index: number, element: HTMLElement) => {
|
|
if (index === currentMatchIndex) {
|
|
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
}
|
|
}, [currentMatchIndex]);
|
|
|
|
// Memoize ReactMarkdown components - only regenerate when dependencies change
|
|
// Uses shared utility from markdownConfig.ts with custom image renderer
|
|
const markdownComponents = useMemo(() => {
|
|
// Create base components with mermaid support and search highlighting
|
|
const baseComponents = createMarkdownComponents({
|
|
theme,
|
|
customLanguageRenderers: {
|
|
mermaid: ({ code, theme: t }) => <MermaidRenderer chart={code} theme={t} />,
|
|
},
|
|
// Add search highlighting when search is active with matches
|
|
searchHighlight: searchOpen && searchQuery.trim() && totalMatches > 0
|
|
? {
|
|
query: searchQuery,
|
|
currentMatchIndex,
|
|
onMatchRendered: handleMatchRendered,
|
|
}
|
|
: undefined,
|
|
});
|
|
|
|
// Add custom image renderer for AttachmentImage
|
|
return {
|
|
...baseComponents,
|
|
img: ({ src, alt, ...props }: any) => (
|
|
<AttachmentImage
|
|
src={src}
|
|
alt={alt}
|
|
folderPath={folderPath}
|
|
theme={theme}
|
|
onImageClick={openLightboxByFilename}
|
|
{...props}
|
|
/>
|
|
),
|
|
};
|
|
}, [theme, folderPath, openLightboxByFilename, searchOpen, searchQuery, totalMatches, currentMatchIndex, handleMatchRendered]);
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className="autorun-panel h-full flex flex-col outline-none relative"
|
|
tabIndex={-1}
|
|
onKeyDown={(e) => {
|
|
// CMD+E to toggle edit/preview (without Shift)
|
|
// Cmd+Shift+E is allowed to propagate to global handler for "Toggle Auto Run Expanded"
|
|
// Skip if edit mode is locked (during Auto Run) - matches button disabled state
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'e' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (!isLocked) {
|
|
toggleMode();
|
|
}
|
|
}
|
|
// CMD+F to open search (works in both modes from container)
|
|
// Only intercept Cmd+F (without Shift) - let Cmd+Shift+F propagate to global "Go to Files" handler
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'f' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openSearch();
|
|
}
|
|
}}
|
|
>
|
|
{/* No folder selected - show centered button only */}
|
|
{!folderPath && (
|
|
<div className="flex-1 flex items-center justify-center">
|
|
<button
|
|
onClick={onOpenSetup}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-colors hover:opacity-90"
|
|
style={{
|
|
backgroundColor: theme.colors.accent,
|
|
color: theme.colors.accentForeground,
|
|
}}
|
|
>
|
|
<FolderOpen className="w-3.5 h-3.5" />
|
|
Select Auto Run Folder
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* All controls and content - only shown when folder is selected */}
|
|
{folderPath && !hideTopControls && (
|
|
<div className="flex gap-2 mb-3 justify-center pt-2">
|
|
{/* Expand button */}
|
|
{onExpand && (
|
|
<button
|
|
onClick={onExpand}
|
|
className="flex items-center justify-center w-8 h-8 rounded transition-colors hover:bg-white/10"
|
|
style={{
|
|
color: theme.colors.textDim,
|
|
border: `1px solid ${theme.colors.border}`
|
|
}}
|
|
title={`Expand to full screen${shortcuts?.toggleAutoRunExpanded ? ` (${formatShortcutKeys(shortcuts.toggleAutoRunExpanded.keys)})` : ''}`}
|
|
>
|
|
<Maximize2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
)}
|
|
{/* Image upload button - hidden for now, can be re-enabled when needed
|
|
<button
|
|
onClick={() => mode === 'edit' && !isLocked && fileInputRef.current?.click()}
|
|
disabled={mode !== 'edit' || isLocked}
|
|
className={`flex items-center justify-center w-8 h-8 rounded text-xs transition-colors ${
|
|
mode === 'edit' && !isLocked ? 'hover:opacity-80' : 'opacity-30 cursor-not-allowed'
|
|
}`}
|
|
style={{
|
|
backgroundColor: 'transparent',
|
|
color: theme.colors.textDim,
|
|
border: `1px solid ${theme.colors.border}`
|
|
}}
|
|
title={mode === 'edit' && !isLocked ? 'Add image (or paste from clipboard)' : 'Switch to Edit mode to add images'}
|
|
>
|
|
<Image className="w-3.5 h-3.5" />
|
|
</button>
|
|
*/}
|
|
<button
|
|
onClick={() => !isLocked && switchMode('edit')}
|
|
disabled={isLocked}
|
|
className={`flex items-center gap-2 px-3 py-1.5 rounded text-xs transition-colors ${
|
|
mode === 'edit' && !isLocked ? 'font-semibold' : ''
|
|
} ${isLocked ? 'opacity-50 cursor-not-allowed' : ''}`}
|
|
style={{
|
|
backgroundColor: mode === 'edit' && !isLocked ? theme.colors.bgActivity : 'transparent',
|
|
color: isLocked ? theme.colors.textDim : (mode === 'edit' ? theme.colors.textMain : theme.colors.textDim),
|
|
border: `1px solid ${mode === 'edit' && !isLocked ? theme.colors.accent : theme.colors.border}`
|
|
}}
|
|
title={isLocked ? 'Editing disabled while Auto Run active' : 'Edit document'}
|
|
>
|
|
<Edit className="w-3.5 h-3.5" />
|
|
Edit
|
|
</button>
|
|
<button
|
|
onClick={() => switchMode('preview')}
|
|
className={`flex items-center gap-2 px-3 py-1.5 rounded text-xs transition-colors ${
|
|
mode === 'preview' || isLocked ? 'font-semibold' : ''
|
|
}`}
|
|
style={{
|
|
backgroundColor: mode === 'preview' || isLocked ? theme.colors.bgActivity : 'transparent',
|
|
color: mode === 'preview' || isLocked ? theme.colors.textMain : theme.colors.textDim,
|
|
border: `1px solid ${mode === 'preview' || isLocked ? theme.colors.accent : theme.colors.border}`
|
|
}}
|
|
title="Preview document"
|
|
>
|
|
<Eye className="w-3.5 h-3.5" />
|
|
Preview
|
|
</button>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
onChange={handleFileSelect}
|
|
className="hidden"
|
|
/>
|
|
{/* Run / Stop button */}
|
|
{isLocked ? (
|
|
<button
|
|
onClick={onStopBatchRun}
|
|
disabled={isStopping}
|
|
className={`flex items-center gap-2 px-3 py-1.5 rounded text-xs transition-colors font-semibold ${isStopping ? 'opacity-50 cursor-not-allowed' : ''}`}
|
|
style={{
|
|
backgroundColor: theme.colors.error,
|
|
color: 'white',
|
|
border: `1px solid ${theme.colors.error}`
|
|
}}
|
|
title={isStopping ? 'Stopping after current task...' : 'Stop batch run'}
|
|
>
|
|
{isStopping ? (
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
) : (
|
|
<Square className="w-3.5 h-3.5" />
|
|
)}
|
|
{isStopping ? 'Stopping...' : 'Stop'}
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={() => {
|
|
// Save before opening batch runner if dirty
|
|
if (isDirty) {
|
|
handleSave();
|
|
}
|
|
onOpenBatchRunner?.();
|
|
}}
|
|
disabled={isAgentBusy}
|
|
className={`flex items-center gap-2 px-3 py-1.5 rounded text-xs transition-colors ${isAgentBusy ? 'opacity-50 cursor-not-allowed' : 'hover:opacity-90'}`}
|
|
style={{
|
|
backgroundColor: theme.colors.accent,
|
|
color: theme.colors.accentForeground,
|
|
border: `1px solid ${theme.colors.accent}`
|
|
}}
|
|
title={isAgentBusy ? "Cannot run while agent is thinking" : "Run batch processing on Auto Run tasks"}
|
|
>
|
|
<Play className="w-3.5 h-3.5" />
|
|
Run
|
|
</button>
|
|
)}
|
|
{/* Help button */}
|
|
<button
|
|
onClick={() => setHelpModalOpen(true)}
|
|
className="flex items-center justify-center w-8 h-8 rounded transition-colors hover:bg-white/10"
|
|
style={{
|
|
color: theme.colors.textDim,
|
|
border: `1px solid ${theme.colors.border}`
|
|
}}
|
|
title="Learn about Auto Runner"
|
|
>
|
|
<HelpCircle className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Document Selector */}
|
|
{folderPath && (
|
|
<div className="px-2 mb-2" data-tour="autorun-document-selector">
|
|
<AutoRunDocumentSelector
|
|
theme={theme}
|
|
documents={documentList}
|
|
documentTree={documentTree as import('./AutoRunDocumentSelector').DocTreeNode[] | undefined}
|
|
selectedDocument={selectedFile}
|
|
onSelectDocument={onSelectDocument}
|
|
onRefresh={onRefresh}
|
|
onChangeFolder={onOpenSetup}
|
|
onCreateDocument={onCreateDocument}
|
|
isLoading={isLoadingDocuments}
|
|
documentTaskCounts={documentTaskCounts}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error Banner (Phase 5.10) - shown when batch is paused due to agent error */}
|
|
{isErrorPaused && batchError && (
|
|
<div
|
|
className="mx-2 mb-2 p-3 rounded-lg border"
|
|
style={{
|
|
backgroundColor: `${theme.colors.error}15`,
|
|
borderColor: theme.colors.error
|
|
}}
|
|
>
|
|
<div className="flex items-start gap-2">
|
|
<AlertTriangle
|
|
className="w-4 h-4 mt-0.5 flex-shrink-0"
|
|
style={{ color: theme.colors.error }}
|
|
/>
|
|
<div className="flex-1 min-w-0">
|
|
<div
|
|
className="text-xs font-semibold mb-1"
|
|
style={{ color: theme.colors.error }}
|
|
>
|
|
Auto Run Paused
|
|
</div>
|
|
<div
|
|
className="text-xs mb-2"
|
|
style={{ color: theme.colors.textMain }}
|
|
>
|
|
{batchError.message}
|
|
{errorDocumentName && (
|
|
<span style={{ color: theme.colors.textDim }}>
|
|
{' '}— while processing <strong>{errorDocumentName}</strong>
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-2 flex-wrap">
|
|
{/* Skip button - only show if there are more documents */}
|
|
{batchRunState && batchRunState.currentDocumentIndex < batchRunState.documents.length - 1 && onSkipCurrentDocument && (
|
|
<button
|
|
onClick={onSkipCurrentDocument}
|
|
className="flex items-center gap-1.5 px-2 py-1 rounded text-[10px] font-medium transition-colors hover:opacity-80"
|
|
style={{
|
|
backgroundColor: theme.colors.bgActivity,
|
|
color: theme.colors.textMain,
|
|
border: `1px solid ${theme.colors.border}`
|
|
}}
|
|
title="Skip this document and continue with the next one"
|
|
>
|
|
<SkipForward className="w-3 h-3" />
|
|
Skip Document
|
|
</button>
|
|
)}
|
|
{/* Resume button - for recoverable errors */}
|
|
{batchError.recoverable && onResumeAfterError && (
|
|
<button
|
|
onClick={onResumeAfterError}
|
|
className="flex items-center gap-1.5 px-2 py-1 rounded text-[10px] font-medium transition-colors hover:opacity-80"
|
|
style={{
|
|
backgroundColor: theme.colors.accent,
|
|
color: theme.colors.accentForeground
|
|
}}
|
|
title="Retry and resume Auto Run"
|
|
>
|
|
<Play className="w-3 h-3" />
|
|
Resume
|
|
</button>
|
|
)}
|
|
{/* Abort button */}
|
|
{onAbortBatchOnError && (
|
|
<button
|
|
onClick={onAbortBatchOnError}
|
|
className="flex items-center gap-1.5 px-2 py-1 rounded text-[10px] font-medium transition-colors hover:opacity-80"
|
|
style={{
|
|
backgroundColor: theme.colors.error,
|
|
color: 'white'
|
|
}}
|
|
title="Stop Auto Run completely"
|
|
>
|
|
<XCircle className="w-3 h-3" />
|
|
Abort Run
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Attached Images Preview (edit mode) - only when folder selected */}
|
|
{folderPath && mode === 'edit' && attachmentsList.length > 0 && (
|
|
<div
|
|
className="px-2 py-2 mx-2 mb-2 rounded"
|
|
style={{ backgroundColor: theme.colors.bgActivity }}
|
|
>
|
|
<button
|
|
onClick={() => setAttachmentsExpanded(!attachmentsExpanded)}
|
|
className="w-full flex items-center gap-1 text-[10px] uppercase font-semibold hover:opacity-80 transition-opacity"
|
|
style={{ color: theme.colors.textDim }}
|
|
>
|
|
{attachmentsExpanded ? (
|
|
<ChevronDown className="w-3 h-3" />
|
|
) : (
|
|
<ChevronRight className="w-3 h-3" />
|
|
)}
|
|
Attached Images ({attachmentsList.length})
|
|
</button>
|
|
{attachmentsExpanded && (
|
|
<div className="flex flex-wrap gap-1 mt-2">
|
|
{attachmentsList.map(filename => (
|
|
<ImagePreview
|
|
key={filename}
|
|
src={attachmentPreviews.get(filename) || ''}
|
|
filename={filename}
|
|
theme={theme}
|
|
onRemove={() => handleRemoveAttachment(filename)}
|
|
onImageClick={openLightboxByFilename}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Search Bar */}
|
|
{searchOpen && (
|
|
<AutoRunSearchBar
|
|
theme={theme}
|
|
searchQuery={searchQuery}
|
|
onSearchQueryChange={setSearchQuery}
|
|
currentMatchIndex={currentMatchIndex}
|
|
totalMatches={totalMatches}
|
|
onNextMatch={goToNextMatchWithFlag}
|
|
onPrevMatch={goToPrevMatchWithFlag}
|
|
onClose={closeSearch}
|
|
/>
|
|
)}
|
|
|
|
{/* Content Area - only shown when folder is selected */}
|
|
{folderPath && (
|
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
|
{/* Empty folder state - show when folder is configured but has no documents */}
|
|
{documentList.length === 0 && !isLoadingDocuments ? (
|
|
<div
|
|
className="h-full flex flex-col items-center justify-center text-center px-6"
|
|
style={{ color: theme.colors.textDim }}
|
|
>
|
|
<div
|
|
className="w-16 h-16 rounded-full flex items-center justify-center mb-4"
|
|
style={{ backgroundColor: theme.colors.bgActivity }}
|
|
>
|
|
<FileText className="w-8 h-8" style={{ color: theme.colors.textDim }} />
|
|
</div>
|
|
<h3
|
|
className="text-lg font-semibold mb-2"
|
|
style={{ color: theme.colors.textMain }}
|
|
>
|
|
No Documents Found
|
|
</h3>
|
|
<p className="mb-4 max-w-xs text-sm">
|
|
The selected folder doesn't contain any markdown (.md) files.
|
|
</p>
|
|
<p className="mb-6 max-w-xs text-xs" style={{ color: theme.colors.textDim }}>
|
|
Create a markdown file in the folder to get started, or select a different folder.
|
|
</p>
|
|
<div className="flex gap-3">
|
|
<button
|
|
onClick={handleEmptyStateRefresh}
|
|
className="flex items-center gap-2 px-4 py-2 rounded text-sm transition-colors hover:opacity-90"
|
|
style={{
|
|
backgroundColor: 'transparent',
|
|
color: theme.colors.textMain,
|
|
border: `1px solid ${theme.colors.border}`,
|
|
}}
|
|
>
|
|
<RefreshCw className={`w-4 h-4 ${isRefreshingEmpty ? 'animate-spin' : ''}`} />
|
|
Refresh
|
|
</button>
|
|
<button
|
|
onClick={onOpenSetup}
|
|
className="flex items-center gap-2 px-4 py-2 rounded text-sm transition-colors hover:opacity-90"
|
|
style={{
|
|
backgroundColor: theme.colors.accent,
|
|
color: theme.colors.accentForeground,
|
|
}}
|
|
>
|
|
<FolderOpen className="w-4 h-4" />
|
|
Change Auto-run Folder
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : mode === 'edit' ? (
|
|
<div className="relative w-full h-full">
|
|
<textarea
|
|
ref={textareaRef}
|
|
value={localContent}
|
|
onChange={(e) => {
|
|
if (!isLocked) {
|
|
// Schedule undo snapshot with current content before the change
|
|
const previousContent = localContent;
|
|
const previousCursor = textareaRef.current?.selectionStart || 0;
|
|
// Use autocomplete handler to detect "{{" triggers
|
|
handleAutocompleteChange(e);
|
|
scheduleUndoSnapshot(previousContent, previousCursor);
|
|
}
|
|
}}
|
|
onFocus={() => { /* no-op, manual save only */ }}
|
|
onKeyDown={!isLocked ? handleKeyDown : undefined}
|
|
onPaste={handlePaste}
|
|
placeholder="Capture notes, images, and tasks in Markdown. (type {{ for variables)"
|
|
readOnly={isLocked}
|
|
className={`w-full h-full border rounded p-4 bg-transparent outline-none resize-none font-mono text-sm ${isLocked ? 'cursor-not-allowed opacity-70' : ''}`}
|
|
style={{
|
|
borderColor: isLocked ? theme.colors.warning : theme.colors.border,
|
|
color: theme.colors.textMain,
|
|
backgroundColor: isLocked ? theme.colors.bgActivity + '30' : 'transparent'
|
|
}}
|
|
/>
|
|
{/* Template Variable Autocomplete Dropdown */}
|
|
<TemplateAutocompleteDropdown
|
|
ref={autocompleteRef}
|
|
theme={theme}
|
|
state={autocompleteState}
|
|
onSelect={selectVariable}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div
|
|
ref={previewRef}
|
|
className="border rounded p-4 prose prose-sm max-w-none outline-none"
|
|
tabIndex={0}
|
|
onKeyDown={(e) => {
|
|
// CMD+E to toggle edit/preview (without Shift)
|
|
// Cmd+Shift+E is allowed to propagate to global handler for "Toggle Auto Run Expanded"
|
|
// Skip if edit mode is locked (during Auto Run) - matches button disabled state
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'e' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (!isLocked) {
|
|
toggleMode();
|
|
}
|
|
}
|
|
// Cmd+F to open search in preview mode (without Shift)
|
|
// Cmd+Shift+F is allowed to propagate to global handler for "Go to Files"
|
|
if (e.key === 'f' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openSearch();
|
|
}
|
|
}}
|
|
onScroll={handlePreviewScroll}
|
|
style={{
|
|
borderColor: theme.colors.border,
|
|
color: theme.colors.textMain,
|
|
fontSize: '13px'
|
|
}}
|
|
>
|
|
<style>{proseStyles}</style>
|
|
<ReactMarkdown
|
|
remarkPlugins={REMARK_PLUGINS}
|
|
components={markdownComponents}
|
|
>
|
|
{localContent || '*No content yet. Switch to Edit mode to start writing.*'}
|
|
</ReactMarkdown>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Bottom Panel - shown when folder selected AND (there are tasks, unsaved changes, or content with token count) */}
|
|
{folderPath && (taskCounts.total > 0 || (isDirty && mode === 'edit' && !isLocked) || tokenCount !== null) && (
|
|
<div
|
|
className="flex-shrink-0 px-3 py-1.5 text-xs border-t flex items-center justify-between"
|
|
style={{
|
|
backgroundColor: theme.colors.bgActivity,
|
|
borderColor: theme.colors.border,
|
|
}}
|
|
>
|
|
{/* Revert button - left side */}
|
|
{isDirty && mode === 'edit' && !isLocked ? (
|
|
<button
|
|
onClick={handleRevert}
|
|
className="px-2 py-0.5 rounded text-xs transition-colors hover:opacity-80"
|
|
style={{
|
|
backgroundColor: 'transparent',
|
|
color: theme.colors.textDim,
|
|
border: `1px solid ${theme.colors.border}`
|
|
}}
|
|
title="Discard changes"
|
|
>
|
|
Revert
|
|
</button>
|
|
) : (
|
|
<div />
|
|
)}
|
|
|
|
{/* Center info: Task count and/or Token count */}
|
|
<div className="flex items-center gap-3">
|
|
{taskCounts.total > 0 && (
|
|
<span style={{ color: taskCounts.completed === taskCounts.total ? theme.colors.success : theme.colors.textDim }}>
|
|
{taskCounts.completed} of {taskCounts.total} task{taskCounts.total !== 1 ? 's' : ''} completed
|
|
</span>
|
|
)}
|
|
{tokenCount !== null && (
|
|
<span style={{ color: theme.colors.textDim }}>
|
|
<span className="opacity-60">Tokens:</span>{' '}
|
|
<span style={{ color: theme.colors.accent }}>{formatTokenCount(tokenCount)}</span>
|
|
</span>
|
|
)}
|
|
{taskCounts.total === 0 && tokenCount === null && isDirty && (
|
|
<span style={{ color: theme.colors.textDim }}>Unsaved changes</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Save button - right side */}
|
|
{isDirty && mode === 'edit' && !isLocked ? (
|
|
<button
|
|
onClick={handleSave}
|
|
className="group relative px-2 py-0.5 rounded text-xs transition-colors hover:opacity-80"
|
|
style={{
|
|
backgroundColor: theme.colors.accent,
|
|
color: theme.colors.accentForeground,
|
|
border: `1px solid ${theme.colors.accent}`
|
|
}}
|
|
title="Save changes"
|
|
>
|
|
Save
|
|
{/* Keyboard shortcut overlay on hover */}
|
|
<span
|
|
className="absolute -top-7 left-1/2 -translate-x-1/2 px-1.5 py-0.5 rounded text-[10px] whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
|
|
style={{
|
|
backgroundColor: theme.colors.bgMain,
|
|
color: theme.colors.textDim,
|
|
border: `1px solid ${theme.colors.border}`,
|
|
}}
|
|
>
|
|
⌘S
|
|
</span>
|
|
</button>
|
|
) : (
|
|
<div />
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Help Modal */}
|
|
{helpModalOpen && (
|
|
<AutoRunnerHelpModal
|
|
theme={theme}
|
|
onClose={() => setHelpModalOpen(false)}
|
|
/>
|
|
)}
|
|
|
|
{/* Lightbox for viewing images with navigation, copy, and delete */}
|
|
<AutoRunLightbox
|
|
theme={theme}
|
|
attachmentsList={attachmentsList}
|
|
attachmentPreviews={attachmentPreviews}
|
|
lightboxFilename={lightboxFilename}
|
|
lightboxExternalUrl={lightboxExternalUrl}
|
|
onClose={closeLightbox}
|
|
onNavigate={handleLightboxNavigate}
|
|
onDelete={handleLightboxDelete}
|
|
/>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
// Memoized AutoRun component with custom comparison to prevent unnecessary re-renders
|
|
export const AutoRun = memo(AutoRunInner, (prevProps, nextProps) => {
|
|
// Only re-render when these specific props actually change
|
|
return (
|
|
prevProps.content === nextProps.content &&
|
|
prevProps.sessionId === nextProps.sessionId &&
|
|
prevProps.mode === nextProps.mode &&
|
|
prevProps.theme === nextProps.theme &&
|
|
// Document state
|
|
prevProps.folderPath === nextProps.folderPath &&
|
|
prevProps.selectedFile === nextProps.selectedFile &&
|
|
prevProps.documentList === nextProps.documentList &&
|
|
prevProps.isLoadingDocuments === nextProps.isLoadingDocuments &&
|
|
// Compare batch run state values, not object reference
|
|
prevProps.batchRunState?.isRunning === nextProps.batchRunState?.isRunning &&
|
|
prevProps.batchRunState?.isStopping === nextProps.batchRunState?.isStopping &&
|
|
prevProps.batchRunState?.currentTaskIndex === nextProps.batchRunState?.currentTaskIndex &&
|
|
prevProps.batchRunState?.totalTasks === nextProps.batchRunState?.totalTasks &&
|
|
// Error state (Phase 5.10)
|
|
prevProps.batchRunState?.errorPaused === nextProps.batchRunState?.errorPaused &&
|
|
prevProps.batchRunState?.error?.type === nextProps.batchRunState?.error?.type &&
|
|
prevProps.batchRunState?.error?.message === nextProps.batchRunState?.error?.message &&
|
|
// Session state affects UI (busy disables Run button)
|
|
prevProps.sessionState === nextProps.sessionState &&
|
|
// Callbacks are typically stable, but check identity
|
|
prevProps.onContentChange === nextProps.onContentChange &&
|
|
prevProps.onModeChange === nextProps.onModeChange &&
|
|
prevProps.onStateChange === nextProps.onStateChange &&
|
|
prevProps.onOpenBatchRunner === nextProps.onOpenBatchRunner &&
|
|
prevProps.onStopBatchRun === nextProps.onStopBatchRun &&
|
|
prevProps.onOpenSetup === nextProps.onOpenSetup &&
|
|
prevProps.onRefresh === nextProps.onRefresh &&
|
|
prevProps.onSelectDocument === nextProps.onSelectDocument &&
|
|
// UI control props
|
|
prevProps.hideTopControls === nextProps.hideTopControls &&
|
|
// External change detection
|
|
prevProps.contentVersion === nextProps.contentVersion
|
|
// Note: initialCursorPosition, initialEditScrollPos, initialPreviewScrollPos
|
|
// are intentionally NOT compared - they're only used on mount
|
|
// Note: documentTree is derived from documentList, comparing documentList is sufficient
|
|
);
|
|
});
|