feat: add ScratchpadSidebar component with auto-save and markdown preview

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-02-04 02:50:37 -05:00
parent a92671157f
commit 26cf66e239
2 changed files with 200 additions and 0 deletions

View File

@@ -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<void>
}
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<ReturnType<typeof setTimeout> | null>(null)
const fadeTimerRef = useRef<ReturnType<typeof setTimeout> | 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 (
<div className="flex w-12 flex-shrink-0 flex-col items-center border-l border-border bg-card pt-4">
<button
onClick={() => setIsCollapsed(false)}
className="rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-foreground"
title="Open scratchpad"
>
<StickyNote className="h-5 w-5" />
</button>
{hasUnsavedChanges && (
<div className="mt-2 h-2 w-2 rounded-full bg-amber-500" title="Unsaved changes" />
)}
</div>
)
}
return (
<div className="flex w-[300px] flex-shrink-0 flex-col border-l border-border bg-card">
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-3 py-2">
<div className="flex items-center gap-2">
<StickyNote className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium text-foreground">Scratchpad</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setShowPreview(!showPreview)}
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
title={showPreview ? 'Edit' : 'Preview'}
>
{showPreview ? <Pencil className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</button>
<button
onClick={() => setIsCollapsed(true)}
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
title="Collapse scratchpad"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-3">
{showPreview ? (
<div className="min-h-[100px]">
{content.trim() ? (
<MarkdownContent content={content} className="text-sm" />
) : (
<p className="text-sm italic text-muted-foreground">Nothing to preview</p>
)}
</div>
) : (
<textarea
value={content}
onChange={(e) => handleChange(e.target.value)}
onBlur={handleBlur}
placeholder={"Capture IPs, error codes, server names, user info...\n\nSupports markdown formatting."}
className={cn(
'h-full min-h-[200px] w-full resize-none rounded-md border-0 bg-transparent p-0 text-sm',
'text-foreground placeholder:text-muted-foreground',
'focus:outline-none focus:ring-0'
)}
/>
)}
</div>
{/* Save Indicator */}
<div className="border-t border-border px-3 py-1.5">
<div className="flex items-center gap-1.5 text-xs">
{saveStatus === 'unsaved' && (
<span className="text-muted-foreground">Unsaved changes</span>
)}
{saveStatus === 'saving' && (
<>
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
<span className="text-muted-foreground">Saving...</span>
</>
)}
{saveStatus === 'saved' && (
<span className="text-green-600 dark:text-green-400">Saved</span>
)}
{saveStatus === 'error' && (
<span className="text-destructive">Save failed</span>
)}
{saveStatus === 'idle' && (
<span className="text-muted-foreground/50">Markdown supported</span>
)}
</div>
</div>
</div>
)
}

View File

@@ -1,3 +1,4 @@
export { PostStepActionModal } from './PostStepActionModal'
export { ContinuationModal, type DescendantNode } from './ContinuationModal'
export { ForkTreeModal } from './ForkTreeModal'
export { ScratchpadSidebar } from './ScratchpadSidebar'