mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(files): unwedge post-stream read-only editor; stop bullet Backspace from destroying the image below (#5608)
* fix(rich-markdown-editor): stop Backspace on an empty bullet from destroying the image below it Reported: clearing an empty bullet with an image after it "nuked the bullet point and the image". Reproduced: when the emptied bullet is the doc's first block, removeEmptyWrappedBlock's Selection.near(resolve(start), -1) finds no text position behind it and silently lands a NodeSelection on the FOLLOWING image — so the user's next keystroke is destructive (a second Backspace while clearing deletes the image; typing replaces it). Only-first-block explains why it wouldn't re-repro. The selection left behind is now always a caret: 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 instead of replacing the image), else the next textblock. The regression test surfaced two adjacent gap-cursor crashes on Backspace, both reachable on current staging whenever a gap cursor exists (e.g. between two dividers/images, the data-gap-between-leaves state): - our own handler threw RangeError from $from.before(0) on a depth-0 (doc-start) gap cursor - with that guarded by falling through, TipTap's blockquote Backspace handler crashes on the same resolution ($from.node(-1) is undefined) — so a doc-start gap cursor consumes the key instead (there is nothing before it for Backspace to act on) * fix(files): poll the content query while the post-stream reconcile waits, so the editor can't wedge read-only Reported: images "don't get selected sometimes" (can't grab/drag/resize, doc uneditable) until a full refresh. Reproduced in a real-Chromium harness driving the actual RichMarkdownEditor + engine: after an agent stream settles, the reconcile phase exits only when a fetch shows the server content advanced past the pre-stream baseline — but that exit had no retry. A single refetch racing the agent's write (or the mothership invalidation never reaching this surface — it's the only place that invalidates this query) left the editor locked read-only indefinitely: contenteditable=false, images not grabbable, until refetchOnWindowFocus or a reload happened to run. Fix: while (and only while) the reducer is in `reconciling`, the content query polls via react-query's refetchInterval — the same pattern this module already uses for the generated-doc 409 polling, and like it, bounded (45s window; past that the write has almost certainly failed and refetchOnWindowFocus remains the recovery). The interval is the function form reading the phase through a ref, re-evaluated by react-query after every fetch, so polling stops the moment a fetch advances without needing an extra render. Harness (real Chromium, real editor + engine, in-memory server): pre-fix the editor stays editable=false with fetches frozen at 1 indefinitely while the server holds the new content; post-fix it unlocks within one poll (~1.5s), polling stops immediately after finalize, and the streamed image click-selects. Unit tests drive stream -> settle -> advance through the real engine and assert the interval flips on/off with the phase (2 of 3 fail pre-fix), plus the bounded window and a no-polling guard for plain at-rest editing. * test(files): cover the refetchInterval passthrough against real react-query Both consumers' test setups (the reconcile unit tests and the browser harness) replace @/hooks/queries/workspace-files, so the real hook's two changed lines were exercised by nothing but the type-checker. These render the real useWorkspaceFileContent under a real QueryClientProvider with a stubbed fetch: no polling by default, polling with a numeric interval, and the function form re-evaluated so flipping its condition stops the polling — the exact mechanism the reconcile fix depends on. The two polling tests fail against the pre-fix hook. * fix(files): degrade reconcile polling to a slow cadence instead of stopping; prove findFrom textOnly never leaf-selects Greptile round 1: - Real gap in my bounded window: past 45s the poll stopped outright, leaving the reducer wedged in reconciling with only focus-refetch/reload as recovery — the exact failure shape this PR exists to remove, just later. Polling now degrades to a 15s cadence instead of stopping, so a write landing late (slow job, replica catch-up) is still picked up automatically; react-query pauses interval refetches in background tabs by default, so an abandoned doc doesn't poll unattended. - Refuted with source + an executable test: Selection.findFrom($gap, -1, true) cannot return a NodeSelection — prosemirror-state's findSelectionIn skips atoms entirely under textOnly (`!text && isSelectable`). New regression test pins the exact scenario (image directly BEFORE the emptied bullet): backward search returns null, the gap-cursor/forward-caret branches take over, and the image is never silently selected.
This commit is contained in:
+98
@@ -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('<hr><hr>')
|
||||
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', () => {
|
||||
|
||||
+32
-1
@@ -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 `<li>` 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()
|
||||
}
|
||||
|
||||
+167
@@ -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<typeof useEditableFileContent> | 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(<Probe {...props} />)
|
||||
})
|
||||
}
|
||||
|
||||
/** 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)
|
||||
})
|
||||
})
|
||||
+44
-1
@@ -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)
|
||||
|
||||
|
||||
@@ -1044,7 +1044,10 @@ export const BrexBlock: BlockConfig<BrexResponse> = {
|
||||
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<BrexResponse> = {
|
||||
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: {
|
||||
|
||||
@@ -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 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<Wrapper>
|
||||
<Probe />
|
||||
</Wrapper>
|
||||
)
|
||||
})
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ export const brexGetCompanyTool: ToolConfig<BrexApiKeyParams, BrexGetCompanyResp
|
||||
id: data.id ?? '',
|
||||
legalName: data.legal_name ?? '',
|
||||
mailingAddress: data.mailing_address ?? null,
|
||||
accountType: data.accountType ?? null,
|
||||
accountType: data.account_type ?? null,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user