diff --git a/web/app/components/base/prompt-editor/plugins/component-picker-block/__tests__/index.spec.tsx b/web/app/components/base/prompt-editor/plugins/component-picker-block/__tests__/index.spec.tsx
index 51532c827d..fc94fbdb7b 100644
--- a/web/app/components/base/prompt-editor/plugins/component-picker-block/__tests__/index.spec.tsx
+++ b/web/app/components/base/prompt-editor/plugins/component-picker-block/__tests__/index.spec.tsx
@@ -9,6 +9,7 @@ import type {
VariableBlockType,
WorkflowVariableBlockType,
} from '../../../types'
+import type { TreeNodeData } from '@/app/components/workflow/skill/type'
import type { NodeOutPutVar, Var } from '@/app/components/workflow/types'
import type { EventEmitterValue } from '@/context/event-emitter'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
@@ -29,6 +30,7 @@ import {
} from 'lexical'
import * as React from 'react'
import { GeneratorType } from '@/app/components/app/configuration/config/automatic/types'
+import { FileReferenceNode } from '@/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/node'
import { VarType } from '@/app/components/workflow/types'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
@@ -64,6 +66,73 @@ beforeAll(() => {
Range.prototype.getBoundingClientRect = vi.fn(() => mockDOMRect as DOMRect)
})
+const mocks = vi.hoisted(() => ({
+ uploadedResourceIds: ['11111111-1111-1111-1111-111111111111'],
+}))
+
+vi.mock('@/app/components/workflow/skill/editor/skill-editor/plugins/file-picker-panel', () => ({
+ FilePickerPanel: ({
+ onSelectNode,
+ showAddFiles,
+ onAddFiles,
+ }: {
+ onSelectNode: (node: TreeNodeData) => void
+ showAddFiles?: boolean
+ onAddFiles?: () => void
+ }) => (
+
+
+ {showAddFiles && (
+
+ )}
+
+ ),
+}))
+
+vi.mock('@/app/components/workflow/skill/editor/skill-editor/plugins/file-picker-upload-modal', () => ({
+ default: ({
+ isOpen,
+ onUploadedFiles,
+ }: {
+ isOpen: boolean
+ onClose: () => void
+ onUploadedFiles?: (resourceIds: string[]) => void
+ }) => {
+ if (!isOpen)
+ return null
+
+ return (
+
+ )
+ },
+}))
+
+vi.mock('@/app/components/workflow/skill/editor/skill-editor/plugins/file-reference-block/component', () => ({
+ default: ({ resourceId }: { resourceId: string }) => (
+ {`mock-file-reference:${resourceId}`}
+ ),
+}))
+
// ─── Typed factories (no `any` / `never`) ────────────────────────────────────
function makeContextBlock(overrides: Partial = {}): ContextBlockType {
@@ -150,6 +219,7 @@ const MinimalEditor: React.FC<{
currentBlock?: CurrentBlockType
errorMessageBlock?: ErrorMessageBlockType
lastRunBlock?: LastRunBlockType
+ isSupportSandbox?: boolean
captures: Captures
}> = ({
triggerString,
@@ -160,10 +230,12 @@ const MinimalEditor: React.FC<{
currentBlock,
errorMessageBlock,
lastRunBlock,
+ isSupportSandbox,
captures,
}) => {
const initialConfig = React.useMemo(() => ({
namespace: `component-picker-test-${Math.random().toString(16).slice(2)}`,
+ nodes: [FileReferenceNode],
onError: (e: Error) => {
throw e
},
@@ -189,6 +261,7 @@ const MinimalEditor: React.FC<{
currentBlock={currentBlock}
errorMessageBlock={errorMessageBlock}
lastRunBlock={lastRunBlock}
+ isSupportSandbox={isSupportSandbox}
/>
@@ -707,179 +780,32 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => {
})
})
- describe('blur/focus menu visibility', () => {
- it('hides the menu after a 200ms delay when blur command is dispatched', async () => {
- const captures: Captures = { editor: null, eventEmitter: null }
+ it('inserts uploaded files into the editor after add files succeeds from the sandbox slash menu', async () => {
+ const user = userEvent.setup()
+ const captures: Captures = { editor: null, eventEmitter: null }
- render((
-
- ))
+ render((
+
+ ))
- const editor = await waitForEditor(captures)
- await setEditorText(editor, '{', true)
- expect(await screen.findByText('common.promptEditor.context.item.title')).toBeInTheDocument()
+ const editor = await waitForEditor(captures)
+ await setEditorText(editor, '/', true)
+ await flushNextTick()
- vi.useFakeTimers()
+ await user.click(await screen.findByText('workflow.nodes.llm.files'))
+ await user.click(await screen.findByRole('button', { name: 'mock-add-files' }))
+ await user.click(await screen.findByRole('button', { name: 'mock-upload-success' }))
- act(() => {
- editor.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur', { relatedTarget: document.createElement('button') }))
- })
-
- expect(screen.queryByText('common.promptEditor.context.item.title')).toBeInTheDocument()
-
- act(() => {
- vi.advanceTimersByTime(200)
- })
-
- expect(screen.queryByText('common.promptEditor.context.item.title')).not.toBeInTheDocument()
-
- vi.useRealTimers()
- })
-
- it('restores menu visibility when focus command is dispatched after blur hides it', async () => {
- const captures: Captures = { editor: null, eventEmitter: null }
-
- render((
-
- ))
-
- const editor = await waitForEditor(captures)
- await setEditorText(editor, '{', true)
- expect(await screen.findByText('common.promptEditor.context.item.title')).toBeInTheDocument()
-
- vi.useFakeTimers()
-
- act(() => {
- editor.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur', { relatedTarget: document.createElement('button') }))
- })
- act(() => {
- vi.advanceTimersByTime(200)
- })
-
- expect(screen.queryByText('common.promptEditor.context.item.title')).not.toBeInTheDocument()
-
- act(() => {
- editor.dispatchCommand(FOCUS_COMMAND, new FocusEvent('focus'))
- })
-
- vi.useRealTimers()
-
- await setEditorText(editor, '{', true)
- await waitFor(() => {
- expect(screen.queryByText('common.promptEditor.context.item.title')).toBeInTheDocument()
- })
- })
-
- it('cancels the blur timer when focus arrives before the 200ms timeout', async () => {
- const captures: Captures = { editor: null, eventEmitter: null }
-
- render((
-
- ))
-
- const editor = await waitForEditor(captures)
- await setEditorText(editor, '{', true)
- expect(await screen.findByText('common.promptEditor.context.item.title')).toBeInTheDocument()
-
- vi.useFakeTimers()
-
- act(() => {
- editor.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur', { relatedTarget: document.createElement('button') }))
- })
-
- act(() => {
- editor.dispatchCommand(FOCUS_COMMAND, new FocusEvent('focus'))
- })
-
- act(() => {
- vi.advanceTimersByTime(200)
- })
-
- expect(screen.queryByText('common.promptEditor.context.item.title')).toBeInTheDocument()
-
- vi.useRealTimers()
- })
-
- it('cancels a pending blur timer when a subsequent blur targets var-search-input', async () => {
- const captures: Captures = { editor: null, eventEmitter: null }
-
- render((
-
- ))
-
- const editor = await waitForEditor(captures)
- await setEditorText(editor, '{', true)
- expect(await screen.findByText('common.promptEditor.context.item.title')).toBeInTheDocument()
-
- vi.useFakeTimers()
-
- act(() => {
- editor.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur', { relatedTarget: document.createElement('button') }))
- })
-
- const varInput = document.createElement('input')
- varInput.classList.add('var-search-input')
-
- act(() => {
- editor.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur', { relatedTarget: varInput }))
- })
-
- act(() => {
- vi.advanceTimersByTime(200)
- })
-
- expect(screen.queryByText('common.promptEditor.context.item.title')).toBeInTheDocument()
-
- vi.useRealTimers()
- })
-
- it('does not hide the menu when blur target is var-search-input', async () => {
- const captures: Captures = { editor: null, eventEmitter: null }
-
- render((
-
- ))
-
- const editor = await waitForEditor(captures)
- await setEditorText(editor, '{', true)
- expect(await screen.findByText('common.promptEditor.context.item.title')).toBeInTheDocument()
-
- vi.useFakeTimers()
-
- const target = document.createElement('input')
- target.classList.add('var-search-input')
-
- act(() => {
- editor.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur', { relatedTarget: target }))
- })
-
- act(() => {
- vi.advanceTimersByTime(200)
- })
-
- expect(screen.queryByText('common.promptEditor.context.item.title')).toBeInTheDocument()
-
- vi.useRealTimers()
+ await waitFor(() => {
+ expect(readEditorText(editor)).toContain('§[file].[app].[11111111-1111-1111-1111-111111111111]§')
+ expect(readEditorText(editor)).not.toContain('/')
})
})
})
diff --git a/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx b/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx
index b4ff8a05e5..76c3227d56 100644
--- a/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx
+++ b/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx
@@ -26,6 +26,7 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext
import { LexicalTypeaheadMenuPlugin } from '@lexical/react/LexicalTypeaheadMenuPlugin'
import { mergeRegister } from '@lexical/utils'
import {
+ $createTextNode,
$getRoot,
$getSelection,
$insertNodes,
@@ -228,16 +229,41 @@ const ComponentPicker = ({
[editor],
)
- const handleSelectFileReference = useCallback((resourceId: string) => {
- editor.update(() => {
- const match = checkForTriggerMatch(triggerString, editor)
- const nodeToRemove = match ? $splitNodeContainingQuery(match) : null
- if (nodeToRemove)
- nodeToRemove.remove()
+ const removeTriggerText = useCallback(() => {
+ const match = getMatchFromSelection()
+ if (!match)
+ return
- $insertNodes([$createFileReferenceNode({ resourceId })])
+ const nodeToRemove = $splitNodeContainingQuery(match)
+ if (nodeToRemove)
+ nodeToRemove.remove()
+ }, [getMatchFromSelection])
+
+ const insertFileReferences = useCallback((resourceIds: string[]) => {
+ if (!resourceIds.length)
+ return
+
+ editor.focus(() => {
+ editor.update(() => {
+ removeTriggerText()
+ if (!$isRangeSelection($getSelection()))
+ $getRoot().selectEnd()
+
+ const fileNodes = resourceIds.flatMap((resourceId, index) => {
+ const node = $createFileReferenceNode({ resourceId })
+ if (index === resourceIds.length - 1)
+ return [node]
+
+ return [node, $createTextNode(' ')]
+ })
+ $insertNodes(fileNodes)
+ })
})
- }, [checkForTriggerMatch, editor, triggerString])
+ }, [editor, removeTriggerText])
+
+ const handleSelectFileReference = useCallback((resourceId: string) => {
+ insertFileReferences([resourceId])
+ }, [insertFileReferences])
const handleSelectWorkflowVariable = useCallback((variables: string[]) => {
editor.update(() => {
@@ -448,6 +474,9 @@ const ComponentPicker = ({
showHeader={false}
showAddFiles
onAddFiles={() => {
+ editor.update(() => {
+ removeTriggerText()
+ })
setFileUploadModalKey(key => key + 1)
setIsFileUploadModalOpen(true)
handleClose()
@@ -566,7 +595,7 @@ const ComponentPicker = ({
}
>
)
- }, [blurHidden, isAgentTrigger, isSupportSandbox, triggerString, allFlattenOptions.length, workflowVariableBlock?.show, workflowVariableBlock?.showManageInputField, workflowVariableBlock?.onManageInputField, workflowVariableBlock?.agentNodes, floatingStyles, isPositioned, refs, agentNodes, handleSelectAgent, handleClose, useExternalSearch, queryString, workflowVariableOptions, isSupportFileVar, showAssembleVariables, handleSelectAssembleVariables, currentBlock?.generatorType, t, activeTab, handleSelectWorkflowVariable, handleSelectFileReference, contextBlock?.show, contextBlock?.selectable, handleSelectContext, agentBlock?.show])
+ }, [isAgentTrigger, blurHidden, isSupportSandbox, triggerString, allFlattenOptions.length, workflowVariableBlock?.show, workflowVariableBlock?.showManageInputField, workflowVariableBlock?.onManageInputField, workflowVariableBlock?.agentNodes, floatingStyles, isPositioned, refs, agentNodes, handleSelectAgent, handleClose, useExternalSearch, queryString, workflowVariableOptions, isSupportFileVar, showAssembleVariables, handleSelectAssembleVariables, currentBlock?.generatorType, t, activeTab, handleSelectWorkflowVariable, handleSelectFileReference, contextBlock?.show, contextBlock?.selectable, handleSelectContext, agentBlock?.show, editor, removeTriggerText])
return (
<>
@@ -589,6 +618,7 @@ const ComponentPicker = ({
key={fileUploadModalKey}
isOpen={isFileUploadModalOpen}
onClose={() => setIsFileUploadModalOpen(false)}
+ onUploadedFiles={insertFileReferences}
/>
)}
>
diff --git a/web/app/components/workflow/skill/editor/skill-editor/plugins/file-picker-upload-modal.tsx b/web/app/components/workflow/skill/editor/skill-editor/plugins/file-picker-upload-modal.tsx
index d73ca0eab9..a1b255cb2d 100644
--- a/web/app/components/workflow/skill/editor/skill-editor/plugins/file-picker-upload-modal.tsx
+++ b/web/app/components/workflow/skill/editor/skill-editor/plugins/file-picker-upload-modal.tsx
@@ -30,6 +30,7 @@ type FilePickerUploadModalProps = {
isOpen: boolean
onClose: () => void
defaultFolderId?: string
+ onUploadedFiles?: (resourceIds: string[]) => void
}
type AddFileMode = 'create' | 'upload'
@@ -133,6 +134,7 @@ const FilePickerUploadModal = ({
isOpen,
onClose,
defaultFolderId,
+ onUploadedFiles,
}: FilePickerUploadModalProps) => {
const { t } = useTranslation('workflow')
const appDetail = useAppStore(s => s.appDetail)
@@ -191,6 +193,7 @@ const FilePickerUploadModal = ({
appId,
storeApi,
onClose: noop,
+ onFilesUploaded: uploadedNodes => onUploadedFiles?.(uploadedNodes.map(node => node.id)),
})
const isCreatingFile = uploadFile.isPending
const isBusy = isCreating || isCreatingFile
@@ -252,18 +255,19 @@ const FilePickerUploadModal = ({
try {
const emptyBlob = new Blob([''], { type: 'text/plain' })
const file = new File([emptyBlob], trimmedFileName)
- await uploadFile.mutateAsync({
+ const createdFile = await uploadFile.mutateAsync({
appId,
file,
parentId: toApiParentId(effectiveUploadFolderId),
})
emitTreeUpdate()
+ onUploadedFiles?.([createdFile.id])
onClose()
}
catch {
toast.error(t('skillSidebar.menu.createError'))
}
- }, [appId, canCreate, effectiveUploadFolderId, emitTreeUpdate, onClose, t, trimmedFileName, uploadFile])
+ }, [appId, canCreate, effectiveUploadFolderId, emitTreeUpdate, onClose, onUploadedFiles, t, trimmedFileName, uploadFile])
const modeLabel = t('skillEditor.uploadIn')
return (
diff --git a/web/app/components/workflow/skill/hooks/file-tree/operations/use-create-operations.spec.tsx b/web/app/components/workflow/skill/hooks/file-tree/operations/use-create-operations.spec.tsx
index b377360f36..33a1b9153f 100644
--- a/web/app/components/workflow/skill/hooks/file-tree/operations/use-create-operations.spec.tsx
+++ b/web/app/components/workflow/skill/hooks/file-tree/operations/use-create-operations.spec.tsx
@@ -1,6 +1,6 @@
import type { StoreApi } from 'zustand'
import type { SkillEditorSliceShape, UploadStatus } from '@/app/components/workflow/store/workflow/skill-editor/types'
-import type { BatchUploadNodeInput } from '@/types/app-asset'
+import type { AppAssetNode, BatchUploadNodeInput } from '@/types/app-asset'
import { act, renderHook } from '@testing-library/react'
import { useCreateOperations } from './use-create-operations'
@@ -28,7 +28,7 @@ const mocks = vi.hoisted(() => ({
createFolderPending: false,
uploadPending: false,
batchPending: false,
- uploadMutateAsync: vi.fn<(payload: UploadMutationPayload) => Promise>(),
+ uploadMutateAsync: vi.fn<(payload: UploadMutationPayload) => Promise>(),
batchMutateAsync: vi.fn<(payload: BatchUploadMutationPayload) => Promise>(),
prepareSkillUploadFile: vi.fn<(file: File) => Promise>(),
emitTreeUpdate: vi.fn<() => void>(),
@@ -88,6 +88,16 @@ const createInputChangeEvent = (files: File[] | null) => {
} as unknown as React.ChangeEvent
}
+const createUploadedNode = (id: string, name: string): AppAssetNode => ({
+ id,
+ node_type: 'file',
+ name,
+ parent_id: null,
+ order: 0,
+ extension: name.split('.').pop() || '',
+ size: 1,
+})
+
const withRelativePath = (file: File, relativePath: string): File => {
Object.defineProperty(file, 'webkitRelativePath', {
value: relativePath,
@@ -245,6 +255,34 @@ describe('useCreateOperations', () => {
expect(onClose).toHaveBeenCalledTimes(1)
})
+ it('should report successfully uploaded nodes to onFilesUploaded', async () => {
+ const { storeApi } = createStoreApi()
+ const onClose = vi.fn()
+ const onFilesUploaded = vi.fn()
+ const first = new File(['first'], 'first.md', { type: 'text/markdown' })
+ const second = new File(['second'], 'second.txt', { type: 'text/plain' })
+ const event = createInputChangeEvent([first, second])
+ const uploadedFirst = createUploadedNode('11111111-1111-1111-1111-111111111111', 'first.md')
+ const uploadedSecond = createUploadedNode('22222222-2222-2222-2222-222222222222', 'second.txt')
+ mocks.uploadMutateAsync
+ .mockResolvedValueOnce(uploadedFirst)
+ .mockResolvedValueOnce(uploadedSecond)
+
+ const { result } = renderHook(() => useCreateOperations({
+ parentId: 'folder-success',
+ appId: 'app-success',
+ storeApi,
+ onClose,
+ onFilesUploaded,
+ }))
+
+ await act(async () => {
+ await result.current.handleFileChange(event)
+ })
+
+ expect(onFilesUploaded).toHaveBeenCalledWith([uploadedFirst, uploadedSecond])
+ })
+
it('should set partial_error when some file uploads fail but still emit updates for uploaded files', async () => {
const { storeApi, setUploadStatus, setUploadProgress } = createStoreApi()
const onClose = vi.fn()
diff --git a/web/app/components/workflow/skill/hooks/file-tree/operations/use-create-operations.ts b/web/app/components/workflow/skill/hooks/file-tree/operations/use-create-operations.ts
index de44bfd6a0..b78bcd4fbe 100644
--- a/web/app/components/workflow/skill/hooks/file-tree/operations/use-create-operations.ts
+++ b/web/app/components/workflow/skill/hooks/file-tree/operations/use-create-operations.ts
@@ -2,7 +2,7 @@
import type { StoreApi } from 'zustand'
import type { SkillEditorSliceShape } from '@/app/components/workflow/store/workflow/skill-editor/types'
-import type { BatchUploadNodeInput } from '@/types/app-asset'
+import type { AppAssetNode, BatchUploadNodeInput } from '@/types/app-asset'
import { useCallback, useRef } from 'react'
import {
useBatchUpload,
@@ -17,6 +17,7 @@ type UseCreateOperationsOptions = {
appId: string
storeApi: StoreApi
onClose: () => void
+ onFilesUploaded?: (nodes: AppAssetNode[]) => void
}
const getRelativePath = (file: File) => {
@@ -28,6 +29,7 @@ export function useCreateOperations({
appId,
storeApi,
onClose,
+ onFilesUploaded,
}: UseCreateOperationsOptions) {
const fileInputRef = useRef(null)
const folderInputRef = useRef(null)
@@ -62,21 +64,27 @@ export function useCreateOperations({
try {
const uploadFiles = await Promise.all(files.map(file => prepareSkillUploadFile(file)))
- await Promise.all(
+ const uploadedNodes = (await Promise.all(
uploadFiles.map(async (file) => {
try {
- await uploadFileAsync({ appId, file, parentId })
+ const node = await uploadFileAsync({ appId, file, parentId })
progress.uploaded++
+ return node
}
catch {
progress.failed++
+ return null
+ }
+ finally {
+ storeApi.getState().setUploadProgress({ uploaded: progress.uploaded, total, failed: progress.failed })
}
- storeApi.getState().setUploadProgress({ uploaded: progress.uploaded, total, failed: progress.failed })
}),
- )
+ )).filter((node): node is AppAssetNode => !!node)
storeApi.getState().setUploadStatus(progress.failed > 0 ? 'partial_error' : 'success')
storeApi.getState().setUploadProgress({ uploaded: progress.uploaded, total, failed: progress.failed })
+ if (uploadedNodes.length > 0)
+ onFilesUploaded?.(uploadedNodes)
}
catch {
storeApi.getState().setUploadStatus('partial_error')
@@ -87,7 +95,7 @@ export function useCreateOperations({
e.target.value = ''
onClose()
}
- }, [appId, uploadFileAsync, onClose, parentId, storeApi, emitTreeUpdate])
+ }, [appId, uploadFileAsync, onClose, onFilesUploaded, parentId, storeApi, emitTreeUpdate])
const handleFolderChange = useCallback(async (e: React.ChangeEvent) => {
const files = Array.from(e.target.files || [])