Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions apps/web/src/pages/library-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
ArtifactRenderer,
artifactMatchesLibraryKindSegment,
filterArtifacts,
isTextDecodableMediaType,
libraryArtifactIdFromPath,
libraryKindSegmentFromPath,
resolveArtifactRendererKind,
Expand Down Expand Up @@ -84,6 +85,7 @@ import {
LIBRARY_BULK_OPERATION_IDS,
mapArtifactListToSummaries,
uploadArtifactFiles,
uploadMimeTypeFromSource,
} from "../shell/library-artifacts";
import { StageTopBar } from "../shell/stage-top-bar";

Expand Down Expand Up @@ -250,6 +252,19 @@ function PreviewPane({
detail !== null && rendererKind === "html" && tenantId !== null
? artifactPreviewPath(tenantId, detail.id)
: undefined;
// Empty `content` on a file artifact is ambiguous on its own: it's the
// honest "nothing here" for an inline-content artifact, but it's also
// what a real upload's row carries when its bytes live out-of-band and
// aren't text-decodable (an image, a real PDF, a legacy `.docx`/`.xlsx`).
// `source.upload.mimeType` disambiguates — present only when this
// artifact really does have stored bytes behind it.
const uploadMimeType =
detail !== null ? uploadMimeTypeFromSource(detail.source) : null;
const contentUnavailable =
detail !== null &&
detail.content === "" &&
uploadMimeType !== null &&
!isTextDecodableMediaType(uploadMimeType);
return (
<aside className="flex min-h-0 min-w-0 flex-col border-l border-border bg-card">
<div className="flex items-center justify-between gap-2 border-b border-border px-4 py-3">
Expand Down Expand Up @@ -301,6 +316,7 @@ function PreviewPane({
rendererKind={rendererKind}
title={detail.title}
content={detail.content}
contentUnavailable={contentUnavailable}
{...(previewSrc !== undefined ? { previewSrc } : {})}
/>
) : null}
Expand Down
21 changes: 21 additions & 0 deletions apps/web/src/shell/library-artifacts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
artifactUploadToast,
copyArtifactLinksActionLabel,
copyArtifactLinksToastLabel,
uploadMimeTypeFromSource,
} from "./library-artifacts";

describe("copy-link labels", () => {
Expand Down Expand Up @@ -31,3 +32,23 @@ describe("artifactUploadToast", () => {
);
});
});

describe("uploadMimeTypeFromSource", () => {
test("reads the mime type off a real upload's source", () => {
expect(
uploadMimeTypeFromSource({
origin: "library-upload",
upload: { id: "u1", mimeType: "text/markdown", filename: "a.md" },
}),
).toBe("text/markdown");
});

test("is null for an artifact with no upload backing", () => {
expect(uploadMimeTypeFromSource({ origin: "chat" })).toBeNull();
});

test("is null when the upload field is malformed", () => {
expect(uploadMimeTypeFromSource({ upload: "not-an-object" })).toBeNull();
expect(uploadMimeTypeFromSource({ upload: { id: "u1" } })).toBeNull();
});
});
17 changes: 17 additions & 0 deletions apps/web/src/shell/library-artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,20 @@ export function copyArtifactLinksToastLabel(count: number): string {
export function copyArtifactLinksActionLabel(count: number): string {
return count > 1 ? `Copy ${count} links` : "Copy link";
}

/**
* The MIME type of a file artifact's out-of-band upload blob, read off
* `source.upload.mimeType` — present on every artifact minted through
* `createFileArtifact` (`@corbits/artifacts`), regardless of whether the
* hub could inline its bytes into `content` as text. Null for an artifact
* with no upload backing at all (e.g. a co-edited doc), which is the only
* case where an empty `content` genuinely means "nothing here yet."
*/
export function uploadMimeTypeFromSource(
source: Record<string, unknown>,
): string | null {
const upload = source.upload;
if (typeof upload !== "object" || upload === null) return null;
const mimeType = (upload as Record<string, unknown>).mimeType;
return typeof mimeType === "string" ? mimeType : null;
}
3 changes: 2 additions & 1 deletion packages/artifact-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"sideEffects": false,
"exports": {
".": "./src/index.ts",
"./kind-filter": "./src/kind-filter.ts"
"./kind-filter": "./src/kind-filter.ts",
"./renderer-kind": "./src/renderer-kind.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
Expand Down
80 changes: 72 additions & 8 deletions packages/artifact-ui/src/artifact-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@ export type ArtifactRenderProps = {
* never distinguished from "not fetched yet" (the host's own loading
* state handles that before this component ever mounts). */
readonly content: string;
/**
* True when `content` is empty NOT because the artifact is genuinely
* blank, but because its real bytes are stored out-of-band (a file
* upload) in a format this renderer can't decode as text — a binary
* `.docx`/`.xlsx`, an image, a real PDF. Empty `content` alone can't
* carry that distinction, so the host that fetched the artifact passes
* it explicitly. Swaps the per-kind "no content yet" copy for an honest
* "couldn't read this file" message — the artifact was NOT uploaded
* empty, its contents just can't be shown here.
*/
readonly contentUnavailable?: boolean;
/** Overrides the default "unsupported" copy with something specific to
* why this content can't be shown (e.g. a binary MIME type). */
readonly unavailableReason?: string;
Expand Down Expand Up @@ -67,9 +78,23 @@ function parseDocLines(content: string): readonly DocLine[] {
});
}

function DocRenderer({ content }: { readonly content: string }) {
function DocRenderer({
content,
contentUnavailable,
}: {
readonly content: string;
readonly contentUnavailable: boolean;
}) {
if (content === "") {
return <EmptyContent message="This document has no content yet." />;
return (
<EmptyContent
message={
contentUnavailable
? "We couldn't read this file's contents for preview."
: "This document has no content yet."
}
/>
);
}
const lines = parseDocLines(content);
const HeadingTag = ["h1", "h2", "h3"] as const;
Expand Down Expand Up @@ -97,9 +122,23 @@ function DocRenderer({ content }: { readonly content: string }) {
);
}

function SheetRenderer({ content }: { readonly content: string }) {
function SheetRenderer({
content,
contentUnavailable,
}: {
readonly content: string;
readonly contentUnavailable: boolean;
}) {
if (content === "") {
return <EmptyContent message="This sheet has no rows yet." />;
return (
<EmptyContent
message={
contentUnavailable
? "We couldn't read this file's contents for preview."
: "This sheet has no rows yet."
}
/>
);
}
return <CsvTable text={content} caption="Sheet contents" />;
}
Expand All @@ -111,13 +150,21 @@ function SheetRenderer({ content }: { readonly content: string }) {
function PdfRenderer({
title,
content,
contentUnavailable,
}: {
readonly title: string;
readonly content: string;
readonly contentUnavailable: boolean;
}) {
if (content === "") {
return (
<EmptyContent message="No extracted text is stored for this PDF — inline preview isn't available yet." />
<EmptyContent
message={
contentUnavailable
? "We couldn't read this file's contents for preview."
: "No extracted text is stored for this PDF — inline preview isn't available yet."
}
/>
);
}
return (
Expand Down Expand Up @@ -181,16 +228,33 @@ export function ArtifactRenderer({
rendererKind,
title,
content,
contentUnavailable,
unavailableReason,
previewSrc,
}: ArtifactRenderProps) {
switch (rendererKind) {
case "doc":
return <DocRenderer content={content} />;
return (
<DocRenderer
content={content}
contentUnavailable={contentUnavailable ?? false}
/>
);
case "sheet":
return <SheetRenderer content={content} />;
return (
<SheetRenderer
content={content}
contentUnavailable={contentUnavailable ?? false}
/>
);
case "pdf":
return <PdfRenderer title={title} content={content} />;
return (
<PdfRenderer
title={title}
content={content}
contentUnavailable={contentUnavailable ?? false}
/>
);
case "html":
return (
<HtmlPreviewRenderer
Expand Down
54 changes: 54 additions & 0 deletions packages/artifact-ui/test/artifact-renderer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,60 @@ describe("ArtifactRenderer sheet cutover", () => {
});
});

describe("ArtifactRenderer contentUnavailable — stored-but-unreadable file", () => {
test("doc renderer says the file couldn't be read, not that it's empty", () => {
const markup = renderToStaticMarkup(
<ArtifactRenderer
rendererKind="doc"
title="notes.docx"
content=""
contentUnavailable
/>,
);
expect(markup).toContain(
"We couldn&#x27;t read this file&#x27;s contents for preview.",
);
expect(markup).not.toContain("This document has no content yet.");
});

test("sheet renderer says the file couldn't be read, not that it's empty", () => {
const markup = renderToStaticMarkup(
<ArtifactRenderer
rendererKind="sheet"
title="budget.xlsx"
content=""
contentUnavailable
/>,
);
expect(markup).toContain(
"We couldn&#x27;t read this file&#x27;s contents for preview.",
);
expect(markup).not.toContain("This sheet has no rows yet.");
});

test("pdf renderer says the file couldn't be read, not that no text is stored", () => {
const markup = renderToStaticMarkup(
<ArtifactRenderer
rendererKind="pdf"
title="contract.pdf"
content=""
contentUnavailable
/>,
);
expect(markup).toContain(
"We couldn&#x27;t read this file&#x27;s contents for preview.",
);
expect(markup).not.toContain("No extracted text is stored");
});

test("a genuinely empty doc still reads as empty, not unreadable", () => {
const markup = renderToStaticMarkup(
<ArtifactRenderer rendererKind="doc" title="Untitled" content="" />,
);
expect(markup).toContain("This document has no content yet.");
});
});

describe("ArtifactRenderer html preview", () => {
test("renders a sandboxed iframe pointed at the preview route", () => {
const markup = renderToStaticMarkup(
Expand Down
Loading
Loading