diff --git a/apps/docs/content/docs/integrations/file.mdx b/apps/docs/content/docs/integrations/file.mdx index a916d2faf4f..f62a9765458 100644 --- a/apps/docs/content/docs/integrations/file.mdx +++ b/apps/docs/content/docs/integrations/file.mdx @@ -22,7 +22,7 @@ With the File block, you can: - **Compress and decompress**: Bundle files into a .zip archive or extract an archive into the workspace - **Manage sharing**: Enable or disable a public share link for a file, with public, password, email, or SSO access modes -In Sim, the File block allows your agents to search, read, and extract text from workspace files, fetch and parse files from URLs, write or append content to files, bundle files into or out of .zip archives, and control public sharing access for a file—all programmatically as steps in a workflow. Folder selection is optional and supports multiple folders; when no folder is selected, search spans the workspace and file pickers are unscoped. Selected folders are expanded when the workflow runs, so newly added files are included automatically. This makes it possible to explore workspace content, move file content into and out of a workflow, package outputs for download or transfer, and expose files to external users through a managed share link. +In Sim, the File block allows your agents to search, read, and extract text from workspace files, fetch and parse files from URLs, write or append content to files, bundle files into or out of .zip archives, and control public sharing access for a file—all programmatically as steps in a workflow. Folder selection is optional and supports multiple folders. Pick them from the workspace, or switch the field to advanced mode and type comma-separated paths, including a value from an earlier block. When no folder is selected, search spans the workspace and file pickers are unscoped. Selected folders are expanded when the workflow runs, so newly added files are included automatically. This makes it possible to explore workspace content, move file content into and out of a workflow, package outputs for download or transfer, and expose files to external users through a managed share link. {/* MANUAL-CONTENT-END */} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx index 9f68beace28..4bfa6ac5db3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx @@ -22,6 +22,7 @@ import { isFileInFolderScope } from '@/lib/workspace-files/folder-path-selection import { findSelectedWorkspaceFile } from '@/lib/workspace-files/selection' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight' +import { useActiveCanonicalSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-canonical-sub-block-value' import { useResourceFolders } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-resource-folders' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider' @@ -56,7 +57,7 @@ interface FileUploadProps { * A sibling folder field that narrows what this picker offers, and the switch * saying whether that scope descends. See `SubBlockConfig.folderScope`. */ - folderScope?: { fieldId: string; manualFieldId?: string; recursiveFieldId?: string } + folderScope?: { fieldId: string; recursiveFieldId?: string } /** * Controlled value. When `onValueChange` is provided the component reads from * this prop and writes through `onValueChange` instead of the subblock store, @@ -353,20 +354,19 @@ export function FileUpload({ * a picker with no folder scope; its own value is never a folder path, so the * scope reads as absent. */ - const [folderScopeValue] = useSubBlockValue(blockId, folderScope?.fieldId ?? subBlockId) + const folderScopeValue = useActiveCanonicalSubBlockValue( + blockId, + folderScope?.fieldId ?? subBlockId + ) /* - * Through `readFolderPaths` rather than a string check so current arrays and - * legacy serialized arrays resolve to the same canonical scopes. + * Through `readFolderPaths` rather than a string check so a picked array, a + * legacy serialized array, and a typed comma-separated list all resolve to + * the same canonical scopes. */ - const [manualFolderScopeValue] = useSubBlockValue( - blockId, - folderScope?.manualFieldId ?? folderScope?.fieldId ?? subBlockId + const folderScopePaths = useMemo( + () => (folderScope ? readFolderPaths(folderScopeValue) : []), + [folderScope, folderScopeValue] ) - const folderScopePaths = useMemo(() => { - if (!folderScope) return [] - const selectedPaths = readFolderPaths(folderScopeValue) - return selectedPaths.length > 0 ? selectedPaths : readFolderPaths(manualFolderScopeValue) - }, [folderScope, folderScopeValue, manualFolderScopeValue]) const [folderScopeRecursive] = useSubBlockValue( blockId, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-canonical-sub-block-value.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-canonical-sub-block-value.ts index 9bec924dcd2..82e89566da9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-canonical-sub-block-value.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-canonical-sub-block-value.ts @@ -3,6 +3,9 @@ import { isEqual } from 'es-toolkit' import { useStoreWithEqualityFn } from 'zustand/traditional' import { buildCanonicalIndexForSurface, + type CanonicalIndex, + type CanonicalModeOverrides, + resolveActiveDependencyValue, resolveDependencyValue, } from '@/lib/workflows/subblocks/visibility' import { getBlock } from '@/blocks/registry' @@ -10,17 +13,18 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' -/** - * Read a sub-block value by either its raw subBlockId or its canonicalParamId. - * - * `useSubBlockValue` only looks up the raw subBlockId. For fields that use - * `canonicalParamId` to unify basic/advanced inputs (e.g. `tableSelector` vs - * `manualTableId` both mapping to `tableId`), this hook resolves to whichever - * member of the canonical group currently holds the value. - */ -export function useCanonicalSubBlockValue( +type CanonicalResolver = ( + key: string, + values: Record, + canonicalIndex: CanonicalIndex, + overrides?: CanonicalModeOverrides +) => unknown + +/** Subscribes to one key of a block's values, read through the given canonical resolver. */ +function useResolvedSubBlockValue( blockId: string, - canonicalOrSubBlockId: string + canonicalOrSubBlockId: string, + resolve: CanonicalResolver ): T | null { const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) const blockState = useWorkflowStore((state) => state.blocks[blockId]) @@ -38,7 +42,7 @@ export function useCanonicalSubBlockValue( (state) => { if (!activeWorkflowId) return null const blockValues = state.workflowValues[activeWorkflowId]?.[blockId] || {} - const resolved = resolveDependencyValue( + const resolved = resolve( canonicalOrSubBlockId, blockValues, canonicalIndex, @@ -46,8 +50,47 @@ export function useCanonicalSubBlockValue( ) return (resolved ?? null) as T | null }, - [activeWorkflowId, blockId, canonicalOrSubBlockId, canonicalIndex, canonicalModeOverrides] + [ + activeWorkflowId, + blockId, + canonicalOrSubBlockId, + canonicalIndex, + canonicalModeOverrides, + resolve, + ] ), (a, b) => isEqual(a, b) ) } + +/** + * Read a sub-block value by either its raw subBlockId or its canonicalParamId. + * + * `useSubBlockValue` only looks up the raw subBlockId. For fields that use + * `canonicalParamId` to unify basic/advanced inputs (e.g. `tableSelector` vs + * `manualTableId` both mapping to `tableId`), this hook resolves to whichever + * member of the canonical group currently holds the value. + */ +export function useCanonicalSubBlockValue( + blockId: string, + canonicalOrSubBlockId: string +): T | null { + return useResolvedSubBlockValue(blockId, canonicalOrSubBlockId, resolveDependencyValue) +} + +/** + * Like {@link useCanonicalSubBlockValue}, but strict: a pair answers with its + * ACTIVE member only, honoring the user's basic/advanced toggle, so a dormant + * half's stale value never leaks. + * + * This is the reading for a control that narrows itself by a sibling field, + * such as the file picker's folder scope. The serializer publishes only the + * active half, so a picker that fell back to the other one would offer a set + * the operation then ignores. + */ +export function useActiveCanonicalSubBlockValue( + blockId: string, + canonicalOrSubBlockId: string +): T | null { + return useResolvedSubBlockValue(blockId, canonicalOrSubBlockId, resolveActiveDependencyValue) +} diff --git a/apps/sim/blocks/blocks.test.ts b/apps/sim/blocks/blocks.test.ts index d6b9b89b99d..1f8aeb28cbd 100644 --- a/apps/sim/blocks/blocks.test.ts +++ b/apps/sim/blocks/blocks.test.ts @@ -196,7 +196,7 @@ describe.concurrent('Blocks Module', () => { recursiveFieldId: 'folderIncludeSubfolders', }) expect(block?.subBlocks.find((subBlock) => subBlock.id === 'folderSelection')?.mode).toBe( - 'both' + 'basic' ) expect(block?.tools.config?.tool({ operation: 'file_read' })).toBe('file_read') expect(block?.tools.config?.tool({ operation: 'file_get_content' })).toBe('file_get_content') diff --git a/apps/sim/blocks/blocks/file-folders.test.ts b/apps/sim/blocks/blocks/file-folders.test.ts index ff698c6b209..09fd1ddf946 100644 --- a/apps/sim/blocks/blocks/file-folders.test.ts +++ b/apps/sim/blocks/blocks/file-folders.test.ts @@ -19,8 +19,9 @@ import { FileV4Block, FileV5Block } from '@/blocks/blocks/file' * rejects as a malformed path rather than reading as "not supplied". * * Every folder field is a canonical pair, so these pass the CANONICAL id - * (`folderRef`, not `folderPath`): the serializer deletes the subblock ids and - * republishes whichever member is active under the canonical one. + * (`folderRef`, not `folderPath`; `folderScopeRef`, not `folderSelection`): the + * serializer deletes the subblock ids and republishes whichever member is + * active under the canonical one. */ function paramsFor(operation: string, extra: Record = {}) { const transform = FileV5Block.tools.config?.params @@ -199,7 +200,7 @@ describe('file_v5 folder operations produce contract-valid tool input', () => { const params = paramsFor('file_append', { appendFileInput: { id: 'wf_abc', name: 'notes.md' }, appendContent: 'more', - folderSelection: '/Reports', + folderScopeRef: '/Reports', }) expect(params.folderPath).toBeUndefined() @@ -215,7 +216,7 @@ describe('file_v5 folder operations produce contract-valid tool input', () => { const params = paramsFor('file_append', { appendFileInput: 'notes.md', appendContent: 'more', - folderSelection: '/Reports', + folderScopeRef: '/Reports', }) expect(params.folderPath).toBe('/Reports') @@ -225,7 +226,7 @@ describe('file_v5 folder operations produce contract-valid tool input', () => { const params = paramsFor('file_append', { appendFileInput: 'notes.md', appendContent: 'more', - folderSelection: ['/Reports', '/Archive'], + folderScopeRef: ['/Reports', '/Archive'], }) expect(params.folderPath).toBeUndefined() @@ -237,7 +238,7 @@ describe('file_v5 folder operations produce contract-valid tool input', () => { paramsFor('file_append', { appendFileInput: 'notes.md', appendContent: 'more', - folderSelection: '/Reports', + folderScopeRef: '/Reports', folderIncludeSubfolders: 'false', }).includeSubfolders ).toBe(false) @@ -248,7 +249,7 @@ describe('file_v5 folder operations produce contract-valid tool input', () => { paramsFor('file_append', { appendFileInput: { id: 'wf_abc', name: 'notes.md' }, appendContent: 'more', - folderSelection: '/Reports', + folderScopeRef: '/Reports', }).folderPath ).toBeUndefined() }) @@ -307,7 +308,7 @@ describe('file_v5 folder operations produce contract-valid tool input', () => { ])('%s sends the picked files alone', (operation, inputId) => { const params = paramsFor(operation, { [inputId]: '["wf_a","wf_b"]', - folderSelection: '/Reports', + folderScopeRef: '/Reports', }) expect(params.fileId).toEqual(['wf_a', 'wf_b']) @@ -317,7 +318,7 @@ describe('file_v5 folder operations produce contract-valid tool input', () => { it.each(['file_read', 'file_get_content', 'file_compress'])( '%s stands for the folder when no file is picked', (operation) => { - const params = paramsFor(operation, { folderSelection: '/Reports' }) + const params = paramsFor(operation, { folderScopeRef: '/Reports' }) expect(params.folderPaths).toEqual(['/Reports']) expect(params.fileId).toBeUndefined() @@ -327,7 +328,7 @@ describe('file_v5 folder operations produce contract-valid tool input', () => { it('sends every selected folder for a folder-only read', () => { expect( - paramsFor('file_read', { folderSelection: ['/Reports', '/Archive'] }).folderPaths + paramsFor('file_read', { folderScopeRef: ['/Reports', '/Archive'] }).folderPaths ).toEqual(['/Reports', '/Archive']) }) @@ -339,7 +340,7 @@ describe('file_v5 folder operations produce contract-valid tool input', () => { }) it('reads the workspace root as no scope at all', () => { - expect(() => paramsFor('file_read', { folderSelection: '/' })).toThrow( + expect(() => paramsFor('file_read', { folderScopeRef: '/' })).toThrow( /File or folder is required/ ) }) @@ -350,11 +351,11 @@ describe('file_v5 folder operations produce contract-valid tool input', () => { */ it('says nothing about scope while subfolders are included', () => { expect( - paramsFor('file_read', { folderSelection: '/Reports' }).includeSubfolders + paramsFor('file_read', { folderScopeRef: '/Reports' }).includeSubfolders ).toBeUndefined() expect( paramsFor('file_read', { - folderSelection: '/Reports', + folderScopeRef: '/Reports', folderIncludeSubfolders: 'true', }).includeSubfolders ).toBeUndefined() @@ -365,7 +366,7 @@ describe('file_v5 folder operations produce contract-valid tool input', () => { (operation) => { expect( paramsFor(operation, { - folderSelection: '/Reports', + folderScopeRef: '/Reports', folderIncludeSubfolders: 'false', }).includeSubfolders ).toBe(false) @@ -374,12 +375,61 @@ describe('file_v5 folder operations produce contract-valid tool input', () => { it('carries the archive name on compress', () => { expect( - paramsFor('file_compress', { folderSelection: '/Reports', archiveName: 'reports.zip' }) + paramsFor('file_compress', { folderScopeRef: '/Reports', archiveName: 'reports.zip' }) .archiveName ).toBe('reports.zip') }) }) + /* + * The advanced half of the scope pair is typed, so the scope can arrive as + * text: one path, a comma-separated list, or the JSON array an earlier picker + * revision serialized. All three have to resolve to the canonical scopes a + * picked folder produces, on every operation the scope applies to. + */ + describe('a typed scope reads like a picked one', () => { + it('reads a comma-separated list on a folder-only read', () => { + expect(paramsFor('file_read', { folderScopeRef: '/Reports, /Archive' }).folderPaths).toEqual([ + '/Reports', + '/Archive', + ]) + }) + + it('confines a search to every typed folder', () => { + expect( + paramsFor('file_search', { + query: 'commitment', + folderScopeRef: '/memory/user-a,/memory/user-b', + }).folderPaths + ).toEqual(['/memory/user-a', '/memory/user-b']) + }) + + it('scopes a named edit to the typed folders', () => { + const params = paramsFor('file_edit', { + editFileInput: 'self.md', + editMode: 'search_replace', + editSearch: 'a', + editContent: 'b', + folderScopeRef: '/memory/user-a, /memory/shared', + }) + + expect(params.folderPath).toBeUndefined() + expect(params.folderPaths).toEqual(['/memory/user-a', '/memory/shared']) + }) + + it('keeps a percent-encoded comma inside one folder name', () => { + expect(paramsFor('file_read', { folderScopeRef: '/Q3%2CQ4' }).folderPaths).toEqual([ + '/Q3%2CQ4', + ]) + }) + + it('still reads the array an earlier picker revision serialized', () => { + expect( + paramsFor('file_read', { folderScopeRef: '["/Reports","/Archive"]' }).folderPaths + ).toEqual(['/Reports', '/Archive']) + }) + }) + /* * The picker has to describe the same set the run reads, or a user can build * a selection the operation then ignores. That wiring is config, and getting @@ -411,26 +461,30 @@ describe('file_v5 folder operations produce contract-valid tool input', () => { } ) - it('keeps the folder scope visible and allows several folders', () => { + /* + * The scope is a basic/advanced pair like every other folder field, so a + * path can be typed or come from an earlier block. A per-user memory folder + * such as /memory/ is only expressible that way. + */ + it('pairs the multi-folder picker with a typed twin', () => { const folder = FileV5Block.subBlocks.find((subBlock) => subBlock.id === 'folderSelection') + const typed = FileV5Block.subBlocks.find((s) => s.id === 'manualFolderSelection') - expect(folder?.mode).toBe('both') + expect(folder?.mode).toBe('basic') expect(folder?.multiSelect).toBe(true) - expect(folder?.canonicalParamId).toBeUndefined() - }) - - it('has no manual twin, since the scope is not a canonical pair', () => { - const ids = FileV5Block.subBlocks.map((subBlock) => subBlock.id) - - expect(ids).not.toContain('manualFolderSelection') + expect(folder?.canonicalParamId).toBe('folderScopeRef') + expect(typed?.mode).toBe('advanced') + expect(typed?.type).toBe('short-input') + expect(typed?.canonicalParamId).toBe('folderScopeRef') + expect(typed?.condition).toEqual(folder?.condition) }) - it('keeps only the recursion switch in additional fields', () => { + it('keeps the recursion switch out of the pair, in additional fields', () => { const folder = FileV5Block.subBlocks.find((subBlock) => subBlock.id === 'folderSelection') const recursive = FileV5Block.subBlocks.find((s) => s.id === 'folderIncludeSubfolders') - expect(folder?.mode).toBe('both') expect(recursive?.mode).toBe('advanced') + expect(recursive?.canonicalParamId).toBeUndefined() expect(recursive?.condition).toEqual(folder?.condition) }) @@ -462,7 +516,7 @@ describe('file_v5 folder operations produce contract-valid tool input', () => { editMode: 'search_replace', editSearch: 'a', editContent: 'b', - folderSelection: '/memory/user-a', + folderScopeRef: '/memory/user-a', folderIncludeSubfolders: 'false', }) @@ -526,10 +580,10 @@ describe('file_v5 folder operations produce contract-valid tool input', () => { }, ], ])('keeps the root on %s, so a duplicate name is refused not guessed', (operation, extra) => { - const recursive = paramsFor(operation, { ...extra, folderSelection: '/' }) + const recursive = paramsFor(operation, { ...extra, folderScopeRef: '/' }) const shallow = paramsFor(operation, { ...extra, - folderSelection: '/', + folderScopeRef: '/', folderIncludeSubfolders: 'false', }) @@ -543,7 +597,7 @@ describe('file_v5 folder operations produce contract-valid tool input', () => { const params = paramsFor('file_append', { appendFileInput: { id: 'wf_abc', name: 'self.md' }, appendContent: 'x', - folderSelection: '/', + folderScopeRef: '/', }) expect(params.folderPath).toBeUndefined() @@ -561,7 +615,7 @@ describe('file_v5 folder operations produce contract-valid tool input', () => { it('confines the search to a chosen folder', () => { const params = paramsFor('file_search', { query: 'commitment', - folderSelection: '/memory/user-a', + folderScopeRef: '/memory/user-a', }) expect(params.folderPaths).toEqual(['/memory/user-a']) @@ -570,14 +624,14 @@ describe('file_v5 folder operations produce contract-valid tool input', () => { it('confines the search to every chosen folder', () => { const params = paramsFor('file_search', { query: 'commitment', - folderSelection: ['/memory/user-a', '/memory/user-b'], + folderScopeRef: ['/memory/user-a', '/memory/user-b'], }) expect(params.folderPaths).toEqual(['/memory/user-a', '/memory/user-b']) }) it('treats the workspace root as no scope at all', () => { - const params = paramsFor('file_search', { query: 'commitment', folderSelection: '/' }) + const params = paramsFor('file_search', { query: 'commitment', folderScopeRef: '/' }) expect(params.folderPaths).toBeUndefined() }) @@ -585,11 +639,11 @@ describe('file_v5 folder operations produce contract-valid tool input', () => { it('sends the narrow scope only when subfolders are switched off', () => { const recursive = paramsFor('file_search', { query: 'commitment', - folderSelection: '/memory/user-a', + folderScopeRef: '/memory/user-a', }) const shallow = paramsFor('file_search', { query: 'commitment', - folderSelection: '/memory/user-a', + folderScopeRef: '/memory/user-a', folderIncludeSubfolders: 'false', }) @@ -608,7 +662,13 @@ describe('file_v5 folder operations produce contract-valid tool input', () => { }) it('leaves neither half of the choice individually required', () => { - for (const pickerId of ['readFile', 'getContentFile', 'compressFile', 'folderSelection']) { + for (const pickerId of [ + 'readFile', + 'getContentFile', + 'compressFile', + 'folderSelection', + 'manualFolderSelection', + ]) { expect( FileV5Block.subBlocks.find((subBlock) => subBlock.id === pickerId)?.required ).toBeUndefined() @@ -629,17 +689,10 @@ describe('file_v5 folder operations produce contract-valid tool input', () => { 'writeFolderPath', ]) /** - * Every picker that names an operand is half of a basic/advanced pair, so a - * path can also be typed. The scope is the unpaired exception because it - * is a visible multi-select refinement rather than one destination. + * Every picker is half of a basic/advanced pair, so a path can also be + * typed. */ for (const picker of pickers) { - if (picker.id === 'folderSelection') { - expect(picker.canonicalParamId).toBeUndefined() - expect(picker.mode).toBe('both') - expect(picker.multiSelect).toBe(true) - continue - } const pair = FileV5Block.subBlocks.filter( (subBlock) => subBlock.canonicalParamId === picker.canonicalParamId ) diff --git a/apps/sim/blocks/blocks/file.ts b/apps/sim/blocks/blocks/file.ts index b18de77f187..cff055af97e 100644 --- a/apps/sim/blocks/blocks/file.ts +++ b/apps/sim/blocks/blocks/file.ts @@ -95,7 +95,7 @@ const SHARE_FILE_FIELD = ['shareFile', 'shareFileId'] as const /* Text and file are mutually exclusive sources, so the clause names whichever one the card actually carries. */ const WRITE_CONTENT_FIELD = ['content', 'writeFile', 'writeFileId'] as const -const FOLDER_SCOPE_FIELD = ['folderSelection'] as const +const FOLDER_SCOPE_FIELD = ['folderSelection', 'manualFolderSelection'] as const const FOLDER_PATH_FIELD = ['folderPath', 'manualFolderPath'] as const const WRITE_FOLDER_FIELD = ['writeFolderPath', 'manualWriteFolderPath'] as const const CREATE_PARENT_FIELD = ['createParentPath', 'manualCreateParentPath'] as const @@ -111,15 +111,26 @@ const FILE_EDIT_MODES: ReadonlySet = new Set([ /** * The folder that narrows a picker's options, and how deep it reaches. * - * The multi-folder picker stays in the main form while its recursion switch is - * an advanced refinement. Neither is a canonical pair: they are one scope and - * one optional behavior, not alternate representations of the same value. + * The multi-folder picker is the basic half of a pair whose advanced half takes + * typed paths, and the picker resolves whichever half is active, so only the + * basic id is named here. The recursion switch is not a member of that pair: + * it is one optional behavior, not another representation of the scope. */ const FOLDER_SCOPE = { fieldId: 'folderSelection', recursiveFieldId: 'folderIncludeSubfolders', } as const +/** The operations a folder scopes; the scope pair and its recursion switch share this condition. */ +const FOLDER_SCOPE_OPERATIONS = [ + 'file_read', + 'file_get_content', + 'file_compress', + 'file_append', + 'file_search', + 'file_edit', +] as const + /** * An untouched text subblock arrives as '', not undefined, and '' is not a * canonical folder path — so an omitted optional path has to be normalized away @@ -149,9 +160,14 @@ function toFileIdList(value: string | string[] | null | undefined): string[] { return Array.isArray(value) ? value : [value] } -/** Only the fields {@link fileFamilyInput} reads, so a shape change fails here rather than at run time. */ +/** + * Only the fields {@link fileFamilyInput} reads, so a shape change fails here rather than at run time. + * + * The scope arrives under its canonical id: the serializer deletes both halves + * of the pair and republishes whichever one is active as `folderScopeRef`. + */ interface FileFamilyParams { - folderSelection?: unknown + folderScopeRef?: unknown folderIncludeSubfolders?: unknown _context?: { workspaceId?: string } } @@ -178,7 +194,7 @@ function fileFamilyInput( const normalized = pickerValue ? normalizeFileInput(pickerValue) : null if (normalized && normalized.length > 0) return { fileInput: normalized, workspaceId } - const folderPaths = folderScopePaths(params.folderSelection) + const folderPaths = folderScopePaths(params.folderScopeRef) if (!folderPaths) { throw new Error(`File or folder is required for ${label}`) } @@ -198,7 +214,7 @@ function fileFamilyInput( * a picked file resolving to a different one. */ function namedFileTarget( - params: FileFamilyParams & { folderSelection?: unknown; folderIncludeSubfolders?: unknown }, + params: FileFamilyParams, pickerValue: unknown, label: string ): { @@ -240,7 +256,7 @@ function namedFileTarget( */ if (resolvedById) return { fileName } - const scopes = readFolderPaths(params.folderSelection) + const scopes = readFolderPaths(params.folderScopeRef) /* * `folderScopePaths` drops the root, because for a whole-folder read the root * and no folder mean the same thing. For a NAMED target they never do: @@ -1108,6 +1124,7 @@ export const FileV5Block: BlockConfig = { - Search reads the query as a line-oriented regular expression: quantifiers, character classes, \\d \\w \\s, alternation, groups, "^" and "$" anchors, and \\b word boundaries. Lookaround, backreferences and patterns spanning a line break are not supported, and a pattern needs at least 3 consecutive literal characters that every match will contain. Set Match to "Exact match" to search for the query text verbatim instead. - Match is a builder setting, not an agent one: the agent writes the query, and Match decides how every query from that block is read. - Search is eventually consistent. Check "complete" and "indexStatus" when pending, failed, skipped, or partially indexed files matter to the task. + - Read, Get Content, Search, Append, Apply Edit, and Compress share a Folder scope. Pick folders, or switch the field to advanced and type canonical percent-encoded paths, comma-separated for several, including a reference from an earlier block such as /memory/. - Use Fetch for external file URLs. Add headers for authenticated downloads, for example Slack private file URLs require an Authorization Bearer token. - Use Write to create a new workspace file and Append to add content to an existing one. Write adds a numeric suffix when the name is taken; turn on "Overwrite Existing File" to replace the contents of the file at that exact path (folder and name) instead — a same-named file in another folder is left alone. - Use Compress to bundle one or more files into a single .zip archive stored in the workspace. The new archive is returned in the "files" output. @@ -1202,22 +1219,24 @@ export const FileV5Block: BlockConfig = { title: 'Folder', type: 'folder-selector' as SubBlockType, resourceType: 'file', - mode: 'both', + canonicalParamId: 'folderScopeRef', + mode: 'basic', multiSelect: true, placeholder: 'Anywhere in the workspace', description: 'Narrows the file options below. Read, get content, and compress also take the whole folder when no file is picked, and search is confined to it.', - condition: { - field: 'operation', - value: [ - 'file_read', - 'file_get_content', - 'file_compress', - 'file_append', - 'file_search', - 'file_edit', - ], - }, + condition: { field: 'operation', value: [...FOLDER_SCOPE_OPERATIONS] }, + }, + { + id: 'manualFolderSelection', + title: 'Folder Paths', + type: 'short-input' as SubBlockType, + canonicalParamId: 'folderScopeRef', + mode: 'advanced', + placeholder: '/Reports/Q3%20Results, /Archive', + description: + 'Canonical percent-encoded folder paths, comma-separated for several, or a reference from an earlier block. Scopes the operation exactly as the picker does.', + condition: { field: 'operation', value: [...FOLDER_SCOPE_OPERATIONS] }, }, { id: 'folderIncludeSubfolders', @@ -1227,17 +1246,7 @@ export const FileV5Block: BlockConfig = { value: () => 'true', description: 'Whether the folder above reaches into nested folders. On by default; turn it off to take only its direct contents, which is also how a name shared with a file deeper in the tree is disambiguated.', - condition: { - field: 'operation', - value: [ - 'file_read', - 'file_get_content', - 'file_compress', - 'file_append', - 'file_search', - 'file_edit', - ], - }, + condition: { field: 'operation', value: [...FOLDER_SCOPE_OPERATIONS] }, }, { id: 'readFile', @@ -1936,7 +1945,7 @@ export const FileV5Block: BlockConfig = { * has its query, so an unset folder means the whole workspace and * never an incomplete configuration. */ - const folderPaths = folderScopePaths(params.folderSelection) + const folderPaths = folderScopePaths(params.folderScopeRef) return { query: params.query, mode: params.mode === 'exact' ? 'exact' : 'regex', @@ -2316,10 +2325,10 @@ export const FileV5Block: BlockConfig = { type: 'boolean', description: 'Whether the folder scope reaches into nested folders; on by default', }, - folderSelection: { - type: 'array', + folderScopeRef: { + type: 'string', description: - 'Folders the operation is scoped to, including everything nested inside them, expanded at run time when no file is picked (read, get content, compress, search, append, edit, insert)', + 'Folders the operation is scoped to (read, get content, compress, search, append, edit): canonical percent-encoded paths, comma-separated for several. Includes everything nested inside them, and is expanded at run time when no file is picked', }, folderLimit: { type: 'number', diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index 50e1b93bc23..6756bb2b28b 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -291,6 +291,10 @@ export interface SubBlockConfig { /** * Narrows this control's options to a folder chosen elsewhere on the block, * and identifies the sibling deciding whether that scope reaches nested folders. + * + * `fieldId` may be the basic half of a basic/advanced pair. The control + * resolves the pair's active half, the same one the run reads, so a scope + * typed into the advanced half narrows the picker just as a picked one does. */ folderScope?: { fieldId: string; recursiveFieldId?: string } /** Controls parameter visibility in agent/tool-input context */ diff --git a/apps/sim/lib/folders/selection.test.ts b/apps/sim/lib/folders/selection.test.ts index 4294a89cb73..e6e8e021ae2 100644 --- a/apps/sim/lib/folders/selection.test.ts +++ b/apps/sim/lib/folders/selection.test.ts @@ -21,6 +21,14 @@ describe('folder selector persistence', () => { expect(readFolderPaths('["/Reports","/Archive"]')).toEqual(['/Reports', '/Archive']) }) + it('reads a typed comma-separated list, dropping blanks and repeats', () => { + expect(readFolderPaths('/Reports, /Archive,, /Reports')).toEqual(['/Reports', '/Archive']) + }) + + it('keeps a percent-encoded comma inside one folder name', () => { + expect(readFolderPaths('/Q3%2CQ4')).toEqual(['/Q3%2CQ4']) + }) + it('replaces a single folder path without changing scalar storage', () => { expect(replaceFolderPath('/Reports', '/Reports', '/Target')).toBe('/Target') expect(replaceFolderPath('/Archive', '/Reports', '/Target')).toBe('/Archive') diff --git a/apps/sim/lib/folders/selection.ts b/apps/sim/lib/folders/selection.ts index aecd973e945..13191472ff6 100644 --- a/apps/sim/lib/folders/selection.ts +++ b/apps/sim/lib/folders/selection.ts @@ -1,4 +1,12 @@ -/** Reads every canonical folder path from current, legacy, or serialized picker values. */ +/** + * Reads every canonical folder path from current, legacy, or serialized picker + * values, or from a typed comma-separated list. + * + * A comma is a safe separator because a canonical path never contains one: + * `encodeFolderPathSegment` percent-encodes it as `%2C`. A list is what the + * advanced half of a folder-scope pair holds, where several folders have to be + * spelled in one text field. + */ export function readFolderPaths(value: unknown): string[] { if (typeof value === 'string') { const trimmed = value.trim() @@ -10,7 +18,7 @@ export function readFolderPaths(value: unknown): string[] { return [trimmed] } } - return [trimmed] + return readFolderPaths(trimmed.split(',')) } if (Array.isArray(value)) { return [ diff --git a/apps/sim/lib/workflows/subblocks/visibility.test.ts b/apps/sim/lib/workflows/subblocks/visibility.test.ts index da470499192..fa9d2d7eaba 100644 --- a/apps/sim/lib/workflows/subblocks/visibility.test.ts +++ b/apps/sim/lib/workflows/subblocks/visibility.test.ts @@ -8,6 +8,8 @@ import { evaluateSubBlockCondition, getCanonicalSubBlocksForSurface, reindexToolCanonicalModes, + resolveActiveDependencyValue, + resolveDependencyValue, scopeCanonicalModesForTool, } from './visibility' @@ -389,3 +391,66 @@ describe('canonical index scoping by surface', () => { expect(group.advancedIds).toEqual(['manualCalendarId']) }) }) + +describe('resolveActiveDependencyValue', () => { + /** The File block's folder scope: a multi-select picker paired with a typed list. */ + const SCOPE_PAIR: SubBlockConfig[] = [ + { + id: 'folderSelection', + type: 'folder-selector', + canonicalParamId: 'folderScopeRef', + mode: 'basic', + }, + { + id: 'manualFolderSelection', + type: 'short-input', + canonicalParamId: 'folderScopeRef', + mode: 'advanced', + }, + { id: 'query', type: 'short-input' }, + ] as SubBlockConfig[] + const index = buildCanonicalIndexForSurface(SCOPE_PAIR, false) + + it.concurrent( + 'answers with the active half whether addressed by a member or the canonical id', + () => { + const values = { folderSelection: ['/Reports'], manualFolderSelection: '/Archive' } + const advanced = { folderScopeRef: 'advanced' as const } + const basic = { folderScopeRef: 'basic' as const } + + expect(resolveActiveDependencyValue('folderSelection', values, index, advanced)).toBe( + '/Archive' + ) + expect(resolveActiveDependencyValue('folderScopeRef', values, index, advanced)).toBe( + '/Archive' + ) + expect(resolveActiveDependencyValue('manualFolderSelection', values, index, basic)).toEqual([ + '/Reports', + ]) + } + ) + + // A picker scoped by the dormant half would offer a set the run then ignores: the + // serializer publishes only the active member, so the strict reading is the one that + // matches execution. The dependency fallback exists for `dependsOn` gating and reaches + // for the other half whenever the active one was never touched. + it.concurrent('never leaks a dormant half, unlike the dependency fallback', () => { + const untouched = { manualFolderSelection: '/Archive' } + const cleared = { folderSelection: '', manualFolderSelection: '/Archive' } + const basic = { folderScopeRef: 'basic' as const } + + expect(resolveActiveDependencyValue('folderSelection', untouched, index, basic)).toBeUndefined() + expect(resolveActiveDependencyValue('folderSelection', cleared, index, basic)).toBe('') + expect(resolveDependencyValue('folderSelection', untouched, index, basic)).toBe('/Archive') + }) + + it.concurrent('follows the value heuristic when no mode was chosen', () => { + const values = { manualFolderSelection: '/Archive' } + + expect(resolveActiveDependencyValue('folderSelection', values, index)).toBe('/Archive') + }) + + it.concurrent('reads a field outside any pair as itself', () => { + expect(resolveActiveDependencyValue('query', { query: 'commitment' }, index)).toBe('commitment') + }) +}) diff --git a/apps/sim/lib/workflows/subblocks/visibility.ts b/apps/sim/lib/workflows/subblocks/visibility.ts index c7924bd8532..03656549c9c 100644 --- a/apps/sim/lib/workflows/subblocks/visibility.ts +++ b/apps/sim/lib/workflows/subblocks/visibility.ts @@ -298,6 +298,29 @@ export function resolveActiveCanonicalValue( return mode === 'advanced' ? advancedValue : basicValue } +/** + * {@link resolveActiveCanonicalValue} addressed by a canonical id or by a member's subblock id, + * for a control that reads a SIBLING field without knowing whether that field is half of a pair. + * + * Strict like its namesake: a pair answers with its active member only, honoring an explicit + * toggle, so a dormant half's stale value never scopes a control the run will not scope. A key + * outside any group reads its own stored value. Contrast {@link resolveDependencyValue}, whose + * cross-mode fallback exists for `dependsOn` gating and is wrong here. + */ +export function resolveActiveDependencyValue( + dependencyKey: string, + values: Record, + canonicalIndex: CanonicalIndex, + overrides?: CanonicalModeOverrides +): unknown { + const canonicalId = + canonicalIndex.groupsById[dependencyKey]?.canonicalId || + canonicalIndex.canonicalIdBySubBlockId[dependencyKey] + const group = canonicalId ? canonicalIndex.groupsById[canonicalId] : undefined + if (!group) return values[dependencyKey] + return resolveActiveCanonicalValue(group, values, overrides) +} + /** Extract override entries matching a `${prefix}` key into a bare-`canonicalId`-keyed object. */ function extractPrefixedModes( overrides: CanonicalModeOverrides,