diff --git a/.changeset/encode-artifact-download-path.md b/.changeset/encode-artifact-download-path.md new file mode 100644 index 000000000..9efa812d8 --- /dev/null +++ b/.changeset/encode-artifact-download-path.md @@ -0,0 +1,5 @@ +--- +'@truefoundry/trueforge-ui': patch +--- + +Encode sandbox artifact paths when building file-download links, so names containing spaces, `#`, `?`, `&`, `%` or non-ASCII characters resolve to the intended file instead of a truncated or misparsed URL. diff --git a/packages/trueforge-ui/src/atoms/ChatFileDownload.tsx b/packages/trueforge-ui/src/atoms/ChatFileDownload.tsx index 19c0a1eb1..c7e7b2457 100644 --- a/packages/trueforge-ui/src/atoms/ChatFileDownload.tsx +++ b/packages/trueforge-ui/src/atoms/ChatFileDownload.tsx @@ -18,6 +18,12 @@ export type ChatFileDownloadProps = { readOnly?: boolean; }; +// Artifact paths are model-authored, so a name may carry characters that change what the +// URL means. Encode per segment so `/` keeps its structural role and the rest stays literal. +function encodeArtifactPath(path: string): string { + return path.split('/').map(encodeURIComponent).join('/'); +} + export function ChatFileDownload({ files, fileDownloadBaseUrl, onDownloadArtifact, readOnly }: ChatFileDownloadProps) { const [downloadingPath, setDownloadingPath] = useState(null); @@ -49,7 +55,7 @@ export function ChatFileDownload({ files, fileDownloadBaseUrl, onDownloadArtifac ); } - const href = fileDownloadBaseUrl ? `${fileDownloadBaseUrl}${path}` : undefined; + const href = fileDownloadBaseUrl ? `${fileDownloadBaseUrl}${encodeArtifactPath(path)}` : undefined; const canDownload = Boolean(onDownloadArtifact || href); const isDownloading = downloadingPath === path; diff --git a/packages/trueforge-ui/test/atoms/ChatFileDownload.test.tsx b/packages/trueforge-ui/test/atoms/ChatFileDownload.test.tsx index e0aa91908..a004f5c46 100644 --- a/packages/trueforge-ui/test/atoms/ChatFileDownload.test.tsx +++ b/packages/trueforge-ui/test/atoms/ChatFileDownload.test.tsx @@ -30,6 +30,32 @@ describe('ChatFileDownload', () => { expect(screen.getByRole('link', { name: 'Download data.csv' })).toHaveAttribute('href', '/downloads/data.csv'); }); + it('encodes artifact names that would otherwise break or rewrite the URL', () => { + render( + , + ); + + expect(screen.getByRole('link', { name: 'Download my report#final,v2.csv' })).toHaveAttribute( + 'href', + 'https://files.example.com/tmp/my%20report%23final%2Cv2.csv', + ); + expect(screen.getByRole('link', { name: 'Download a&b?c%d.txt' })).toHaveAttribute( + 'href', + 'https://files.example.com/tmp/a%26b%3Fc%25d.txt', + ); + expect(screen.getByRole('link', { name: 'Download ünïcode.txt' })).toHaveAttribute( + 'href', + 'https://files.example.com/tmp/%C3%BCn%C3%AFcode.txt', + ); + }); + it('shows per-file progress and suppresses duplicate artifact downloads', async () => { let resolveDownload: (() => void) | undefined; const downloadPromise = new Promise(resolve => {