@open-mercato/ui 0.5.1-develop.3036.f02c281f23 → 0.5.1-develop.3045.b4b3320cc2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +2 -1
  3. package/__integration__/TC-AI-UI-003-aichat-registry.spec.tsx +204 -0
  4. package/dist/ai/AiAssistantLauncher.js +596 -0
  5. package/dist/ai/AiAssistantLauncher.js.map +7 -0
  6. package/dist/ai/AiChat.js +1092 -0
  7. package/dist/ai/AiChat.js.map +7 -0
  8. package/dist/ai/AiChatSessions.js +297 -0
  9. package/dist/ai/AiChatSessions.js.map +7 -0
  10. package/dist/ai/AiDock.js +347 -0
  11. package/dist/ai/AiDock.js.map +7 -0
  12. package/dist/ai/AiMessageContent.js +369 -0
  13. package/dist/ai/AiMessageContent.js.map +7 -0
  14. package/dist/ai/ChatPaneTabs.js +251 -0
  15. package/dist/ai/ChatPaneTabs.js.map +7 -0
  16. package/dist/ai/index.js +115 -0
  17. package/dist/ai/index.js.map +7 -0
  18. package/dist/ai/parts/ConfirmationCard.js +211 -0
  19. package/dist/ai/parts/ConfirmationCard.js.map +7 -0
  20. package/dist/ai/parts/FieldDiffCard.js +119 -0
  21. package/dist/ai/parts/FieldDiffCard.js.map +7 -0
  22. package/dist/ai/parts/MutationPreviewCard.js +224 -0
  23. package/dist/ai/parts/MutationPreviewCard.js.map +7 -0
  24. package/dist/ai/parts/MutationResultCard.js +240 -0
  25. package/dist/ai/parts/MutationResultCard.js.map +7 -0
  26. package/dist/ai/parts/approval-cards-map.js +15 -0
  27. package/dist/ai/parts/approval-cards-map.js.map +7 -0
  28. package/dist/ai/parts/index.js +24 -0
  29. package/dist/ai/parts/index.js.map +7 -0
  30. package/dist/ai/parts/pending-action-api.js +60 -0
  31. package/dist/ai/parts/pending-action-api.js.map +7 -0
  32. package/dist/ai/parts/types.js +1 -0
  33. package/dist/ai/parts/types.js.map +7 -0
  34. package/dist/ai/parts/useAiPendingActionPolling.js +126 -0
  35. package/dist/ai/parts/useAiPendingActionPolling.js.map +7 -0
  36. package/dist/ai/records/ActivityCard.js +83 -0
  37. package/dist/ai/records/ActivityCard.js.map +7 -0
  38. package/dist/ai/records/CompanyCard.js +81 -0
  39. package/dist/ai/records/CompanyCard.js.map +7 -0
  40. package/dist/ai/records/DealCard.js +76 -0
  41. package/dist/ai/records/DealCard.js.map +7 -0
  42. package/dist/ai/records/PersonCard.js +68 -0
  43. package/dist/ai/records/PersonCard.js.map +7 -0
  44. package/dist/ai/records/ProductCard.js +68 -0
  45. package/dist/ai/records/ProductCard.js.map +7 -0
  46. package/dist/ai/records/RecordCard.js +29 -0
  47. package/dist/ai/records/RecordCard.js.map +7 -0
  48. package/dist/ai/records/RecordCardShell.js +103 -0
  49. package/dist/ai/records/RecordCardShell.js.map +7 -0
  50. package/dist/ai/records/index.js +31 -0
  51. package/dist/ai/records/index.js.map +7 -0
  52. package/dist/ai/records/registry.js +51 -0
  53. package/dist/ai/records/registry.js.map +7 -0
  54. package/dist/ai/records/types.js +1 -0
  55. package/dist/ai/records/types.js.map +7 -0
  56. package/dist/ai/ui-part-registry.js +112 -0
  57. package/dist/ai/ui-part-registry.js.map +7 -0
  58. package/dist/ai/ui-part-slots.js +14 -0
  59. package/dist/ai/ui-part-slots.js.map +7 -0
  60. package/dist/ai/ui-parts/pending-phase3-placeholder.js +35 -0
  61. package/dist/ai/ui-parts/pending-phase3-placeholder.js.map +7 -0
  62. package/dist/ai/upload-adapter.js +256 -0
  63. package/dist/ai/upload-adapter.js.map +7 -0
  64. package/dist/ai/useAiChat.js +549 -0
  65. package/dist/ai/useAiChat.js.map +7 -0
  66. package/dist/ai/useAiChatUpload.js +127 -0
  67. package/dist/ai/useAiChatUpload.js.map +7 -0
  68. package/dist/ai/useAiShortcuts.js +43 -0
  69. package/dist/ai/useAiShortcuts.js.map +7 -0
  70. package/dist/backend/AppShell.js +8 -4
  71. package/dist/backend/AppShell.js.map +2 -2
  72. package/dist/backend/BackendChromeProvider.js +2 -0
  73. package/dist/backend/BackendChromeProvider.js.map +2 -2
  74. package/dist/backend/DataTable.js +19 -2
  75. package/dist/backend/DataTable.js.map +2 -2
  76. package/dist/backend/FilterBar.js +19 -15
  77. package/dist/backend/FilterBar.js.map +2 -2
  78. package/dist/backend/dashboard/DashboardScreen.js +31 -3
  79. package/dist/backend/dashboard/DashboardScreen.js.map +2 -2
  80. package/dist/backend/injection/spotIds.js +6 -0
  81. package/dist/backend/injection/spotIds.js.map +2 -2
  82. package/dist/backend/notifications/useNotificationEffect.js +38 -2
  83. package/dist/backend/notifications/useNotificationEffect.js.map +2 -2
  84. package/dist/index.js +1 -0
  85. package/dist/index.js.map +2 -2
  86. package/jest.config.cjs +7 -1
  87. package/jest.markdown-mock.tsx +7 -0
  88. package/package.json +10 -4
  89. package/src/ai/AiAssistantLauncher.tsx +805 -0
  90. package/src/ai/AiChat.tsx +1483 -0
  91. package/src/ai/AiChatSessions.tsx +429 -0
  92. package/src/ai/AiDock.tsx +505 -0
  93. package/src/ai/AiMessageContent.tsx +515 -0
  94. package/src/ai/ChatPaneTabs.tsx +310 -0
  95. package/src/ai/__tests__/AiChat.conversation.test.tsx +160 -0
  96. package/src/ai/__tests__/AiChat.debug.test.tsx +152 -0
  97. package/src/ai/__tests__/AiChat.registry.test.tsx +213 -0
  98. package/src/ai/__tests__/AiChat.test.tsx +257 -0
  99. package/src/ai/__tests__/AiDock.test.tsx +124 -0
  100. package/src/ai/__tests__/AiMessageContent.test.ts +111 -0
  101. package/src/ai/__tests__/ui-part-registry.test.ts +199 -0
  102. package/src/ai/__tests__/ui-part-slots.test.ts +43 -0
  103. package/src/ai/__tests__/upload-adapter.test.ts +213 -0
  104. package/src/ai/__tests__/useAiChatUpload.test.tsx +163 -0
  105. package/src/ai/__tests__/useAiShortcuts.test.tsx +100 -0
  106. package/src/ai/index.ts +125 -0
  107. package/src/ai/parts/ConfirmationCard.tsx +310 -0
  108. package/src/ai/parts/FieldDiffCard.tsx +173 -0
  109. package/src/ai/parts/MutationPreviewCard.tsx +302 -0
  110. package/src/ai/parts/MutationResultCard.tsx +360 -0
  111. package/src/ai/parts/__tests__/ConfirmationCard.test.tsx +169 -0
  112. package/src/ai/parts/__tests__/FieldDiffCard.test.tsx +74 -0
  113. package/src/ai/parts/__tests__/MutationPreviewCard.test.tsx +177 -0
  114. package/src/ai/parts/__tests__/MutationResultCard.test.tsx +127 -0
  115. package/src/ai/parts/__tests__/useAiPendingActionPolling.test.tsx +151 -0
  116. package/src/ai/parts/approval-cards-map.ts +24 -0
  117. package/src/ai/parts/index.ts +27 -0
  118. package/src/ai/parts/pending-action-api.ts +123 -0
  119. package/src/ai/parts/types.ts +84 -0
  120. package/src/ai/parts/useAiPendingActionPolling.ts +210 -0
  121. package/src/ai/records/ActivityCard.tsx +102 -0
  122. package/src/ai/records/CompanyCard.tsx +89 -0
  123. package/src/ai/records/DealCard.tsx +85 -0
  124. package/src/ai/records/PersonCard.tsx +77 -0
  125. package/src/ai/records/ProductCard.tsx +83 -0
  126. package/src/ai/records/RecordCard.tsx +37 -0
  127. package/src/ai/records/RecordCardShell.tsx +169 -0
  128. package/src/ai/records/index.ts +30 -0
  129. package/src/ai/records/registry.tsx +80 -0
  130. package/src/ai/records/types.ts +90 -0
  131. package/src/ai/ui-part-registry.ts +233 -0
  132. package/src/ai/ui-part-slots.ts +32 -0
  133. package/src/ai/ui-parts/pending-phase3-placeholder.tsx +50 -0
  134. package/src/ai/upload-adapter.ts +421 -0
  135. package/src/ai/useAiChat.ts +865 -0
  136. package/src/ai/useAiChatUpload.ts +180 -0
  137. package/src/ai/useAiShortcuts.ts +79 -0
  138. package/src/backend/AppShell.tsx +12 -5
  139. package/src/backend/BackendChromeProvider.tsx +2 -0
  140. package/src/backend/DataTable.tsx +20 -1
  141. package/src/backend/FilterBar.tsx +26 -13
  142. package/src/backend/__tests__/BackendChromeProvider.test.tsx +45 -0
  143. package/src/backend/dashboard/DashboardScreen.tsx +38 -3
  144. package/src/backend/dashboard/__tests__/DashboardScreen.test.tsx +24 -1
  145. package/src/backend/injection/spotIds.ts +6 -0
  146. package/src/backend/notifications/__tests__/useNotificationEffect.test.tsx +77 -0
  147. package/src/backend/notifications/useNotificationEffect.ts +47 -2
  148. package/src/index.ts +1 -0
