import { AppWindowIcon, DownloadIcon, FileJsonIcon, ImageIcon } from "lucide-react"; import { useLightboxOptional } from "../../../timeline"; import { useEffect, useState } from "react"; import { toast } from "../../lib/toast"; import { useAppContext } from "../../app-context"; import { usePage } from "../../lib/navigation"; import { userErrorText } from "../../../ui/api-error"; import { cn } from "../../lib/utils"; import type { FileAsset, ResourceRef } from "../../types"; import { MessageFileSkeletons, MessageRepositoryChips } from "./message-resource-placeholders"; type FileResource = Extract; export default function MessageResourceAttachments({ workspaceId, sessionId, resources, }: { workspaceId: string; sessionId?: string | undefined; resources: ResourceRef[]; }) { const fileResources = resources.filter( (resource): resource is FileResource => resource.kind === "file", ); const { assets, ready } = useFileAssets(workspaceId, fileResources, sessionId); const filesPending = fileResources.length > 0 && !ready; const imageResources = filesPending ? [] : fileResources.filter((resource) => isImageAsset(assets.get(resource.fileId))); const otherFileResources = filesPending ? [] : fileResources.filter((resource) => !isImageAsset(assets.get(resource.fileId))); const hasChips = otherFileResources.length > 0 || resources.some((resource) => resource.kind === "repository"); return ( <> {filesPending ? : null} {imageResources.length > 0 ? (
{imageResources.map((resource) => ( 1} /> ))}
) : null} {hasChips ? (
{otherFileResources.map((resource) => ( ))}
) : null} ); } /** Fetch metadata as one batch; never flash an image as a file chip first. */ function useFileAssets( workspaceId: string, resources: FileResource[], sessionId?: string, ): { assets: Map; ready: boolean } { const { client } = useAppContext(); // The map remembers WHICH id-key it was fetched for: when the attachments // change, the stale map must not masquerade as this message's metadata while // the new fetch is in flight (previews briefly showed the previous message's // files). `ready` is key-matched, never inferred from map size. const [loaded, setLoaded] = useState<{ key: string; assets: Map }>({ key: "", assets: new Map(), }); const fileIdsKey = resources.map((resource) => resource.fileId).join(","); const key = `${sessionId ?? ""}:${fileIdsKey}`; useEffect(() => { let mounted = true; const ids = fileIdsKey ? fileIdsKey.split(",") : []; if (ids.length === 0) { setLoaded({ key, assets: new Map() }); return; } void Promise.all( ids.map(async (id): Promise => { try { return [id, await client.getFile(workspaceId, id, { sessionId })] as const; } catch { return [id, null] as const; } }), ).then((entries) => { if (mounted) { setLoaded({ key, assets: new Map(entries) }); } }); return () => { mounted = false; }; }, [client, workspaceId, sessionId, fileIdsKey, key]); return { assets: loaded.key === key ? loaded.assets : new Map(), ready: loaded.key === key }; } function isImageAsset(asset: FileAsset | null | undefined): boolean { return Boolean(asset?.contentType.startsWith("image/")); } /** Signed image preview with lightbox and download; failures become file chips. */ function MessageImagePreview({ workspaceId, sessionId, resource, asset, grid, }: { workspaceId: string; sessionId?: string | undefined; resource: FileResource; asset: FileAsset; grid: boolean; }) { const { client } = useAppContext(); const page = usePage(); const lightbox = useLightboxOptional(); const [url, setUrl] = useState(null); const [failed, setFailed] = useState(false); const [loaded, setLoaded] = useState(false); useEffect(() => { let mounted = true; // Same mounted instance, new file (virtualized/reused rows): the previous // attachment's url/failed/loaded must never bleed into this one. setUrl(null); setFailed(false); setLoaded(false); void client .createFileDownloadUrl(workspaceId, resource.fileId, { sessionId }) .then((signed) => { if (mounted) { setUrl(signed.url); } }) .catch(() => { if (mounted) { setFailed(true); } }); return () => { mounted = false; }; }, [client, workspaceId, sessionId, resource.fileId]); // A dead/expired signed URL degrades to the plain file card — never a broken image. if (failed) { return ( ); } // An app-shot names the window it shows rather than the screenshot's file. const appShot = asset.appShot; const appShotCaption = appShot ? [appShot.app, appShot.title.trim()].filter(Boolean).join(" · ") : ""; const openFull = () => { if (!url) { return; } if (lightbox) { lightbox.open(url, asset.filename); } else { page.open(url); } }; async function download() { try { const signed = await client.createFileDownloadUrl(workspaceId, resource.fileId, { sessionId, }); page.open(signed.url); } catch (error) { toast.error("Couldn't download the image", { description: userErrorText(error), }); } } return (
{appShot ? ( <> App-shot: {appShotCaption} ) : ( <> {asset.filename} )}
); } /** File chip with the download-url affordance (signed URL on click). */ function MessageFileAttachment({ workspaceId, sessionId, resource, asset: preloaded, }: { workspaceId: string; sessionId?: string | undefined; resource: FileResource; /** When the parent already fetched the asset, skip the redundant lookup. */ asset?: FileAsset | undefined; }) { const { client } = useAppContext(); const page = usePage(); const [file, setFile] = useState(preloaded ?? null); const [busy, setBusy] = useState(false); useEffect(() => { if (preloaded) { setFile(preloaded); return; } let mounted = true; void client .getFile(workspaceId, resource.fileId, { sessionId }) .then((asset) => { if (mounted) { setFile(asset); } }) .catch(() => undefined); return () => { mounted = false; }; }, [client, workspaceId, sessionId, resource.fileId, preloaded]); async function openFile() { setBusy(true); try { const signed = await client.createFileDownloadUrl(workspaceId, resource.fileId, { sessionId, }); page.open(signed.url); } catch (error) { toast.error("Couldn't open the file", { description: userErrorText(error), }); } finally { setBusy(false); } } const isImage = file?.contentType.startsWith("image/"); return ( ); }