diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index a54a3305dd..de013ffa4d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -91,6 +91,7 @@ interface FileViewerProps { onSaveStatusChange?: (status: 'idle' | 'saving' | 'saved' | 'error') => void saveRef?: React.MutableRefObject<(() => Promise) | null> streamingContent?: string + isAgentEditing?: boolean disableStreamingAutoScroll?: boolean previewContextKey?: string } @@ -106,6 +107,7 @@ export function FileViewer({ onSaveStatusChange, saveRef, streamingContent, + isAgentEditing, disableStreamingAutoScroll = false, previewContextKey, }: FileViewerProps) { @@ -147,6 +149,7 @@ export function FileViewer({ onSaveStatusChange={onSaveStatusChange} saveRef={saveRef} streamingContent={streamingContent} + isAgentEditing={isAgentEditing} disableStreamingAutoScroll={disableStreamingAutoScroll} previewContextKey={previewContextKey} /> @@ -164,6 +167,7 @@ export function FileViewer({ onSaveStatusChange={onSaveStatusChange} saveRef={saveRef} streamingContent={streamingContent} + isAgentEditing={isAgentEditing} disableStreamingAutoScroll={disableStreamingAutoScroll} previewContextKey={previewContextKey} /> diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx index 41af20c54c..9a7a2eb887 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx @@ -1,4 +1,6 @@ -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { posToDOMRect } from '@tiptap/core' +import { PluginKey } from '@tiptap/pm/state' import type { Editor } from '@tiptap/react' import { useEditorState } from '@tiptap/react' import { BubbleMenu } from '@tiptap/react/menus' @@ -61,8 +63,24 @@ function ToolbarDivider() { return
} +/** + * Whether the formatting toolbar may show for the given range: the editor is editable, the range + * isn't inside a code block, and it covers some non-whitespace text. Single source of truth shared by + * `shouldShow` and the pointer-release reveal so the two can't drift apart. + */ +function hasFormattableSelection(editor: Editor, from: number, to: number): boolean { + if (!editor.isEditable || editor.isActive('codeBlock')) return false + return editor.state.doc.textBetween(from, to, ' ').trim().length > 0 +} + +// Pin the toolbar to the viewport (fixed) and never attach a scroll listener, so once it's placed for +// a selection it stays put while the document scrolls instead of tracking the text — matching Linear. +const FLOATING_OPTIONS = { strategy: 'fixed' } as const + interface EditorBubbleMenuProps { editor: Editor + /** The editor's scrollable viewport, used to keep the toolbar on-screen for selections taller than it. */ + scrollContainerRef: React.RefObject } /** @@ -71,12 +89,16 @@ interface EditorBubbleMenuProps { * live in the `/` slash menu. Active states are read through {@link useEditorState} so the bar * stays correct without re-rendering the editor on every transaction. */ -export function EditorBubbleMenu({ editor }: EditorBubbleMenuProps) { +export function EditorBubbleMenu({ editor, scrollContainerRef }: EditorBubbleMenuProps) { const [linkValue, setLinkValue] = useState(null) const linkInputRef = useRef(null) const linkRangeRef = useRef<{ from: number; to: number } | null>(null) const isEditingLink = linkValue !== null + // Explicit key so `setMeta` can target this menu to reveal it after a drag-select. + const bubbleMenuKey = useMemo(() => new PluginKey('markdownBubbleMenu'), []) + const isPointerDownRef = useRef(false) + const active = useEditorState({ editor, selector: ({ editor: e }) => ({ @@ -109,6 +131,38 @@ export function EditorBubbleMenu({ editor }: EditorBubbleMenuProps) { } }, [editor]) + // Reveal the toolbar only once a drag-select finishes (Linear-style); `shouldShow` keeps it hidden + // while the pointer is down. Keyboard selection has no pointer, so it still shows live. + useEffect(() => { + const dom = editor.view.dom + const onPointerDown = () => { + isPointerDownRef.current = true + } + const onPointerUp = () => { + if (!isPointerDownRef.current || editor.isDestroyed) return + isPointerDownRef.current = false + const { from, to } = editor.state.selection + if (hasFormattableSelection(editor, from, to)) { + // `show` alone leaves the bar visible-but-unpositioned (its updatePosition no-ops until shown), + // so a second `updatePosition` anchors it. Both are step-free, so the doc isn't marked dirty. + editor.commands.setMeta(bubbleMenuKey, 'show') + editor.commands.setMeta(bubbleMenuKey, 'updatePosition') + } + } + // A release outside the window delivers no mouseup; clear the flag on blur so it can't stay wedged. + const onWindowBlur = () => { + isPointerDownRef.current = false + } + dom.addEventListener('mousedown', onPointerDown) + window.addEventListener('mouseup', onPointerUp) + window.addEventListener('blur', onWindowBlur) + return () => { + dom.removeEventListener('mousedown', onPointerDown) + window.removeEventListener('mouseup', onPointerUp) + window.removeEventListener('blur', onWindowBlur) + } + }, [editor, bubbleMenuKey]) + const openLinkEditor = () => { if (editor.isActive('codeBlock') || editor.isActive('code')) return const { from, to } = editor.state.selection @@ -158,9 +212,27 @@ export function EditorBubbleMenu({ editor }: EditorBubbleMenuProps) { setLinkValue(null) } + // The default whole-selection anchor pushes the toolbar off-screen when the selection is taller than + // the viewport (e.g. select-all in a long doc). There, anchor to the selection's top edge clamped + // into the viewport so the bar settles at the top of the view; `null` keeps the default otherwise. + const resolveAnchor = useCallback(() => { + const { view, state } = editor + if (!view.dom.isConnected) return null + const viewport = scrollContainerRef.current?.getBoundingClientRect() + if (!viewport) return null + const selection = posToDOMRect(view, state.selection.from, state.selection.to) + if (selection.height <= viewport.height) return null + const top = Math.min(Math.max(selection.top, viewport.top), viewport.bottom) + const rect = new DOMRect(selection.left, top, selection.width, 0) + return { getBoundingClientRect: () => rect, getClientRects: () => [rect] } + }, [editor, scrollContainerRef]) + return ( 0 + // Suppressed mid-drag; the pointer-release handler forces it back open once the selection sticks. + if (isPointerDownRef.current) return false + return hasFormattableSelection(e, from, to) }} className='fade-in-0 z-[var(--z-popover)] flex animate-in items-center gap-0.5 rounded-lg border border-[var(--border)] bg-[var(--bg)] p-1 shadow-sm duration-100 motion-reduce:animate-none' > diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css index a7c9182e5d..6abc89a601 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css @@ -153,8 +153,11 @@ gap: 0.5em; } +/* One line tall with the box centered, so it aligns with the item's first line. */ .rich-markdown-prose ul[data-type="taskList"] li > label { - margin-top: 0.28em; + display: flex; + align-items: center; + height: 1.6667em; /* = the prose 25px line-height at 15px font */ flex-shrink: 0; user-select: none; } @@ -164,11 +167,39 @@ min-width: 0; } +/* TaskItem nests content as li > div > p, which the `li > p` reset misses, leaving UA margins. */ +.rich-markdown-prose ul[data-type="taskList"] li > div > p { + margin: 0; +} + +/* Match the design-system Checkbox (emcn) rather than the platform-native control. */ .rich-markdown-prose ul[data-type="taskList"] input[type="checkbox"] { - accent-color: var(--text-primary); + appearance: none; + -webkit-appearance: none; + display: inline-grid; + place-content: center; + width: 16px; + height: 16px; + margin: 0; + border: 1px solid var(--border-1); + border-radius: 3px; + background: transparent; cursor: pointer; } +.rich-markdown-prose ul[data-type="taskList"] input[type="checkbox"]:checked { + background-color: var(--text-primary); + border-color: var(--text-primary); +} + +.rich-markdown-prose ul[data-type="taskList"] input[type="checkbox"]:checked::after { + content: ""; + width: 10px; + height: 10px; + background-color: var(--surface-2); + clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); +} + .rich-markdown-prose blockquote { border-left: 2px solid var(--divider); padding-left: 1rem; diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index adca8bf72e..189eb750ce 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -30,6 +30,10 @@ const EXTENSIONS = createMarkdownEditorExtensions({ placeholder: "Write something, or press '/' for commands…", }) +// Throttle the per-frame full re-parse above this body size so a large streaming file can't saturate the main thread. +const STREAM_REPARSE_THROTTLE_THRESHOLD = 40_000 +const STREAM_REPARSE_THROTTLE_MS = 120 + interface RichMarkdownEditorProps { file: WorkspaceFileRecord workspaceId: string @@ -39,22 +43,12 @@ interface RichMarkdownEditorProps { onSaveStatusChange?: (status: SaveStatus) => void saveRef?: React.MutableRefObject<(() => Promise) | null> streamingContent?: string + isAgentEditing?: boolean disableStreamingAutoScroll?: boolean previewContextKey?: string } -/** - * Inline WYSIWYG markdown editor (TipTap/ProseMirror) for markdown files — a single editing surface - * (markdown transformed inline as you type), no raw/preview split and no separate streaming preview. - * Owns the file lifecycle through a single {@link useEditableFileContent} engine, and the TipTap - * editor is the ONLY thing the user ever sees: while agent output streams in it renders that content - * read-only (synced per chunk), then the same editor instance becomes editable once the stream - * settles — so the stream→edit transition has no renderer swap or flash. - * - * The editor is keyed by file id (+ streaming context). A file opened outside a stream uses the plain - * create-time initial-content model (no sync). See {@link LoadedRichMarkdownEditor} for the - * read-only-stream → editable hand-off. - */ +/** Inline WYSIWYG markdown editor: agent output streams in read-only, then the same instance becomes editable on settle. */ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ file, workspaceId, @@ -64,6 +58,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ onSaveStatusChange, saveRef, streamingContent, + isAgentEditing, disableStreamingAutoScroll = false, previewContextKey, }: RichMarkdownEditorProps) { @@ -79,6 +74,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ workspaceId, canEdit, streamingContent, + isAgentEditing, onDirtyChange, onSaveStatusChange, saveRef, @@ -130,25 +126,12 @@ interface SettledContent { verdict: boolean } -/** - * Lock the round-trip verdict + frontmatter on the content the editor "opens" with — once, at mount - * for a settled file or at the moment a stream settles. A round-trip-unsafe document (raw HTML, - * footnotes, >128KB, …) opens read-only so an edit can't corrupt it; a safe one stays editable. Never - * re-derived: a dirty document is safe by construction (the editor only emits safe markdown), so - * flipping editability off mid-edit would only strand edits. - */ +/** Locks the round-trip verdict + frontmatter once; a round-trip-unsafe doc (raw HTML, footnotes, >128KB) opens read-only. */ function lockSettled(content: string): SettledContent { return { frontmatter: splitFrontmatter(content).frontmatter, verdict: isRoundTripSafe(content) } } -/** - * The single TipTap editor for a markdown file — the only surface the user ever sees. While agent - * output streams in ({@link isStreaming}) it renders that content read-only and re-syncs each chunk; - * when the stream settles it locks the round-trip verdict + frontmatter on the final content and - * hands control to the user. A file opened outside a stream skips straight to that editable state via - * the initial-content model (no imperative sync). Frontmatter is held aside and re-applied on every - * change, so the editor only ever round-trips the body. - */ +/** The single TipTap editor: read-only while streaming, editable on settle; frontmatter is held aside and re-applied. */ export function LoadedRichMarkdownEditor({ file, workspaceId, @@ -163,22 +146,17 @@ export function LoadedRichMarkdownEditor({ // Whether this editor mounted mid-stream — if so it starts empty and syncs streamed chunks until settle. const streamingAtMountRef = useRef(isStreaming) - // Verdict + frontmatter locked once via {@link lockSettled} (at mount when settled, else when the - // stream settles below); null until then reads as read-only. + // Verdict + frontmatter, locked once (at mount if settled, else on settle); null reads as read-only. const settledRef = useRef(null) if (!streamingAtMountRef.current && settledRef.current === null) { settledRef.current = lockSettled(content) } const isEditable = canEdit && !isStreaming && (settledRef.current?.verdict ?? false) - // Seed the editor with the chunked-parsed doc (linear vs the editor's ~O(n²) markdown parse), computed - // once via lazy state init — `useRef(parseMarkdownToDoc(...))` would re-parse the whole body every render. + // Seed the doc once via lazy init — chunked parse is linear vs the editor's ~O(n²) whole-body markdown parse. const [initialContent] = useState(() => streamingAtMountRef.current ? '' : parseMarkdownToDoc(splitFrontmatter(content).body) ) - // Frontmatter held aside and re-attached on every change (the editor never shows it); re-derived per - // stream→settle in the settle effect, so a repeat stream uses the new doc's frontmatter, not a stale one. - const frontmatterRef = useRef(settledRef.current?.frontmatter ?? '') const onChangeRef = useRef(onChange) onChangeRef.current = onChange const onSaveShortcutRef = useRef(onSaveShortcut) @@ -191,12 +169,7 @@ export function LoadedRichMarkdownEditor({ const uploadFile = useUploadWorkspaceFile() const editorInstanceRef = useRef(null) - /** - * Upload each image to the workspace, then insert it at `at` (paste = caret, drop = cursor under - * the pointer). Sequential so multiple images stack in order; the upload hook surfaces its own - * success/error toasts, so a failed upload is skipped without interrupting the rest. Held in a ref - * (reassigned each render) so the once-built `editorProps` handlers always reach the latest values. - */ + // Upload then insert each image at `at` (paste caret / drop point), sequentially; held in a ref so handlers reach the latest. const insertImagesRef = useRef<(images: File[], at: number) => Promise>(() => Promise.resolve() ) @@ -243,11 +216,9 @@ export function LoadedRichMarkdownEditor({ handleClick: (view, _pos, event) => { const href = (event.target as HTMLElement | null)?.closest('a')?.getAttribute('href') if (!href) return false - // Editing: require a modifier so a plain click can place the cursor. Read-only (a reader, e.g. - // the public share page): a plain click follows the link. + // Editing requires a modifier to follow a link (a plain click places the cursor); read-only follows it directly. if (view.editable && !(event.metaKey || event.ctrlKey)) return false - // Same-page anchor (`[x](#slug)`): scroll to the matching heading instead of opening a tab, - // restoring the table-of-contents links that worked via rehype-slug in the old preview. + // Same-page anchor (`[x](#slug)`): scroll to the matching heading instead of opening a tab. if (href.startsWith('#')) { const pos = findHeadingPos(view.state.doc, href.slice(1)) if (pos < 0) return false @@ -259,8 +230,7 @@ export function LoadedRichMarkdownEditor({ } const normalized = normalizeLinkHref(href) if (!normalized) return false - // A same-origin in-app path navigates within the SPA (same tab) — unless the reader - // modifier-clicked for a new tab. External URLs always open a new tab. + // A same-origin in-app path navigates within the SPA (same tab); external URLs open a new tab. if ( !(event.metaKey || event.ctrlKey) && normalized.startsWith('/') && @@ -292,22 +262,18 @@ export function LoadedRichMarkdownEditor({ }, onUpdate: ({ editor }) => { const md = postProcessSerializedMarkdown(editor.getMarkdown()) - onChangeRef.current(applyFrontmatter(frontmatterRef.current, md)) + onChangeRef.current(applyFrontmatter(settledRef.current?.frontmatter ?? '', md)) }, }) editorInstanceRef.current = editor - // Stream content in read-only until it settles, then lock the verdict + frontmatter and hand off; after - // that only `canEdit` touches the editor (it owns the content, so no sync can clobber a user edit). const lastSyncedBodyRef = useRef(null) - // Tracks whether the previous run was streaming so the settle branch re-locks on every stream→settle: - // one instance can receive several agent edits in a chat (kept mounted by `previewContextKey`), so the - // verdict/frontmatter must follow the latest stream, not the first settled snapshot. + const wasStreamingRef = useRef(streamingAtMountRef.current) - // Coalesce streamed chunks to one re-parse per animation frame — a fast agent emits many per frame and - // each would re-parse the whole accumulating body. Read-only while streaming, so only the latest renders. + const pendingStreamBodyRef = useRef(null) const streamRafRef = useRef(null) + const lastStreamParseAtRef = useRef(0) useEffect(() => { if (!editor) return if (isStreaming) { @@ -316,11 +282,23 @@ export function LoadedRichMarkdownEditor({ if (body === lastSyncedBodyRef.current) return pendingStreamBodyRef.current = body if (streamRafRef.current !== null) return - streamRafRef.current = requestAnimationFrame(() => { - streamRafRef.current = null + // Self-re-arming tick: parse the latest pending body, but throttle a large one (cheap re-check, no parse) until due. + const tick = () => { const pending = pendingStreamBodyRef.current - if (pending === null || pending === lastSyncedBodyRef.current) return + if (pending === null || pending === lastSyncedBodyRef.current) { + streamRafRef.current = null + return + } + if ( + pending.length > STREAM_REPARSE_THROTTLE_THRESHOLD && + performance.now() - lastStreamParseAtRef.current < STREAM_REPARSE_THROTTLE_MS + ) { + streamRafRef.current = requestAnimationFrame(tick) + return + } + streamRafRef.current = null lastSyncedBodyRef.current = pending + lastStreamParseAtRef.current = performance.now() const el = containerRef.current const pinnedToBottom = el ? el.scrollHeight - el.scrollTop - el.clientHeight < 80 : false editor.setEditable(false) @@ -329,7 +307,8 @@ export function LoadedRichMarkdownEditor({ emitUpdate: false, }) if (!disableStreamingAutoScroll && el && pinnedToBottom) el.scrollTop = el.scrollHeight - }) + } + streamRafRef.current = requestAnimationFrame(tick) return } // Drop a frame scheduled just before settle so it can't land afterward and clobber the final content. @@ -337,16 +316,12 @@ export function LoadedRichMarkdownEditor({ cancelAnimationFrame(streamRafRef.current) streamRafRef.current = null } - // Settle: re-lock the verdict + frontmatter on the freshly-settled content — on the first settle and - // every later stream→settle, so a repeat agent edit gates on the NEW content, not a stale snapshot. - // User edits never reach here (`isStreaming`/`wasStreamingRef` stay false), preserving don't-strand-edits. + // Settle: re-lock the verdict + frontmatter on the freshly-settled content (every stream→settle, not just the first). const isInitialSettle = settledRef.current === null if (isInitialSettle || wasStreamingRef.current) { wasStreamingRef.current = false settledRef.current = lockSettled(content) - frontmatterRef.current = settledRef.current.frontmatter - // Re-seed only if the settled body differs from the last streamed chunk — it usually doesn't, - // and an extra setContent would needlessly rebuild the doc and drop selection/scroll. + // Re-seed only if the settled body differs from the last streamed chunk (avoids a needless doc rebuild + selection loss). const body = splitFrontmatter(content).body if (body !== lastSyncedBodyRef.current) { lastSyncedBodyRef.current = body @@ -374,7 +349,7 @@ export function LoadedRichMarkdownEditor({ ref={containerRef} className={cn('flex flex-1 flex-col overflow-y-auto', isEditable && 'cursor-text')} > - {editor && } + {editor && } { @@ -144,6 +149,7 @@ describe('syncTextEditorContentState — static fetch updates', () => { content: 'user edit', savedContent: 'v1', lastStreamedContent: null, + hasBaseline: true, } const next = syncTextEditorContentState(state, { canReconcileToFetchedContent: true, @@ -241,6 +247,7 @@ describe('syncTextEditorContentState — reconciling', () => { content: 'streamed', savedContent: 'v1', lastStreamedContent: null, + hasBaseline: true, } const next = syncTextEditorContentState(state, { canReconcileToFetchedContent: true, @@ -431,3 +438,69 @@ describe('syncTextEditorContentState — mothership streamed-file lifecycle (rep expect(state.content).toBe(state.savedContent) }) }) + +/** + * When the user opens an existing, non-empty file's tab while the agent is already mid-stream on it, + * streaming begins from `uninitialized` before the content fetch resolves — so `savedContent` is the + * placeholder `''`. The first fetched value to arrive is the file's PRE-EDIT content, not the agent's + * write; it must be adopted as the baseline, never finalized to (which would flash stale content and, + * if the agent had stopped, let the user edit over the agent's write). + */ +describe('syncTextEditorContentState — stream begins before fetch on an existing file', () => { + it('adopts the first fetched content as the baseline instead of finalizing to it mid-stream', () => { + const preEdit = '# Original\n\nold content' + const agentWrite = '# Original\n\nold content, plus a new section.' + + // 1. Editor mounts mid-stream: chunk arrives before the fetch resolves. + let state = syncTextEditorContentState(INITIAL_TEXT_EDITOR_CONTENT_STATE, { + canReconcileToFetchedContent: true, + fetchedContent: undefined, + streamingContent: '# Original\n\nold', + }) + expect(state.phase).toBe('streaming') + expect(state.savedContent).toBe('') + expect(state.hasBaseline).toBe(false) + + // 2. The fetch resolves to the file's pre-edit content WHILE streaming. Adopt it as the baseline; + // do NOT finalize (the agent hasn't persisted its write yet). + state = syncTextEditorContentState(state, { + canReconcileToFetchedContent: true, + fetchedContent: preEdit, + streamingContent: '# Original\n\nold content, plus', + }) + expect(state.phase).toBe('streaming') + expect(state.content).toBe('# Original\n\nold content, plus') + expect(state.savedContent).toBe(preEdit) + expect(state.hasBaseline).toBe(true) + + // 3. Stream ends; the refetch is still the pre-edit content → hold in reconciling, never finalize + // to stale (savedContent === fetched, so it has not "advanced"). + state = syncTextEditorContentState(state, { + canReconcileToFetchedContent: true, + fetchedContent: preEdit, + streamingContent: undefined, + }) + expect(state.phase).toBe('reconciling') + + // 4. The agent's write lands (advanced past the adopted baseline) → finalize to it. + state = syncTextEditorContentState(state, { + canReconcileToFetchedContent: true, + fetchedContent: agentWrite, + streamingContent: undefined, + }) + expect(state.phase).toBe('ready') + expect(state.content).toBe(agentWrite) + expect(state.savedContent).toBe(agentWrite) + }) + + it('still finalizes mid-stream once a real baseline is established (no regression)', () => { + // With hasBaseline=true, an advancing fetch finalizes immediately — the established-baseline path. + const next = syncTextEditorContentState(streaming('v1 chunk', 'v1 chunk', 'v1'), { + canReconcileToFetchedContent: true, + fetchedContent: 'v2', + streamingContent: 'chunk', + }) + expect(next.phase).toBe('ready') + expect(next.content).toBe('v2') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-state.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-state.ts index 3bc5f15290..0742749845 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-state.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-state.ts @@ -5,6 +5,13 @@ export interface TextEditorContentState { content: string savedContent: string lastStreamedContent: string | null + /** + * Whether `savedContent` is the file's real baseline (not the initial placeholder). False only + * before the first fetched content has been observed — e.g. a stream that began before the initial + * fetch resolved. While false, a fetched value is treated as the baseline to adopt, not as the + * agent's write advancing past the baseline (which would finalize the editor to stale content). + */ + hasBaseline: boolean } export interface SyncTextEditorContentStateOptions { @@ -23,6 +30,7 @@ export const INITIAL_TEXT_EDITOR_CONTENT_STATE: TextEditorContentState = { content: '', savedContent: '', lastStreamedContent: null, + hasBaseline: false, } function finalizeTextEditorContentState( @@ -33,7 +41,8 @@ function finalizeTextEditorContentState( state.phase === 'ready' && state.content === nextContent && state.savedContent === nextContent && - state.lastStreamedContent === null + state.lastStreamedContent === null && + state.hasBaseline ) { return state } @@ -43,17 +52,30 @@ function finalizeTextEditorContentState( content: nextContent, savedContent: nextContent, lastStreamedContent: null, + hasBaseline: true, } } function moveTextEditorContentStateToStreaming( state: TextEditorContentState, - nextContent: string + nextContent: string, + fetchedBaseline?: string ): TextEditorContentState { + // A stream that begins before the initial fetch resolves leaves `savedContent` at its placeholder. + // The first fetched value to arrive during the stream IS the file's pre-edit baseline (the agent + // hasn't persisted its write yet), so adopt it. Without this, a later refetch of that same pre-edit + // content would read as an "advance" past the placeholder and finalize the editor to stale content + // mid-stream. Empty-file creates are unaffected: their baseline genuinely is ''. + const adoptBaseline = !state.hasBaseline && fetchedBaseline !== undefined + const savedContent = adoptBaseline ? fetchedBaseline : state.savedContent + const hasBaseline = state.hasBaseline || adoptBaseline + if ( state.phase === 'streaming' && state.content === nextContent && - state.lastStreamedContent === nextContent + state.lastStreamedContent === nextContent && + state.savedContent === savedContent && + state.hasBaseline === hasBaseline ) { return state } @@ -63,6 +85,8 @@ function moveTextEditorContentStateToStreaming( phase: 'streaming', content: nextContent, lastStreamedContent: nextContent, + savedContent, + hasBaseline, } } @@ -92,7 +116,12 @@ export function syncTextEditorContentState( fetchedContent !== undefined && state.lastStreamedContent !== null && fetchedContent === state.lastStreamedContent - const hasFetchedAdvanced = fetchedContent !== undefined && fetchedContent !== state.savedContent + // Only an ESTABLISHED baseline makes "fetched differs from savedContent" mean "the agent's write + // advanced". Before the baseline is established (stream started before the fetch resolved), + // savedContent is a placeholder, so the file's own pre-edit content would falsely read as an + // advance and finalize to stale content; instead it is adopted as the baseline in moveToStreaming. + const hasFetchedAdvanced = + fetchedContent !== undefined && state.hasBaseline && fetchedContent !== state.savedContent if ( (state.phase === 'streaming' || state.phase === 'reconciling') && @@ -110,7 +139,7 @@ export function syncTextEditorContentState( return finalizeTextEditorContentState(state, fetchedContent) } - return moveTextEditorContentStateToStreaming(state, nextContent) + return moveTextEditorContentStateToStreaming(state, nextContent, fetchedContent) } if (state.phase === 'streaming' || state.phase === 'reconciling') { @@ -182,6 +211,7 @@ export function textEditorContentReducer( phase: 'ready', savedContent: action.content, lastStreamedContent: null, + hasBaseline: true, } default: return state diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx index 68bd6205de..ca3a2e27e2 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx @@ -331,6 +331,7 @@ interface TextEditorProps { onSaveStatusChange?: (status: 'idle' | 'saving' | 'saved' | 'error') => void saveRef?: React.MutableRefObject<(() => Promise) | null> streamingContent?: string + isAgentEditing?: boolean disableStreamingAutoScroll: boolean previewContextKey?: string } @@ -345,6 +346,7 @@ export const TextEditor = memo(function TextEditor({ onSaveStatusChange, saveRef, streamingContent, + isAgentEditing, disableStreamingAutoScroll, previewContextKey, }: TextEditorProps) { @@ -379,6 +381,7 @@ export const TextEditor = memo(function TextEditor({ workspaceId, canEdit, streamingContent, + isAgentEditing, onDirtyChange, onSaveStatusChange, saveRef, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.ts index 06f558b2f7..d50da96819 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.ts @@ -7,6 +7,7 @@ import { useWorkspaceFileContent, } from '@/hooks/queries/workspace-files' import { type SaveStatus, useAutosave } from '@/hooks/use-autosave' +import { useSmoothText } from '@/hooks/use-smooth-text' import { INITIAL_TEXT_EDITOR_CONTENT_STATE, type SyncTextEditorContentStateOptions, @@ -31,6 +32,7 @@ interface UseEditableFileContentOptions { workspaceId: string canEdit: boolean streamingContent?: string + isAgentEditing?: boolean onDirtyChange?: (isDirty: boolean) => void onSaveStatusChange?: (status: SaveStatus) => void saveRef?: React.MutableRefObject<(() => Promise) | null> @@ -102,6 +104,7 @@ export function useEditableFileContent({ workspaceId, canEdit, streamingContent, + isAgentEditing, onDirtyChange, onSaveStatusChange, saveRef, @@ -130,7 +133,7 @@ export function useEditableFileContent({ content, savedContent, isInitialized, - isStreamInteractionLocked, + isStreamInteractionLocked: isStreamPhaseLocked, setDraftContent, markSavedContent, } = useFileContentState({ @@ -139,6 +142,17 @@ export function useEditableFileContent({ streamingContent, }) + const isStreamInteractionLocked = isStreamPhaseLocked || Boolean(isAgentEditing) + + // Pace the streamed reveal for DISPLAY only. The reducer above keeps the true content so + // reconciliation, dirty tracking, and saves are never thrown off by the paced prefix. Pacing is + // gated on the stream phase (not the agent-edit lock) and fed '' off-stream, so a user's own typing + // is never throttled; snapOnNonAppend shows in-place rewrites/patches in full, not re-revealed. + const pacedReveal = useSmoothText(isStreamPhaseLocked ? content : '', isStreamPhaseLocked, { + snapOnNonAppend: true, + }) + const displayContent = isStreamPhaseLocked ? pacedReveal : content + const contentRef = useRef(content) contentRef.current = content @@ -174,11 +188,16 @@ export function useEditableFileContent({ }, [saveImmediately, saveRef]) return { - content, + content: displayContent, setDraftContent, isInitialized, isStreamInteractionLocked, - isContentLoading: streamingContent === undefined && isLoading, + // `!isInitialized` mirrors `hasContentError`: once any content (fetched OR streamed) has + // initialized the editor, never fall back to the loading frame. A stream that finishes before the + // initial file fetch resolves flips `streamingContent` to undefined while `isLoading` is still + // true — without this guard that would unmount the settled editor (losing the read-only→editable + // hand-off, scroll, and parsed doc) until the fetch lands. + isContentLoading: streamingContent === undefined && isLoading && !isInitialized, hasContentError: streamingContent === undefined && Boolean(error) && !isInitialized, saveStatus, saveImmediately, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx index f2e4326b13..c485697230 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx @@ -13,7 +13,7 @@ import { TagInput, type TagItem, } from '@/components/emcn' -import { Link } from '@/components/emcn/icons' +import { Send } from '@/components/emcn/icons' import { GeneratedPasswordInput } from '@/components/ui' import type { ShareAuthType, ShareRecord } from '@/lib/api/contracts/public-shares' import { getEnv, isTruthy } from '@/lib/core/config/env' @@ -200,7 +200,7 @@ export function ShareModal({ return ( - + Share file diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 84476a1215..b91a4e0a44 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -1,6 +1,6 @@ 'use client' -import { lazy, memo, Suspense, useEffect, useMemo, useRef } from 'react' +import { lazy, memo, Suspense, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { format } from 'date-fns' import { useRouter } from 'next/navigation' @@ -76,6 +76,7 @@ interface ResourceContentProps { resource: MothershipResource previewMode?: PreviewMode previewSession?: FilePreviewSession | null + isAgentResponding?: boolean genericResourceData?: GenericResourceData previewContextKey?: string onNotFound?: (resourceId: string) => void @@ -88,11 +89,56 @@ interface ResourceContentProps { */ const STREAMING_EPOCH = new Date(0) +/** + * Grace window kept locked after the agent stops streaming into the file, so the lock bridges the + * gaps between the file subagent's sequential edit sections instead of flickering open between them. + */ +const AGENT_EDIT_LOCK_GRACE_MS = 1500 + +/** + * Holds the editor read-only while the agent is actively writing to the file, plus a short grace so + * brief gaps between edit sections don't unlock it. Releases as soon as the turn ends + * (`isAgentResponding` false) so the file becomes editable the moment the agent is done, even when + * the surrounding turn keeps running — the completed preview session otherwise lingers all turn. + */ +function useAgentFileEditLock(isStreamingToFile: boolean, isAgentResponding: boolean): boolean { + const [locked, setLocked] = useState(isStreamingToFile) + const graceTimerRef = useRef | null>(null) + + useEffect(() => { + if (graceTimerRef.current !== null) { + clearTimeout(graceTimerRef.current) + graceTimerRef.current = null + } + if (isStreamingToFile) { + setLocked(true) + return + } + if (!isAgentResponding) { + setLocked(false) + return + } + graceTimerRef.current = setTimeout(() => { + graceTimerRef.current = null + setLocked(false) + }, AGENT_EDIT_LOCK_GRACE_MS) + return () => { + if (graceTimerRef.current !== null) { + clearTimeout(graceTimerRef.current) + graceTimerRef.current = null + } + } + }, [isStreamingToFile, isAgentResponding]) + + return locked +} + export const ResourceContent = memo(function ResourceContent({ workspaceId, resource, previewMode, previewSession, + isAgentResponding, genericResourceData, previewContextKey, onNotFound, @@ -134,6 +180,11 @@ export const ResourceContent = memo(function ResourceContent({ ? previewSession.previewText : undefined + const isAgentEditing = useAgentFileEditLock( + previewSession?.status === 'streaming', + Boolean(isAgentResponding) + ) + if (resource.id === 'streaming-file') { return (
@@ -143,6 +194,7 @@ export const ResourceContent = memo(function ResourceContent({ canEdit={false} previewMode={previewMode ?? 'preview'} streamingContent={textStreamingContent} + isAgentEditing={isAgentEditing} disableStreamingAutoScroll={disableStreamingAutoScroll} previewContextKey={previewContextKey} /> @@ -165,6 +217,7 @@ export const ResourceContent = memo(function ResourceContent({ streamingContent={ previewSession?.fileId === resource.id ? textStreamingContent : undefined } + isAgentEditing={isAgentEditing} disableStreamingAutoScroll={disableStreamingAutoScroll} previewContextKey={previewContextKey} /> @@ -550,6 +603,7 @@ interface EmbeddedFileProps { filePath?: string previewMode?: PreviewMode streamingContent?: string + isAgentEditing?: boolean disableStreamingAutoScroll?: boolean previewContextKey?: string } @@ -560,6 +614,7 @@ function EmbeddedFile({ filePath, previewMode, streamingContent, + isAgentEditing, disableStreamingAutoScroll = false, previewContextKey, }: EmbeddedFileProps) { @@ -601,6 +656,7 @@ function EmbeddedFile({ canEdit={canEdit} previewMode={previewMode} streamingContent={streamingContent} + isAgentEditing={isAgentEditing} disableStreamingAutoScroll={disableStreamingAutoScroll} previewContextKey={previewContextKey} /> diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx index 6cb035617c..e65c96e96d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx @@ -52,6 +52,7 @@ interface MothershipViewProps { isCollapsed: boolean className?: string previewSession?: FilePreviewSession | null + isAgentResponding?: boolean genericResourceData?: GenericResourceData } @@ -65,6 +66,7 @@ export const MothershipView = memo( isCollapsed, className, previewSession, + isAgentResponding, genericResourceData, }: MothershipViewProps, ref @@ -136,6 +138,7 @@ export const MothershipView = memo( resource={active} previewMode={isActivePreviewable ? previewMode : undefined} previewSession={previewForActive} + isAgentResponding={isAgentResponding} genericResourceData={active.type === 'generic' ? genericResourceData : undefined} previewContextKey={chatId} onNotFound={(resourceId) => removeResource('log', resourceId)} diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index b22b783d7e..4e21e754eb 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -461,6 +461,7 @@ export function Home({ chatId, userName, userId, initialResourceId = null }: Hom activeResourceId={activeResourceId} isCollapsed={isResourceCollapsed} previewSession={previewSession} + isAgentResponding={isSending} genericResourceData={genericResourceData ?? undefined} className={skipResourceTransition ? '!transition-none' : undefined} /> diff --git a/apps/sim/hooks/use-smooth-text.test.tsx b/apps/sim/hooks/use-smooth-text.test.tsx new file mode 100644 index 0000000000..b4fe89d991 --- /dev/null +++ b/apps/sim/hooks/use-smooth-text.test.tsx @@ -0,0 +1,91 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useSmoothText } from '@/hooks/use-smooth-text' + +interface ProbeProps { + content: string + isStreaming: boolean + snapOnNonAppend?: boolean +} + +/** + * Minimal dependency-free hook harness (the repo has no `@testing-library/react`). Mounts the hook in + * a real React root under jsdom so effects and refs run exactly as in the app. Fake timers keep the + * paced reveal from advancing, so each assertion observes the synchronous reveal decision only. + */ +function renderSmoothText(initial: ProbeProps) { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const container = document.createElement('div') + const root: Root = createRoot(container) + const props = { ...initial } + let latest = '' + + function Probe(p: ProbeProps) { + latest = useSmoothText(p.content, p.isStreaming, { snapOnNonAppend: p.snapOnNonAppend }) + return null + } + + const render = () => + act(() => { + root.render() + }) + render() + + return { + value: () => latest, + rerender: (next: Partial) => { + Object.assign(props, next) + render() + }, + unmount: () => act(() => root.unmount()), + } +} + +const LONG = `# Existing Document\n\n${'Lorem ipsum dolor sit amet, '.repeat(8)}` + +describe('useSmoothText — streaming that begins on an already-open document', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() + }) + + it('reveals a pre-existing document in full when an edit stream starts (no full-file replay)', () => { + // The editor mounts showing a static file (no stream yet). + const h = renderSmoothText({ content: '', isStreaming: false, snapOnNonAppend: true }) + expect(h.value()).toBe('') + + // The agent begins editing it: the first streamed value carries the whole existing document. + // It must appear instantly, not replay word-by-word from the first character. + h.rerender({ content: LONG, isStreaming: true }) + expect(h.value()).toBe(LONG) + h.unmount() + }) + + it('still animates a brand-new file from the start (short content stays below the threshold)', () => { + // A create stream mounts already-streaming with a tiny first chunk → begins empty and paces in. + const h = renderSmoothText({ content: '# New file', isStreaming: true, snapOnNonAppend: true }) + expect(h.value()).toBe('') + h.unmount() + }) + + it('shows content that is already large at mount in full (mount-time skip, unchanged)', () => { + const h = renderSmoothText({ content: LONG, isStreaming: true, snapOnNonAppend: true }) + expect(h.value()).toBe(LONG) + h.unmount() + }) + + it('does not pre-reveal for chat (mounts already streaming with a small first chunk)', () => { + // Chat (no snapOnNonAppend) mounts streaming; the not-streaming→streaming edge never occurs, so + // the new transition skip cannot fire and ordinary paced reveal is preserved. + const h = renderSmoothText({ content: 'Hello', isStreaming: true }) + expect(h.value()).toBe('') + h.unmount() + }) +}) diff --git a/apps/sim/hooks/use-smooth-text.ts b/apps/sim/hooks/use-smooth-text.ts index f13d93a4b7..85413fc89a 100644 --- a/apps/sim/hooks/use-smooth-text.ts +++ b/apps/sim/hooks/use-smooth-text.ts @@ -101,13 +101,27 @@ export function useSmoothText( const revealedRef = useRef(revealed) const timeoutRef = useRef | null>(null) const prevContentRef = useRef(content) + const prevIsStreamingRef = useRef(isStreaming) let effectiveRevealed = revealed + + if ( + isStreaming && + !prevIsStreamingRef.current && + content.length > RESUME_SKIP_THRESHOLD && + revealed < content.length + ) { + effectiveRevealed = content.length + revealedRef.current = content.length + setRevealed(content.length) + } + prevIsStreamingRef.current = isStreaming + if ( snapOnNonAppend && content !== prevContentRef.current && !content.startsWith(prevContentRef.current) && - revealed < content.length + effectiveRevealed < content.length ) { effectiveRevealed = content.length revealedRef.current = content.length