diff --git a/apps/docs/content/docs/integrations/file.mdx b/apps/docs/content/docs/integrations/file.mdx index 0fc151aad4e..e3749ea2828 100644 --- a/apps/docs/content/docs/integrations/file.mdx +++ b/apps/docs/content/docs/integrations/file.mdx @@ -28,7 +28,7 @@ In Sim, the File block allows your agents to search, read, and extract text from ## Usage Instructions -Read workspace file objects, search indexed text across the workspace or selected folder scopes, extract the text content of files, fetch and parse files from URLs with optional headers, write new workspace files, append content to existing files, compress files into a .zip archive, extract a .zip archive into the workspace, or manage the public share link for a file. +Read workspace file objects, search indexed text across the workspace or selected folder scopes, extract the text content of files, fetch and parse files from URLs with optional headers, write new workspace files at relative paths, append content to existing files, compress files into a .zip archive, extract a .zip archive into the workspace, or manage the public share link for a file. @@ -127,13 +127,13 @@ Fetch and parse a file from a URL with optional custom headers. ### File Write -Create a new workspace file, either from text content or from an existing file. If a file with the same name already exists, a numeric suffix is added (e.g., "data (1).csv") unless overwrite is enabled. +Create a new workspace file at a relative path, either from text content or from an existing file. Missing folders are created automatically. If a file with the same name already exists, a numeric suffix is added (e.g., "data (1).csv") unless overwrite is enabled. #### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `fileName` | string | No | File name \(e.g., "data.csv"\). Required when writing text; optional when storing a file, which keeps its own name unless this overrides it. If the name already exists, a numeric suffix is added automatically unless overwrite is enabled. | +| `fileName` | string | No | Relative workspace file path \(e.g., "Reports/2026/report.md"\). Missing folders are created automatically. Required when writing text; optional when storing a file, which keeps its own name unless this overrides it. If the name already exists, a numeric suffix is added automatically unless overwrite is enabled. | | `folderPath` | string | No | Folder to create the file in. Omit for the workspace root. Canonical folder path, percent-encoded, e.g. "/Reports/Q3%20Results". The workspace root is "/". | | `content` | string | No | The text content to write to the file. Provide exactly one of content or fileInput. | | `fileInput` | file | No | An existing file to store in the workspace, such as one produced by an earlier tool. Use this for anything that is not text — PDFs, images, audio, archives. Provide exactly one of content or fileInput. | @@ -147,6 +147,7 @@ Create a new workspace file, either from text content or from an existing file. | --------- | ---- | ----------- | | `id` | string | File ID | | `name` | string | File name | +| `vfsPath` | string | Canonical workspace path of the created file \(e.g., files/Reports/2026/report.md\) | | `size` | number | File size in bytes | | `url` | string | URL to access the file | | `version` | number | Version number of the content this write recorded | 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 44065aa2c4f..e756b502682 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 @@ -17,9 +17,9 @@ import { ROOT_FOLDER_PATH } from '@/lib/folders/paths' import { readFolderPaths } from '@/lib/folders/selection' import { formatFileSize, getExtensionFromMimeType } from '@/lib/uploads/utils/file-utils' import { containsReference } from '@/lib/workflows/sanitization/references' -import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path' import { isFileInFolderScope } from '@/lib/workspace-files/folder-path-selection' import { findSelectedWorkspaceFile } from '@/lib/workspace-files/selection' +import { getWorkspaceFileDisplayLabel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/workspace-file-display' 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' @@ -68,23 +68,6 @@ interface FileUploadProps { onValueChange?: (value: UploadedFile | UploadedFile[] | null) => void } -/** - * Label for a workspace file, prefixed with its folder so two files sharing a - * name are distinguishable. - * - * The stored folder path escapes a slash inside a folder name, so it is decoded - * into segments rather than split — otherwise a folder named `Q3/Q4` reads as - * two levels. - */ -function workspaceFileOptionLabel(file: { name: string; folderPath?: string | null }): string { - if (!file.folderPath) return file.name - try { - return `${parseWorkspaceFileFolderDisplayPath(file.folderPath).join(' / ')} / ${file.name}` - } catch { - return file.name - } -} - function byFolderThenName( a: { name: string; folderPath?: string | null }, b: { name: string; folderPath?: string | null } @@ -126,6 +109,7 @@ export interface UploadedFile { interface SingleFileSelectorProps { file: UploadedFile + displayName: string options: Array<{ label: string; value: string; disabled?: boolean }> selectedValue: string onInputChange: (value: string) => void @@ -145,6 +129,7 @@ interface SingleFileSelectorProps { */ function SingleFileSelector({ file, + displayName, options, selectedValue, onInputChange, @@ -156,7 +141,7 @@ function SingleFileSelector({ isDeleting, workflowSearchHighlight, }: SingleFileSelectorProps) { - const displayLabel = `${truncateMiddle(file.name, 20, 12)} (${workspaceFileSizeLabel(file.size)})` + const displayLabel = `${truncateMiddle(displayName, 20, 12)} (${workspaceFileSizeLabel(file.size)})` const [searchQuery, setSearchQuery] = useState('') const [isEditing, setIsEditing] = useState(false) // When not editing, always show the file's display label. When editing, show the user's query. @@ -736,7 +721,9 @@ export function FileUpload({ const renderFileItem = (file: UploadedFile, index: number) => { const fileKey = file.path || '' const isDeleting = deletingFiles[fileKey] - const displayName = truncateMiddle(file.name) + const matchedWorkspaceFile = findSelectedWorkspaceFile(file, workspaceFiles) + const fullDisplayName = getWorkspaceFileDisplayLabel(matchedWorkspaceFile ?? file) + const displayName = truncateMiddle(fullDisplayName) const workflowSearchHighlight = getWorkflowSearchLabelHighlight({ activeSearchTarget, blockId, @@ -750,7 +737,7 @@ export function FileUpload({ key={fileKey} className='relative rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-2 py-1.5 hover-hover:bg-[var(--surface-active)] dark:bg-[var(--surface-5)]' > -
+
{formatDisplayText(displayName, { workflowSearchHighlight })} @@ -810,7 +797,7 @@ export function FileUpload({ const isAccepted = !acceptedTypes || acceptedTypes === '*' || isFileTypeAccepted(file.type, acceptedTypes) return { - label: workspaceFileOptionLabel(file), + label: getWorkspaceFileDisplayLabel(file), value: file.id, // When cloud is required, local workspace files are also unpublishable. disabled: !isAccepted || cloudUploadBlocked, @@ -832,7 +819,7 @@ export function FileUpload({ const isAccepted = !acceptedTypes || acceptedTypes === '*' || isFileTypeAccepted(file.type, acceptedTypes) return { - label: workspaceFileOptionLabel(file), + label: getWorkspaceFileDisplayLabel(file), value: file.id, disabled: !isAccepted || cloudUploadBlocked, } @@ -958,6 +945,9 @@ export function FileUpload({ {hasFiles && !multiple && !isUploading && ( )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/workspace-file-display.test.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/workspace-file-display.test.ts new file mode 100644 index 00000000000..5dc2d968976 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/workspace-file-display.test.ts @@ -0,0 +1,29 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { getWorkspaceFileDisplayLabel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/workspace-file-display' + +const reportsFile = { + id: 'file-reports', + name: 'report.md', + key: 'workspace/workspace-1/report-reports.md', + path: '/api/files/serve/report-reports', + folderPath: 'Reports/2026', +} + +describe('workspace file picker display', () => { + it('shows folder breadcrumbs while keeping root-level labels compact', () => { + expect(getWorkspaceFileDisplayLabel(reportsFile)).toBe('Reports / 2026 / report.md') + expect(getWorkspaceFileDisplayLabel({ name: 'root.md', folderPath: null })).toBe('root.md') + }) + + it('decodes escaped slashes in folder display paths', () => { + expect( + getWorkspaceFileDisplayLabel({ + name: 'contract.pdf', + folderPath: 'Finance\\/Legal/2026', + }) + ).toBe('Finance/Legal / 2026 / contract.pdf') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/workspace-file-display.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/workspace-file-display.ts new file mode 100644 index 00000000000..a8a554a60af --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/workspace-file-display.ts @@ -0,0 +1,22 @@ +import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path' + +interface WorkspaceFileDisplayRecord { + id: string + name: string + key: string + path: string + folderPath?: string | null +} + +/** Formats a workspace file as a readable path while keeping root-level labels compact. */ +export function getWorkspaceFileDisplayLabel( + file: Pick +): string { + if (!file.folderPath) return file.name + + try { + return [...parseWorkspaceFileFolderDisplayPath(file.folderPath), file.name].join(' / ') + } catch { + return `${file.folderPath} / ${file.name}` + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block-password.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block-password.test.tsx index cfbfebbdb30..c8e70929e5f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block-password.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block-password.test.tsx @@ -21,6 +21,7 @@ const { stubInput } = vi.hoisted(() => { vi.mock('@sim/emcn', () => ({ Button: ({ children }: { children?: ReactNode }) => , cn: (...classes: unknown[]) => classes.filter(Boolean).join(' '), + Info: ({ children }: { children?: ReactNode }) => {children}, Input: () => null, Label: ({ children }: { children?: ReactNode }) => {children}, Tooltip: ({ children }: { children?: ReactNode }) => <>{children}, @@ -108,3 +109,17 @@ describe('SubBlock password forwarding', () => { expect(html).toContain('data-password="on"') }) }) + +describe('SubBlock label guidance', () => { + it('renders configured tooltip content beside the field label', () => { + const html = renderSubBlock({ + id: 'fileName', + title: 'File Path', + type: 'short-input', + tooltip: 'Missing folders are created automatically.', + }) + + expect(html).toContain('data-info="true"') + expect(html).toContain('Missing folders are created automatically.') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx index 4795928d1c6..d9767938d1c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx @@ -1,5 +1,5 @@ import { type JSX, type MouseEvent, memo, useCallback, useMemo, useRef, useState } from 'react' -import { Button, cn, Input, Label, Tooltip } from '@sim/emcn' +import { Button, cn, Info, Input, Label, Tooltip } from '@sim/emcn' import { ArrowLeftRight, ArrowUp, @@ -258,6 +258,11 @@ const renderLabel = (