1import { EnvironmentId, type ServerConfig } from "@t3tools/contracts";
2import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments";
3
4import { parseLegacyNewTaskDraftKey } from "../state/new-task-draft-key";
5import type { DraftComposerAttachment } from "./composerImages";
6
7export interface ComposerAttachmentUploadRequest {
8 readonly environmentId: EnvironmentId;
9 readonly attachment: DraftComposerAttachment;
10}
11
12export type ComposerAttachmentUploadState =
13 | { readonly status: "uploading"; readonly progress: number }
14 | { readonly status: "ready" }
15 | { readonly status: "failed"; readonly reason: string };
16
17export function composerAttachmentUploadKey(
18 environmentId: EnvironmentId,
19 attachmentId: string,
20): string {
21 return `${environmentId}:${attachmentId}`;
22}
23
24/** Environment and thread ids may both contain colons, so a thread draft key resolves to the longest known environment it starts with. */
25export function composerDraftEnvironmentId(
26 draftKey: string,
27 environmentIds: Iterable<EnvironmentId>,
28 queuedMessages: ReadonlyArray<{
29 readonly messageId: string;
30 readonly environmentId: EnvironmentId;
31 }>,
32 draft?: { readonly project?: { readonly environmentId: EnvironmentId } },
33): EnvironmentId | null {
34 if (draftKey.startsWith("pending-task:")) {
35 return (
36 queuedMessages.find((message) => `pending-task:${message.messageId}` === draftKey)
37 ?.environmentId ?? null
38 );
39 }
40 if (draftKey.startsWith("new-task:")) {
41 if (draft?.project) {
42 return draft.project.environmentId;
43 }
44 const legacy = parseLegacyNewTaskDraftKey(draftKey);
45 return legacy === null ? null : EnvironmentId.make(legacy.environmentId);
46 }
47 let match: EnvironmentId | null = null;
48 for (const environmentId of environmentIds) {
49 if (
50 draftKey.startsWith(`${environmentId}:`) &&
51 (match === null || environmentId.length > match.length)
52 ) {
53 match = environmentId;
54 }
55 }
56 return match;
57}
58
59type UploadServerConfig = {
60 readonly environment: {
61 readonly capabilities: Pick<ServerConfig["environment"]["capabilities"], "fileAttachments">;
62 };
63};
64
65export function canUploadComposerAttachment(
66 attachment: DraftComposerAttachment,
67 config: UploadServerConfig | null | undefined,
68): boolean {
69 const capabilities = config?.environment.capabilities;
70 return (
71 capabilities !== undefined &&
72 (attachment.type === "image" ||
73 (capabilities.fileAttachments !== undefined &&
74 attachment.sizeBytes <=
75 clampFileAttachmentUploadBytes(capabilities.fileAttachments.maxUploadBytes)))
76 );
77}
78
79export function composerAttachmentUploadBlockReason(input: {
80 readonly environmentId: EnvironmentId;
81 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
82 readonly connected: boolean;
83 readonly serverConfig: UploadServerConfig | null;
84 readonly states: Readonly<Record<string, ComposerAttachmentUploadState>>;
85}): string | null {
86 if (!input.connected) return null;
87 for (const attachment of input.attachments) {
88 if (!canUploadComposerAttachment(attachment, input.serverConfig)) continue;
89 const state = input.states[composerAttachmentUploadKey(input.environmentId, attachment.id)];
90 if (state?.status === "failed") return "Retry or remove the failed attachment";
91 }
92 return null;
93}
94
95export function composerAttachmentsStillUploading(input: {
96 readonly environmentId: EnvironmentId;
97 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
98 readonly serverConfig: UploadServerConfig | null;
99 readonly states: Readonly<Record<string, ComposerAttachmentUploadState>>;
100}): boolean {
101 return input.attachments.some((attachment) => {
102 if (!canUploadComposerAttachment(attachment, input.serverConfig)) return false;
103 const state = input.states[composerAttachmentUploadKey(input.environmentId, attachment.id)];
104 return state?.status !== "ready" && state?.status !== "failed";
105 });
106}
107
108export function createComposerAttachmentUploadQueue(options: {
109 readonly upload: (
110 request: ComposerAttachmentUploadRequest,
111 signal: AbortSignal,
112 onProgress: (progress: number) => void,
113 ) => Promise<boolean>;
114 readonly onChange: (states: Readonly<Record<string, ComposerAttachmentUploadState>>) => void;
115}) {
116 const jobs = new Map<
117 string,
118 { readonly controller: AbortController; readonly done: Promise<void> }
119 >();
120 let desired = new Map<string, ComposerAttachmentUploadRequest>();
121 let states: Readonly<Record<string, ComposerAttachmentUploadState>> = {};
122 let disposed = false;
123
124 function setState(key: string, state: ComposerAttachmentUploadState | undefined) {
125 const previous = states[key];
126 if (
127 previous === state ||
128 (previous?.status === "uploading" &&
129 state?.status === "uploading" &&
130 previous.progress === state.progress)
131 )
132 return;
133 const next = { ...states };
134 if (state) next[key] = state;
135 else delete next[key];
136 states = next;
137 options.onChange(states);
138 }
139
140 function pump() {
141 if (disposed) return;
142 for (const [key, request] of desired) {
143 if (jobs.size >= 3) break;
144 if (jobs.has(key) || states[key]?.status === "ready" || states[key]?.status === "failed")
145 continue;
146 const controller = new AbortController();
147 setState(key, { status: "uploading", progress: 0 });
148 // Publish the job before starting async work, including synchronous test transports.
149 const done = Promise.resolve()
150 .then(() =>
151 options.upload(request, controller.signal, (progress) => {
152 if (controller.signal.aborted) return;
153 setState(key, {
154 status: "uploading",
155 progress: Math.floor(Math.max(0, Math.min(1, progress)) * 20) / 20,
156 });
157 }),
158 )
159 .then((persisted) => {
160 if (!controller.signal.aborted && desired.has(key)) {
161 if (!persisted) desired.delete(key);
162 setState(key, persisted ? { status: "ready" } : undefined);
163 }
164 })
165 .catch((error: unknown) => {
166 if (!controller.signal.aborted && desired.has(key)) {
167 setState(key, {
168 status: "failed",
169 reason: error instanceof Error ? error.message : "Upload failed. Tap to retry.",
170 });
171 }
172 })
173 .finally(() => {
174 jobs.delete(key);
175 pump();
176 });
177 jobs.set(key, { controller, done });
178 }
179 }
180
181 return {
182 sync(requests: ReadonlyArray<ComposerAttachmentUploadRequest>) {
183 if (disposed) return;
184 desired = new Map(
185 requests.map((request) => [
186 composerAttachmentUploadKey(request.environmentId, request.attachment.id),
187 request,
188 ]),
189 );
190 for (const [key, job] of jobs) {
191 if (!desired.has(key)) job.controller.abort();
192 }
193 for (const key of Object.keys(states)) {
194 if (!desired.has(key)) setState(key, undefined);
195 }
196 for (const key of desired.keys()) {
197 if (!states[key]) setState(key, { status: "uploading", progress: 0 });
198 }
199 pump();
200 },
201 retry(environmentId: EnvironmentId, attachmentId: string) {
202 const key = composerAttachmentUploadKey(environmentId, attachmentId);
203 if (states[key]?.status !== "failed") return;
204 setState(key, undefined);
205 pump();
206 },
207 async settled() {
208 while (jobs.size > 0) await Promise.all([...jobs.values()].map((job) => job.done));
209 },
210 dispose() {
211 disposed = true;
212 desired.clear();
213 for (const job of jobs.values()) job.controller.abort();
214 states = {};
215 options.onChange(states);
216 },
217 };
218}