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![img](/api/files/view/wf_x)\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![img](', isAgentEditing: true }) + 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