From ef71e3b22835547932b18f97a3bd7900b42899c9 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 19 Jun 2026 19:51:07 -0700 Subject: [PATCH 01/18] fix(files): isAgentEditing flag passthrough --- .../files/components/file-viewer/file-viewer.tsx | 4 ++++ .../rich-markdown-editor/rich-markdown-editor.tsx | 3 +++ .../files/components/file-viewer/text-editor.tsx | 3 +++ .../components/file-viewer/use-editable-file-content.ts | 6 +++++- .../components/resource-content/resource-content.tsx | 9 +++++++++ .../home/components/mothership-view/mothership-view.tsx | 3 +++ apps/sim/app/workspace/[workspaceId]/home/home.tsx | 1 + 7 files changed, 28 insertions(+), 1 deletion(-) 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 a54a3305dd1..de013ffa4dd 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/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index adca8bf72e9..db545e7f00e 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 @@ -39,6 +39,7 @@ interface RichMarkdownEditorProps { onSaveStatusChange?: (status: SaveStatus) => void saveRef?: React.MutableRefObject<(() => Promise) | null> streamingContent?: string + isAgentEditing?: boolean disableStreamingAutoScroll?: boolean previewContextKey?: string } @@ -64,6 +65,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ onSaveStatusChange, saveRef, streamingContent, + isAgentEditing, disableStreamingAutoScroll = false, previewContextKey, }: RichMarkdownEditorProps) { @@ -79,6 +81,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ workspaceId, canEdit, streamingContent, + isAgentEditing, onDirtyChange, onSaveStatusChange, saveRef, 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 68bd6205def..ca3a2e27e2b 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 06f558b2f76..97d3b010f46 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 @@ -31,6 +31,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 +103,7 @@ export function useEditableFileContent({ workspaceId, canEdit, streamingContent, + isAgentEditing, onDirtyChange, onSaveStatusChange, saveRef, @@ -130,7 +132,7 @@ export function useEditableFileContent({ content, savedContent, isInitialized, - isStreamInteractionLocked, + isStreamInteractionLocked: isStreamPhaseLocked, setDraftContent, markSavedContent, } = useFileContentState({ @@ -139,6 +141,8 @@ export function useEditableFileContent({ streamingContent, }) + const isStreamInteractionLocked = isStreamPhaseLocked || Boolean(isAgentEditing) + const contentRef = useRef(content) contentRef.current = content 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 84476a12153..dcd4dde17da 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 @@ -76,6 +76,7 @@ interface ResourceContentProps { resource: MothershipResource previewMode?: PreviewMode previewSession?: FilePreviewSession | null + isAgentResponding?: boolean genericResourceData?: GenericResourceData previewContextKey?: string onNotFound?: (resourceId: string) => void @@ -93,6 +94,7 @@ export const ResourceContent = memo(function ResourceContent({ resource, previewMode, previewSession, + isAgentResponding, genericResourceData, previewContextKey, onNotFound, @@ -134,6 +136,8 @@ export const ResourceContent = memo(function ResourceContent({ ? previewSession.previewText : undefined + const isAgentEditing = Boolean(isAgentResponding && previewSession) + if (resource.id === 'streaming-file') { return (
@@ -143,6 +147,7 @@ export const ResourceContent = memo(function ResourceContent({ canEdit={false} previewMode={previewMode ?? 'preview'} streamingContent={textStreamingContent} + isAgentEditing={isAgentEditing} disableStreamingAutoScroll={disableStreamingAutoScroll} previewContextKey={previewContextKey} /> @@ -165,6 +170,7 @@ export const ResourceContent = memo(function ResourceContent({ streamingContent={ previewSession?.fileId === resource.id ? textStreamingContent : undefined } + isAgentEditing={isAgentEditing} disableStreamingAutoScroll={disableStreamingAutoScroll} previewContextKey={previewContextKey} /> @@ -550,6 +556,7 @@ interface EmbeddedFileProps { filePath?: string previewMode?: PreviewMode streamingContent?: string + isAgentEditing?: boolean disableStreamingAutoScroll?: boolean previewContextKey?: string } @@ -560,6 +567,7 @@ function EmbeddedFile({ filePath, previewMode, streamingContent, + isAgentEditing, disableStreamingAutoScroll = false, previewContextKey, }: EmbeddedFileProps) { @@ -601,6 +609,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 6cb035617c2..e65c96e96df 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 b22b783d7e3..4e21e754eb0 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} /> From eb5c1d0e0649bf32e4b99959db2c771f2a871457 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 19 Jun 2026 22:38:00 -0700 Subject: [PATCH 02/18] use smooth streaming hook --- .../files/components/file-viewer/file-viewer.tsx | 3 +++ .../rich-markdown-editor.tsx | 7 ++++++- .../file-viewer/use-editable-file-content.ts | 16 +++++++++++++++- .../resource-content/resource-content.tsx | 2 ++ 4 files changed, 26 insertions(+), 2 deletions(-) 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 de013ffa4dd..a939253abfa 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 @@ -94,6 +94,7 @@ interface FileViewerProps { isAgentEditing?: boolean disableStreamingAutoScroll?: boolean previewContextKey?: string + showBubbleMenu?: boolean } export function FileViewer({ @@ -110,6 +111,7 @@ export function FileViewer({ isAgentEditing, disableStreamingAutoScroll = false, previewContextKey, + showBubbleMenu = true, }: FileViewerProps) { const category = resolveFileCategory(file.type, file.name) @@ -152,6 +154,7 @@ export function FileViewer({ isAgentEditing={isAgentEditing} disableStreamingAutoScroll={disableStreamingAutoScroll} previewContextKey={previewContextKey} + showBubbleMenu={showBubbleMenu} /> ) } 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 db545e7f00e..1a4bac255cc 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 @@ -42,6 +42,7 @@ interface RichMarkdownEditorProps { isAgentEditing?: boolean disableStreamingAutoScroll?: boolean previewContextKey?: string + showBubbleMenu?: boolean } /** @@ -68,6 +69,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ isAgentEditing, disableStreamingAutoScroll = false, previewContextKey, + showBubbleMenu = true, }: RichMarkdownEditorProps) { const { content, @@ -108,6 +110,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ canEdit={canEdit} autoFocus={autoFocus} disableStreamingAutoScroll={disableStreamingAutoScroll} + showBubbleMenu={showBubbleMenu} onChange={setDraftContent} onSaveShortcut={saveImmediately} /> @@ -124,6 +127,7 @@ interface LoadedRichMarkdownEditorProps { canEdit: boolean autoFocus?: boolean disableStreamingAutoScroll?: boolean + showBubbleMenu: boolean onChange: (markdown: string) => void onSaveShortcut: () => Promise } @@ -160,6 +164,7 @@ export function LoadedRichMarkdownEditor({ canEdit, autoFocus, disableStreamingAutoScroll, + showBubbleMenu, onChange, onSaveShortcut, }: LoadedRichMarkdownEditorProps) { @@ -377,7 +382,7 @@ export function LoadedRichMarkdownEditor({ ref={containerRef} className={cn('flex flex-1 flex-col overflow-y-auto', isEditable && 'cursor-text')} > - {editor && } + {showBubbleMenu && editor && } 0, fetchedContent, - streamingContent, + streamingContent: effectiveStreamingContent, }) const isStreamInteractionLocked = isStreamPhaseLocked || Boolean(isAgentEditing) 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 dcd4dde17da..fc02fe91cd9 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 @@ -150,6 +150,7 @@ export const ResourceContent = memo(function ResourceContent({ isAgentEditing={isAgentEditing} disableStreamingAutoScroll={disableStreamingAutoScroll} previewContextKey={previewContextKey} + showBubbleMenu={false} />
) @@ -612,6 +613,7 @@ function EmbeddedFile({ isAgentEditing={isAgentEditing} disableStreamingAutoScroll={disableStreamingAutoScroll} previewContextKey={previewContextKey} + showBubbleMenu={false} /> ) From 6773fe9a71799661c450c03838d4355eca9ccc35 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 19 Jun 2026 22:55:26 -0700 Subject: [PATCH 03/18] improve performance --- .../rich-markdown-editor.tsx | 50 ++++++---- .../file-viewer/text-editor-state.test.ts | 83 ++++++++++++++++- .../file-viewer/text-editor-state.ts | 40 +++++++- .../file-viewer/use-editable-file-content.ts | 33 +++---- .../resource-content/resource-content.tsx | 51 ++++++++++- apps/sim/hooks/use-smooth-text.test.tsx | 91 +++++++++++++++++++ apps/sim/hooks/use-smooth-text.ts | 16 +++- 7 files changed, 319 insertions(+), 45 deletions(-) create mode 100644 apps/sim/hooks/use-smooth-text.test.tsx 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 1a4bac255cc..1e8236547ed 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,16 @@ const EXTENSIONS = createMarkdownEditorExtensions({ placeholder: "Write something, or press '/' for commands…", }) +/** + * Each streamed re-sync re-parses the whole accumulating body and rebuilds the ProseMirror doc — + * ~O(n) per frame (~22ms at 60KB; see {@link parseMarkdownToDoc}). Below this size we re-sync every + * frame for a smooth reveal; at or above it we throttle to {@link STREAM_REPARSE_THROTTLE_MS} so a + * large file doesn't saturate the main thread with full re-parses the reader can't follow anyway. The + * settle path always re-seeds the exact final body, so throttling only affects mid-stream cadence. + */ +const STREAM_REPARSE_THROTTLE_THRESHOLD = 40_000 +const STREAM_REPARSE_THROTTLE_MS = 120 + interface RichMarkdownEditorProps { file: WorkspaceFileRecord workspaceId: string @@ -184,9 +194,6 @@ export function LoadedRichMarkdownEditor({ 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) @@ -300,22 +307,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) { @@ -324,11 +327,26 @@ 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: it parses the latest pending body, but for a large body that exceeds the + // re-parse budget it re-schedules instead (a cheap length+clock check, no parse) until enough + // time has passed — so newer chunks keep updating `pendingStreamBodyRef` and only the latest is + // ever parsed. Settle cancels any in-flight tick and re-seeds the final body. + 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) @@ -337,7 +355,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. @@ -352,7 +371,6 @@ export function LoadedRichMarkdownEditor({ 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. const body = splitFrontmatter(content).body diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-state.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-state.test.ts index 68c5ae567bc..ccc8b1c5530 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-state.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-state.test.ts @@ -10,19 +10,24 @@ import { } from './text-editor-state' function ready(content: string, savedContent = content): TextEditorContentState { - return { phase: 'ready', content, savedContent, lastStreamedContent: null } + return { phase: 'ready', content, savedContent, lastStreamedContent: null, hasBaseline: true } } function streaming( content: string, lastStreamedContent: string, - savedContent = '' + savedContent = '', + hasBaseline = true ): TextEditorContentState { - return { phase: 'streaming', content, savedContent, lastStreamedContent } + return { phase: 'streaming', content, savedContent, lastStreamedContent, hasBaseline } } -function reconciling(content: string, savedContent = ''): TextEditorContentState { - return { phase: 'reconciling', content, savedContent, lastStreamedContent: null } +function reconciling( + content: string, + savedContent = '', + hasBaseline = true +): TextEditorContentState { + return { phase: 'reconciling', content, savedContent, lastStreamedContent: null, hasBaseline } } describe("reducer 'edit' action", () => { @@ -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 3bc5f15290c..0742749845e 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/use-editable-file-content.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.ts index 3a46512da91..d50da96819a 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 @@ -129,19 +129,6 @@ export function useEditableFileContent({ const updateContentRef = useRef(updateContent) updateContentRef.current = updateContent - // Pace only the streamed text (never user edits) so the preview reveals at the same word-by-word - // cadence as chat. Off-stream it reverts to undefined so the engine reconciles to the agent's - // persisted write; snapOnNonAppend shows in-place rewrites/patches in full instead of re-revealing. - const pacedStreamingContent = useSmoothText( - streamingContent ?? '', - streamingContent !== undefined, - { - snapOnNonAppend: true, - } - ) - const effectiveStreamingContent = - streamingContent !== undefined ? pacedStreamingContent : undefined - const { content, savedContent, @@ -152,11 +139,20 @@ export function useEditableFileContent({ } = useFileContentState({ canReconcileToFetchedContent: file.key.length > 0, fetchedContent, - streamingContent: effectiveStreamingContent, + 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 @@ -192,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]/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 fc02fe91cd9..b7332939bde 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' @@ -89,6 +89,50 @@ 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, @@ -136,7 +180,10 @@ export const ResourceContent = memo(function ResourceContent({ ? previewSession.previewText : undefined - const isAgentEditing = Boolean(isAgentResponding && previewSession) + const isAgentEditing = useAgentFileEditLock( + previewSession?.status === 'streaming', + Boolean(isAgentResponding) + ) if (resource.id === 'streaming-file') { return ( 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 00000000000..b4fe89d9911 --- /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 f13d93a4b77..85413fc89a8 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 From 0e4605250b2023a88a5be7bae5c7eedf50ea1397 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 19 Jun 2026 23:03:12 -0700 Subject: [PATCH 04/18] remove comments --- .../rich-markdown-editor.tsx | 72 ++++--------------- 1 file changed, 13 insertions(+), 59 deletions(-) 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 1e8236547ed..d68282230ba 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,13 +30,7 @@ const EXTENSIONS = createMarkdownEditorExtensions({ placeholder: "Write something, or press '/' for commands…", }) -/** - * Each streamed re-sync re-parses the whole accumulating body and rebuilds the ProseMirror doc — - * ~O(n) per frame (~22ms at 60KB; see {@link parseMarkdownToDoc}). Below this size we re-sync every - * frame for a smooth reveal; at or above it we throttle to {@link STREAM_REPARSE_THROTTLE_MS} so a - * large file doesn't saturate the main thread with full re-parses the reader can't follow anyway. The - * settle path always re-seeds the exact final body, so throttling only affects mid-stream cadence. - */ +// 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 @@ -55,18 +49,7 @@ interface RichMarkdownEditorProps { showBubbleMenu?: boolean } -/** - * 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, @@ -147,25 +130,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, @@ -181,16 +151,14 @@ 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) ) @@ -206,12 +174,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() ) @@ -258,11 +221,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 @@ -274,8 +235,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('/') && @@ -327,10 +287,7 @@ export function LoadedRichMarkdownEditor({ if (body === lastSyncedBodyRef.current) return pendingStreamBodyRef.current = body if (streamRafRef.current !== null) return - // Self-re-arming tick: it parses the latest pending body, but for a large body that exceeds the - // re-parse budget it re-schedules instead (a cheap length+clock check, no parse) until enough - // time has passed — so newer chunks keep updating `pendingStreamBodyRef` and only the latest is - // ever parsed. Settle cancels any in-flight tick and re-seeds the final body. + // 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) { @@ -364,15 +321,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) - // 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 From 7a20d8a6d39d0086032b76f9a2b60c51edc12a22 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 19 Jun 2026 23:33:29 -0700 Subject: [PATCH 05/18] improvement(rich-md-editor): reveal bubble after drag-select, keep it on-screen for tall selections, restyle task-list checkbox --- .../menus/bubble-menu.tsx | 76 ++++++++++++++++++- .../rich-markdown-editor.css | 35 ++++++++- .../rich-markdown-editor.tsx | 4 +- 3 files changed, 108 insertions(+), 7 deletions(-) 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 41af20c54c1..92ca852e028 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,20 @@ 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 +} + 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 +85,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 +127,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 +208,26 @@ 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 a7c9182e5df..6abc89a6010 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 d68282230ba..fb44708d38e 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 @@ -354,7 +354,9 @@ export function LoadedRichMarkdownEditor({ ref={containerRef} className={cn('flex flex-1 flex-col overflow-y-auto', isEditable && 'cursor-text')} > - {showBubbleMenu && editor && } + {showBubbleMenu && editor && ( + + )} Date: Fri, 19 Jun 2026 23:33:29 -0700 Subject: [PATCH 06/18] improvement(share-modal): use Send icon in the share file header --- .../files/components/share-modal/share-modal.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 f2e4326b132..c485697230c 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 From dffce5294c4a6b347e673fb903e3fc00e147796d Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 20 Jun 2026 09:14:39 -0700 Subject: [PATCH 07/18] improvement(rich-md-editor): pin the formatting toolbar so it stays put while scrolling --- .../file-viewer/rich-markdown-editor/menus/bubble-menu.tsx | 5 +++++ 1 file changed, 5 insertions(+) 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 92ca852e028..9a7a2eb887c 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 @@ -73,6 +73,10 @@ function hasFormattableSelection(editor: Editor, from: number, to: number): bool 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. */ @@ -228,6 +232,7 @@ export function EditorBubbleMenu({ editor, scrollContainerRef }: EditorBubbleMen editor={editor} pluginKey={bubbleMenuKey} getReferencedVirtualElement={resolveAnchor} + options={FLOATING_OPTIONS} role='toolbar' aria-label='Text formatting' updateDelay={0} From f3035aba3a7c50a9598b4d501e854f56534b709b Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 20 Jun 2026 09:14:40 -0700 Subject: [PATCH 08/18] improvement(rich-md-editor): show the formatting toolbar in the mothership file view --- .../files/components/file-viewer/file-viewer.tsx | 3 --- .../rich-markdown-editor/rich-markdown-editor.tsx | 9 +-------- .../components/resource-content/resource-content.tsx | 2 -- 3 files changed, 1 insertion(+), 13 deletions(-) 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 a939253abfa..de013ffa4dd 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 @@ -94,7 +94,6 @@ interface FileViewerProps { isAgentEditing?: boolean disableStreamingAutoScroll?: boolean previewContextKey?: string - showBubbleMenu?: boolean } export function FileViewer({ @@ -111,7 +110,6 @@ export function FileViewer({ isAgentEditing, disableStreamingAutoScroll = false, previewContextKey, - showBubbleMenu = true, }: FileViewerProps) { const category = resolveFileCategory(file.type, file.name) @@ -154,7 +152,6 @@ export function FileViewer({ isAgentEditing={isAgentEditing} disableStreamingAutoScroll={disableStreamingAutoScroll} previewContextKey={previewContextKey} - showBubbleMenu={showBubbleMenu} /> ) } 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 fb44708d38e..189eb750cec 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 @@ -46,7 +46,6 @@ interface RichMarkdownEditorProps { isAgentEditing?: boolean disableStreamingAutoScroll?: boolean previewContextKey?: string - showBubbleMenu?: boolean } /** Inline WYSIWYG markdown editor: agent output streams in read-only, then the same instance becomes editable on settle. */ @@ -62,7 +61,6 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ isAgentEditing, disableStreamingAutoScroll = false, previewContextKey, - showBubbleMenu = true, }: RichMarkdownEditorProps) { const { content, @@ -103,7 +101,6 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ canEdit={canEdit} autoFocus={autoFocus} disableStreamingAutoScroll={disableStreamingAutoScroll} - showBubbleMenu={showBubbleMenu} onChange={setDraftContent} onSaveShortcut={saveImmediately} /> @@ -120,7 +117,6 @@ interface LoadedRichMarkdownEditorProps { canEdit: boolean autoFocus?: boolean disableStreamingAutoScroll?: boolean - showBubbleMenu: boolean onChange: (markdown: string) => void onSaveShortcut: () => Promise } @@ -144,7 +140,6 @@ export function LoadedRichMarkdownEditor({ canEdit, autoFocus, disableStreamingAutoScroll, - showBubbleMenu, onChange, onSaveShortcut, }: LoadedRichMarkdownEditorProps) { @@ -354,9 +349,7 @@ export function LoadedRichMarkdownEditor({ ref={containerRef} className={cn('flex flex-1 flex-col overflow-y-auto', isEditable && 'cursor-text')} > - {showBubbleMenu && editor && ( - - )} + {editor && }
) @@ -660,7 +659,6 @@ function EmbeddedFile({ isAgentEditing={isAgentEditing} disableStreamingAutoScroll={disableStreamingAutoScroll} previewContextKey={previewContextKey} - showBubbleMenu={false} /> ) From 8842ad7cc8d54485dcf46c7da2874982dedb41d5 Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 20 Jun 2026 13:36:16 -0700 Subject: [PATCH 09/18] fix(sidebar): drive collapsed width from server-rendered attribute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A collapsed rail painted at the expanded width then animated to 51px on refresh: structure came from the cookie (server) while width came from independent cookie reads (blocking script + store), so any disagreement left the collapsed structure at the persisted expanded width until the store corrected it. Unify collapse into one derivation in WorkspaceChrome and drive the collapsed width from a server-rendered data-collapsed attribute via CSS (.sidebar-shell-outer[data-collapsed]) — the same cookie source as the structure, so width can never diverge from it. This is shadcn's documented pattern (data-attribute selectors over JS ternaries for collapsed dimensions). Also removes the redundant migratedCollapsed reconciliation (the store already seeds from the migrated cookie and hasHydrated flips in the same pre-paint effect) and the now-unused per-Sidebar derivation; Sidebar takes isCollapsed as a prop. --- apps/sim/app/_styles/globals.css | 11 +++++ .../workspace-chrome/workspace-chrome.tsx | 26 ++++++++++-- .../w/components/sidebar/sidebar.tsx | 42 +++---------------- 3 files changed, 40 insertions(+), 39 deletions(-) diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index d8765bb9fdd..0c1b9fe923d 100644 --- a/apps/sim/app/_styles/globals.css +++ b/apps/sim/app/_styles/globals.css @@ -55,6 +55,17 @@ transition: width 200ms cubic-bezier(0.25, 0.1, 0.25, 1); } +/** + * Collapsed width is driven by the server-rendered `data-collapsed` attribute — + * the same cookie source as the collapsed structure — so the rail can never paint + * at the expanded width and then snap narrow. Overrides `--sidebar-width` for the + * shell subtree (outer, inner, and the aside cascade from it). Must equal + * SIDEBAR_WIDTH.COLLAPSED in stores/constants.ts. + */ +.sidebar-shell-outer[data-collapsed] { + --sidebar-width: 51px; +} + .sidebar-container span, .sidebar-container .text-small { transition: opacity 120ms ease; diff --git a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx index 87fafd3183f..f463da30949 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect } from 'react' +import { useEffect, useLayoutEffect } from 'react' import { usePathname } from 'next/navigation' import { cn } from '@/lib/core/utils/cn' import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar' @@ -43,15 +43,34 @@ function isFullscreenPath(pathname: string | null): boolean { * On a direct load of a fullscreen route the wrapper mounts already collapsed, * so no slide plays (CSS transitions don't run on mount). */ -export function WorkspaceChrome({ children, initialSidebarCollapsed }: WorkspaceChromeProps) { +export function WorkspaceChrome({ + children, + initialSidebarCollapsed = false, +}: WorkspaceChromeProps) { const pathname = usePathname() const isFullscreen = isFullscreenPath(pathname) const setOrigin = useFullscreenOriginStore((s) => s.setOrigin) + const storeIsCollapsed = useSidebarStore((s) => s.isCollapsed) const hasHydrated = useSidebarStore((s) => s._hasHydrated) const syncSidebarWidth = useSidebarStore((s) => s.syncWidth) + /** + * Single source of collapse for the whole chrome, driving the rail's structure, + * labels, and width. The server renders from the `sidebar_collapsed` cookie + * (`initialSidebarCollapsed`) and the store seeds from the same cookie — after + * the pre-paint script migrates any legacy `localStorage` flag — so prop and + * store agree. The prop is used until the store hydrates (keeping the first + * client render identical to the server), then the store takes over. + */ + const isCollapsed = hasHydrated ? storeIsCollapsed : initialSidebarCollapsed + + // Hydrate the persisted width before paint (collapse comes from the cookie/prop). + useLayoutEffect(() => { + void useSidebarStore.persist.rehydrate() + }, []) + // Remember the last non-fullscreen page so a fullscreen route's Back control // can return there, deterministically and for any trigger. useEffect(() => { @@ -95,6 +114,7 @@ export function WorkspaceChrome({ children, initialSidebarCollapsed }: Workspace SLIDE_TRANSITION, isFullscreen ? 'w-0' : 'w-[var(--sidebar-width)]' )} + data-collapsed={isCollapsed || undefined} aria-hidden={isFullscreen || undefined} suppressHydrationWarning > @@ -105,7 +125,7 @@ export function WorkspaceChrome({ children, initialSidebarCollapsed }: Workspace isFullscreen && '-translate-x-full' )} > - +
state.setSidebarWidth) - const storeIsCollapsed = useSidebarStore((state) => state.isCollapsed) - const hasHydrated = useSidebarStore((state) => state._hasHydrated) const toggleCollapsed = useSidebarStore((state) => state.toggleCollapsed) const isOnWorkflowPage = !!workflowId - /** - * The server renders from the `sidebar_collapsed` cookie (via `initialCollapsed`) - * and the client store seeds from the same cookie, so both agree on the first - * paint. The prop is read until the store reports hydration, after which the - * store takes over. - * - * A legacy user whose collapse lived only in `localStorage` has no cookie at SSR - * (so `initialCollapsed` is false), but the pre-paint script migrates them to a - * cookie. Reconcile to that cookie synchronously before paint — the first render - * still matches the server, so there's no hydration mismatch and no narrow-rail flash. - */ - const [migratedCollapsed, setMigratedCollapsed] = useState(null) - useLayoutEffect(() => { - const cookieCollapsed = readCollapsedCookie() - if (cookieCollapsed !== initialCollapsed) setMigratedCollapsed(cookieCollapsed) - }, [initialCollapsed]) - const isCollapsed = hasHydrated ? storeIsCollapsed : (migratedCollapsed ?? initialCollapsed) - - /** - * Hydrates the persisted width before paint (collapse already came from the - * cookie) so any width-dependent layout settles in the same commit. - */ - useLayoutEffect(() => { - void useSidebarStore.persist.rehydrate() - }, []) - const isCollapsedRef = useRef(isCollapsed) useLayoutEffect(() => { isCollapsedRef.current = isCollapsed From ced099a2a519a02119a3977ed8de39b218777565 Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 20 Jun 2026 13:55:17 -0700 Subject: [PATCH 10/18] feat(rich-md-editor): let focused editors claim shortcuts from the global command registry --- .../global-commands-provider.test.tsx | 89 +++++++++++++++++++ .../providers/global-commands-provider.tsx | 26 ++++++ 2 files changed, 115 insertions(+) create mode 100644 apps/sim/app/workspace/[workspaceId]/providers/global-commands-provider.test.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/providers/global-commands-provider.test.tsx b/apps/sim/app/workspace/[workspaceId]/providers/global-commands-provider.test.tsx new file mode 100644 index 00000000000..84dfd588e18 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/providers/global-commands-provider.test.tsx @@ -0,0 +1,89 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockIsMac } = vi.hoisted(() => ({ mockIsMac: vi.fn(() => false) })) +vi.mock('@/lib/core/utils/platform', () => ({ isMacPlatform: mockIsMac })) +vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }) })) + +import { + GlobalCommandsProvider, + useRegisterGlobalCommands, +} from '@/app/workspace/[workspaceId]/providers/global-commands-provider' + +function RegisterModK({ handler }: { handler: () => void }) { + useRegisterGlobalCommands([{ id: 'search', shortcut: 'Mod+K', handler }]) + return null +} + +let container: HTMLDivElement +let root: Root + +function mount(ui: ReactNode) { + act(() => { + root.render(ui) + }) +} + +/** Non-mac (mocked): `Mod` resolves to Ctrl, so Ctrl+K matches a `Mod+K` shortcut. */ +function pressModK() { + window.dispatchEvent( + new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, bubbles: true, cancelable: true }) + ) +} + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.clearAllMocks() +}) + +describe('GlobalCommandsProvider owned-shortcut yielding', () => { + it('fires a global command when nothing owns the shortcut', () => { + const handler = vi.fn() + mount( + + + + ) + pressModK() + expect(handler).toHaveBeenCalledTimes(1) + }) + + it('yields the shortcut to a focused element that declares it owns it', () => { + const handler = vi.fn() + mount( + + + {/* biome-ignore lint/a11y/noNoninteractiveTabindex: focusable stand-in for the editor */} +
+ + ) + ;(container.querySelector('[data-owned-shortcuts]') as HTMLElement).focus() + pressModK() + expect(handler).not.toHaveBeenCalled() + }) + + it('still fires when the focused element owns only a different shortcut', () => { + const handler = vi.fn() + mount( + + + {/* biome-ignore lint/a11y/noNoninteractiveTabindex: focusable stand-in for the editor */} +
+ + ) + ;(container.querySelector('[data-owned-shortcuts]') as HTMLElement).focus() + pressModK() + expect(handler).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/providers/global-commands-provider.tsx b/apps/sim/app/workspace/[workspaceId]/providers/global-commands-provider.tsx index 3c3d6ae0db6..ee8ae3f5c97 100644 --- a/apps/sim/app/workspace/[workspaceId]/providers/global-commands-provider.tsx +++ b/apps/sim/app/workspace/[workspaceId]/providers/global-commands-provider.tsx @@ -86,6 +86,30 @@ function matchesShortcut(e: KeyboardEvent, parsed: ParsedShortcut): boolean { ) } +/** Platform-resolved signature of a shortcut, so `Mod+K`, `Cmd+K`, and `Meta+K` compare equal on mac. */ +function shortcutSignature(parsed: ParsedShortcut, isMac: boolean): string { + const ctrl = parsed.ctrl || (parsed.mod ? !isMac : false) + const meta = parsed.meta || (parsed.mod ? isMac : false) + return `${parsed.key}|${+ctrl}|${+meta}|${+!!parsed.shift}|${+!!parsed.alt}` +} + +/** + * Whether the focused element (or an ancestor) declares it owns `parsed` via a comma-separated + * `data-owned-shortcuts` attribute (e.g. a rich-text editor that binds `Mod+K` to links). Such a + * shortcut is left for that element to handle instead of firing the global command. + */ +function focusedElementOwnsShortcut(parsed: ParsedShortcut, isMac: boolean): boolean { + const active = document.activeElement + const owner = active instanceof HTMLElement ? active.closest('[data-owned-shortcuts]') : null + if (!owner) return false + const target = shortcutSignature(parsed, isMac) + return (owner.getAttribute('data-owned-shortcuts') ?? '') + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + .some((entry) => shortcutSignature(parseShortcut(entry), isMac) === target) +} + export function GlobalCommandsProvider({ children }: { children: ReactNode }) { const registryRef = useRef>(new Map()) const isMac = useMemo(() => isMacPlatform(), []) @@ -127,6 +151,8 @@ export function GlobalCommandsProvider({ children }: { children: ReactNode }) { } if (matchesShortcut(e, cmd.parsed)) { + // A focused rich editor that owns this shortcut (e.g. Mod+K for links) handles it itself. + if (focusedElementOwnsShortcut(cmd.parsed, isMac)) continue e.preventDefault() e.stopPropagation() try { From 98377cb458ea875f457165c954e18e7f1ee95688 Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 20 Jun 2026 13:55:18 -0700 Subject: [PATCH 11/18] refactor(rich-md-editor): freeze the formatting toolbar on scroll and extract the shared toolbar button --- .../menus/bubble-menu.tsx | 74 ++++++------------- .../menus/toolbar-button.tsx | 50 +++++++++++++ 2 files changed, 73 insertions(+), 51 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/toolbar-button.tsx 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 9a7a2eb887c..70d7a1392ca 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 @@ -15,53 +15,12 @@ import { List, ListChecks, ListOrdered, - type LucideIcon, Strikethrough, TextQuote, Unlink, } from 'lucide-react' -import { Tooltip } from '@/components/emcn' -import { cn } from '@/lib/core/utils/cn' import { normalizeLinkHref } from '../markdown-fidelity' - -interface ToolbarButtonProps { - icon: LucideIcon - label: string - shortcut?: string - isActive: boolean - onClick: () => void -} - -function ToolbarButton({ icon: Icon, label, shortcut, isActive, onClick }: ToolbarButtonProps) { - return ( - - - - - - {shortcut ? {label} : label} - - - ) -} - -function ToolbarDivider() { - return
-} +import { ToolbarButton, ToolbarDivider } from './toolbar-button' /** * Whether the formatting toolbar may show for the given range: the editor is editable, the range @@ -212,18 +171,31 @@ export function EditorBubbleMenu({ editor, scrollContainerRef }: EditorBubbleMen 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. + // Freeze the anchor per selection: the rect is computed once (in viewport coordinates) and reused on + // every scroll/resize reposition, so the toolbar stays where it first appeared instead of tracking + // the moving text — matching Linear. A new selection recomputes it. A selection taller than the + // viewport (e.g. select-all) is clamped into the visible area so the bar isn't placed off-screen. + const anchorCacheRef = useRef<{ key: string; rect: DOMRect } | null>(null) 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) + const { from, to } = state.selection + const key = `${from}:${to}` + if (anchorCacheRef.current?.key !== key) { + const selection = posToDOMRect(view, from, to) + const viewport = scrollContainerRef.current?.getBoundingClientRect() + const rect = + viewport && selection.height > viewport.height + ? new DOMRect( + selection.left, + Math.min(Math.max(selection.top, viewport.top), viewport.bottom), + selection.width, + 0 + ) + : selection + anchorCacheRef.current = { key, rect } + } + const { rect } = anchorCacheRef.current return { getBoundingClientRect: () => rect, getClientRects: () => [rect] } }, [editor, scrollContainerRef]) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/toolbar-button.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/toolbar-button.tsx new file mode 100644 index 00000000000..f2c9a4a1b51 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/toolbar-button.tsx @@ -0,0 +1,50 @@ +import type { LucideIcon } from 'lucide-react' +import { Tooltip } from '@/components/emcn' +import { cn } from '@/lib/core/utils/cn' + +interface ToolbarButtonProps { + icon: LucideIcon + label: string + shortcut?: string + isActive?: boolean + onClick: () => void +} + +/** A single icon button for the editor's floating toolbars (bubble menu, link hover card). */ +export function ToolbarButton({ + icon: Icon, + label, + shortcut, + isActive = false, + onClick, +}: ToolbarButtonProps) { + return ( + + + + + + {shortcut ? {label} : label} + + + ) +} + +/** Thin vertical separator between groups of {@link ToolbarButton}s. */ +export function ToolbarDivider() { + return
+} From 138a0c9f2784852d98b177b9785083a2898974b3 Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 20 Jun 2026 13:55:18 -0700 Subject: [PATCH 12/18] feat(rich-md-editor): add a link hover card and claim Cmd+K for the link shortcut --- .../menus/link-hover-card.tsx | 206 ++++++++++++++++++ .../rich-markdown-editor.tsx | 5 +- 2 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card.tsx new file mode 100644 index 00000000000..08ce7074c46 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card.tsx @@ -0,0 +1,206 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom' +import { getMarkRange } from '@tiptap/core' +import type { Editor } from '@tiptap/react' +import { Check, Copy, Pencil, Unlink } from 'lucide-react' +import { normalizeLinkHref } from '../markdown-fidelity' +import { ToolbarButton } from './toolbar-button' + +interface LinkHoverCardProps { + editor: Editor +} + +interface LinkRange { + from: number + to: number + href: string +} + +/** Resolves the document range and href of the link rendered by `el`, or null if it isn't a link. */ +function resolveLinkRange(editor: Editor, el: HTMLElement): LinkRange | null { + const { state } = editor.view + const linkType = state.schema.marks.link + if (!linkType) return null + const pos = editor.view.posAtDOM(el, 0) + if (pos < 0) return null + const range = + getMarkRange(state.doc.resolve(pos), linkType) ?? + getMarkRange(state.doc.resolve(pos + 1), linkType) + if (!range) return null + const href = el.getAttribute('href') ?? '' + return { from: range.from, to: range.to, href } +} + +/** + * Floating card shown when hovering a link, so the destination is visible even when the link text + * differs from the URL. The URL opens in a new tab; Copy is always available, while Edit (inline) and + * Remove require an editable document. Positioned with Floating UI against the hovered anchor; a short + * close delay plus the card's own hover bridge let the pointer travel from the link into the card. + */ +export function LinkHoverCard({ editor }: LinkHoverCardProps) { + const [activeLink, setActiveLink] = useState(null) + const [draftHref, setDraftHref] = useState(null) + const [position, setPosition] = useState<{ x: number; y: number } | null>(null) + const isEditing = draftHref !== null + const editInputRef = useRef(null) + const floatingRef = useRef(null) + const hideTimerRef = useRef(undefined) + + // Keep the card anchored to the hovered link with Floating UI's DOM core (the same primitive the + // bubble menu positions through) — no React wrapper, so the harness/app share one React instance. + useEffect(() => { + const floating = floatingRef.current + if (!activeLink || !floating) { + setPosition(null) + return + } + return autoUpdate(activeLink, floating, () => { + computePosition(activeLink, floating, { + strategy: 'fixed', + placement: 'top', + middleware: [offset(8), flip({ padding: 8 }), shift({ padding: 8 })], + }).then(({ x, y }) => setPosition({ x, y })) + }) + }, [activeLink]) + + const cancelHide = useCallback(() => window.clearTimeout(hideTimerRef.current), []) + const dismiss = useCallback(() => { + cancelHide() + setActiveLink(null) + setDraftHref(null) + }, [cancelHide]) + const scheduleHide = useCallback(() => { + cancelHide() + hideTimerRef.current = window.setTimeout(() => { + setActiveLink(null) + setDraftHref(null) + }, 120) + }, [cancelHide]) + + useEffect(() => { + const dom = editor.view.dom + const onOver = (event: Event) => { + // Don't compete with the selection toolbar while text is selected. + if (!editor.state.selection.empty) return + const link = (event.target as HTMLElement | null)?.closest('a') + if (link && dom.contains(link)) { + cancelHide() + setActiveLink(link) + } + } + const onOut = (event: MouseEvent) => { + const link = (event.target as HTMLElement | null)?.closest('a') + if (!link) return + // Ignore moves that stay within the same link. + if (link.contains(event.relatedTarget as Node | null)) return + scheduleHide() + } + dom.addEventListener('mouseover', onOver) + dom.addEventListener('mouseout', onOut) + return () => { + dom.removeEventListener('mouseover', onOver) + dom.removeEventListener('mouseout', onOut) + window.clearTimeout(hideTimerRef.current) + } + }, [editor, cancelHide, scheduleHide]) + + useEffect(() => { + if (isEditing) editInputRef.current?.focus() + }, [isEditing]) + + if (!activeLink) return null + + const rawHref = activeLink.getAttribute('href') ?? '' + const safeHref = normalizeLinkHref(rawHref) + const canEdit = editor.isEditable + + const startEdit = () => setDraftHref(rawHref) + + const commitEdit = () => { + const range = resolveLinkRange(editor, activeLink) + if (range) { + const href = normalizeLinkHref((draftHref ?? '').trim()) + const chain = editor.chain().focus().setTextSelection(range).extendMarkRange('link') + if (href) chain.setLink({ href }) + else chain.unsetLink() + chain.run() + } + dismiss() + } + + const removeLink = () => { + const range = resolveLinkRange(editor, activeLink) + if (range) { + editor.chain().focus().setTextSelection(range).extendMarkRange('link').unsetLink().run() + } + dismiss() + } + + return ( +
+ {isEditing ? ( + <> + setDraftHref(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + commitEdit() + } else if (event.key === 'Escape') { + event.preventDefault() + setDraftHref(null) + } + }} + placeholder='Paste or type a link…' + className='h-[28px] w-[220px] bg-transparent px-2 text-[var(--text-body)] text-small outline-none placeholder:text-[var(--text-subtle)]' + /> + + + ) : ( + <> + {safeHref ? ( + + {rawHref} + + ) : ( + + {rawHref} + + )} + copyToClipboard(rawHref)} /> + {canEdit && } + {canEdit && } + + )} +
+ ) +} + +function copyToClipboard(text: string) { + if (text) void navigator.clipboard?.writeText(text).catch(() => {}) +} 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 189eb750cec..a4de58fb785 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 @@ -22,6 +22,7 @@ import { } from './markdown-fidelity' import { parseMarkdownToDoc } from './markdown-parse' import { EditorBubbleMenu } from './menus/bubble-menu' +import { LinkHoverCard } from './menus/link-hover-card' import { isRoundTripSafe } from './round-trip-safety' import '@/components/emcn/components/code/code.css' import './rich-markdown-editor.css' @@ -205,7 +206,8 @@ export function LoadedRichMarkdownEditor({ shouldRerenderOnTransaction: false, content: initialContent, editorProps: { - attributes: { class: 'rich-markdown-prose' }, + // Claim Mod+K so the global command registry yields it to the editor's link shortcut. + attributes: { class: 'rich-markdown-prose', 'data-owned-shortcuts': 'Mod+K' }, handleKeyDown: (_view, event) => { const isSaveShortcut = (event.metaKey || event.ctrlKey) && event.key?.toLowerCase() === 's' if (!isSaveShortcut) return false @@ -350,6 +352,7 @@ export function LoadedRichMarkdownEditor({ className={cn('flex flex-1 flex-col overflow-y-auto', isEditable && 'cursor-text')} > {editor && } + {editor && } Date: Sat, 20 Jun 2026 14:22:44 -0700 Subject: [PATCH 13/18] fix(rich-md-editor): portal the toolbar + link card to body so a transformed ancestor can't offset them; align fade with the tooltip --- .../rich-markdown-editor/menus/bubble-menu.tsx | 7 ++++++- .../rich-markdown-editor/menus/link-hover-card.tsx | 11 +++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) 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 70d7a1392ca..de0620ce9fe 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 @@ -36,6 +36,10 @@ function hasFormattableSelection(editor: Editor, from: number, to: number): bool // a selection it stays put while the document scrolls instead of tracking the text — matching Linear. const FLOATING_OPTIONS = { strategy: 'fixed' } as const +// Render into the body so a transformed/clipping ancestor (e.g. the mothership panels) can't reparent +// the fixed-positioned toolbar and shift it off the selection. +const APPEND_TO_BODY = () => document.body + interface EditorBubbleMenuProps { editor: Editor /** The editor's scrollable viewport, used to keep the toolbar on-screen for selections taller than it. */ @@ -205,6 +209,7 @@ export function EditorBubbleMenu({ editor, scrollContainerRef }: EditorBubbleMen pluginKey={bubbleMenuKey} getReferencedVirtualElement={resolveAnchor} options={FLOATING_OPTIONS} + appendTo={APPEND_TO_BODY} role='toolbar' aria-label='Text formatting' updateDelay={0} @@ -217,7 +222,7 @@ export function EditorBubbleMenu({ editor, scrollContainerRef }: EditorBubbleMen 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' + 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-150 ease-out motion-reduce:animate-none' > {isEditingLink ? ( <> diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card.tsx index 08ce7074c46..004c436e260 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card.tsx @@ -3,6 +3,7 @@ import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/d import { getMarkRange } from '@tiptap/core' import type { Editor } from '@tiptap/react' import { Check, Copy, Pencil, Unlink } from 'lucide-react' +import { createPortal } from 'react-dom' import { normalizeLinkHref } from '../markdown-fidelity' import { ToolbarButton } from './toolbar-button' @@ -136,7 +137,7 @@ export function LinkHoverCard({ editor }: LinkHoverCardProps) { dismiss() } - return ( + return createPortal(
{isEditing ? ( <> @@ -197,7 +199,8 @@ export function LinkHoverCard({ editor }: LinkHoverCardProps) { {canEdit && } )} -
+
, + document.body ) } From 0834afe8557e4246c66dd11e8e0a8bd2775daaa1 Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 20 Jun 2026 14:22:45 -0700 Subject: [PATCH 14/18] fix(rich-md-editor): hide the code line-wrap toggle in read-only --- .../components/file-viewer/rich-markdown-editor/code-block.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx index 54ad3d9624c..16e38ea987e 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx @@ -179,7 +179,7 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView {label} ))} - {!isMermaid && ( + {!isMermaid && editor.isEditable && (