Skip to content

Commit f0bd78b

Browse files
committed
feat(files): rich markdown editor across files + chat, read-only for unsafe, robust load/save
- chat resource view streams into the rich editor (streamdown while streaming → editable on completion); agent persists server-side, editor never saves mid-stream - round-trip-unsafe / >128KB markdown renders read-only in the rich editor (no Monaco, no corruption) - markdown always uses the rich editor (dropped the inline-markdown opt-in flag) - editor loads content as TipTap's initial content keyed by file id — strict-mode/SSR-safe, no content-sync effect - fix autosave "Saving…" status suppression under React strict mode - lock the streamed-file persistence handoff with a state-machine lifecycle test
1 parent ce582c0 commit f0bd78b

8 files changed

Lines changed: 219 additions & 108 deletions

File tree

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

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -101,12 +101,6 @@ interface FileViewerProps {
101101
streamingMode?: StreamingMode
102102
disableStreamingAutoScroll?: boolean
103103
previewContextKey?: string
104-
/**
105-
* Render idle markdown in the inline rich editor. Off by default so preview surfaces (e.g. the
106-
* Chat resource view, which streams content and persists via the raw editor) keep the
107-
* mode-toggleable raw/preview editor; the files view opts in.
108-
*/
109-
inlineMarkdownEditor?: boolean
110104
}
111105

