fix(sky-8861): UI shows real filename for short artifact URLs (#5666)

This commit is contained in:
Shuchang Zheng 2026-04-25 15:00:27 -07:00 committed by GitHub
parent 595955614e
commit a6203ef52b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 92 additions and 9 deletions

View file

@ -40,6 +40,7 @@ import { useWorkflowRunWithWorkflowQuery } from "./hooks/useWorkflowRunWithWorkf
import { WorkflowRunTimeline } from "./workflowRun/WorkflowRunTimeline";
import { useWorkflowRunTimelineQuery } from "./hooks/useWorkflowRunTimelineQuery";
import { findActiveItem } from "./workflowRun/workflowTimelineUtils";
import { filenameForDownloadedFileUrl } from "./workflowRun/blockDownloadedFiles";
import { isBlockItem } from "./types/workflowRunTypes";
import { Label } from "@/components/ui/label";
import { CodeEditor } from "./components/CodeEditor";
@ -515,9 +516,7 @@ function WorkflowRun() {
<ScrollAreaViewport className="max-h-[250px] space-y-2">
{fileUrls.length > 0 ? (
fileUrls.map((url) => {
// Extract filename from URL path, stripping query params from signed URLs
const urlPath = url.split("?")[0] ?? url;
const filename = urlPath.split("/").pop() || "download";
const filename = filenameForDownloadedFileUrl(url);
return (
<div key={url} title={url} className="flex gap-2">
<FileIcon className="size-6" />

View file

@ -17,7 +17,10 @@ import { AutoResizingTextarea } from "@/components/AutoResizingTextarea/AutoResi
import { SummarizeOutput } from "@/components/SummarizeOutput";
import { isTaskVariantBlock } from "../types/workflowTypes";
import { statusIsAFailureType } from "@/routes/tasks/types";
import { getBlockDownloadedFileUrls } from "./blockDownloadedFiles";
import {
filenameForDownloadedFileUrl,
getBlockDownloadedFileUrls,
} from "./blockDownloadedFiles";
function SummaryDisplay({
summary,
@ -344,9 +347,7 @@ function WorkflowRunOutput() {
<div className="space-y-2">
{fileUrls.length > 0 ? (
fileUrls.map((url) => {
// Extract filename from URL path, stripping query params from signed URLs
const urlPath = url.split("?")[0] ?? url;
const filename = urlPath.split("/").pop() || "download";
const filename = filenameForDownloadedFileUrl(url);
return (
<div key={url} title={url} className="flex gap-2">
<FileIcon className="size-6" />

View file

@ -1,5 +1,50 @@
import { describe, expect, test } from "vitest";
import { getBlockDownloadedFileUrls } from "./blockDownloadedFiles";
import {
filenameForDownloadedFileUrl,
getBlockDownloadedFileUrls,
} from "./blockDownloadedFiles";
describe("filenameForDownloadedFileUrl", () => {
test("short signed URL: pulls filename from artifact_name query param", () => {
const url =
"https://api.skyvern.com/v1/artifacts/a_123/content" +
"?expiry=1&kid=k&sig=s&artifact_name=invoice-2026.pdf&artifact_type=download";
expect(filenameForDownloadedFileUrl(url)).toBe("invoice-2026.pdf");
});
test("short signed URL with non-ASCII filename: round-trips via URL decoding", () => {
const url =
"https://api.skyvern.com/v1/artifacts/a_123/content" +
"?expiry=1&kid=k&sig=s&artifact_name=" +
encodeURIComponent("\u62a5\u544a-2026.pdf");
expect(filenameForDownloadedFileUrl(url)).toBe("报告-2026.pdf");
});
test("legacy S3 presigned URL: pulls filename from path basename", () => {
const url =
"https://skyvern-uploads.s3.amazonaws.com/" +
"downloads/production/o_1/wr_1/legacy-report.pdf" +
"?AWSAccessKeyId=ASIA&Signature=sig&Expires=1234567890";
expect(filenameForDownloadedFileUrl(url)).toBe("legacy-report.pdf");
});
test("legacy S3 presigned URL with percent-encoded filename: decodes the basename", () => {
const url =
"https://skyvern-uploads.s3.amazonaws.com/" +
"downloads/production/o_1/wr_1/Q2%20report.pdf" +
"?AWSAccessKeyId=x&Signature=y";
expect(filenameForDownloadedFileUrl(url)).toBe("Q2 report.pdf");
});
test("short URL without artifact_name and trailing /content: returns 'download'", () => {
const url = "https://api.skyvern.com/v1/artifacts/a_123/content?sig=x";
expect(filenameForDownloadedFileUrl(url)).toBe("download");
});
test("malformed URL: returns 'download'", () => {
expect(filenameForDownloadedFileUrl("not a url")).toBe("download");
});
});
describe("getBlockDownloadedFileUrls", () => {
test("returns [] when blockOutput is null/undefined/string/array", () => {

View file

@ -8,6 +8,44 @@ function urlKey(url: string): string {
return url.split("?")[0] ?? url;
}
/**
* Best-effort filename for a downloaded-file URL.
*
* Two URL shapes are in the wild:
* 1. Short signed artifact URLs introduced in SKY-8861, of shape
* ``/v1/artifacts/{id}/content?artifact_name=foo.pdf&...``. The path
* basename is always ``content``; the real filename is in the query
* parameter.
* 2. Legacy S3 presigned URLs, of shape
* ``https://skyvern-uploads.s3.amazonaws.com/downloads/.../foo.pdf?...``.
* The path basename *is* the filename, but it may be percent-encoded
* for filenames containing spaces / unicode.
*/
function filenameForDownloadedFileUrl(url: string): string {
const fallback = "download";
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return fallback;
}
// searchParams.get auto-decodes percent-encoding, so non-ASCII
// artifact_name values round-trip without an explicit decodeURIComponent.
const fromQuery = parsed.searchParams.get("artifact_name");
if (fromQuery) {
return fromQuery;
}
const last = parsed.pathname.split("/").pop();
if (!last || last === "content") {
return fallback;
}
try {
return decodeURIComponent(last);
} catch {
return last;
}
}
function getBlockDownloadedFileUrls(
blockOutput: object | Array<unknown> | string | null | undefined,
freshFallbackUrls: ReadonlyArray<string>,
@ -41,4 +79,4 @@ function getBlockDownloadedFileUrls(
return blockUrls.map((url) => freshByPath.get(urlKey(url)) ?? url);
}
export { getBlockDownloadedFileUrls };
export { filenameForDownloadedFileUrl, getBlockDownloadedFileUrls };