diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts
index 862414f9fd6..b9392de02c8 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts
@@ -7,6 +7,7 @@
* keymap's `isSuggestionMenuOpen` guard reads flips on when a menu opens.
*/
import { Editor } from '@tiptap/core'
+import { GapCursor } from '@tiptap/pm/gapcursor'
import { AllSelection, NodeSelection } from '@tiptap/pm/state'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createMarkdownEditorExtensions } from './editor-extensions'
@@ -195,6 +196,103 @@ describe('empty wrapped-block Backspace', () => {
editor.destroy()
}
)
+
+ it('leaves a gap cursor — never a NodeSelection — when the removed bullet was followed by an image at doc start', () => {
+ // Regression: `Selection.near` after the delete silently NodeSelected the following image, so a
+ // second Backspace while "clearing the bullet" deleted the image (and typing would have replaced
+ // it). The selection left behind must never make the next keystroke destructive.
+ const editor = editorWith({
+ type: 'doc',
+ content: [
+ { type: 'bulletList', content: [{ type: 'listItem', content: [{ type: 'paragraph' }] }] },
+ { type: 'image', attrs: { src: '/api/files/view/wf_img', alt: 'photo' } },
+ ],
+ })
+ editor.commands.setTextSelection(3)
+ pressBackspace(editor)
+
+ expect(blockShape(editor)).toEqual(['image', 'paragraph'])
+ expect(editor.state.selection).not.toBeInstanceOf(NodeSelection)
+
+ pressBackspace(editor)
+ expect(blockShape(editor)).toEqual(['image', 'paragraph'])
+ editor.destroy()
+ })
+
+ it('does not crash on Backspace from a gap cursor (top-level selection, depth 0)', () => {
+ // Regression: `$from.before($from.depth)` with a gap cursor's depth of 0 threw
+ // "RangeError: There is no position before the top-level node" — reachable whenever a gap
+ // cursor sits between two leaves (the `data-gap-between-leaves` state) and Backspace is pressed.
+ const editor = editorWith('
')
+ const gapPos = editor.state.doc.firstChild ? editor.state.doc.firstChild.nodeSize : 1
+ editor.view.dispatch(
+ editor.state.tr.setSelection(new GapCursor(editor.state.doc.resolve(gapPos)))
+ )
+ expect(editor.state.selection).toBeInstanceOf(GapCursor)
+
+ expect(() => pressBackspace(editor)).not.toThrow()
+ // TipTap appends a trailing paragraph after the final leaf; the dividers must both survive.
+ expect(blockShape(editor)).toEqual(['horizontalRule', 'horizontalRule', 'paragraph'])
+ editor.destroy()
+ })
+
+ it('never NodeSelects a leaf BEFORE the removed bullet either (findFrom textOnly skips atoms)', () => {
+ // `Selection.findFrom($gap, -1, true)` cannot return a NodeSelection: with textOnly,
+ // prosemirror-state's findSelectionIn skips atoms entirely (`!text && isSelectable`). With an
+ // image directly before the emptied bullet and no textblock behind it, the backward search
+ // returns null and the gap-cursor branch takes over — the image is never silently selected.
+ const editor = editorWith({
+ type: 'doc',
+ content: [
+ { type: 'image', attrs: { src: '/api/files/view/wf_img', alt: 'photo' } },
+ { type: 'bulletList', content: [{ type: 'listItem', content: [{ type: 'paragraph' }] }] },
+ ],
+ })
+ editor.commands.setTextSelection(4)
+ expect(editor.state.selection.$from.parent.type.name).toBe('paragraph')
+ pressBackspace(editor)
+
+ expect(blockShape(editor)).toEqual(['image', 'paragraph'])
+ expect(editor.state.selection).not.toBeInstanceOf(NodeSelection)
+
+ pressBackspace(editor)
+ expect(blockShape(editor)).toEqual(['image', 'paragraph'])
+ editor.destroy()
+ })
+
+ it('does not crash on Backspace from a gap cursor at the very start of the doc', () => {
+ // Depth-0 + offset-0 is the worst case: our old code threw before(0), and TipTap's blockquote
+ // handler crashes on $from.node(-1) if the key falls through — so it must be consumed.
+ const editor = editorWith({
+ type: 'doc',
+ content: [{ type: 'image', attrs: { src: '/api/files/view/wf_img', alt: 'photo' } }],
+ })
+ editor.view.dispatch(editor.state.tr.setSelection(new GapCursor(editor.state.doc.resolve(0))))
+ expect(editor.state.selection).toBeInstanceOf(GapCursor)
+
+ expect(() => pressBackspace(editor)).not.toThrow()
+ expect(blockShape(editor)).toEqual(['image', 'paragraph'])
+ editor.destroy()
+ })
+
+ it('still prefers the previous textblock caret when one exists (image after the bullet untouched)', () => {
+ const editor = editorWith({
+ type: 'doc',
+ content: [
+ { type: 'paragraph', content: [{ type: 'text', text: 'hello' }] },
+ { type: 'bulletList', content: [{ type: 'listItem', content: [{ type: 'paragraph' }] }] },
+ { type: 'image', attrs: { src: '/api/files/view/wf_img', alt: 'photo' } },
+ ],
+ })
+ editor.commands.setTextSelection(10)
+ pressBackspace(editor)
+
+ expect(blockShape(editor)).toEqual(['paragraph', 'image', 'paragraph'])
+ expect(editor.state.selection.empty).toBe(true)
+ expect(editor.state.selection).not.toBeInstanceOf(NodeSelection)
+ expect(editor.state.selection.$from.parent.textContent).toBe('hello')
+ editor.destroy()
+ })
})
describe('empty list-item Enter', () => {
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts
index 3034fd66fe6..0d5bc22801c 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts
@@ -35,6 +35,14 @@ function isInsideWrapper($from: ResolvedPos): boolean {
* container and strand an empty paragraph (a visible gap, and markdown that re-parses to a different
* document). Walking up while `childCount === 1` deletes the whole now-empty wrapper (the emptied list
* item, not just its paragraph) so no orphan `` or empty continuation line is left behind.
+ *
+ * The selection left behind must be a CARET, never a NodeSelection: `Selection.near` can silently
+ * land a NodeSelection on an adjacent leaf (deleting the sole bullet at the top of a doc whose next
+ * block is an image selected that image), turning the user's next keystroke destructive — a second
+ * Backspace while "clearing the bullet" deleted the image, and typing would have replaced it. So:
+ * end of the previous textblock first; else a gap cursor at the deletion point when the neighbour is
+ * a leaf (typing there inserts a new block where the emptied one was, instead of replacing the leaf);
+ * else the next textblock.
*/
function removeEmptyWrappedBlock(editor: Editor, $from: ResolvedPos): boolean {
let depth = $from.depth
@@ -44,13 +52,31 @@ function removeEmptyWrappedBlock(editor: Editor, $from: ResolvedPos): boolean {
return editor.commands.command(({ tr, dispatch }) => {
if (dispatch) {
tr.delete(start, end)
- tr.setSelection(Selection.near(tr.doc.resolve(start), -1))
+ const $gap = tr.doc.resolve(start)
+ tr.setSelection(
+ Selection.findFrom($gap, -1, true) ??
+ (isLeafGap($gap) ? new GapCursor($gap) : null) ??
+ Selection.findFrom($gap, 1, true) ??
+ Selection.near($gap, -1)
+ )
dispatch(tr.scrollIntoView())
}
return true
})
}
+/**
+ * True when `$pos` is a block boundary a gap cursor is valid at in this schema: the following node is
+ * a selectable leaf (divider/image) and there is nothing before it, or another leaf — i.e. no textblock
+ * on either side for a normal caret to land in.
+ */
+function isLeafGap($pos: ResolvedPos): boolean {
+ const after = $pos.nodeAfter
+ if (!after || !SELECTABLE_LEAVES.has(after.type.name)) return false
+ const before = $pos.nodeBefore
+ return !before || SELECTABLE_LEAVES.has(before.type.name)
+}
+
/**
* True while a `/` or `@` suggestion menu is open. Arrow keys must reach that menu's own handler, so
* the leaf-selection shortcuts below yield rather than stealing the key to select an adjacent divider.
@@ -146,6 +172,11 @@ export const RichMarkdownKeymap = Extension.create({
const { selection, doc } = editor.state
if (!selection.empty || selection.$from.parentOffset !== 0) return false
const { $from } = selection
+ // A gap cursor at the start of the doc resolves at the top level (`depth === 0`, offset 0):
+ // `$from.before(0)` below throws, and falling through instead is no better — TipTap's
+ // blockquote Backspace handler crashes on the same resolution (`$from.node(-1)` is
+ // undefined). There is nothing before the gap for Backspace to act on, so consume the key.
+ if ($from.depth === 0) return true
if ($from.parent.type.name === 'heading') {
return editor.commands.setParagraph()
}
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.test.tsx
new file mode 100644
index 00000000000..58c5447df66
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.test.tsx
@@ -0,0 +1,167 @@
+/**
+ * @vitest-environment jsdom
+ *
+ * The post-stream reconcile must poll the content query until a fetch shows the server content
+ * advanced past the pre-stream baseline — its exit is data-driven, and without the poll a single
+ * refetch racing the agent's write (or an invalidation that never reaches this surface) wedged the
+ * editor read-only until a window refocus or full reload. These drive the real
+ * `useEditableFileContent` engine through stream → settle → advance and assert the
+ * `refetchInterval` handed to the content query flips on and off with the `reconciling` phase.
+ */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { queryState } = vi.hoisted(() => ({
+ queryState: {
+ fetched: undefined as string | undefined,
+ lastOptions: undefined as
+ | { refetchInterval?: number | false | (() => number | false) }
+ | undefined,
+ },
+}))
+
+vi.mock('@/hooks/queries/workspace-files', () => ({
+ useWorkspaceFileContent: (
+ _workspaceId: string,
+ _fileId: string,
+ _key: string,
+ _raw?: boolean,
+ options?: { refetchInterval?: number | false | (() => number | false) }
+ ) => {
+ queryState.lastOptions = options
+ return { data: queryState.fetched, isLoading: queryState.fetched === undefined, error: null }
+ },
+ useUpdateWorkspaceFileContent: () => ({ mutateAsync: vi.fn(async () => ({ success: true })) }),
+}))
+
+vi.mock('idb-keyval', () => ({
+ get: vi.fn(async () => undefined),
+ set: vi.fn(async () => {}),
+ del: vi.fn(async () => {}),
+}))
+
+import {
+ RECONCILING_REFETCH_INTERVAL_MS,
+ RECONCILING_REFETCH_SLOW_INTERVAL_MS,
+ RECONCILING_REFETCH_WINDOW_MS,
+ useEditableFileContent,
+} from './use-editable-file-content'
+
+const FILE = {
+ id: 'f1',
+ key: 'workspace/ws-1/123-abc-doc.md',
+ name: 'doc.md',
+ type: 'text/markdown',
+ folderId: null,
+} as any
+
+interface ProbeProps {
+ streamingContent?: string
+ isAgentEditing?: boolean
+}
+
+let container: HTMLDivElement | null = null
+let root: Root | null = null
+let latest: ReturnType | null = null
+
+function Probe(props: ProbeProps) {
+ latest = useEditableFileContent({
+ file: FILE,
+ workspaceId: 'ws-1',
+ canEdit: true,
+ streamingContent: props.streamingContent,
+ isAgentEditing: props.isAgentEditing,
+ })
+ return null
+}
+
+function render(props: ProbeProps) {
+ act(() => {
+ root?.render()
+ })
+}
+
+/** The interval value react-query would currently be using (resolving the function form). */
+function currentInterval(): number | false {
+ const raw = queryState.lastOptions?.refetchInterval
+ return typeof raw === 'function' ? raw() : (raw ?? false)
+}
+
+beforeEach(() => {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ queryState.fetched = undefined
+ queryState.lastOptions = undefined
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+})
+
+afterEach(() => {
+ if (root) act(() => root?.unmount())
+ container?.remove()
+ container = null
+ root = null
+ latest = null
+ vi.useRealTimers()
+})
+
+describe('reconcile refetch polling', () => {
+ it('polls only while reconciling, and stops the moment the fetched content advances', () => {
+ const BASELINE = '# Doc\n\nstart\n'
+ const STREAMED = '# Doc\n\nstart\n\n\n'
+
+ // Mount mid-stream (fetch not resolved yet): no polling.
+ render({ streamingContent: '', isAgentEditing: true })
+ expect(currentInterval()).toBe(false)
+
+ // Baseline fetch resolves during the stream; chunks arrive: still no polling.
+ queryState.fetched = BASELINE
+ render({ streamingContent: '# Doc\n\nstart\n\n
+ render({ streamingContent: STREAMED, isAgentEditing: true })
+ expect(currentInterval()).toBe(false)
+ expect(latest?.isStreamInteractionLocked).toBe(true)
+
+ // Stream settles but the cached fetch still shows the pre-stream baseline: reconciling → poll.
+ render({ streamingContent: undefined, isAgentEditing: false })
+ expect(latest?.isStreamInteractionLocked).toBe(true)
+ expect(currentInterval()).toBe(RECONCILING_REFETCH_INTERVAL_MS)
+
+ // A poll returns the advanced server content: finalize, unlock, polling off.
+ queryState.fetched = STREAMED
+ render({ streamingContent: undefined, isAgentEditing: false })
+ expect(latest?.isStreamInteractionLocked).toBe(false)
+ expect(latest?.content).toBe(STREAMED)
+ expect(currentInterval()).toBe(false)
+ })
+
+ it('degrades to the slow cadence after the fast window — never stops outright while reconciling', () => {
+ vi.useFakeTimers()
+ vi.setSystemTime(1_000_000)
+ const BASELINE = '# Doc\n\nstart\n'
+
+ render({ streamingContent: '', isAgentEditing: true })
+ queryState.fetched = BASELINE
+ render({ streamingContent: `${BASELINE}\nmore`, isAgentEditing: true })
+ render({ streamingContent: undefined, isAgentEditing: false })
+ expect(currentInterval()).toBe(RECONCILING_REFETCH_INTERVAL_MS)
+
+ vi.setSystemTime(1_000_000 + RECONCILING_REFETCH_WINDOW_MS - 1)
+ expect(currentInterval()).toBe(RECONCILING_REFETCH_INTERVAL_MS)
+
+ // A write landing late (slow job, replica catch-up) must still be picked up automatically —
+ // the poll degrades in rate but the editor can never end up with no recovery path at all.
+ vi.setSystemTime(1_000_000 + RECONCILING_REFETCH_WINDOW_MS)
+ expect(currentInterval()).toBe(RECONCILING_REFETCH_SLOW_INTERVAL_MS)
+ })
+
+ it('never polls during plain at-rest editing (no stream involved)', () => {
+ queryState.fetched = '# Plain\n\ndoc\n'
+ render({})
+ expect(latest?.isStreamInteractionLocked).toBe(false)
+ expect(currentInterval()).toBe(false)
+ act(() => latest?.setDraftContent('# Plain\n\ndoc edited\n'))
+ render({})
+ expect(currentInterval()).toBe(false)
+ })
+})
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 f466249e74f..cb6defd8d95 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
@@ -28,6 +28,25 @@ const GENERATED_SOURCE_FILE_TYPES = new Set([
'text/x-python-xlsx',
])
+/**
+ * Poll cadence for the content query while the post-stream reconcile waits for a fetch showing the
+ * server content advanced past the pre-stream baseline. Only active during `reconciling` — a short
+ * window ending the moment a fetch advances — so the cost is a few small GETs after an agent edit.
+ */
+export const RECONCILING_REFETCH_INTERVAL_MS = 1500
+
+/**
+ * How long the reconcile polls at the fast cadence after a stream settles. A write that hasn't
+ * landed within this window has almost certainly failed or is badly delayed, so polling degrades
+ * to {@link RECONCILING_REFETCH_SLOW_INTERVAL_MS} — never stopping outright, so the editor can't
+ * end up locked read-only with no automatic recovery (react-query pauses interval refetches in
+ * background tabs by default, so a wedged doc left open does not poll unattended).
+ */
+export const RECONCILING_REFETCH_WINDOW_MS = 45_000
+
+/** Slow-poll cadence once the fast window has elapsed without the server content advancing. */
+export const RECONCILING_REFETCH_SLOW_INTERVAL_MS = 15_000
+
interface UseEditableFileContentOptions {
file: WorkspaceFileRecord
workspaceId: string
@@ -99,6 +118,7 @@ function useFileContentState(options: SyncTextEditorContentStateOptions) {
savedContent: state.savedContent,
isInitialized: state.phase !== 'uninitialized',
isStreamInteractionLocked: state.phase === 'streaming' || state.phase === 'reconciling',
+ isReconciling: state.phase === 'reconciling',
setDraftContent,
markSavedContent,
}
@@ -127,6 +147,25 @@ export function useEditableFileContent({
onDirtyChangeRef.current = onDirtyChange
onSaveStatusChangeRef.current = onSaveStatusChange
+ /**
+ * Mirrors the reducer's `reconciling` phase (assigned below the reducer hook; read here through a
+ * stable function that react-query re-evaluates after every fetch and options pass, so polling
+ * starts and stops with the phase, no extra re-render required). While reconciling — the stream
+ * ended but no fetch has shown the server content advancing past the pre-stream baseline yet —
+ * the content query polls. The reconcile's exit is data-driven and this is its only retry: a
+ * single refetch that races the agent's write (or an invalidation that never reaches this
+ * surface) would otherwise leave the editor read-only until a window refocus or a full reload.
+ */
+ const isReconcilingRef = useRef(false)
+ const reconcilingSinceRef = useRef(0)
+ const reconcileRefetchInterval = useCallback(() => {
+ if (!isReconcilingRef.current) return false
+ if (Date.now() - reconcilingSinceRef.current >= RECONCILING_REFETCH_WINDOW_MS) {
+ return RECONCILING_REFETCH_SLOW_INTERVAL_MS
+ }
+ return RECONCILING_REFETCH_INTERVAL_MS
+ }, [])
+
const {
data: fetchedContent,
isLoading,
@@ -135,7 +174,8 @@ export function useEditableFileContent({
workspaceId,
file.id,
file.key,
- GENERATED_SOURCE_FILE_TYPES.has(file.type)
+ GENERATED_SOURCE_FILE_TYPES.has(file.type),
+ { refetchInterval: reconcileRefetchInterval }
)
/**
@@ -165,6 +205,7 @@ export function useEditableFileContent({
savedContent,
isInitialized,
isStreamInteractionLocked: isStreamPhaseLocked,
+ isReconciling,
setDraftContent,
markSavedContent,
} = useFileContentState({
@@ -172,6 +213,8 @@ export function useEditableFileContent({
fetchedContent: baselineContent,
streamingContent,
})
+ if (isReconciling && !isReconcilingRef.current) reconcilingSinceRef.current = Date.now()
+ isReconcilingRef.current = isReconciling
const isStreamInteractionLocked = isStreamPhaseLocked || Boolean(isAgentEditing)
diff --git a/apps/sim/blocks/blocks/brex.ts b/apps/sim/blocks/blocks/brex.ts
index 086617b2615..b7d8da6fa61 100644
--- a/apps/sim/blocks/blocks/brex.ts
+++ b/apps/sim/blocks/blocks/brex.ts
@@ -1044,7 +1044,10 @@ export const BrexBlock: BlockConfig = {
routingNumber: { type: 'string', description: 'Bank routing number of the cash account' },
primary: { type: 'boolean', description: 'Whether the cash account is primary' },
accountId: { type: 'string', description: 'Account ID of the budget or spend limit' },
- description: { type: 'string', description: 'Description of the budget or spend limit' },
+ description: {
+ type: 'string',
+ description: 'Description of the budget, spend limit, or transfer',
+ },
parentBudgetId: { type: 'string', description: 'Parent budget ID' },
ownerUserIds: { type: 'json', description: 'Owner user IDs of the budget or spend limit' },
memberUserIds: { type: 'json', description: 'Member user IDs of the spend limit' },
@@ -1052,7 +1055,7 @@ export const BrexBlock: BlockConfig = {
spendType: { type: 'string', description: 'Spend type of the spend limit' },
startDate: { type: 'string', description: 'Start date of the budget or spend limit' },
endDate: { type: 'string', description: 'End date of the budget or spend limit' },
- amount: { type: 'json', description: 'Amount of the budget' },
+ amount: { type: 'json', description: 'Amount of the budget or transfer' },
spendBudgetStatus: { type: 'string', description: 'Status of the budget' },
limitType: { type: 'string', description: 'Limit type of the budget' },
currentPeriodBalance: {
diff --git a/apps/sim/hooks/queries/workspace-files.test.tsx b/apps/sim/hooks/queries/workspace-files.test.tsx
new file mode 100644
index 00000000000..db51e9fc452
--- /dev/null
+++ b/apps/sim/hooks/queries/workspace-files.test.tsx
@@ -0,0 +1,104 @@
+/**
+ * @vitest-environment jsdom
+ *
+ * `useWorkspaceFileContent` against REAL react-query (no module mocks): the `refetchInterval`
+ * option must reach the query — the editor's post-stream reconcile depends on it to poll until the
+ * server content advances (see `use-editable-file-content.ts`), and both its consumers' test
+ * setups replace this module, so without this file the passthrough itself would be exercised by
+ * nothing but the type-checker.
+ */
+import { act, type ReactNode } from 'react'
+import { sleep } from '@sim/utils/helpers'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { useWorkspaceFileContent } from '@/hooks/queries/workspace-files'
+
+let fetchCount = 0
+
+beforeEach(() => {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ fetchCount = 0
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => {
+ fetchCount += 1
+ return new Response('# content', { status: 200 })
+ })
+ )
+})
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
+function renderContentHook(options?: {
+ refetchInterval?: number | false | (() => number | false)
+}): { unmount: () => void } {
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
+ const container = document.createElement('div')
+ const root: Root = createRoot(container)
+
+ function Probe() {
+ useWorkspaceFileContent('ws-1', 'file-1', 'workspace/ws-1/123-abc-doc.md', false, options)
+ return null
+ }
+
+ function Wrapper({ children }: { children: ReactNode }) {
+ return {children}
+ }
+
+ act(() => {
+ root.render(
+
+
+
+ )
+ })
+ return {
+ unmount: () => {
+ act(() => root.unmount())
+ queryClient.clear()
+ },
+ }
+}
+
+describe('useWorkspaceFileContent refetchInterval passthrough', () => {
+ it('fetches once and does not poll by default', async () => {
+ const { unmount } = renderContentHook()
+ await act(async () => {
+ await sleep(150)
+ })
+ expect(fetchCount).toBe(1)
+ unmount()
+ })
+
+ it('polls when a numeric refetchInterval is passed', async () => {
+ const { unmount } = renderContentHook({ refetchInterval: 30 })
+ await act(async () => {
+ await sleep(200)
+ })
+ expect(fetchCount).toBeGreaterThanOrEqual(3)
+ unmount()
+ })
+
+ it('function form is re-evaluated so flipping its condition stops the polling', async () => {
+ let polling = true
+ const { unmount } = renderContentHook({ refetchInterval: () => (polling ? 30 : false) })
+ await act(async () => {
+ await sleep(200)
+ })
+ expect(fetchCount).toBeGreaterThanOrEqual(3)
+
+ polling = false
+ await act(async () => {
+ await sleep(100)
+ })
+ const settled = fetchCount
+ await act(async () => {
+ await sleep(150)
+ })
+ expect(fetchCount).toBe(settled)
+ unmount()
+ })
+})
diff --git a/apps/sim/hooks/queries/workspace-files.ts b/apps/sim/hooks/queries/workspace-files.ts
index edd65bb3984..b58da2b15ab 100644
--- a/apps/sim/hooks/queries/workspace-files.ts
+++ b/apps/sim/hooks/queries/workspace-files.ts
@@ -137,12 +137,20 @@ async function fetchWorkspaceFileContent(url: string, signal?: AbortSignal): Pro
* Hook to fetch workspace file content as text.
* `key` (the storage object key) is forwarded into the query key factory so that a new
* storage key (e.g. after a file is re-uploaded) correctly busts the cache.
+ *
+ * `refetchInterval` lets a caller poll while waiting for the server content to advance — the
+ * editor's post-stream reconcile (see `use-editable-file-content.ts`) exits only when a fetch
+ * returns content that moved past its baseline, and would otherwise wedge read-only forever if
+ * its single refetch raced the agent's write. The function form is re-evaluated by react-query
+ * after every fetch and options pass, so a condition read through a ref stops the polling as soon
+ * as it flips — no re-render required.
*/
export function useWorkspaceFileContent(
workspaceId: string,
fileId: string,
key: string,
- raw?: boolean
+ raw?: boolean,
+ options?: { refetchInterval?: number | false | (() => number | false) }
) {
const source = useFileContentSource()
return useQuery({
@@ -152,6 +160,7 @@ export function useWorkspaceFileContent(
enabled: !!workspaceId && !!fileId && !!key,
staleTime: WORKSPACE_FILE_CONTENT_STALE_TIME,
refetchOnWindowFocus: 'always',
+ refetchInterval: options?.refetchInterval ?? false,
})
}
diff --git a/apps/sim/tools/brex/get_company.ts b/apps/sim/tools/brex/get_company.ts
index 700e339a089..5e222e387c1 100644
--- a/apps/sim/tools/brex/get_company.ts
+++ b/apps/sim/tools/brex/get_company.ts
@@ -31,7 +31,7 @@ export const brexGetCompanyTool: ToolConfig