Skip to content

Commit 1d0fee9

Browse files
committed
feat(file-viewer): stream agent output directly into the rich editor; add more code languages
- rich-markdown-editor: the TipTap editor is now the only markdown surface. Agent output streams into it read-only (synced per chunk, autoscrolled), then the same instance hands off to an editable editor on settle — no separate streamdown preview, so no stream→edit flash. The round-trip verdict + frontmatter lock when the content settles. - code-block/code-highlight/detect-language: register Go, Rust, Java, C, C++, C#, Ruby, PHP grammars and add detectors, so those blocks highlight and the picker offers them. - css: style h5/h6 in the prose stylesheet.
1 parent f8ac591 commit 1d0fee9

5 files changed

Lines changed: 121 additions & 52 deletions

File tree

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,19 @@ const PLAIN = 'plain'
2121
const LANGUAGE_OPTIONS = [
2222
{ value: PLAIN, label: 'Plain text' },
2323
{ value: 'bash', label: 'Bash' },
24+
{ value: 'c', label: 'C' },
25+
{ value: 'cpp', label: 'C++' },
26+
{ value: 'csharp', label: 'C#' },
2427
{ value: 'css', label: 'CSS' },
25-
{ value: 'markup', label: 'HTML' },
28+
{ value: 'go', label: 'Go' },
29+
{ value: 'java', label: 'Java' },
2630
{ value: 'javascript', label: 'JavaScript' },
2731
{ value: 'json', label: 'JSON' },
32+
{ value: 'markup', label: 'HTML' },
33+
{ value: 'php', label: 'PHP' },
2834
{ value: 'python', label: 'Python' },
35+
{ value: 'ruby', label: 'Ruby' },
36+
{ value: 'rust', label: 'Rust' },
2937
{ value: 'sql', label: 'SQL' },
3038
{ value: 'typescript', label: 'TypeScript' },
3139
{ value: 'yaml', label: 'YAML' },

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@ import 'prismjs/components/prism-yaml'
1212
import 'prismjs/components/prism-sql'
1313
import 'prismjs/components/prism-python'
1414
import 'prismjs/components/prism-json'
15+
import 'prismjs/components/prism-c'
16+
import 'prismjs/components/prism-cpp'
17+
import 'prismjs/components/prism-csharp'
18+
import 'prismjs/components/prism-go'
19+
import 'prismjs/components/prism-java'
20+
import 'prismjs/components/prism-markup-templating'
21+
import 'prismjs/components/prism-php'
22+
import 'prismjs/components/prism-ruby'
23+
import 'prismjs/components/prism-rust'
1524
import { detectLanguage } from './detect-language'
1625

1726
const KEY = new PluginKey('codeBlockHighlight')

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,18 @@ const DETECTORS: ReadonlyArray<{ language: string; test: RegExp }> = [
1515
language: 'bash',
1616
test: /^#!.*\b(ba)?sh\b|^\s*(sudo|apt|brew|npm|yarn|bun|git|cd|echo|export|chmod|mkdir)\s|\$\(/m,
1717
},
18+
{
19+
language: 'go',
20+
test: /^\s*package\s+\w+|\bfunc\s+(\(\w[^)]*\)\s+)?\w+\s*\(|\bfmt\.\w|:=/m,
21+
},
22+
{
23+
language: 'rust',
24+
test: /\bfn\s+\w+\s*[(<]|\blet\s+mut\b|\bimpl\b|\bpub\s+(fn|struct|enum|mod)\b|println!/,
25+
},
26+
{
27+
language: 'java',
28+
test: /\b(public|private|protected)\s+(static\s+)?(final\s+)?(class|void|int|String|boolean)\b|System\.out\.print/,
29+
},
1830
{
1931
language: 'typescript',
2032
test: /\b(interface|type)\s+\w+\s*[={]|:\s*(string|number|boolean)\b|\bimport\s+type\b|\bas\s+\w+\s*;/,

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@
4747
.rich-markdown-prose h1,
4848
.rich-markdown-prose h2,
4949
.rich-markdown-prose h3,
50-
.rich-markdown-prose h4 {
50+
.rich-markdown-prose h4,
51+
.rich-markdown-prose h5,
52+
.rich-markdown-prose h6 {
5153
font-weight: 600;
5254
line-height: 1.3;
5355
color: var(--text-primary);
@@ -73,6 +75,17 @@
7375
margin-top: 1.1em;
7476
}
7577

78+
.rich-markdown-prose h5 {
79+
font-size: 0.875em;
80+
margin-top: 1.1em;
81+
}
82+
83+
.rich-markdown-prose h6 {
84+
font-size: 0.8em;
85+
margin-top: 1.1em;
86+
color: var(--text-secondary);
87+
}
88+
7689
.rich-markdown-prose strong {
7790
font-weight: 600;
7891
color: var(--text-primary);

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

Lines changed: 77 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { cn } from '@/lib/core/utils/cn'
77
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
88
import { useUploadWorkspaceFile } from '@/hooks/queries/workspace-files'
99
import type { SaveStatus } from '@/hooks/use-autosave'
10-
import { PreviewPanel } from '../preview-panel'
1110
import { PreviewLoadingFrame } from '../preview-shared'
1211
import type { StreamingMode } from '../text-editor-state'
1312
import { useEditableFileContent } from '../use-editable-file-content'
@@ -96,29 +95,17 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({
9695
)
9796
}
9897

99-
if (isStreamInteractionLocked) {
100-
return (
101-
<PreviewPanel
102-
key={previewContextKey ? `${file.id}:${previewContextKey}` : file.id}
103-
content={content}
104-
mimeType={file.type}
105-
filename={file.name}
106-
workspaceId={workspaceId}
107-
fileKey={file.key}
108-
isStreaming
109-
disableAutoScroll={disableStreamingAutoScroll}
110-
/>
111-
)
112-
}
113-
11498
return (
11599
<LoadedRichMarkdownEditor
116-
key={file.id}
100+
// Remount on a new streaming context so the stream/settle state is re-established fresh.
101+
key={previewContextKey ? `${file.id}:${previewContextKey}` : file.id}
117102
file={file}
118103
workspaceId={workspaceId}
119-
initialContent={content}
104+
content={content}
105+
streaming={isStreamInteractionLocked}
120106
canEdit={canEdit}
121107
autoFocus={autoFocus}
108+
disableStreamingAutoScroll={disableStreamingAutoScroll}
122109
onChange={setDraftContent}
123110
onSaveShortcut={saveImmediately}
124111
/>
@@ -128,54 +115,66 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({
128115
interface LoadedRichMarkdownEditorProps {
129116
file: WorkspaceFileRecord
130117
workspaceId: string
131-
initialContent: string
118+
/** The live content from the engine — grows as the agent streams, then settles to the saved doc. */
119+
content: string
120+
/** True while agent output is streaming in: the editor renders it read-only and syncs each chunk. */
121+
streaming: boolean
132122
canEdit: boolean
133123
autoFocus?: boolean
124+
disableStreamingAutoScroll?: boolean
134125
onChange: (markdown: string) => void
135126
onSaveShortcut: () => Promise<void>
136127
}
137128

138129
/**
139-
* The mounted TipTap editor. Receives the file's loaded markdown as {@link initialContent} and hands
140-
* it to {@link useEditor} as the initial document (parsed at create time by the markdown extension),
141-
* so there is no imperative content sync. Frontmatter is held aside and re-applied on every change,
142-
* so the editor only ever round-trips the body.
130+
* The single TipTap editor for a markdown file — the only surface the user ever sees. While agent
131+
* output streams in ({@link streaming}) it renders that content read-only and re-syncs each chunk;
132+
* when the stream settles it locks the round-trip verdict + frontmatter on the final content and
133+
* hands control to the user. A file opened outside a stream skips straight to that editable state via
134+
* the initial-content model (no imperative sync). Frontmatter is held aside and re-applied on every
135+
* change, so the editor only ever round-trips the body.
143136
*/
144-
function LoadedRichMarkdownEditor({
137+
export function LoadedRichMarkdownEditor({
145138
file,
146139
workspaceId,
147-
initialContent,
140+
content,
141+
streaming,
148142
canEdit,
149143
autoFocus,
144+
disableStreamingAutoScroll,
150145
onChange,
151146
onSaveShortcut,
152147
}: LoadedRichMarkdownEditorProps) {
153-
// Whether the opened content round-trips losslessly through the editor — computed once, on the
154-
// exact content the editor opens with (keyed by file id, so it remounts per file), and locked for
155-
// the editor's lifetime. A round-trip-unsafe document (raw HTML, footnotes, >128KB, …) opens
156-
// read-only so an edit can't corrupt it; a safe one stays editable. It is never re-derived: a
157-
// dirty document is round-trip-safe by construction (the editor only emits safe markdown), so
158-
// flipping editability off mid-edit would only strand unsaved edits (autosave, ⌘S, the toolbar
159-
// Save, and the unmount flush all gate on it).
160-
const roundTripSafeRef = useRef<boolean | null>(null)
161-
if (roundTripSafeRef.current === null) {
162-
roundTripSafeRef.current = isRoundTripSafe(initialContent)
163-
}
164-
const isEditable = canEdit && roundTripSafeRef.current
148+
// Whether this editor mounted mid-stream. If so it starts empty + read-only and syncs the streamed
149+
// content until the stream settles; otherwise it uses the plain create-time initial-content model.
150+
const streamingAtMountRef = useRef(streaming)
165151

166-
// Split frontmatter off once, on the opened content (stable for the editor's lifetime, like the
167-
// verdict above): the body seeds the editor's initial document, and the frontmatter is re-attached
168-
// on every change so the editor only ever round-trips the body.
169-
const splitRef = useRef<{ frontmatter: string; body: string } | null>(null)
170-
if (splitRef.current === null) {
171-
splitRef.current = splitFrontmatter(initialContent)
152+
// The round-trip verdict + frontmatter, locked once on the content the editor "opens" with — at
153+
// mount for a settled file, or at the moment the stream settles for a streamed one. A round-trip-
154+
// unsafe document (raw HTML, footnotes, >128KB, …) opens read-only so an edit can't corrupt it; a
155+
// safe one is editable. Once locked it is never re-derived: a dirty document is safe by construction
156+
// (the editor only emits safe markdown), so flipping editability off mid-edit would strand edits.
157+
const settledRef = useRef<{ frontmatter: string; verdict: boolean } | null>(null)
158+
if (!streamingAtMountRef.current && settledRef.current === null) {
159+
settledRef.current = {
160+
frontmatter: splitFrontmatter(content).frontmatter,
161+
verdict: isRoundTripSafe(content),
162+
}
172163
}
173-
const { frontmatter, body } = splitRef.current
164+
const isEditable = canEdit && !streaming && (settledRef.current?.verdict ?? false)
165+
166+
// The body that seeds the editor at create time — empty when streaming (filled by the sync effect).
167+
const initialBodyRef = useRef(streamingAtMountRef.current ? '' : splitFrontmatter(content).body)
168+
// The frontmatter re-attached on every change. Empty until the content settles (the editor never
169+
// displays frontmatter, so a streamed doc simply shows its body).
170+
const frontmatterRef = useRef('')
171+
frontmatterRef.current = settledRef.current?.frontmatter ?? ''
174172
const onChangeRef = useRef(onChange)
175173
onChangeRef.current = onChange
176174
const onSaveShortcutRef = useRef(onSaveShortcut)
177175
onSaveShortcutRef.current = onSaveShortcut
178176

177+
const containerRef = useRef<HTMLDivElement>(null)
179178
const uploadFile = useUploadWorkspaceFile()
180179
const editorInstanceRef = useRef<Editor | null>(null)
181180

@@ -215,10 +214,10 @@ function LoadedRichMarkdownEditor({
215214
const editor = useEditor({
216215
extensions: EXTENSIONS,
217216
editable: isEditable,
218-
autofocus: autoFocus ? 'end' : false,
217+
autofocus: streamingAtMountRef.current ? false : autoFocus ? 'end' : false,
219218
immediatelyRender: false,
220219
shouldRerenderOnTransaction: false,
221-
content: body,
220+
content: initialBodyRef.current,
222221
contentType: 'markdown',
223222
editorProps: {
224223
attributes: { class: 'rich-markdown-prose' },
@@ -258,17 +257,45 @@ function LoadedRichMarkdownEditor({
258257
},
259258
onUpdate: ({ editor }) => {
260259
const md = postProcessSerializedMarkdown(editor.getMarkdown())
261-
onChangeRef.current(applyFrontmatter(frontmatter, md))
260+
onChangeRef.current(applyFrontmatter(frontmatterRef.current, md))
262261
},
263262
})
264263
editorInstanceRef.current = editor
265264

265+
// Stream content into the editor (read-only) until it settles, then lock the verdict + frontmatter
266+
// and hand control to the user. After the hand-off, only `canEdit` changes touch the editor — the
267+
// editor owns the content, so there is no sync that could clobber a user edit.
268+
const lastSyncedBodyRef = useRef<string | null>(null)
266269
useEffect(() => {
267-
editor?.setEditable(isEditable)
268-
}, [editor, isEditable])
270+
if (!editor) return
271+
if (streaming) {
272+
const body = splitFrontmatter(content).body
273+
if (body === lastSyncedBodyRef.current) return
274+
lastSyncedBodyRef.current = body
275+
const el = containerRef.current
276+
const pinnedToBottom = el ? el.scrollHeight - el.scrollTop - el.clientHeight < 80 : false
277+
editor.setEditable(false)
278+
editor.commands.setContent(body, { contentType: 'markdown', emitUpdate: false })
279+
if (!disableStreamingAutoScroll && el && pinnedToBottom) el.scrollTop = el.scrollHeight
280+
return
281+
}
282+
if (settledRef.current === null) {
283+
const { frontmatter, body } = splitFrontmatter(content)
284+
settledRef.current = { frontmatter, verdict: isRoundTripSafe(content) }
285+
lastSyncedBodyRef.current = body
286+
editor.commands.setContent(body, { contentType: 'markdown', emitUpdate: false })
287+
editor.setEditable(canEdit && settledRef.current.verdict)
288+
if (autoFocus) editor.commands.focus('end')
289+
return
290+
}
291+
editor.setEditable(canEdit && settledRef.current.verdict)
292+
}, [editor, content, streaming, canEdit, autoFocus, disableStreamingAutoScroll])
269293

270294
return (
271-
<div className={cn('flex flex-1 flex-col overflow-y-auto', isEditable && 'cursor-text')}>
295+
<div
296+
ref={containerRef}
297+
className={cn('flex flex-1 flex-col overflow-y-auto', isEditable && 'cursor-text')}
298+
>
272299
{editor && <EditorBubbleMenu editor={editor} />}
273300
<EditorContent
274301
editor={editor}

0 commit comments

Comments
 (0)