@@ -0,0 +1,421 @@
1
+ /**
2
+ * Framework-agnostic upload adapter for the AI chat composer.
3
+ *
4
+ * Forwards files dropped into {@link AiChat} to the existing attachments API
5
+ * (`POST /api/attachments`, multipart form-data — see
6
+ * `packages/core/src/modules/attachments/api/route.ts`) and returns the
7
+ * resulting `attachmentIds` so the chat request layer can thread them into the
8
+ * dispatcher body (`POST /api/ai_assistant/ai/chat?agent=<id>` reads
9
+ * `attachmentIds` from JSON).
10
+ *
11
+ * The adapter is intentionally framework-agnostic: no Next.js imports, no
12
+ * React. A thin React hook ({@link useAiChatUpload}) wraps it for the composer.
13
+ */
14
+
15
+ const DEFAULT_ATTACHMENTS_ENDPOINT = '/api/attachments'
16
+ const DEFAULT_AI_CHAT_ENTITY_ID = 'ai-chat-draft'
17
+ const DEFAULT_CONCURRENCY = 3
18
+ // Hard cap on a single upload's wall-clock time. The previous implementation
19
+ // had no timeout, so a stalled `/api/attachments` request would leave the
20
+ // composer chip spinning forever (the server never returned, the client
21
+ // never unblocked the Send button). 60s is generous for the documented
22
+ // per-file size limits and matches the behaviour of the rest of the
23
+ // backoffice's `apiCall` helpers.
24
+ const DEFAULT_PER_FILE_TIMEOUT_MS = 60_000
25
+
26
+ export type UploadFailureReason =
27
+ | 'mime_rejected'
28
+ | 'size_exceeded'
29
+ | 'network'
30
+ | 'server'
31
+ | 'aborted'
32
+
33
+ export interface UploadAttachmentsForChatOptions {
34
+ /** Optional override for the attachments endpoint (defaults to `/api/attachments`). */
35
+ endpoint?: string
36
+ /** Entity identifier recorded alongside the attachment. Defaults to `'ai-chat-draft'`. */
37
+ entityType?: string
38
+ /**
39
+ * Record identifier for the chat draft. When omitted, the adapter mints a
40
+ * per-invocation UUID so every batch groups cleanly in the attachments table.
41
+ */
42
+ recordId?: string
43
+ /** Optional partition code; forwarded verbatim to the attachments route. */
44
+ partitionCode?: string
45
+ /** Optional injectable fetch (tests, portal). Defaults to `globalThis.fetch`. */
46
+ fetchImpl?: typeof fetch
47
+ /** Optional progress callback fired once per file completion. */
48
+ onProgress?: (
49
+ fileIndex: number,
50
+ progress: { loaded: number; total: number },
51
+ ) => void
52
+ /** Abort the whole batch; queued files short-circuit as `'aborted'`. */
53
+ signal?: AbortSignal
54
+ /** Parallelism cap. Defaults to 3. */
55
+ concurrency?: number
56
+ /**
57
+ * Hard timeout per upload, in milliseconds. Defaults to 60_000 (60s).
58
+ * When the upload exceeds the timeout the request is aborted and the
59
+ * file lands in `failed` with `reason: 'aborted'` instead of the chip
60
+ * spinning forever. Pass `0` to disable.
61
+ */
62
+ perFileTimeoutMs?: number
63
+ }
64
+
65
+ export interface UploadedAttachment {
66
+ attachmentId: string
67
+ /**
68
+ * Server-returned (possibly sanitized) filename. The original
69
+ * client-side `File.name` is preserved on `originalFileName` so the
70
+ * chat composer can pair the upload result back to its chip without a
71
+ * sanitization-induced map miss.
72
+ */
73
+ fileName: string
74
+ /** The exact `File.name` the caller passed in. Always set. */
75
+ originalFileName: string
76
+ /** Position of this file in the original input array. Always set. */
77
+ inputIndex: number
78
+ mediaType: string
79
+ size: number
80
+ }
81
+
82
+ export interface UploadFailure {
83
+ fileName: string
84
+ /** The exact `File.name` the caller passed in. Always set. */
85
+ originalFileName: string
86
+ /** Position of this file in the original input array. Always set. */
87
+ inputIndex: number
88
+ reason: UploadFailureReason
89
+ message: string
90
+ }
91
+
92
+ export interface UploadAttachmentsForChatResult {
93
+ items: UploadedAttachment[]
94
+ failed: UploadFailure[]
95
+ }
96
+
97
+ function mintRecordId(): string {
98
+ const cryptoApi = (globalThis as unknown as { crypto?: Crypto }).crypto
99
+ if (cryptoApi && typeof cryptoApi.randomUUID === 'function') {
100
+ return cryptoApi.randomUUID()
101
+ }
102
+ const random = Math.random().toString(36).slice(2, 10)
103
+ const time = Date.now().toString(36)
104
+ return `ai-chat-${time}-${random}`
105
+ }
106
+
107
+ function resolveFetchImpl(explicit?: typeof fetch): typeof fetch {
108
+ if (explicit) return explicit
109
+ const fallback = (globalThis as typeof globalThis & { fetch?: typeof fetch }).fetch
110
+ if (!fallback) {
111
+ throw new Error('No fetch implementation available for uploadAttachmentsForChat')
112
+ }
113
+ return fallback.bind(globalThis) as typeof fetch
114
+ }
115
+
116
+ function normalizeServerErrorMessage(raw: unknown): string {
117
+ if (raw && typeof raw === 'object') {
118
+ const err = (raw as { error?: unknown; message?: unknown }).error
119
+ if (typeof err === 'string' && err.trim()) return err
120
+ const msg = (raw as { message?: unknown }).message
121
+ if (typeof msg === 'string' && msg.trim()) return msg
122
+ }
123
+ return ''
124
+ }
125
+
126
+ function mapStatusToReason(status: number, message: string): UploadFailureReason {
127
+ if (status === 413) return 'size_exceeded'
128
+ if (status === 403 || status === 415) return 'mime_rejected'
129
+ if (status === 400) {
130
+ const lower = message.toLowerCase()
131
+ if (lower.includes('file type') || lower.includes('active content')) {
132
+ return 'mime_rejected'
133
+ }
134
+ if (lower.includes('size') || lower.includes('quota')) {
135
+ return 'size_exceeded'
136
+ }
137
+ }
138
+ return 'server'
139
+ }
140
+
141
+ function parseServerItem(
142
+ payload: unknown,
143
+ fallbackFile: File,
144
+ inputIndex: number,
145
+ ): UploadedAttachment | null {
146
+ if (!payload || typeof payload !== 'object') return null
147
+ const item = (payload as { item?: unknown }).item
148
+ if (!item || typeof item !== 'object') return null
149
+ const id = (item as { id?: unknown }).id
150
+ if (typeof id !== 'string' || !id.trim()) return null
151
+ const fileName =
152
+ typeof (item as { fileName?: unknown }).fileName === 'string'
153
+ ? (item as { fileName: string }).fileName
154
+ : fallbackFile.name
155
+ const fileSize = (item as { fileSize?: unknown }).fileSize
156
+ const size =
157
+ typeof fileSize === 'number' && Number.isFinite(fileSize) ? fileSize : fallbackFile.size
158
+ const mimeTypeCandidate = (item as { mimeType?: unknown; mediaType?: unknown })
159
+ const mediaType =
160
+ typeof mimeTypeCandidate.mimeType === 'string' && mimeTypeCandidate.mimeType.trim()
161
+ ? mimeTypeCandidate.mimeType
162
+ : typeof mimeTypeCandidate.mediaType === 'string' && mimeTypeCandidate.mediaType.trim()
163
+ ? mimeTypeCandidate.mediaType
164
+ : fallbackFile.type || 'application/octet-stream'
165
+ return {
166
+ attachmentId: id,
167
+ fileName,
168
+ originalFileName: fallbackFile.name,
169
+ inputIndex,
170
+ mediaType,
171
+ size,
172
+ }
173
+ }
174
+
175
+ interface UploadSingleArgs {
176
+ file: File
177
+ fileIndex: number
178
+ endpoint: string
179
+ entityType: string
180
+ recordId: string
181
+ partitionCode?: string
182
+ fetchImpl: typeof fetch
183
+ signal: AbortSignal
184
+ perFileTimeoutMs: number
185
+ onProgress?: UploadAttachmentsForChatOptions['onProgress']
186
+ }
187
+
188
+ type SingleOutcome =
189
+ | { ok: true; item: UploadedAttachment }
190
+ | { ok: false; failure: UploadFailure }
191
+
192
+ async function uploadSingleFile(args: UploadSingleArgs): Promise<SingleOutcome> {
193
+ const {
194
+ file,
195
+ fileIndex,
196
+ endpoint,
197
+ entityType,
198
+ recordId,
199
+ partitionCode,
200
+ fetchImpl,
201
+ signal,
202
+ perFileTimeoutMs,
203
+ onProgress,
204
+ } = args
205
+
206
+ const buildFailure = (
207
+ reason: UploadFailureReason,
208
+ message: string,
209
+ ): UploadFailure => ({
210
+ fileName: file.name,
211
+ originalFileName: file.name,
212
+ inputIndex: fileIndex,
213
+ reason,
214
+ message,
215
+ })
216
+
217
+ if (signal.aborted) {
218
+ return {
219
+ ok: false,
220
+ failure: buildFailure('aborted', 'Upload aborted before starting.'),
221
+ }
222
+ }
223
+
224
+ const form = new FormData()
225
+ form.append('entityId', entityType)
226
+ form.append('recordId', recordId)
227
+ form.append('file', file)
228
+ if (partitionCode && partitionCode.trim().length > 0) {
229
+ form.append('partitionCode', partitionCode.trim())
230
+ }
231
+
232
+ // Per-file timeout — wired through a child AbortController that is also
233
+ // cancelled when the parent batch aborts. Without this guard a stalled
234
+ // server (slow OCR, dead connection) would leave the chip spinning
235
+ // forever and block the composer's Send button indefinitely.
236
+ const localController = new AbortController()
237
+ const onParentAbort = () => localController.abort()
238
+ signal.addEventListener('abort', onParentAbort, { once: true })
239
+ let timeoutHandle: ReturnType<typeof setTimeout> | null = null
240
+ let timedOut = false
241
+ if (perFileTimeoutMs > 0) {
242
+ timeoutHandle = setTimeout(() => {
243
+ timedOut = true
244
+ localController.abort()
245
+ }, perFileTimeoutMs)
246
+ }
247
+ const clearTimers = () => {
248
+ if (timeoutHandle !== null) {
249
+ clearTimeout(timeoutHandle)
250
+ timeoutHandle = null
251
+ }
252
+ signal.removeEventListener('abort', onParentAbort)
253
+ }
254
+
255
+ let response: Response
256
+ try {
257
+ response = await fetchImpl(endpoint, {
258
+ method: 'POST',
259
+ body: form,
260
+ signal: localController.signal,
261
+ })
262
+ } catch (networkError) {
263
+ clearTimers()
264
+ if (timedOut) {
265
+ return {
266
+ ok: false,
267
+ failure: buildFailure(
268
+ 'aborted',
269
+ `Upload timed out after ${Math.round(perFileTimeoutMs / 1000)}s. The server did not respond — try again, or attach the file to a record first and reference it in the chat.`,
270
+ ),
271
+ }
272
+ }
273
+ const aborted =
274
+ signal.aborted ||
275
+ localController.signal.aborted ||
276
+ (networkError as { name?: string } | undefined)?.name === 'AbortError'
277
+ if (aborted) {
278
+ return { ok: false, failure: buildFailure('aborted', 'Upload aborted.') }
279
+ }
280
+ const message =
281
+ networkError instanceof Error ? networkError.message : 'Network request failed.'
282
+ return { ok: false, failure: buildFailure('network', message) }
283
+ }
284
+ clearTimers()
285
+
286
+ let payload: unknown = null
287
+ try {
288
+ const text = await response.text()
289
+ if (text && text.trim()) {
290
+ payload = JSON.parse(text) as unknown
291
+ }
292
+ } catch {
293
+ // Response may not be JSON (HTML error page, empty body, etc.)
294
+ payload = null
295
+ }
296
+
297
+ if (!response.ok) {
298
+ const rawMessage = normalizeServerErrorMessage(payload)
299
+ const fallbackMessage = rawMessage || `Upload failed (${response.status}).`
300
+ const reason = mapStatusToReason(response.status, rawMessage)
301
+ return { ok: false, failure: buildFailure(reason, fallbackMessage) }
302
+ }
303
+
304
+ const item = parseServerItem(payload, file, fileIndex)
305
+ if (!item) {
306
+ return {
307
+ ok: false,
308
+ failure: buildFailure(
309
+ 'server',
310
+ 'Attachment API returned an unexpected response shape.',
311
+ ),
312
+ }
313
+ }
314
+
315
+ if (onProgress) {
316
+ try {
317
+ onProgress(fileIndex, { loaded: item.size, total: item.size })
318
+ } catch {
319
+ // A misbehaving progress callback must never abort the upload pipeline.
320
+ }
321
+ }
322
+
323
+ return { ok: true, item }
324
+ }
325
+
326
+ /**
327
+ * Uploads files in parallel (bounded to `concurrency`, default 3) via the
328
+ * attachments API and pairs the returned IDs back to the input order.
329
+ *
330
+ * The batch promise only rejects on programming errors — server rejections,
331
+ * network errors, and aborts are surfaced via {@link UploadAttachmentsForChatResult.failed}
332
+ * so the caller can render chips and retry UX without try/catch noise.
333
+ */
334
+ export async function uploadAttachmentsForChat(
335
+ files: File[],
336
+ options: UploadAttachmentsForChatOptions = {},
337
+ ): Promise<UploadAttachmentsForChatResult> {
338
+ const items: UploadedAttachment[] = []
339
+ const failed: UploadFailure[] = []
340
+ if (!Array.isArray(files) || files.length === 0) {
341
+ return { items, failed }
342
+ }
343
+
344
+ const fetchImpl = resolveFetchImpl(options.fetchImpl)
345
+ const endpoint = options.endpoint?.trim() || DEFAULT_ATTACHMENTS_ENDPOINT
346
+ const entityType = options.entityType?.trim() || DEFAULT_AI_CHAT_ENTITY_ID
347
+ const recordId = options.recordId?.trim() || mintRecordId()
348
+ const rawConcurrency = options.concurrency ?? DEFAULT_CONCURRENCY
349
+ const concurrency = Math.max(
350
+ 1,
351
+ Math.min(files.length, Math.floor(rawConcurrency) || DEFAULT_CONCURRENCY),
352
+ )
353
+ const signal = options.signal ?? new AbortController().signal
354
+ const perFileTimeoutMs = (() => {
355
+ const raw = options.perFileTimeoutMs
356
+ if (raw === 0) return 0
357
+ if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) return raw
358
+ return DEFAULT_PER_FILE_TIMEOUT_MS
359
+ })()
360
+
361
+ const outcomes: Array<SingleOutcome | null> = new Array(files.length).fill(null)
362
+ let nextIndex = 0
363
+
364
+ const worker = async (): Promise<void> => {
365
+ while (true) {
366
+ if (signal.aborted) return
367
+ const currentIndex = nextIndex
368
+ if (currentIndex >= files.length) return
369
+ nextIndex = currentIndex + 1
370
+ const file = files[currentIndex]
371
+ outcomes[currentIndex] = await uploadSingleFile({
372
+ file,
373
+ fileIndex: currentIndex,
374
+ endpoint,
375
+ entityType,
376
+ recordId,
377
+ partitionCode: options.partitionCode,
378
+ fetchImpl,
379
+ signal,
380
+ perFileTimeoutMs,
381
+ onProgress: options.onProgress,
382
+ })
383
+ }
384
+ }
385
+
386
+ const workerCount = Math.min(concurrency, files.length)
387
+ const workers = Array.from({ length: workerCount }, () => worker())
388
+ await Promise.all(workers)
389
+
390
+ for (let index = 0; index < files.length; index += 1) {
391
+ const outcome = outcomes[index]
392
+ if (outcome && outcome.ok) {
393
+ items.push(outcome.item)
394
+ continue
395
+ }
396
+ if (outcome && !outcome.ok) {
397
+ failed.push(outcome.failure)
398
+ continue
399
+ }
400
+ // Worker exited without processing this slot → it was skipped due to abort.
401
+ const file = files[index]
402
+ const fallbackName = file?.name ?? `file-${index}`
403
+ failed.push({
404
+ fileName: fallbackName,
405
+ originalFileName: fallbackName,
406
+ inputIndex: index,
407
+ reason: 'aborted',
408
+ message: 'Upload aborted before starting.',
409
+ })
410
+ }
411
+
412
+ return { items, failed }
413
+ }
414
+
415
+ export const __testables = {
416
+ DEFAULT_ATTACHMENTS_ENDPOINT,
417
+ DEFAULT_AI_CHAT_ENTITY_ID,
418
+ DEFAULT_CONCURRENCY,
419
+ DEFAULT_PER_FILE_TIMEOUT_MS,
420
+ mapStatusToReason,
421
+ }