import type { AppShotCapture, FileAsset, FileResourceRef } from "@opengeni/sdk"; import { useCallback, useEffect, useRef, useState } from "react"; import { useEmbeddedFileAttachments, type EmbeddedFileAttachmentClientOverride, } from "../session-context"; import { useErrorMessage } from "../lib/error-message"; export type UseFileAttachmentsOptions = EmbeddedFileAttachmentClientOverride & { /** * Only files matching this predicate are accepted by {@link * UseFileAttachmentsResult.addFromPaste} (the clipboard path). Defaults to * `image/*` — the console's historical paste filter. {@link * UseFileAttachmentsResult.addFiles} (the explicit picker / drop path) * bypasses it. */ /** Retain original uploads in the chat owner’s personal scope when private. */ scope?: "workspace" | "personal"; pasteFilter?: ((file: File) => boolean) | undefined; }; export type FileAttachment = { id: string; name: string; contentType: string; sizeBytes: number; status: "uploading" | "ready" | "failed"; /** The SDK `FileAsset` once the upload finishes. */ file?: FileAsset | undefined; /** Object-URL for an inline preview; minted for `image/*` files only. */ previewUrl?: string | undefined; /** Stable SDK failure code for UI behavior that must not parse error copy. */ errorCode?: "secure_context_required" | undefined; error?: string | undefined; /** Original diagnostic failure; presentation copy is kept separately in `error`. */ errorCause?: unknown; /** * An app-shot: a screenshot of another app's window with the window it * shows and the text it exposed, sent to the agent with the image. */ appShot?: AppShotCapture | undefined; }; export type UseFileAttachmentsResult = { attachments: FileAttachment[]; /** * `FileResourceRef[]` for every attachment that finished uploading — feed * straight into `useComposer`'s `sendExtras.resources`. */ readyResources: FileResourceRef[]; /** True while any attachment is still uploading (drives progress UI). */ uploading: boolean; /** * True while any attachment still needs an explicit outcome: wait for an * upload, retry a failure, or remove it. This is the loss-prevention send * gate; failed cards must never be silently omitted from a message. */ hasUnresolved: boolean; /** Explicit picker / drop path — uploads every file, no filter. */ addFiles: (files: Iterable) => void; /** * Attach a window screenshot as an app-shot: the image plus the window it * shows and the text it exposed, stored with the file. Optional so custom * attachment stores written before app-shots keep satisfying this type. */ addAppShot?: ((image: File, appShot: AppShotCapture) => void) | undefined; /** Clipboard path — applies `pasteFilter` (default `image/*`) then uploads. */ addFromPaste: (event: { clipboardData: DataTransfer | null }) => void; /** Restore already-ready server assets without recreating browser-local bytes. */ restoreReadyFiles: (files: Iterable) => void; /** * Resolve a ready image's short-lived server preview URL on demand. Optional * for upload-only embedded clients; local object-URL previews remain usable * without it. */ loadPreview?: ((id: string, signal?: AbortSignal) => Promise) | undefined; /** * Re-run the upload for a `failed` attachment, in place (same id, same * source file). No-op for an id that isn't a known failed upload. */ retry: (id: string) => void; /** * Keep an attachment's object-URL alive for a consumer that outlives its * queue entry (for example, an open route-level lightbox). The returned * release is idempotent; pending revocation finishes after the last holder * releases. Returns `undefined` when the attachment has no local preview. */ retainPreview: (id: string) => (() => void) | undefined; /** Remove one attachment; revokes its object-URL after retained users finish. */ remove: (id: string) => void; /** * Remove only finalized files whose durable ids were accepted by a send. * Attachments added while that request was in flight remain queued for the * next message. */ removeReadyFiles: (fileIds: Iterable) => void; /** Remove all attachments and revoke every unretained object-URL. */ clear: () => void; }; const isImage = (file: File): boolean => file.type.startsWith("image/"); let fallbackAttachmentId = 0; function createAttachmentId(): string { const cryptoSource = globalThis.crypto; if (typeof cryptoSource?.randomUUID === "function") return cryptoSource.randomUUID(); fallbackAttachmentId += 1; // This id is only a browser-local React key and retry lookup, never durable // authority. Keep attachment tracking usable when HTTP withholds randomUUID // or Web Crypto is unavailable so the SDK's typed failure reaches the card. return `attachment:${Date.now().toString(36)}:${fallbackAttachmentId.toString(36)}`; } function secureContextRequiredErrorCode(error: unknown): "secure_context_required" | undefined { return typeof error === "object" && error !== null && "code" in error && error.code === "secure_context_required" ? error.code : undefined; } /** * Upload-and-track state for files attached to the next message. Owns the * full client-side upload layer: a per-file `uploading | ready | failed` * status machine driven by the SDK's `client.uploadFile`, object-URL image * previews with create/revoke lifecycle, the `image/*` clipboard paste filter, * and a `FileResourceRef[]` projection that drops straight into a message's * `resources`. Workspace-scoped, so it resolves both client and workspace from * the {@link OpenGeniProvider} (or a per-call `{ client, workspaceId }`). */ export function useFileAttachments( options: UseFileAttachmentsOptions = {}, ): UseFileAttachmentsResult { const { client, workspaceId } = useEmbeddedFileAttachments(options); const formatError = useErrorMessage(); const pasteFilter = options.pasteFilter ?? isImage; const [attachments, setAttachments] = useState([]); const attachmentsRef = useRef(attachments); attachmentsRef.current = attachments; // Keep the source File per attachment id so a failed upload can be retried // in place. Cleared on remove/clear so it never outlives its attachment. const sources = useRef>(new Map()); const appShots = useRef>(new Map()); // Object URLs must be owned synchronously: React may discard an attachment // state update when its component unmounts in the same batch that minted the // preview. The registry remains available to cleanup even before a render. const previewUrls = useRef>(new Map()); const previewRetainers = useRef>(new Map()); const pendingPreviewRevocations = useRef>(new Set()); const revokePreview = useCallback((id: string) => { const previewUrl = previewUrls.current.get(id); if (!previewUrl) return; if ((previewRetainers.current.get(id) ?? 0) > 0) { pendingPreviewRevocations.current.add(id); return; } previewUrls.current.delete(id); pendingPreviewRevocations.current.delete(id); URL.revokeObjectURL(previewUrl); }, []); const revokeAllPreviews = useCallback(() => { for (const id of previewUrls.current.keys()) revokePreview(id); }, [revokePreview]); const retainPreview = useCallback( (id: string): (() => void) | undefined => { if (!previewUrls.current.has(id)) return undefined; previewRetainers.current.set(id, (previewRetainers.current.get(id) ?? 0) + 1); let released = false; return () => { if (released) return; released = true; const remaining = (previewRetainers.current.get(id) ?? 1) - 1; if (remaining > 0) { previewRetainers.current.set(id, remaining); return; } previewRetainers.current.delete(id); if (pendingPreviewRevocations.current.has(id)) revokePreview(id); }; }, [revokePreview], ); // Ready-file reconciliation uses functional state updaters, which React may // evaluate during a concurrent render that later suspends or is abandoned. // Diff only committed attachment sets here so URL revocation cannot run from // render-phase code while the previously committed DOM still uses a preview. const committedAttachmentIds = useRef>(new Set()); useEffect(() => { const currentIds = new Set(attachments.map((attachment) => attachment.id)); for (const id of committedAttachmentIds.current) { if (!currentIds.has(id)) revokePreview(id); } committedAttachmentIds.current = currentIds; }, [attachments, revokePreview]); // Upload promises are not cancellable at this layer. Fence their settlements // when the hook changes client/workspace or unmounts so an old tenant/session // cannot mutate the next attachment queue (or an already-unmounted component). const scopeGeneration = useRef(0); const previousScope = useRef({ client, workspaceId }); useEffect(() => { const previous = previousScope.current; if (previous.client === client && previous.workspaceId === workspaceId) return; previousScope.current = { client, workspaceId }; scopeGeneration.current += 1; sources.current.clear(); appShots.current.clear(); revokeAllPreviews(); setAttachments([]); }, [client, revokeAllPreviews, workspaceId]); useEffect( () => () => { scopeGeneration.current += 1; sources.current.clear(); revokeAllPreviews(); }, [revokeAllPreviews], ); // Run (or re-run) the upload for one already-tracked attachment id. Sets it // back to `uploading`, then resolves to `ready` (with the asset) or `failed` // (with the error message). const startUpload = useCallback( (id: string, file: File) => { const generation = scopeGeneration.current; void client .uploadFile(workspaceId, { filename: file.name || "file", contentType: file.type || "application/octet-stream", data: file, ...(options.scope ? { scope: options.scope } : {}), ...(appShots.current.has(id) ? { appShot: appShots.current.get(id) } : {}), }) .then((asset) => { if (scopeGeneration.current !== generation) return; // Retry bytes are useful only until durable finalization succeeds. // Drop the source File immediately; restored/ready attachments must // never retain browser-local byte authority. sources.current.delete(id); appShots.current.delete(id); setAttachments((current) => current.map((attachment) => attachment.id === id ? { ...attachment, status: "ready", file: asset, name: asset.filename, contentType: asset.contentType, sizeBytes: asset.sizeBytes, errorCode: undefined, error: undefined, errorCause: undefined, } : attachment, ), ); }) .catch((error: unknown) => { if (scopeGeneration.current !== generation) return; setAttachments((current) => current.map((attachment) => attachment.id === id ? { ...attachment, status: "failed", errorCode: secureContextRequiredErrorCode(error), error: formatError(error), errorCause: error, } : attachment, ), ); }); }, [client, workspaceId, options.scope, formatError], ); const addFiles = useCallback( (files: Iterable) => { for (const file of files) { const id = createAttachmentId(); sources.current.set(id, file); const previewUrl = isImage(file) ? URL.createObjectURL(file) : undefined; if (previewUrl) previewUrls.current.set(id, previewUrl); setAttachments((current) => [ ...current, { id, name: file.name || "image", contentType: file.type || "application/octet-stream", sizeBytes: file.size, status: "uploading", ...(previewUrl ? { previewUrl } : {}), }, ]); startUpload(id, file); } }, [startUpload], ); const addAppShot = useCallback( (image: File, appShot: AppShotCapture) => { const id = createAttachmentId(); sources.current.set(id, image); appShots.current.set(id, appShot); const previewUrl = URL.createObjectURL(image); previewUrls.current.set(id, previewUrl); setAttachments((current) => [ ...current, { id, name: image.name || `${appShot.app}.png`, contentType: image.type || "image/png", sizeBytes: image.size, status: "uploading", previewUrl, appShot, }, ]); startUpload(id, image); }, [startUpload], ); const retry = useCallback( (id: string) => { const file = sources.current.get(id); if (!file) { return; } setAttachments((current) => current.map((attachment) => attachment.id === id ? { ...attachment, status: "uploading", errorCode: undefined, error: undefined, errorCause: undefined, } : attachment, ), ); startUpload(id, file); }, [startUpload], ); const addFromPaste = useCallback( (event: { clipboardData: DataTransfer | null }) => { const clipboardFiles = event.clipboardData?.files; if (!clipboardFiles) { return; } const files = [...clipboardFiles].filter(pasteFilter); if (files.length > 0) { addFiles(files); } }, [addFiles, pasteFilter], ); const restoreReadyFiles = useCallback( (files: Iterable) => { const incoming = new Map(); for (const file of files) { if (file.status === "ready" && file.workspaceId === workspaceId) { incoming.set(file.id, file); } } setAttachments((current) => { const unresolved = current.filter((attachment) => attachment.status !== "ready"); const existingReady = new Map( current.flatMap((attachment) => attachment.status === "ready" && attachment.file ? ([[attachment.file.id, attachment]] as const) : [], ), ); const restored = [...incoming.values()].map((file): FileAttachment => { const existing = existingReady.get(file.id); return existing ? { ...existing, name: file.filename, contentType: file.contentType, sizeBytes: file.sizeBytes, status: "ready", file, appShot: file.appShot ?? existing.appShot, errorCode: undefined, error: undefined, errorCause: undefined, } : { id: `restored:${file.id}`, name: file.filename, contentType: file.contentType, sizeBytes: file.sizeBytes, status: "ready", file, ...(file.appShot ? { appShot: file.appShot } : {}), // No source File and no object URL: server metadata is the // only authority restored across page/device boundaries. }; }); // A server restoration is authoritative for finalized assets, but an // upload that has not finalized still belongs to the local actor. Keep // those unresolved entries while replacing the ready set exactly. return [...unresolved, ...restored]; }); }, [workspaceId], ); const loadPreview = useCallback( async (id: string, signal?: AbortSignal): Promise => { const generation = scopeGeneration.current; const attachment = attachmentsRef.current.find((candidate) => candidate.id === id); const createDownloadUrl = client.createFileDownloadUrl; if ( !attachment || attachment.status !== "ready" || !attachment.file || !attachment.contentType.startsWith("image/") || typeof createDownloadUrl !== "function" || signal?.aborted ) { return undefined; } const fileId = attachment.file.id; const signed = await createDownloadUrl.call(client, workspaceId, fileId, { signal }); if (scopeGeneration.current !== generation || signal?.aborted) return undefined; const current = attachmentsRef.current.find((candidate) => candidate.id === id); if (current?.status !== "ready" || current.file?.id !== fileId) return undefined; return signed.url; }, [client, workspaceId], ); const remove = useCallback( (id: string) => { sources.current.delete(id); appShots.current.delete(id); revokePreview(id); setAttachments((current) => current.filter((attachment) => attachment.id !== id)); }, [revokePreview], ); const removeReadyFiles = useCallback((fileIds: Iterable) => { const accepted = new Set(fileIds); if (accepted.size === 0) return; setAttachments((current) => current.filter((attachment) => { return !( attachment.status === "ready" && attachment.file !== undefined && accepted.has(attachment.file.id) ); }), ); }, []); const clear = useCallback(() => { sources.current.clear(); appShots.current.clear(); revokeAllPreviews(); setAttachments([]); }, [revokeAllPreviews]); return { attachments, readyResources: attachments.flatMap((attachment): FileResourceRef[] => attachment.status === "ready" && attachment.file ? [{ kind: "file", fileId: attachment.file.id }] : [], ), uploading: attachments.some((attachment) => attachment.status === "uploading"), hasUnresolved: attachments.some((attachment) => attachment.status !== "ready"), addFiles, addAppShot, addFromPaste, restoreReadyFiles, ...(typeof client.createFileDownloadUrl === "function" ? { loadPreview } : {}), retry, retainPreview, remove, removeReadyFiles, clear, }; }