Skip to content

Commit a3487da

Browse files
authored
improvement(files): remove Save UI in favor of silent autosave with local-first draft recovery (#5549)
* improvement(files): remove Save UI in favor of silent autosave with local-first draft recovery Files editor no longer shows a Save/Saving/Save failed button - autosave already ran in the background, the button was vestigial. Cmd+S still works. Adds local-first draft persistence to the shared useAutosave hook (opt-in via draftKey): edits mirror into IndexedDB on a 400ms debounce, independent of the 1.5s network save, and flush best-effort on visibilitychange/pagehide. On reopen, a newer local draft is silently recovered and resynced. This replaces the beforeunload "leave site?" warning, which only blocked navigation - it never actually saved anything. A toast (with a Retry action) surfaces a save failure, since there's no more persistent status indicator to show it. * improvement(files): simplify autosave draft persistence and dedupe editor resync Structural cleanup after the local-draft feature: draftKey is now ANDed with enabled inside useAutosave itself (rather than trusting every caller to replicate that gating), the combined dirty-transition/debounce effect is split into two single-purpose effects, redundant back-to-back IndexedDB writes on visibilitychange+pagehide are deduped, a dead identity wrapper around setDraftContent is removed, and the three near-identical "resync editor body if changed" blocks in rich-markdown-editor.tsx collapse into one local helper. * fix(files): flush pending local draft on unmount, fix stale Retry target Two real bugs from Greptile's first review pass: - Unmounting before the 400ms local-draft debounce fired cancelled the pending timer without ever writing the draft, so if the network flush also failed on the way out, the edit had no backup anywhere. The unmount cleanup now calls persistLocalDraft() synchronously before attempting the network flush. - The save-failure toast's Retry action read saveRef.current lazily at click time, so navigating to a different file before clicking Retry would retry-save the wrong file. It now captures the failing file's save function at the moment the toast is created. * fix(files): Discard Changes now actually resets editor content Discard previously only cleared the parent's mirrored isDirty/saveStatus state; the editor's own content was never reset to match the server baseline. On unmount, useAutosave's flush logic saw content still diverged and (a) re-saved the "discarded" edit to the server, and (b) after this PR's local-draft addition, also persisted it to IndexedDB — so even a future fix to (a) would still have the draft resurrect the discarded text on next open. Adds a discardRef bridge (mirrors the existing saveRef pattern) so Discard resets the editor's draft content back to savedContent before navigating away, closing both paths at the root. * fix(files): make Discard deterministic, independent of state-update timing The previous discard fix (setDraftContent(savedContent) before navigating) relied on that dispatch landing before the FileViewer unmounts. If unmount raced ahead of it, the autosave cleanup would still see stale dirty content and could resurrect the discarded edit via the local draft. useAutosave now exposes discard(): it flags the instance as discarded, cancels any pending timers, and clears the local draft immediately. Every write path (persistLocalDraft, save, the unmount flush) checks that flag first, so nothing written after discard() can bring the edit back, regardless of whether the content-reset render has committed yet. * fix(files): correct in-flight save after discard, fix IndexedDB write/delete ordering Two more real races from round 4 of review: - discard() couldn't stop a save that had already started (discardedRef only blocks saves not yet begun). Once that in-flight save lands, it now schedules a corrective save to push the reverted content, rather than leaving the discarded edit on the server permanently. Only fires when a save was genuinely in flight at discard time. - persistLocalDraft's set() and clearLocalDraft's del() were independent promises with no ordering guarantee. A slow write starting before discard could resolve after discard's delete and resurrect the draft. Both now go through a single serialized queue per hook instance, so a delete queued after a write always runs after it completes. * fix(files): make discard's corrective save use an explicit baseline The corrective save (from the previous fix) relied on the caller's setDraftContent(savedContent) having landed by the time it ran — a real race, not a guarantee: React commits that render on its own schedule, and if the correction's continuation runs first, onSave still reads the ambient (still-dirty) content ref and re-persists the discarded edit. onSave now accepts an optional override content; discard() captures savedContentRef.current as an explicit target at the moment it's called and passes it through, so the correction always pushes the true reverted baseline regardless of render timing. Widened the shared onSave type is backward compatible — the other useAutosave caller (chunk-editor) ignores the extra optional param. * fix(files): retry no longer depends on a remount-able shared ref, purge stale drafts Two more from round 6: - The failure toast's Retry action captured saveRef.current inside the effect reacting to saveStatus='error' — but if the user switched files between the failure occurring and that effect committing, the keyed remount could have already repointed saveRef at the new file's save function first. onSaveStatusChange now passes the failing instance's own saveImmediately alongside the 'error' status directly from the hook that owns it, so retry can never be sourced from the wrong file regardless of remount timing. - A local draft with a stale (mismatched) baseline was left in IndexedDB after being correctly skipped for recovery, so it could resurrect later if the server baseline ever coincidentally matched it again. It's now purged as soon as it's identified as stale. * fix(files): clear inFlightRef once a save settles inFlightRef.current was never reset after a save resolved or rejected — it stayed pointing at the (now-fulfilled) promise indefinitely. discard() reads it to decide whether a save is genuinely in flight; since a resolved promise is still truthy, discard() treated any prior completed save as still pending, captured savedContentRef.current as the "corrective" target, and could push a stale baseline if that capture happened before the save's own dispatch had updated it. Now cleared to null as soon as the save settles, so discard()'s in-flight check reflects reality regardless of how long ago the last save finished. * fix(files): surface a failed discard correction, resume autosave after discard if editing continues Two more from round 8: - If discard()'s corrective save failed, it was only logged — the server could permanently keep the discarded edit with zero user-facing signal. Now surfaced via a dedicated onDiscardCorrectionFailed callback, which closes over the specific file's own name rather than routing through the shared onSaveStatusChange path (that path reads whichever file is currently selected, which by the time this fires is already the file the user navigated to, not the discarded one). - discardedRef never cleared once set, so if the editor stayed mounted briefly after discard (before navigation completes) and the user typed again, every save path silently no-op'd forever for that new edit too. It now clears itself as soon as a genuinely new edit (content diverging from savedContent again) is observed. * fix(files): serialize local drafts by key across mounts, not just within one idbQueueRef was a per-instance ref, so it only ordered IndexedDB ops issued by the same hook instance. A slow write queued by an unmount's flush lived on as a bare promise after that instance was gone, with nothing sequencing it against a freshly-mounted instance for the same file — its del() or recovery read could run first, and the late write would land afterward and resurrect a draft that was supposed to be gone. Replaced the per-instance ref with a module-level queue keyed by draft key, shared by every useAutosave instance (past or present) touching that key, so ordering holds across a fast unmount+remount of the same file. * improvement(files): consolidate autosave hook after 9 rounds of incremental fixes Cosmetic-only pass, no behavior change: - Hoisted MIN_SAVING_DISPLAY_MS to module scope alongside LOCAL_DRAFT_DELAY_MS (was declared inside the hook body, re-allocated every render, inconsistent with its sibling constant). - Grouped the ~15 refs by concern (save/network, content mirrors, draft-key + callbacks, local-draft persistence, discard) instead of the chronological order they were added across nine review rounds. - Removed a provably-dead re-check in the unmount cleanup: content/savedContent can't change between the outer guard and the inner one (no renders happen post-unmount), so only the discardedRef half of the inner check was live. - Removed an unnecessary useCallback around onDiscardCorrectionFailed — its reference is never observed by anything (useAutosave copies it into a ref every render regardless of identity), unlike handleSaveStatusChange in files.tsx, which is correctly memoized because it flows through React.memo-wrapped TextEditor/RichMarkdownEditor. Validated against external research: the two-tier debounce (network + local IndexedDB draft) matches how Tiptap/Notion describe their own local-first persistence; the discardedRef+corrective-save approach over AbortController is a deliberate, justified choice (onSave has no signal parameter, and an abort can't undo a write that's already landed server-side); the module-level per-key promise queue is a recognized idiomatic pattern. Splitting this hook into three smaller ones (useDebouncedSave/useLocalDraft/useDiscard) is a legitimate future refactor, deliberately deferred given the risk of touching this heavily-interdependent, already-hardened state this late in review. * fix(files): serialize discard correction against newer saves, recover local drafts only once per mount Two more real races, both interactions between earlier fixes: - discard()'s corrective save and a genuinely new edit made right after could race independently: if the user typed again before the correction fired, and that correction landed after the new edit's own save, the server would end up with the discarded baseline instead of the user's latest content. The correction now shares the same inFlightRef/savingRef mutual exclusion normal saves use, and skips entirely once content has moved on to something that's neither the discarded baseline nor what it was at the moment discard() was called. - The local-draft recovery effect re-ran every time draftKey toggled through enabled (e.g. autosave turning off during agent streaming and back on once it settles), re-scanning IndexedDB as if freshly mounted. If the settled content coincidentally matched the stale draft's stored baseline, a pre-stream local edit could silently overwrite the agent's work. Recovery now attempts exactly once per mount.
1 parent cbe5e0f commit a3487da

7 files changed

Lines changed: 865 additions & 70 deletions

File tree

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,12 @@ interface FileViewerProps {
9999
previewMode?: PreviewMode
100100
autoFocus?: boolean
101101
onDirtyChange?: (isDirty: boolean) => void
102-
onSaveStatusChange?: (status: 'idle' | 'saving' | 'saved' | 'error') => void
102+
onSaveStatusChange?: (
103+
status: 'idle' | 'saving' | 'saved' | 'error',
104+
retry?: () => Promise<void>
105+
) => void
103106
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>
107+
discardRef?: React.MutableRefObject<(() => void) | null>
104108
streamingContent?: string
105109
isAgentEditing?: boolean
106110
streamIsIncremental?: boolean
@@ -131,6 +135,7 @@ function FileViewerContent({
131135
onDirtyChange,
132136
onSaveStatusChange,
133137
saveRef,
138+
discardRef,
134139
streamingContent,
135140
isAgentEditing,
136141
streamIsIncremental,
@@ -174,6 +179,7 @@ function FileViewerContent({
174179
onDirtyChange={onDirtyChange}
175180
onSaveStatusChange={onSaveStatusChange}
176181
saveRef={saveRef}
182+
discardRef={discardRef}
177183
streamingContent={streamingContent}
178184
isAgentEditing={isAgentEditing}
179185
streamIsIncremental={streamIsIncremental}
@@ -193,6 +199,7 @@ function FileViewerContent({
193199
onDirtyChange={onDirtyChange}
194200
onSaveStatusChange={onSaveStatusChange}
195201
saveRef={saveRef}
202+
discardRef={discardRef}
196203
streamingContent={streamingContent}
197204
isAgentEditing={isAgentEditing}
198205
disableStreamingAutoScroll={disableStreamingAutoScroll}

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,9 @@ interface RichMarkdownEditorProps {
4545
canEdit: boolean
4646
autoFocus?: boolean
4747
onDirtyChange?: (isDirty: boolean) => void
48-
onSaveStatusChange?: (status: SaveStatus) => void
48+
onSaveStatusChange?: (status: SaveStatus, retry?: () => Promise<void>) => void
4949
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>
50+
discardRef?: React.MutableRefObject<(() => void) | null>
5051
streamingContent?: string
5152
isAgentEditing?: boolean
5253
/**
@@ -70,6 +71,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({
7071
onDirtyChange,
7172
onSaveStatusChange,
7273
saveRef,
74+
discardRef,
7375
streamingContent,
7476
isAgentEditing,
7577
streamIsIncremental,
@@ -93,6 +95,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({
9395
onDirtyChange,
9496
onSaveStatusChange,
9597
saveRef,
98+
discardRef,
9699
normalizeBaseline: normalizeMarkdownContent,
97100
})
98101

@@ -356,6 +359,14 @@ export function LoadedRichMarkdownEditor({
356359
const lastStreamParseAtRef = useRef(0)
357360
useEffect(() => {
358361
if (!editor) return
362+
const syncEditorBody = (body: string) => {
363+
if (body === lastSyncedBodyRef.current) return
364+
lastSyncedBodyRef.current = body
365+
editor.commands.setContent(parseMarkdownToDoc(body), {
366+
contentType: 'json',
367+
emitUpdate: false,
368+
})
369+
}
359370
if (isStreaming) {
360371
wasStreamingRef.current = true
361372
if (editor.isEditable) editor.setEditable(false)
@@ -407,14 +418,7 @@ export function LoadedRichMarkdownEditor({
407418
if (isInitialSettle || wasStreamingRef.current) {
408419
wasStreamingRef.current = false
409420
settledRef.current = lockSettled(content)
410-
const body = splitFrontmatter(content).body
411-
if (body !== lastSyncedBodyRef.current) {
412-
lastSyncedBodyRef.current = body
413-
editor.commands.setContent(parseMarkdownToDoc(body), {
414-
contentType: 'json',
415-
emitUpdate: false,
416-
})
417-
}
421+
syncEditorBody(splitFrontmatter(content).body)
418422
// `setContent` maps any pre-existing selection onto the new doc rather than clearing it — a
419423
// select-all survives as "select everything," permanently painting every divider/image with the
420424
// `rich-leaf-in-selection` decoration (keymap.ts) until the user clicks elsewhere. This must run
@@ -428,6 +432,7 @@ export function LoadedRichMarkdownEditor({
428432
if (isInitialSettle && autoFocus) editor.commands.focus('end')
429433
return
430434
}
435+
syncEditorBody(splitFrontmatter(content).body)
431436
if (settledRef.current) editor.setEditable(canEdit && settledRef.current.verdict)
432437
}, [editor, content, isStreaming, canEdit, autoFocus, disableStreamingAutoScroll])
433438

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -328,8 +328,12 @@ interface TextEditorProps {
328328
previewMode: PreviewMode
329329
autoFocus?: boolean
330330
onDirtyChange?: (isDirty: boolean) => void
331-
onSaveStatusChange?: (status: 'idle' | 'saving' | 'saved' | 'error') => void
331+
onSaveStatusChange?: (
332+
status: 'idle' | 'saving' | 'saved' | 'error',
333+
retry?: () => Promise<void>
334+
) => void
332335
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>
336+
discardRef?: React.MutableRefObject<(() => void) | null>
333337
streamingContent?: string
334338
isAgentEditing?: boolean
335339
disableStreamingAutoScroll: boolean
@@ -345,6 +349,7 @@ export const TextEditor = memo(function TextEditor({
345349
onDirtyChange,
346350
onSaveStatusChange,
347351
saveRef,
352+
discardRef,
348353
streamingContent,
349354
isAgentEditing,
350355
disableStreamingAutoScroll,
@@ -385,6 +390,7 @@ export const TextEditor = memo(function TextEditor({
385390
onDirtyChange,
386391
onSaveStatusChange,
387392
saveRef,
393+
discardRef,
388394
})
389395
contentRef.current = content
390396

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.ts

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client'
22

33
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react'
4+
import { toast } from '@sim/emcn'
45
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
56
import {
67
useUpdateWorkspaceFileContent,
@@ -34,8 +35,11 @@ interface UseEditableFileContentOptions {
3435
streamingContent?: string
3536
isAgentEditing?: boolean
3637
onDirtyChange?: (isDirty: boolean) => void
37-
onSaveStatusChange?: (status: SaveStatus) => void
38+
/** `retry` is this instance's own `saveImmediately`, passed alongside an `'error'` status so a caller-side retry never depends on a shared, remount-able ref. */
39+
onSaveStatusChange?: (status: SaveStatus, retry?: () => Promise<void>) => void
3840
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>
41+
/** Bridges an imperative "discard the current draft" command up to the caller, mirroring `saveRef`. */
42+
discardRef?: React.MutableRefObject<(() => void) | null>
3943
/**
4044
* Optional transform applied to the fetched content before it becomes the editor's baseline. A
4145
* surface whose editor re-serializes its content to a canonical form (the rich markdown editor)
@@ -115,6 +119,7 @@ export function useEditableFileContent({
115119
onDirtyChange,
116120
onSaveStatusChange,
117121
saveRef,
122+
discardRef,
118123
normalizeBaseline,
119124
}: UseEditableFileContentOptions): EditableFileContent {
120125
const onDirtyChangeRef = useRef(onDirtyChange)
@@ -182,26 +187,40 @@ export function useEditableFileContent({
182187
const contentRef = useRef(content)
183188
contentRef.current = content
184189

185-
const onSave = useCallback(async () => {
186-
const next = contentRef.current
187-
await updateContentRef.current.mutateAsync({ workspaceId, fileId: file.id, content: next })
188-
markSavedContent(next)
189-
}, [workspaceId, file.id, markSavedContent])
190+
const onSave = useCallback(
191+
async (overrideContent?: string) => {
192+
const next = overrideContent ?? contentRef.current
193+
await updateContentRef.current.mutateAsync({ workspaceId, fileId: file.id, content: next })
194+
markSavedContent(next)
195+
},
196+
[workspaceId, file.id, markSavedContent]
197+
)
198+
199+
const autosaveEnabled = canEdit && isInitialized && !isStreamInteractionLocked
190200

191-
const { saveStatus, saveImmediately, isDirty } = useAutosave({
201+
const { saveStatus, saveImmediately, isDirty, discard } = useAutosave({
192202
content,
193203
savedContent,
194204
onSave,
195-
enabled: canEdit && isInitialized && !isStreamInteractionLocked,
205+
enabled: autosaveEnabled,
206+
draftKey: autosaveEnabled ? `${workspaceId}:${file.id}` : undefined,
207+
onRestoreDraft: setDraftContent,
208+
onDiscardCorrectionFailed: () =>
209+
toast.error(
210+
`Failed to discard "${file.name}" — the server may still have the discarded edit`
211+
),
196212
})
197213

198214
useEffect(() => {
199215
onDirtyChangeRef.current?.(isDirty)
200216
}, [isDirty])
201217

202218
useEffect(() => {
203-
onSaveStatusChangeRef.current?.(saveStatus)
204-
}, [saveStatus])
219+
onSaveStatusChangeRef.current?.(
220+
saveStatus,
221+
saveStatus === 'error' ? saveImmediately : undefined
222+
)
223+
}, [saveStatus, saveImmediately])
205224

206225
useEffect(() => {
207226
if (!saveRef) return
@@ -213,6 +232,21 @@ export function useEditableFileContent({
213232
}
214233
}, [saveImmediately, saveRef])
215234

235+
const discardChanges = useCallback(() => {
236+
discard()
237+
setDraftContent(savedContent)
238+
}, [discard, setDraftContent, savedContent])
239+
240+
useEffect(() => {
241+
if (!discardRef) return
242+
discardRef.current = discardChanges
243+
return () => {
244+
if (discardRef.current === discardChanges) {
245+
discardRef.current = null
246+
}
247+
}
248+
}, [discardChanges, discardRef])
249+
216250
return {
217251
content: displayContent,
218252
setDraftContent,

apps/sim/app/workspace/[workspaceId]/files/files.tsx

Lines changed: 16 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ function formatFileType(mimeType: string | null, filename: string): string {
171171
export function Files() {
172172
const fileInputRef = useRef<HTMLInputElement>(null)
173173
const saveRef = useRef<(() => Promise<void>) | null>(null)
174+
const discardRef = useRef<(() => void) | null>(null)
174175

175176
const params = useParams()
176177
const router = useRouter()
@@ -970,6 +971,15 @@ export function Files() {
970971
await saveRef.current()
971972
}, [])
972973

974+
const handleSaveStatusChange = useCallback((status: SaveStatus, retry?: () => Promise<void>) => {
975+
setSaveStatus(status)
976+
if (status === 'error') {
977+
toast.error(`Failed to save "${selectedFileRef.current?.name ?? 'file'}"`, {
978+
action: { label: 'Retry', onClick: () => void retry?.() },
979+
})
980+
}
981+
}, [])
982+
973983
const handleNavigateFromFileDetail = useCallback(
974984
(url: string) => {
975985
if (isDirtyRef.current) {
@@ -1106,6 +1116,7 @@ export function Files() {
11061116
])
11071117

11081118
const handleDiscardChanges = () => {
1119+
discardRef.current?.()
11091120
setShowUnsavedChangesAlert(false)
11101121
setIsDirty(false)
11111122
setSaveStatus('idle')
@@ -1358,16 +1369,8 @@ export function Files() {
13581369
handleSave()
13591370
}
13601371
}
1361-
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
1362-
if (!isDirtyRef.current) return
1363-
e.preventDefault()
1364-
}
13651372
window.addEventListener('keydown', handleKeyDown)
1366-
window.addEventListener('beforeunload', handleBeforeUnload)
1367-
return () => {
1368-
window.removeEventListener('keydown', handleKeyDown)
1369-
window.removeEventListener('beforeunload', handleBeforeUnload)
1370-
}
1373+
return () => window.removeEventListener('keydown', handleKeyDown)
13711374
}, [handleSave])
13721375

13731376
const selectedRowIdsRef = useRef(selectedRowIds)
@@ -1428,43 +1431,21 @@ export function Files() {
14281431
const fileActions = useMemo<ResourceAction[]>(() => {
14291432
if (!selectedFile) return []
14301433
// A large CSV renders as a read-only streamed preview (no editor), so it gets neither the
1431-
// Save action nor the edit/split/preview toggle — just like a non-editable file.
1434+
// edit/split/preview toggle nor autosave — just like a non-editable file.
14321435
const streamOnly = isCsvStreamOnly(selectedFile)
14331436
const canEditText = isTextEditable(selectedFile) && !streamOnly
14341437
const canPreview = isPreviewable(selectedFile) && !streamOnly
1435-
// Markdown renders in the single-surface inline editor, which has no raw/split/preview
1436-
// modes — so it keeps Save but drops the mode toggle.
1438+
// Markdown renders in the single-surface inline editor, which has no raw/split/preview modes.
14371439
const isInlineMarkdown = isMarkdownFile(selectedFile)
14381440
const hasSplitView = canEditText && canPreview && !isInlineMarkdown
14391441
const showPreviewToggle = canPreview && !isInlineMarkdown
14401442

1441-
const saveLabel =
1442-
saveStatus === 'saving'
1443-
? 'Saving...'
1444-
: saveStatus === 'saved'
1445-
? 'Saved'
1446-
: saveStatus === 'error'
1447-
? 'Save failed'
1448-
: 'Save'
1449-
14501443
const nextModeLabel =
14511444
previewMode === 'editor' ? 'Split' : previewMode === 'split' ? 'Preview' : 'Edit'
14521445
const nextModeIcon =
14531446
previewMode === 'editor' ? Columns2 : previewMode === 'split' ? Eye : Pencil
14541447

14551448
return [
1456-
...(canEditText
1457-
? [
1458-
{
1459-
text: saveLabel,
1460-
onSelect: handleSave,
1461-
disabled:
1462-
(!isDirty && saveStatus === 'idle') ||
1463-
saveStatus === 'saving' ||
1464-
saveStatus === 'saved',
1465-
},
1466-
]
1467-
: []),
14681449
...(hasSplitView
14691450
? [
14701451
{
@@ -1505,12 +1486,9 @@ export function Files() {
15051486
}, [
15061487
selectedFile,
15071488
canEdit,
1508-
saveStatus,
15091489
previewMode,
1510-
isDirty,
15111490
handleCyclePreviewMode,
15121491
handleTogglePreview,
1513-
handleSave,
15141492
handleDownloadSelected,
15151493
handleShareSelected,
15161494
handleDeleteSelected,
@@ -1897,8 +1875,9 @@ export function Files() {
18971875
previewMode={previewMode}
18981876
autoFocus={isNewFile || justCreatedFileIdRef.current === selectedFile.id}
18991877
onDirtyChange={setIsDirty}
1900-
onSaveStatusChange={setSaveStatus}
1878+
onSaveStatusChange={handleSaveStatusChange}
19011879
saveRef={saveRef}
1880+
discardRef={discardRef}
19021881
/>
19031882

19041883
<ChipConfirmModal

0 commit comments

Comments
 (0)