import { EnvironmentId, type ServerConfig } from "@t3tools/contracts"; import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; import { parseLegacyNewTaskDraftKey } from "../state/new-task-draft-key"; import type { DraftComposerAttachment } from "./composerImages"; export interface ComposerAttachmentUploadRequest { readonly environmentId: EnvironmentId; readonly attachment: DraftComposerAttachment; } export type ComposerAttachmentUploadState = | { readonly status: "uploading"; readonly progress: number } | { readonly status: "ready" } | { readonly status: "failed"; readonly reason: string }; export function composerAttachmentUploadKey( environmentId: EnvironmentId, attachmentId: string, ): string { return `${environmentId}:${attachmentId}`; } /** * Which environment a composer draft belongs to. Thread drafts carry it in * the key; pending-task editor drafts borrow it from the queued message; * new-task drafts carry it in their project stamp (legacy project-keyed * new-task drafts still parse from the key until they are migrated on load). */ export function composerDraftEnvironmentId( draftKey: string, queuedMessages: ReadonlyArray<{ readonly messageId: string; readonly environmentId: EnvironmentId; }>, draft?: { readonly project?: { readonly environmentId: EnvironmentId } }, ): EnvironmentId | null { if (draftKey.startsWith("pending-task:")) { return ( queuedMessages.find((message) => `pending-task:${message.messageId}` === draftKey) ?.environmentId ?? null ); } if (draftKey.startsWith("new-task:")) { if (draft?.project) { return draft.project.environmentId; } const legacy = parseLegacyNewTaskDraftKey(draftKey); return legacy === null ? null : EnvironmentId.make(legacy.environmentId); } const separator = draftKey.lastIndexOf(":"); return separator > 0 ? EnvironmentId.make(draftKey.slice(0, separator)) : null; } type UploadServerConfig = { readonly environment: { readonly capabilities: Pick< ServerConfig["environment"]["capabilities"], "attachmentUploads" | "fileAttachments" >; }; }; export function canUploadComposerAttachment( attachment: DraftComposerAttachment, config: UploadServerConfig | null | undefined, ): boolean { const capabilities = config?.environment.capabilities; return ( capabilities?.attachmentUploads === true && (attachment.type === "image" || (capabilities.fileAttachments !== undefined && attachment.sizeBytes <= clampFileAttachmentUploadBytes(capabilities.fileAttachments.maxUploadBytes))) ); } /** * Only a failed upload blocks sending: the outbox drain would hit the same * failure, so the user has to retry or remove the file first. An upload still * in flight does not block; the message queues and the drain reuses the * finished upload (or re-sends the local bytes) when it delivers. */ export function composerAttachmentUploadBlockReason(input: { readonly environmentId: EnvironmentId; readonly attachments: ReadonlyArray; readonly connected: boolean; readonly serverConfig: UploadServerConfig | null; readonly states: Readonly>; }): string | null { if (!input.connected) return null; for (const attachment of input.attachments) { if (!canUploadComposerAttachment(attachment, input.serverConfig)) continue; const state = input.states[composerAttachmentUploadKey(input.environmentId, attachment.id)]; if (state?.status === "failed") return "Retry or remove the failed attachment"; } return null; } /** Whether any attachment the environment accepts is still being uploaded. */ export function composerAttachmentsStillUploading(input: { readonly environmentId: EnvironmentId; readonly attachments: ReadonlyArray; readonly serverConfig: UploadServerConfig | null; readonly states: Readonly>; }): boolean { return input.attachments.some((attachment) => { if (!canUploadComposerAttachment(attachment, input.serverConfig)) return false; const state = input.states[composerAttachmentUploadKey(input.environmentId, attachment.id)]; return state?.status !== "ready" && state?.status !== "failed"; }); } /** Bounds transfers across environments; disconnected or discarded drafts keep their local bytes. */ export function createComposerAttachmentUploadQueue(options: { readonly upload: ( request: ComposerAttachmentUploadRequest, signal: AbortSignal, onProgress: (progress: number) => void, ) => Promise; readonly onChange: (states: Readonly>) => void; }) { const jobs = new Map< string, { readonly controller: AbortController; readonly done: Promise } >(); let desired = new Map(); let states: Readonly> = {}; let disposed = false; function setState(key: string, state: ComposerAttachmentUploadState | undefined) { const previous = states[key]; if ( previous === state || (previous?.status === "uploading" && state?.status === "uploading" && previous.progress === state.progress) ) return; const next = { ...states }; if (state) next[key] = state; else delete next[key]; states = next; options.onChange(states); } function pump() { if (disposed) return; for (const [key, request] of desired) { if (jobs.size >= 3) break; if (jobs.has(key) || states[key]?.status === "ready" || states[key]?.status === "failed") continue; const controller = new AbortController(); setState(key, { status: "uploading", progress: 0 }); // Publish the job before starting async work, including synchronous test transports. const done = Promise.resolve() .then(() => options.upload(request, controller.signal, (progress) => { if (controller.signal.aborted) return; setState(key, { status: "uploading", progress: Math.floor(Math.max(0, Math.min(1, progress)) * 20) / 20, }); }), ) .then((persisted) => { if (!controller.signal.aborted && desired.has(key)) { if (!persisted) desired.delete(key); setState(key, persisted ? { status: "ready" } : undefined); } }) .catch((error: unknown) => { if (!controller.signal.aborted && desired.has(key)) { setState(key, { status: "failed", reason: error instanceof Error ? error.message : "Upload failed. Tap to retry.", }); } }) .finally(() => { jobs.delete(key); pump(); }); jobs.set(key, { controller, done }); } } return { sync(requests: ReadonlyArray) { if (disposed) return; desired = new Map( requests.map((request) => [ composerAttachmentUploadKey(request.environmentId, request.attachment.id), request, ]), ); for (const [key, job] of jobs) { if (!desired.has(key)) job.controller.abort(); } for (const key of Object.keys(states)) { if (!desired.has(key)) setState(key, undefined); } for (const key of desired.keys()) { if (!states[key]) setState(key, { status: "uploading", progress: 0 }); } pump(); }, retry(environmentId: EnvironmentId, attachmentId: string) { const key = composerAttachmentUploadKey(environmentId, attachmentId); if (states[key]?.status !== "failed") return; setState(key, undefined); pump(); }, /** Waits for the current transfers, useful for shutdown and focused verification. */ async settled() { while (jobs.size > 0) await Promise.all([...jobs.values()].map((job) => job.done)); }, dispose() { disposed = true; desired.clear(); for (const job of jobs.values()) job.controller.abort(); states = {}; options.onChange(states); }, }; }