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<DraftComposerAttachment>; readonly connected: boolean; readonly serverConfig: UploadServerConfig | null; readonly states: Readonly<Record<string, ComposerAttachmentUploadState>>;}): 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<DraftComposerAttachment>; readonly serverConfig: UploadServerConfig | null; readonly states: Readonly<Record<string, ComposerAttachmentUploadState>>;}): 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<boolean>; readonly onChange: (states: Readonly<Record<string, ComposerAttachmentUploadState>>) => void;}) { const jobs = new Map< string, { readonly controller: AbortController; readonly done: Promise<void> } >(); let desired = new Map<string, ComposerAttachmentUploadRequest>(); let states: Readonly<Record<string, ComposerAttachmentUploadState>> = {}; 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<ComposerAttachmentUploadRequest>) { 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); }, };}
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<DraftComposerAttachment>;
readonly connected: boolean;
readonly serverConfig: UploadServerConfig | null;
readonly states: Readonly<Record<string, ComposerAttachmentUploadState>>;
}): 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<DraftComposerAttachment>;
readonly serverConfig: UploadServerConfig | null;
readonly states: Readonly<Record<string, ComposerAttachmentUploadState>>;
}): 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<boolean>;
readonly onChange: (states: Readonly<Record<string, ComposerAttachmentUploadState>>) => void;
}) {
const jobs = new Map<
string,
{ readonly controller: AbortController; readonly done: Promise<void> }
>();
let desired = new Map<string, ComposerAttachmentUploadRequest>();
let states: Readonly<Record<string, ComposerAttachmentUploadState>> = {};
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<ComposerAttachmentUploadRequest>) {
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);
},
};
}