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:
199
frontend/src/components/session/ScratchpadSidebar.tsx
Normal file
199
frontend/src/components/session/ScratchpadSidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export { PostStepActionModal } from './PostStepActionModal'
|
||||
export { ContinuationModal, type DescendantNode } from './ContinuationModal'
|
||||
export { ForkTreeModal } from './ForkTreeModal'
|
||||
export { ScratchpadSidebar } from './ScratchpadSidebar'
|
||||
|
||||
Reference in New Issue
Block a user