diff --git a/doc/plans/2026-10-22-artifacts-mvp-v1.md b/doc/plans/2026-10-22-artifacts-mvp-v1.md index fcae7a0e209..270e77e5762 100644 --- a/doc/plans/2026-10-22-artifacts-mvp-v1.md +++ b/doc/plans/2026-10-22-artifacts-mvp-v1.md @@ -137,5 +137,9 @@ the wiring is covered by tests. the default path unchanged, and the V1 path gets its own tests. - Pictures without known dimensions render at 4:3 until they load; the fitted print then resizes once. The stack frame's height is fixed, so nothing below it moves. +- A picture whose file cannot be loaded (an attachment that returns 404, a file that is not + a picture after all) falls back to the icon sheet on the Artifacts page and to the document + icon in the panel and thread rows, from the element's own `error` event; nothing is + fetched to find out, and a front that moves on to another picture gets a fresh chance. - Token count grows by about thirty `--artifact-*` entries; they are namespaced and documented in the token block. diff --git a/ui/src/components/artifacts/v1/ArtifactFront.tsx b/ui/src/components/artifacts/v1/ArtifactFront.tsx index a86c6e35921..ea118d5c6c1 100644 --- a/ui/src/components/artifacts/v1/ArtifactFront.tsx +++ b/ui/src/components/artifacts/v1/ArtifactFront.tsx @@ -1,26 +1,32 @@ import type { CSSProperties, SyntheticEvent } from "react"; -import { ArtifactPrint, type PrintSize } from "./ArtifactPrint"; +import { ArtifactPrint, usePreviewFailure, type PrintSize } from "./ArtifactPrint"; import { IconSheet, PreviewSheet } from "./ArtifactSheet"; -import { treatmentOf, type ArtifactSubject } from "./kinds"; +import { previewSourceOf, treatmentOf, type ArtifactSubject } from "./kinds"; /** * The artifact's own representation: one of the mock's four treatments. A * print is sized by the caller (a stack fits it to its own box); on its own - * it fits the single-artifact box. The root carries data-artifact-front so - * a hovered tile can lift it (index.css). + * it fits the single-artifact box. A picture that cannot be loaded gives + * way to the icon sheet, the face a file without a preview gets, rather + * than the browser's broken-picture glyph; the caller hears of it through + * onMediaError so it can stand the sheet where sheets stand. The root + * carries data-artifact-front so a hovered tile can lift it (index.css). */ export function ArtifactFront({ subject, print, onMediaLoad, + onMediaError, style, }: { subject: ArtifactSubject; print?: PrintSize; onMediaLoad?: (event: SyntheticEvent) => void; + onMediaError?: (event: SyntheticEvent) => void; style?: CSSProperties; }) { - switch (treatmentOf(subject)) { + const [failed, onOwnError] = usePreviewFailure(previewSourceOf(subject)); + switch (failed ? "icon" : treatmentOf(subject)) { case "preview": return (
@@ -28,7 +34,18 @@ export function ArtifactFront({
); case "media": - return ; + return ( + { + onOwnError(); + onMediaError?.(event); + }} + style={style} + /> + ); case "media-fallback": return (
diff --git a/ui/src/components/artifacts/v1/ArtifactPrint.tsx b/ui/src/components/artifacts/v1/ArtifactPrint.tsx index 6fed91aae3c..ac8cedf638c 100644 --- a/ui/src/components/artifacts/v1/ArtifactPrint.tsx +++ b/ui/src/components/artifacts/v1/ArtifactPrint.tsx @@ -8,7 +8,10 @@ import type { ArtifactSubject } from "./kinds"; * soft shadow (`.artifact-print`, index.css). A print is sized to fit a box * while keeping the picture's ratio, so a leading picture never grows past * the stack it stands in. The ratio comes with the subject when known and - * from the loaded picture otherwise; until then the print is 4:3. + * from the loaded picture otherwise; until then the print is 4:3. When the + * picture (or a video's metadata) cannot be loaded — the file is gone, or + * not a picture after all — the print reports it through onMediaError and + * the caller shows the icon sheet in its place. */ export interface PrintSize { width: number; @@ -35,16 +38,31 @@ export function useImageAspect(known: number | null | undefined): [number | null return [known && known > 0 ? known : loaded, onLoad]; } +/** + * Whether the picture at `source` (or a video's metadata) could not be + * loaded. The failure is remembered by source rather than as a flag, so a + * front that moves on to another picture (a stack's newest artifact) gets a + * fresh chance, and nothing is fetched to find out. + */ +export function usePreviewFailure(source: string | null): [boolean, () => void] { + const [failedSource, setFailedSource] = useState(null); + const onError = useCallback(() => setFailedSource(source), [source]); + return [source !== null && failedSource === source, onError]; +} + export function ArtifactPrint({ subject, size, onMediaLoad, + onMediaError, style, }: { subject: ArtifactSubject; /** Sized by the caller (a stack fits it to its own box); on its own it fits the single box. */ size?: PrintSize; onMediaLoad?: (event: SyntheticEvent) => void; + /** The picture (or the video's metadata) could not be loaded; the caller shows another face. */ + onMediaError?: (event: SyntheticEvent) => void; style?: CSSProperties; }) { const [ownAspect, onOwnLoad] = useImageAspect(subject.aspect); @@ -67,6 +85,7 @@ export function ArtifactPrint({ onOwnLoad(event); onMediaLoad?.(event); }} + onError={onMediaError} /> ) : ( )} {subject.kind === "video" ? ( diff --git a/ui/src/components/artifacts/v1/ArtifactRow.test.tsx b/ui/src/components/artifacts/v1/ArtifactRow.test.tsx index 9ad01901305..1f51578ee21 100644 --- a/ui/src/components/artifacts/v1/ArtifactRow.test.tsx +++ b/ui/src/components/artifacts/v1/ArtifactRow.test.tsx @@ -59,6 +59,27 @@ describe("ArtifactRow", () => { expect(link?.querySelector("img")?.getAttribute("src")).toBe("/sheet.png"); }); + it("shows the document icon in place of a thumbnail that cannot be loaded, and gives a new picture its own chance", () => { + const render = (thumbnailUrl: string) => act(() => root.render()); + render("/gone.png"); + const row = () => container.querySelector('[data-testid="artifact-v1-row"]'); + expect(row()?.textContent).not.toContain("PNG"); + act(() => { container.querySelector('img[src="/gone.png"]')!.dispatchEvent(new Event("error")); }); + expect(container.querySelector('img[src="/gone.png"]')).toBeNull(); + expect(row()?.textContent).toContain("PNG"); + expect(row()?.querySelector('button[data-testid="artifact-v1-row-open"]')?.getAttribute("aria-label")).toBe("Open contact-sheet.png"); + render("/fresh.png"); + expect(container.querySelector('img[src="/fresh.png"]')).not.toBeNull(); + expect(row()?.textContent).not.toContain("PNG"); + }); + + it("shows the document icon for a video whose stream cannot be loaded", () => { + act(() => root.render()); + act(() => { container.querySelector("video")!.dispatchEvent(new Event("error")); }); + expect(container.querySelector("video")).toBeNull(); + expect(container.querySelector('[data-testid="artifact-v1-row"]')?.textContent).toContain("WEBM"); + }); + it("keeps an anchor's address while a plain click runs the handler and a modifier click follows the link", () => { const onClick = vi.fn(); act(() => root.render()); diff --git a/ui/src/components/artifacts/v1/ArtifactRow.tsx b/ui/src/components/artifacts/v1/ArtifactRow.tsx index df462d830c9..ff896aa251d 100644 --- a/ui/src/components/artifacts/v1/ArtifactRow.tsx +++ b/ui/src/components/artifacts/v1/ArtifactRow.tsx @@ -7,8 +7,9 @@ import panelDocCorner from "./assets/panel-doc-corner.svg"; import panelGlyphAudio from "./assets/panel-glyph-audio.svg"; import panelGlyphSlides from "./assets/panel-glyph-slides.svg"; import panelPlay from "./assets/panel-play.svg"; +import { usePreviewFailure } from "./ArtifactPrint"; import { ROW_ICON, ROW_PLAY } from "./geometry"; -import { glyphOf, isAudio, labelOf, treatmentOf, type ArtifactSubject } from "./kinds"; +import { glyphOf, isAudio, labelOf, previewSourceOf, treatmentOf, type ArtifactSubject } from "./kinds"; import { panelDateLabel } from "./when"; /** @@ -19,7 +20,8 @@ import { panelDateLabel } from "./when"; * a 14px white glyph at (5, 5) and the type in 11px bold at (5, 22); PDF and * DOC use the mock's three bars, PPTX and audio the mock's own glyphs, other * types their lucide icon. Pictures and video show a 40px thumbnail instead, - * video with a 32px play disc. + * video with a 32px play disc; a thumbnail that cannot be loaded gives way + * to the document icon. */ const ICON_LABEL: CSSProperties = { @@ -67,12 +69,14 @@ export function ArtifactRowIcon({ subject }: { subject: ArtifactSubject }) { } function Thumbnail({ subject }: { subject: ArtifactSubject }) { + const [failed, onError] = usePreviewFailure(previewSourceOf(subject)); + if (failed) return ; return ( {subject.kind === "video" && !subject.thumbnailUrl ? ( -
); } diff --git a/ui/src/components/artifacts/v1/ArtifactTile.test.tsx b/ui/src/components/artifacts/v1/ArtifactTile.test.tsx index 54fc2da6bad..b6f0f00326c 100644 --- a/ui/src/components/artifacts/v1/ArtifactTile.test.tsx +++ b/ui/src/components/artifacts/v1/ArtifactTile.test.tsx @@ -96,4 +96,77 @@ describe("the Artifacts page tiles", () => { expect(container.querySelector('[data-artifact-sheet="preview"]')?.textContent).toContain("Brand strategy"); expect(container.querySelector("[data-artifact-front]")).not.toBeNull(); }); + + it("keeps the 4:3 print until a single picture loads, and shows the icon sheet when its file cannot be loaded", () => { + act(() => + root.render( + + + + + , + ), + ); + const print = container.querySelector("[data-artifact-print]")!; + expect([print.style.width, print.style.height]).toEqual(["240px", "180px"]); + act(() => { print.querySelector("img")!.dispatchEvent(new Event("error")); }); + expect(container.querySelector("[data-artifact-print]")).toBeNull(); + const sheet = container.querySelector('[data-artifact-front] [data-artifact-sheet="icon"]'); + expect(sheet?.textContent).toContain("PNG"); + expect(sheet?.querySelector(".lucide-image")).not.toBeNull(); + expect(container.querySelector('button[data-testid="artifact-v1-single-tile"]')?.getAttribute("aria-label")).toBe("Open hero.png"); + }); + + it("stands a picture stack's front as the icon sheet, where audio stands, when the picture cannot be loaded, and prints a new picture back in its place", () => { + const render = (thumbnailUrl: string) => + act(() => + root.render( + + + + + + , + ), + ); + const tiles = () => Array.from(container.querySelectorAll('[data-testid="artifact-v1-stack-tile"]')); + const placement = (el: HTMLElement | null) => el && [el.style.left, el.style.top, el.style.width, el.style.height]; + render("/api/attachments/gone/content"); + let [pictures, audio] = tiles(); + const printed = placement(pictures.querySelector("[data-artifact-print]")); + expect(printed?.[2]).toBe("245px"); + act(() => { pictures.querySelector("[data-artifact-print] img")!.dispatchEvent(new Event("error")); }); + expect(pictures.querySelector("[data-artifact-print]")).toBeNull(); + expect(pictures.querySelector('[data-artifact-stack="media"]')).not.toBeNull(); + expect(pictures.querySelector('[data-artifact-sheet="icon"]')?.textContent).toContain("PNG"); + const front = pictures.querySelector("[data-artifact-front]")!; + const audioFront = audio.querySelector("[data-artifact-front]")!; + expect([front.style.left, front.style.top]).toEqual([audioFront.style.left, audioFront.style.top]); + expect(audio.querySelector("[data-artifact-print]")).toBeNull(); + // A newer artifact takes the front: the stack's geometry and the front's face both start over from the new source. + render("/api/attachments/fresh/content"); + [pictures] = tiles(); + expect(pictures.querySelector('[data-artifact-sheet="icon"]')).toBeNull(); + expect(pictures.querySelector("[data-artifact-print] img")?.getAttribute("src")).toBe("/api/attachments/fresh/content"); + expect(placement(pictures.querySelector("[data-artifact-print]"))).toEqual(printed); + }); + + it("shows the icon sheet without a play disc for a video whose stream cannot be loaded", () => { + act(() => + root.render( + + + + + , + ), + ); + expect(container.querySelector("[data-artifact-print] .lucide-play")).not.toBeNull(); + act(() => { container.querySelector("[data-artifact-print] video")!.dispatchEvent(new Event("error")); }); + expect(container.querySelector("[data-artifact-print]")).toBeNull(); + const sheet = container.querySelector('[data-artifact-sheet="icon"]'); + expect(sheet?.textContent).toContain("WEBM"); + expect(sheet?.querySelector(".lucide-film")).not.toBeNull(); + expect(sheet?.querySelector(".lucide-play")).toBeNull(); + }); }); diff --git a/ui/src/components/artifacts/v1/index.ts b/ui/src/components/artifacts/v1/index.ts index a1835e08818..abe4be5bb95 100644 --- a/ui/src/components/artifacts/v1/index.ts +++ b/ui/src/components/artifacts/v1/index.ts @@ -1,7 +1,7 @@ export * from "./kinds"; export * from "./when"; export * from "./geometry"; -export { ArtifactPrint, fitPrint, useImageAspect, type PrintSize } from "./ArtifactPrint"; +export { ArtifactPrint, fitPrint, useImageAspect, usePreviewFailure, type PrintSize } from "./ArtifactPrint"; export { IconSheet, PreviewSheet } from "./ArtifactSheet"; export { ArtifactFront } from "./ArtifactFront"; export { ArtifactStack } from "./ArtifactStack";