diff --git a/frontend/src/components/session/ScratchpadSidebar.tsx b/frontend/src/components/session/ScratchpadSidebar.tsx new file mode 100644 index 00000000..951453af --- /dev/null +++ b/frontend/src/components/session/ScratchpadSidebar.tsx @@ -0,0 +1,199 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import { cn } from '@/lib/utils' +import { MarkdownContent } from '@/components/ui/MarkdownContent' +import { StickyNote, ChevronRight, Eye, Pencil, Loader2 } from 'lucide-react' + +interface ScratchpadSidebarProps { + sessionId: string + initialContent: string + onSave: (content: string) => Promise +} + +export function ScratchpadSidebar({ sessionId, initialContent, onSave }: ScratchpadSidebarProps) { + const [content, setContent] = useState(initialContent) + const [lastSaved, setLastSaved] = useState(initialContent) + const [isCollapsed, setIsCollapsed] = useState(() => { + return localStorage.getItem('scratchpad-collapsed') === 'true' + }) + const [isSaving, setIsSaving] = useState(false) + const [showPreview, setShowPreview] = useState(false) + const [saveStatus, setSaveStatus] = useState<'idle' | 'unsaved' | 'saving' | 'saved' | 'error'>('idle') + + const debounceRef = useRef | null>(null) + const fadeTimerRef = useRef | null>(null) + + const hasUnsavedChanges = content !== lastSaved + + // Reset content when session changes + useEffect(() => { + setContent(initialContent) + setLastSaved(initialContent) + setSaveStatus('idle') + }, [sessionId]) // eslint-disable-line react-hooks/exhaustive-deps + + // Update save status based on state + useEffect(() => { + if (isSaving) { + setSaveStatus('saving') + } else if (hasUnsavedChanges) { + setSaveStatus('unsaved') + } + }, [isSaving, hasUnsavedChanges]) + + // Persist collapse state + useEffect(() => { + localStorage.setItem('scratchpad-collapsed', String(isCollapsed)) + }, [isCollapsed]) + + // beforeunload warning + useEffect(() => { + if (!hasUnsavedChanges) return + + const handler = (e: BeforeUnloadEvent) => { + e.preventDefault() + } + window.addEventListener('beforeunload', handler) + return () => window.removeEventListener('beforeunload', handler) + }, [hasUnsavedChanges]) + + const doSave = useCallback(async (text: string) => { + setIsSaving(true) + try { + await onSave(text) + setLastSaved(text) + setSaveStatus('saved') + + // Clear "Saved" indicator after 2 seconds + if (fadeTimerRef.current) clearTimeout(fadeTimerRef.current) + fadeTimerRef.current = setTimeout(() => setSaveStatus('idle'), 2000) + } catch { + setSaveStatus('error') + } finally { + setIsSaving(false) + } + }, [onSave]) + + const handleChange = (value: string) => { + setContent(value) + + // Cancel any pending debounce + if (debounceRef.current) clearTimeout(debounceRef.current) + + // Schedule save after 1000ms of inactivity + debounceRef.current = setTimeout(() => { + if (value !== lastSaved) { + doSave(value) + } + }, 1000) + } + + const handleBlur = () => { + // Cancel pending debounce and save immediately + if (debounceRef.current) clearTimeout(debounceRef.current) + if (content !== lastSaved) { + doSave(content) + } + } + + // Cleanup timers on unmount + useEffect(() => { + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current) + if (fadeTimerRef.current) clearTimeout(fadeTimerRef.current) + } + }, []) + + if (isCollapsed) { + return ( +
+ + {hasUnsavedChanges && ( +
+ )} +
+ ) + } + + return ( +
+ {/* Header */} +
+
+ + Scratchpad +
+
+ + +
+
+ + {/* Content */} +
+ {showPreview ? ( +
+ {content.trim() ? ( + + ) : ( +

Nothing to preview

+ )} +
+ ) : ( +