112106
export function FileViewer({
@@ -123,7 +117,6 @@ export function FileViewer({
123117
streamingMode,
124118
disableStreamingAutoScroll = false,
125119
previewContextKey,
126-
inlineMarkdownEditor = false,
127120
}: FileViewerProps) {
128121
const category = resolveFileCategory(file.type, file.name)
129122

@@ -135,6 +128,14 @@ export function FileViewer({
135128
if (isCsvStreamOnly(file)) {
136129
return <UnsupportedPreview file={file} />
137130
}
131+
// Markdown renders through the inline rich editor (non-editable) so the public share
132+
// surface matches the in-app reading experience; canEdit={false} disables autosave,
133+
// the bubble menu, and every other editing affordance.
134+
if (isMarkdownFile(file)) {
135+
return (
136+
<MarkdownFileEditor key={file.id} file={file} workspaceId={workspaceId} canEdit={false} />
137+
)
138+
}
138139
return <ReadOnlyTextPreview file={file} workspaceId={workspaceId} />
139140
}
140141
// A large CSV can't be loaded whole into the editor (the browser OOMs on the full text).
@@ -143,7 +144,7 @@ export function FileViewer({
143144
return <CsvTablePreview key={file.id} file={file} workspaceId={workspaceId} />
144145
}
145146

146-
if (inlineMarkdownEditor && isMarkdownFile(file) && streamingContent === undefined) {
147+
if (isMarkdownFile(file)) {
147148
return (
148149
<MarkdownFileEditor
149150
key={file.id}
@@ -154,6 +155,10 @@ export function FileViewer({
154155
onDirtyChange={onDirtyChange}
155156
onSaveStatusChange={onSaveStatusChange}
156157
saveRef={saveRef}
158+
streamingContent={streamingContent}
159+
streamingMode={streamingMode}
160+
disableStreamingAutoScroll={disableStreamingAutoScroll}
161+
previewContextKey={previewContextKey}
157162
/>
158163
)
159164
}

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

Lines changed: 27 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
55
import { useWorkspaceFileContent } from '@/hooks/queries/workspace-files'
66
import type { SaveStatus } from '@/hooks/use-autosave'
77
import { PreviewLoadingFrame } from '../preview-shared'
8-
import { TextEditor } from '../text-editor'
8+
import type { StreamingMode } from '../text-editor-state'
99
import { RichMarkdownEditor } from './rich-markdown-editor'
1010
import { isRoundTripSafe } from './round-trip-safety'
1111

@@ -17,22 +17,22 @@ interface MarkdownFileEditorProps {
1717
onDirtyChange?: (isDirty: boolean) => void
1818
onSaveStatusChange?: (status: SaveStatus) => void
1919
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>
20+
streamingContent?: string
21+
streamingMode?: StreamingMode
22+
disableStreamingAutoScroll?: boolean
23+
previewContextKey?: string
2024
}
2125

2226
/**
23-
* Chooses the editing surface for a markdown file. Almost every file renders in the inline
24-
* {@link RichMarkdownEditor}, but a small set of constructs can't survive the markdown
25-
* round-trip losslessly (a linked image, inline code containing a backtick). For those we fall
26-
* back to the raw {@link TextEditor} so the file is never silently corrupted on save.
27+
* Renders a markdown file in the inline {@link RichMarkdownEditor} — the single surface for
28+
* markdown everywhere. A small tail of constructs can't survive the markdown round-trip losslessly
29+
* (raw HTML, footnotes, linked images, >128KB); editing those would corrupt them, so the gate marks
30+
* them read-only (autosave never fires) while still rendering them in the same rich editor. There is
31+
* no separate raw/Monaco editor.
2732
*
28-
* The gate peeks the (React Query-cached) content before mounting either editor, so the chosen
29-
* surface re-reads the same content instantly and only one autosave engine is ever live.
30-
*
31-
* The decision is made once — on the first loaded content — and locked for the lifetime of the
32-
* mount (the component is keyed by file id, so it remounts per file). This keeps the editor from
33-
* ever swapping out from under the user on a background refetch (window focus, post-save), and
34-
* keeps the round-trip probe off the hot path. Anything typed in the rich editor is inherently
35-
* round-trip-safe, so the lock can never cause silent data loss.
33+
* The round-trip decision is made once, on the first SETTLED content, and locked for the mount
34+
* (keyed by file id). It is deferred while streaming — partial content would misclassify, and the
35+
* editor renders the live stream read-only regardless of the eventual verdict.
3636
*/
3737
export function MarkdownFileEditor({
3838
file,
@@ -42,45 +42,37 @@ export function MarkdownFileEditor({
4242
onDirtyChange,
4343
onSaveStatusChange,
4444
saveRef,
45+
streamingContent,
46+
streamingMode,
47+
disableStreamingAutoScroll,
48+
previewContextKey,
4549
}: MarkdownFileEditorProps) {
46-
const { data, isLoading, error } = useWorkspaceFileContent(workspaceId, file.id, file.key)
50+
const { data, isLoading } = useWorkspaceFileContent(workspaceId, file.id, file.key)
51+
52+
const isStreaming = streamingContent !== undefined
4753

4854
const decisionRef = useRef<boolean | null>(null)
49-
if (decisionRef.current === null && data !== undefined) {
55+
if (decisionRef.current === null && !isStreaming && data !== undefined) {
5056
decisionRef.current = isRoundTripSafe(data)
5157
}
5258

53-
if (decisionRef.current === null && isLoading) {
59+
if (decisionRef.current === null && isLoading && !isStreaming) {
5460
return <PreviewLoadingFrame className='flex flex-1 flex-col' />
5561
}
5662

57-
// Fall back to the raw editor when the content can't round-trip losslessly, or the fetch failed
58-
// (a later retry-success resolves `data` and the gate decides normally).
59-
if (decisionRef.current === false || (decisionRef.current === null && error)) {
60-
return (
61-
<TextEditor
62-
file={file}
63-
workspaceId={workspaceId}
64-
canEdit={canEdit}
65-
previewMode='editor'
66-
autoFocus={autoFocus}
67-
onDirtyChange={onDirtyChange}
68-
onSaveStatusChange={onSaveStatusChange}
69-
saveRef={saveRef}
70-
disableStreamingAutoScroll={false}
71-
/>
72-
)
73-
}
74-
7563
return (
7664
<RichMarkdownEditor
7765
file={file}
7866
workspaceId={workspaceId}
79-
canEdit={canEdit}
67+
canEdit={canEdit && decisionRef.current !== false}
8068
autoFocus={autoFocus}
8169
onDirtyChange={onDirtyChange}
8270
onSaveStatusChange={onSaveStatusChange}
8371
saveRef={saveRef}
72+
streamingContent={streamingContent}
73+
streamingMode={streamingMode}
74+
disableStreamingAutoScroll={disableStreamingAutoScroll}
75+
previewContextKey={previewContextKey}
8476
/>
8577
)
8678
}

0 commit comments

Comments
 (0)