diff --git a/packages/shared/src/types/workspace-file-resource.ts b/packages/shared/src/types/workspace-file-resource.ts index bd39769ed4a..5ae97b8c7b6 100644 --- a/packages/shared/src/types/workspace-file-resource.ts +++ b/packages/shared/src/types/workspace-file-resource.ts @@ -1,7 +1,7 @@ export type WorkspaceFileWorkspaceKind = "execution_workspace" | "project_workspace"; export type WorkspaceFileSelector = "auto" | "execution" | "project"; export type WorkspaceFileListMode = "all" | "recent" | "changed"; -export type WorkspaceFilePreviewKind = "text" | "image" | "pdf" | "unsupported"; +export type WorkspaceFilePreviewKind = "text" | "image" | "video" | "pdf" | "unsupported"; export type WorkspaceFileResourceKind = "file" | "remote_resource"; export type WorkspaceFileContentEncoding = "utf8" | "base64"; diff --git a/packages/shared/src/validators/workspace-file-resource.ts b/packages/shared/src/validators/workspace-file-resource.ts index 668ae1c8bcb..a6407eca5ce 100644 --- a/packages/shared/src/validators/workspace-file-resource.ts +++ b/packages/shared/src/validators/workspace-file-resource.ts @@ -9,7 +9,7 @@ function utf8ByteLength(value: string) { export const workspaceFileWorkspaceKindSchema = z.enum(["execution_workspace", "project_workspace"]); export const workspaceFileSelectorSchema = z.enum(["auto", "execution", "project"]).default("auto"); export const workspaceFileListModeSchema = z.enum(["all", "recent", "changed"]).default("all"); -export const workspaceFilePreviewKindSchema = z.enum(["text", "image", "pdf", "unsupported"]); +export const workspaceFilePreviewKindSchema = z.enum(["text", "image", "video", "pdf", "unsupported"]); export const workspaceFileResourceKindSchema = z.enum(["file", "remote_resource"]); export const workspaceFileRefSchema = z.object({ diff --git a/server/src/__tests__/file-resources.test.ts b/server/src/__tests__/file-resources.test.ts index 5e572732d70..21230721edc 100644 --- a/server/src/__tests__/file-resources.test.ts +++ b/server/src/__tests__/file-resources.test.ts @@ -215,6 +215,28 @@ describeEmbeddedPostgres("workspace file resources", () => { expect(resolved.capabilities.preview).toBe(true); }); + it("resolves and reads video workspace files as base64 previews", async () => { + const { projectRoot, executionRoot } = await makeWorkspace(); + const graph = await seedGraph(db, { projectRoot, executionRoot }); + await fs.writeFile(path.join(projectRoot, "demo.mp4"), Buffer.from([0, 0, 0, 24, 102, 116, 121, 112])); + + const resolved = await workspaceFileResourceService(db).resolve(graph.issueId, { + path: "demo.mp4", + workspace: "project", + }); + expect(resolved.previewKind).toBe("video"); + expect(resolved.contentType).toBe("video/mp4"); + expect(resolved.capabilities.preview).toBe(true); + + const content = await workspaceFileResourceService(db).readContent(graph.issueId, { + path: "demo.mp4", + workspace: "project", + }); + expect(content.resource.previewKind).toBe("video"); + expect(content.content.encoding).toBe("base64"); + expect(content.content.data).toBe(Buffer.from([0, 0, 0, 24, 102, 116, 121, 112]).toString("base64")); + }); + it("lists and searches safe file candidates from the preferred execution workspace", async () => { const { root, projectRoot, executionRoot } = await makeWorkspace(); const graph = await seedGraph(db, { projectRoot, executionRoot }); diff --git a/server/src/services/workspace-file-resources.ts b/server/src/services/workspace-file-resources.ts index b06510bd23c..e01c2d743e5 100644 --- a/server/src/services/workspace-file-resources.ts +++ b/server/src/services/workspace-file-resources.ts @@ -87,6 +87,13 @@ const IMAGE_CONTENT_TYPES = new Map([ [".webp", "image/webp"], ]); +const VIDEO_CONTENT_TYPES = new Map([ + [".m4v", "video/mp4"], + [".mov", "video/quicktime"], + [".mp4", "video/mp4"], + [".webm", "video/webm"], +]); + type IssueRow = typeof issues.$inferSelect; type ExecutionWorkspaceRow = typeof executionWorkspaces.$inferSelect; type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect; @@ -118,7 +125,9 @@ type WorkspaceFileListQueryInput = { }; function previewCapForKind(kind: WorkspaceFilePreviewKind) { - return kind === "image" || kind === "pdf" ? WORKSPACE_FILE_MEDIA_MAX_BYTES : WORKSPACE_FILE_TEXT_MAX_BYTES; + return kind === "image" || kind === "video" || kind === "pdf" + ? WORKSPACE_FILE_MEDIA_MAX_BYTES + : WORKSPACE_FILE_TEXT_MAX_BYTES; } function relativePathFromReal(rootReal: string, targetReal: string) { @@ -184,6 +193,7 @@ function shouldPruneSegments(segments: string[]) { function contentTypeForPath(filePath: string): string | null { const ext = path.extname(filePath).toLowerCase(); if (IMAGE_CONTENT_TYPES.has(ext)) return IMAGE_CONTENT_TYPES.get(ext) ?? null; + if (VIDEO_CONTENT_TYPES.has(ext)) return VIDEO_CONTENT_TYPES.get(ext) ?? null; if (ext === ".pdf") return "application/pdf"; if (ext === ".svg") return "image/svg+xml"; if (ext === ".html" || ext === ".htm") return "text/html"; @@ -194,6 +204,7 @@ function contentTypeForPath(filePath: string): string | null { function previewKindForKnownContentType(contentType: string | null): WorkspaceFilePreviewKind | null { if (!contentType) return null; if (contentType.startsWith("image/") && contentType !== "image/svg+xml") return "image"; + if (contentType.startsWith("video/")) return "video"; if (contentType === "application/pdf") return "pdf"; if (contentType === "text/html") return "unsupported"; if (contentType === "image/svg+xml" || contentType.startsWith("text/")) return "text"; diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index 2f89eba17db..90bcdcc5953 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -1936,6 +1936,7 @@ async function isRuntimeServiceUrlHealthy( ) { if (!url) return true; const healthUrl = resolveRuntimeServiceHealthUrl(url, input); + if (!healthUrl) return true; try { const response = await fetch(healthUrl, { signal: AbortSignal.timeout(2_000) }); return response.ok; diff --git a/ui/src/components/FileViewerSheet.test.ts b/ui/src/components/FileViewerSheet.test.ts deleted file mode 100644 index c6d761fd107..00000000000 --- a/ui/src/components/FileViewerSheet.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { describeDenial } from "./FileViewerSheet"; - -describe("describeDenial", () => { - it("returns the curated body for too_large regardless of fallback", () => { - expect(describeDenial("too_large", "").body).toBe( - "This file exceeds the supported preview size.", - ); - expect(describeDenial("too_large", "ignored fallback").body).toBe( - "This file exceeds the supported preview size.", - ); - }); - - it("does not leak the raw denial code as body when fallback is empty", () => { - for (const code of [ - "denied_by_policy_sensitive", - "outside_workspace_root", - "workspace_archived", - "binary_unsupported", - "remote_preview_unsupported", - ]) { - const { body } = describeDenial(code, ""); - expect(body).not.toBe(code); - expect(body).not.toMatch(/^[a-z_]+$/); - } - }); - - it("falls back to the generic message for unknown codes with empty fallback", () => { - const { body, title } = describeDenial("", ""); - expect(title).toBe("Can't preview this file"); - expect(body).toBe("The viewer was unable to load this file."); - }); - - it("prefers a human-readable server message for unknown codes", () => { - const { body } = describeDenial("unknown_code", "Server refused the request."); - expect(body).toBe("Server refused the request."); - }); -}); diff --git a/ui/src/components/FileViewerSheet.test.tsx b/ui/src/components/FileViewerSheet.test.tsx new file mode 100644 index 00000000000..f648b748271 --- /dev/null +++ b/ui/src/components/FileViewerSheet.test.tsx @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { WorkspaceFileContent } from "@paperclipai/shared"; +import { describeDenial, FileContentViewer } from "./FileViewerSheet"; + +describe("describeDenial", () => { + it("returns the curated body for too_large regardless of fallback", () => { + expect(describeDenial("too_large", "").body).toBe( + "This file exceeds the supported preview size.", + ); + expect(describeDenial("too_large", "ignored fallback").body).toBe( + "This file exceeds the supported preview size.", + ); + }); + + it("does not leak the raw denial code as body when fallback is empty", () => { + for (const code of [ + "denied_by_policy_sensitive", + "outside_workspace_root", + "workspace_archived", + "binary_unsupported", + "remote_preview_unsupported", + ]) { + const { body } = describeDenial(code, ""); + expect(body).not.toBe(code); + expect(body).not.toMatch(/^[a-z_]+$/); + } + }); + + it("describes unsupported previews in terms of text, image, and video", () => { + expect(describeDenial("unsupported_content", "").body).toBe( + "This file does not have a text, image, or video preview available.", + ); + }); + + it("falls back to the generic message for unknown codes with empty fallback", () => { + const { body, title } = describeDenial("", ""); + expect(title).toBe("Can't preview this file"); + expect(body).toBe("The viewer was unable to load this file."); + }); + + it("prefers a human-readable server message for unknown codes", () => { + const { body } = describeDenial("unknown_code", "Server refused the request."); + expect(body).toBe("Server refused the request."); + }); +}); + +describe("FileContentViewer", () => { + function content(overrides: Partial = {}): WorkspaceFileContent { + return { + resource: { + kind: "file", + provider: "local_fs", + title: "notes.txt", + displayPath: "notes.txt", + workspaceLabel: "Workspace", + workspaceKind: "project_workspace", + workspaceId: "11111111-1111-4111-8111-111111111111", + contentType: "text/plain; charset=utf-8", + byteSize: 18, + previewKind: "text", + capabilities: { preview: true, download: false, listChildren: false }, + }, + content: { + encoding: "utf8", + data: "one very long line", + }, + ...overrides, + }; + } + + it("wraps text content instead of forcing horizontal preformatted scrolling", () => { + const markup = renderToStaticMarkup(); + expect(markup).toContain("whitespace-pre-wrap"); + expect(markup).toContain("break-words"); + }); + + it("renders video previews with native controls", () => { + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain("