feat(autosave): files and chunk editor autosave with debounce + refetch (#3508)

* feat(files): debounced autosave while editing

* address review comments

* more comments
This commit is contained in:
Vikhyath Mondreti
2026-03-10 15:36:00 -07:00
committed by GitHub
parent de36e332d7
commit 5362f7417f
7 changed files with 242 additions and 57 deletions

View File

@@ -10,6 +10,7 @@ import {
useUpdateWorkspaceFileContent,
useWorkspaceFileContent,
} from '@/hooks/queries/workspace-files'
import { useAutosave } from '@/hooks/use-autosave'
import { PreviewPanel, resolvePreviewType } from './preview-panel'
const logger = createLogger('FileViewer')
@@ -60,6 +61,7 @@ interface FileViewerProps {
showPreview?: boolean
autoFocus?: boolean
onDirtyChange?: (isDirty: boolean) => void
onSaveStatusChange?: (status: 'idle' | 'saving' | 'saved' | 'error') => void
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>
}
@@ -70,6 +72,7 @@ export function FileViewer({
showPreview,
autoFocus,
onDirtyChange,
onSaveStatusChange,
saveRef,
}: FileViewerProps) {
const category = resolveFileCategory(file.type, file.name)
@@ -83,6 +86,7 @@ export function FileViewer({
showPreview={showPreview}
autoFocus={autoFocus}
onDirtyChange={onDirtyChange}
onSaveStatusChange={onSaveStatusChange}
saveRef={saveRef}
/>
)
@@ -102,6 +106,7 @@ interface TextEditorProps {
showPreview?: boolean
autoFocus?: boolean
onDirtyChange?: (isDirty: boolean) => void
onSaveStatusChange?: (status: 'idle' | 'saving' | 'saved' | 'error') => void
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>
}
@@ -112,6 +117,7 @@ function TextEditor({
showPreview,
autoFocus,
onDirtyChange,
onSaveStatusChange,
saveRef,
}: TextEditorProps) {
const initializedRef = useRef(false)
@@ -126,40 +132,49 @@ function TextEditor({
data: fetchedContent,
isLoading,
error,
dataUpdatedAt,
} = useWorkspaceFileContent(workspaceId, file.id, file.key)
const updateContent = useUpdateWorkspaceFileContent()
const [content, setContent] = useState('')
const [savedContent, setSavedContent] = useState('')
const savedContentRef = useRef('')
useEffect(() => {
if (fetchedContent !== undefined && !initializedRef.current) {
if (fetchedContent === undefined) return
if (!initializedRef.current) {
setContent(fetchedContent)
setSavedContent(fetchedContent)
savedContentRef.current = fetchedContent
contentRef.current = fetchedContent
initializedRef.current = true
if (autoFocus) {
requestAnimationFrame(() => textareaRef.current?.focus())
}
return
}
}, [fetchedContent, autoFocus])
if (fetchedContent === savedContentRef.current) return
const isClean = contentRef.current === savedContentRef.current
if (isClean) {
setContent(fetchedContent)
setSavedContent(fetchedContent)
savedContentRef.current = fetchedContent
contentRef.current = fetchedContent
}
}, [fetchedContent, dataUpdatedAt, autoFocus])
const handleContentChange = useCallback((value: string) => {
setContent(value)
contentRef.current = value
}, [])
const isDirty = initializedRef.current && content !== savedContent
useEffect(() => {
onDirtyChange?.(isDirty)
}, [isDirty, onDirtyChange])
const handleSave = useCallback(async () => {
const onSave = useCallback(async () => {
const currentContent = contentRef.current
if (currentContent === savedContent) return
if (currentContent === savedContentRef.current) return
await updateContent.mutateAsync({
workspaceId,
@@ -167,18 +182,34 @@ function TextEditor({
content: currentContent,
})
setSavedContent(currentContent)
}, [savedContent, workspaceId, file.id])
savedContentRef.current = currentContent
}, [workspaceId, file.id, updateContent])
const { saveStatus, saveImmediately, isDirty } = useAutosave({
content,
savedContent,
onSave,
enabled: canEdit && initializedRef.current,
})
useEffect(() => {
onDirtyChange?.(isDirty)
}, [isDirty, onDirtyChange])
useEffect(() => {
onSaveStatusChange?.(saveStatus)
}, [saveStatus, onSaveStatusChange])
useEffect(() => {
if (saveRef) {
saveRef.current = handleSave
saveRef.current = saveImmediately
}
return () => {
if (saveRef) {
saveRef.current = null
}
}
}, [saveRef, handleSave])
}, [saveRef, saveImmediately])
useEffect(() => {
if (!isResizing) return

View File

@@ -315,14 +315,7 @@ export function Files() {
const handleSave = useCallback(async () => {
if (!saveRef.current || !isDirty || saveStatus === 'saving') return
setSaveStatus('saving')
try {
await saveRef.current()
setSaveStatus('saved')
} catch {
setSaveStatus('error')
}
await saveRef.current()
}, [isDirty, saveStatus])
const handleBackAttempt = useCallback(() => {
@@ -413,12 +406,6 @@ export function Files() {
setShowPreview(true)
}, [selectedFileId])
useEffect(() => {
if (saveStatus !== 'saved' && saveStatus !== 'error') return
const timer = setTimeout(() => setSaveStatus('idle'), 2000)
return () => clearTimeout(timer)
}, [saveStatus])
useEffect(() => {
if (!selectedFile) return
const handleKeyDown = (e: KeyboardEvent) => {
@@ -557,6 +544,7 @@ export function Files() {
showPreview={showPreview && canPreview}
autoFocus={justCreatedFileIdRef.current === selectedFile.id}
onDirtyChange={setIsDirty}
onSaveStatusChange={setSaveStatus}
saveRef={saveRef}
/>

View File

@@ -5,6 +5,7 @@ import { Label, Switch } from '@/components/emcn'
import type { ChunkData, DocumentData } from '@/lib/knowledge/types'
import { getAccurateTokenCount, getTokenStrings } from '@/lib/tokenization/estimators'
import { useCreateChunk, useUpdateChunk } from '@/hooks/queries/kb/knowledge'
import { useAutosave } from '@/hooks/use-autosave'
const TOKEN_BG_COLORS = [
'rgba(239, 68, 68, 0.55)',
@@ -27,6 +28,7 @@ interface ChunkEditorProps {
canEdit: boolean
maxChunkSize?: number
onDirtyChange: (isDirty: boolean) => void
onSaveStatusChange?: (status: 'idle' | 'saving' | 'saved' | 'error') => void
saveRef: React.MutableRefObject<(() => Promise<void>) | null>
onCreated?: (chunkId: string) => void
}
@@ -39,6 +41,7 @@ export function ChunkEditor({
canEdit,
maxChunkSize,
onDirtyChange,
onSaveStatusChange,
saveRef,
onCreated,
}: ChunkEditorProps) {
@@ -50,23 +53,55 @@ export function ChunkEditor({
const chunkContent = chunk?.content ?? ''
const [editedContent, setEditedContent] = useState(isCreateMode ? '' : chunkContent)
const [savedContent, setSavedContent] = useState(chunkContent)
const [tokenizerOn, setTokenizerOn] = useState(false)
const [hoveredTokenIndex, setHoveredTokenIndex] = useState<number | null>(null)
const prevChunkIdRef = useRef(chunk?.id)
const savedContentRef = useRef(chunkContent)
const editedContentRef = useRef(editedContent)
editedContentRef.current = editedContent
const isDirty = isCreateMode ? editedContent.trim().length > 0 : editedContent !== chunkContent
useEffect(() => {
if (!isCreateMode) {
if (isCreateMode) return
if (chunk?.id !== prevChunkIdRef.current) {
prevChunkIdRef.current = chunk?.id
savedContentRef.current = chunkContent
setSavedContent(chunkContent)
setEditedContent(chunkContent)
}
}, [isCreateMode, chunk?.id, chunkContent])
useEffect(() => {
onDirtyChange(isDirty)
}, [isDirty, onDirtyChange])
if (isCreateMode || !chunk?.id) return
const controller = new AbortController()
const handleVisibility = async () => {
if (document.visibilityState !== 'visible') return
try {
const res = await fetch(
`/api/knowledge/${knowledgeBaseId}/documents/${documentData.id}/chunks/${chunk.id}`,
{ signal: controller.signal }
)
if (!res.ok) return
const json = await res.json()
const serverContent: string = json.data?.content ?? ''
if (serverContent === savedContentRef.current) return
const isClean = editedContentRef.current === savedContentRef.current
savedContentRef.current = serverContent
setSavedContent(serverContent)
if (isClean) {
setEditedContent(serverContent)
}
} catch (err) {
if ((err as Error).name === 'AbortError') return
}
}
document.addEventListener('visibilitychange', handleVisibility)
return () => {
document.removeEventListener('visibilitychange', handleVisibility)
controller.abort()
}
}, [isCreateMode, chunk?.id, knowledgeBaseId, documentData.id])
useEffect(() => {
if (isCreateMode && textareaRef.current) {
@@ -74,6 +109,8 @@ export function ChunkEditor({
}
}, [isCreateMode])
const isConnectorDocument = Boolean(documentData.connectorId)
const handleSave = useCallback(async () => {
const content = editedContentRef.current
const trimmed = content.trim()
@@ -89,26 +126,59 @@ export function ChunkEditor({
})
onCreated?.(created.id)
} else {
if (!chunk || trimmed === chunk.content) return
if (!chunk?.id) return
await updateChunk({
knowledgeBaseId,
documentId: documentData.id,
chunkId: chunk.id,
content: trimmed,
})
savedContentRef.current = content
setSavedContent(content)
}
}, [isCreateMode, chunk, knowledgeBaseId, documentData.id, updateChunk, createChunk, onCreated])
}, [
isCreateMode,
chunk?.id,
knowledgeBaseId,
documentData.id,
updateChunk,
createChunk,
onCreated,
])
const {
saveStatus,
saveImmediately,
isDirty: autosaveDirty,
} = useAutosave({
content: editedContent,
savedContent,
onSave: handleSave,
enabled: canEdit && !isCreateMode && !isConnectorDocument,
})
const isDirty = isCreateMode ? editedContent.trim().length > 0 : autosaveDirty
useEffect(() => {
onDirtyChange(isDirty)
}, [isDirty, onDirtyChange])
useEffect(() => {
onSaveStatusChange?.(saveStatus)
}, [saveStatus, onSaveStatusChange])
const saveFunction = isCreateMode ? handleSave : saveImmediately
useEffect(() => {
if (saveRef) {
saveRef.current = handleSave
saveRef.current = saveFunction
}
return () => {
if (saveRef) {
saveRef.current = null
}
}
}, [saveRef, handleSave])
}, [saveRef, saveFunction])
const tokenStrings = useMemo(() => {
if (!tokenizerOn || !editedContent) return []
@@ -121,8 +191,6 @@ export function ChunkEditor({
return getAccurateTokenCount(editedContent)
}, [editedContent, tokenizerOn, tokenStrings])
const isConnectorDocument = Boolean(documentData.connectorId)
return (
<div className='flex flex-1 flex-col overflow-hidden'>
<div

View File

@@ -339,15 +339,19 @@ export function Document({
const handleSave = useCallback(async () => {
if (!saveRef.current || !isDirty || saveStatusRef.current === 'saving') return
setSaveStatus('saving')
try {
if (isCreatingNewChunk) {
setSaveStatus('saving')
try {
await saveRef.current()
setSaveStatus('saved')
} catch {
setSaveStatus('error')
setTimeout(() => setSaveStatus('idle'), 2000)
}
} else {
await saveRef.current()
setSaveStatus('saved')
} catch {
setSaveStatus('error')
}
}, [isDirty])
}, [isDirty, isCreatingNewChunk])
const handleDiscardChanges = useCallback(() => {
setShowUnsavedChangesAlert(false)
@@ -361,14 +365,6 @@ export function Document({
}
}, [pendingAction, closeEditor])
// Auto-clear save status after 2 seconds
useEffect(() => {
if (saveStatus === 'saved' || saveStatus === 'error') {
const timer = setTimeout(() => setSaveStatus('idle'), 2000)
return () => clearTimeout(timer)
}
}, [saveStatus])
// Cmd+S keyboard shortcut
useEffect(() => {
if (!isInEditorView) return
@@ -967,6 +963,7 @@ export function Document({
canEdit
maxChunkSize={knowledgeBase?.chunkingConfig?.maxSize}
onDirtyChange={setIsDirty}
onSaveStatusChange={setSaveStatus}
saveRef={saveRef}
onCreated={handleChunkCreated}
/>
@@ -1055,6 +1052,7 @@ export function Document({
canEdit={canEdit && !isConnectorDocument}
maxChunkSize={knowledgeBase?.chunkingConfig?.maxSize}
onDirtyChange={setIsDirty}
onSaveStatusChange={setSaveStatus}
saveRef={saveRef}
/>
</div>

View File

@@ -81,6 +81,7 @@ export function useWorkspaceFileContent(workspaceId: string, fileId: string, key
queryFn: ({ signal }) => fetchWorkspaceFileContent(key, signal),
enabled: !!workspaceId && !!fileId && !!key,
staleTime: 30 * 1000,
refetchOnWindowFocus: 'always',
})
}

View File

@@ -0,0 +1,99 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
interface UseAutosaveOptions {
content: string
savedContent: string
onSave: () => Promise<void>
delay?: number
enabled?: boolean
}
interface UseAutosaveReturn {
saveStatus: SaveStatus
saveImmediately: () => Promise<void>
isDirty: boolean
}
/**
* Shared autosave hook that debounces content changes and persists them automatically.
* Keeps Cmd+S / Save button working via `saveImmediately`, and flushes on unmount
* so edits aren't lost when navigating away.
*/
export function useAutosave({
content,
savedContent,
onSave,
delay = 1500,
enabled = true,
}: UseAutosaveOptions): UseAutosaveReturn {
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const savingRef = useRef(false)
const onSaveRef = useRef(onSave)
onSaveRef.current = onSave
const savedContentRef = useRef(savedContent)
savedContentRef.current = savedContent
const contentRef = useRef(content)
contentRef.current = content
const isDirty = content !== savedContent
const savingStartRef = useRef(0)
const MIN_SAVING_DISPLAY_MS = 600
const save = useCallback(async () => {
if (savingRef.current || contentRef.current === savedContentRef.current) return
savingRef.current = true
savingStartRef.current = Date.now()
setSaveStatus('saving')
let nextStatus: SaveStatus = 'saved'
try {
await onSaveRef.current()
} catch {
nextStatus = 'error'
} finally {
const elapsed = Date.now() - savingStartRef.current
const remaining = Math.max(0, MIN_SAVING_DISPLAY_MS - elapsed)
setTimeout(() => {
setSaveStatus(nextStatus)
savingRef.current = false
if (nextStatus !== 'error' && contentRef.current !== savedContentRef.current) {
save()
}
}, remaining)
}
}, [])
useEffect(() => {
if (!enabled || !isDirty || savingRef.current) return
clearTimeout(timerRef.current)
timerRef.current = setTimeout(save, delay)
return () => clearTimeout(timerRef.current)
}, [content, enabled, isDirty, delay, save])
useEffect(() => {
if (saveStatus === 'saved' || saveStatus === 'error') {
const t = setTimeout(() => setSaveStatus('idle'), 2000)
return () => clearTimeout(t)
}
}, [saveStatus])
useEffect(() => {
return () => {
clearTimeout(timerRef.current)
if (contentRef.current !== savedContentRef.current && !savingRef.current) {
onSaveRef.current().catch(() => {})
}
}
}, [])
const saveImmediately = useCallback(async () => {
clearTimeout(timerRef.current)
await save()
}, [save])
return { saveStatus, saveImmediately, isDirty }
}

View File

@@ -1,7 +1,7 @@
/**
* Environment utility functions for consistent environment detection across the application
*/
import { env, isFalsy, isTruthy } from './env'
import { env, getEnv, isFalsy, isTruthy } from './env'
/**
* Is the application running in production mode
@@ -21,9 +21,9 @@ export const isTest = env.NODE_ENV === 'test'
/**
* Is this the hosted version of the application
*/
export const isHosted = true
// getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.sim.ai' ||
// getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.staging.sim.ai'
export const isHosted =
getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.sim.ai' ||
getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.staging.sim.ai'
/**
* Is billing enforcement enabled