@wix/himalaya-cli 0.1029.0 → 0.1031.0

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 (185) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/dev-server/src/app-config.d.ts +7 -0
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/dev-server/src/wix-gateway.d.ts +32 -0
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/dev-server/src/wix-gateway.rules.d.ts +2 -1
  4. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/define-app.d.ts +7 -0
  5. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/genie-chat/src/index.d.ts +64 -0
  6. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-genie-chat.mjs +41 -1
  7. package/dist/cli.mjs +298 -114
  8. package/dist/preview-assets.generated.bin +0 -0
  9. package/guides/component-reference.json +0 -7
  10. package/guides/component-reference.md +0 -2
  11. package/guides/sdk-reference.json +50 -2
  12. package/package.json +2 -2
  13. package/templates/beauty-salon/dev/index.ts +575 -214
  14. package/templates/beauty-salon/tier5-src/_shared/collection-contract.ts +4 -1
  15. package/templates/beauty-salon/tier5-src/_shared/owner-load-error.ts +7 -1
  16. package/templates/beauty-salon/tier5-src/himi-branded-lite-appearance/index.ts +12 -1
  17. package/templates/beauty-salon/tier5-src/himi-store-product/index.ts +36 -5
  18. package/templates/beauty-salon/tier5-src/wix-spaces-app-language/index.ts +4 -2
  19. package/templates/beauty-salon-owner/config.ts +1 -1
  20. package/templates/beauty-salon-owner/dev/index.ts +247 -9
  21. package/templates/beauty-salon-owner/tier5-src/_shared/aria-attachments.ts +179 -0
  22. package/templates/beauty-salon-owner/tier5-src/_shared/aria-chat.ts +405 -20
  23. package/templates/beauty-salon-owner/tier5-src/_shared/collection-contract.ts +4 -1
  24. package/templates/beauty-salon-owner/tier5-src/_shared/owner-bookings.ts +20 -0
  25. package/templates/beauty-salon-owner/tier5-src/_shared/owner-load-error.ts +7 -1
  26. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-aria/index.ts +40 -1
  27. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-home/index.ts +2 -12
  28. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-manage/index.ts +10 -4
  29. package/templates/branded-lite/dev/index.ts +699 -228
  30. package/templates/branded-lite/tier5-src/_shared/collection-contract.ts +4 -1
  31. package/templates/branded-lite/tier5-src/_shared/owner-load-error.ts +7 -1
  32. package/templates/branded-lite/tier5-src/himi-branded-lite-appearance/index.ts +12 -1
  33. package/templates/branded-lite/tier5-src/himi-branded-lite-notifsettings/index.ts +11 -6
  34. package/templates/branded-lite/tier5-src/himi-branded-lite-reviews/index.ts +11 -6
  35. package/templates/branded-lite/tier5-src/himi-store-product/index.ts +36 -5
  36. package/templates/branded-lite/tier5-src/wix-spaces-app-language/index.ts +4 -2
  37. package/templates/community-club/dev/index.ts +451 -204
  38. package/templates/community-club/tier5-src/_shared/collection-contract.ts +4 -1
  39. package/templates/community-club/tier5-src/_shared/owner-load-error.ts +7 -1
  40. package/templates/community-club/tier5-src/himi-branded-lite-appearance/index.ts +12 -1
  41. package/templates/community-club/tier5-src/wix-spaces-app-language/index.ts +4 -2
  42. package/templates/community-club-owner/config.ts +1 -1
  43. package/templates/community-club-owner/dev/index.ts +247 -9
  44. package/templates/community-club-owner/tier5-src/_shared/aria-attachments.ts +179 -0
  45. package/templates/community-club-owner/tier5-src/_shared/aria-chat.ts +405 -20
  46. package/templates/community-club-owner/tier5-src/_shared/collection-contract.ts +4 -1
  47. package/templates/community-club-owner/tier5-src/_shared/owner-bookings.ts +20 -0
  48. package/templates/community-club-owner/tier5-src/_shared/owner-load-error.ts +7 -1
  49. package/templates/community-club-owner/tier5-src/himi-owner-lite-aria/index.ts +40 -1
  50. package/templates/community-club-owner/tier5-src/himi-owner-lite-home/index.ts +2 -12
  51. package/templates/community-club-owner/tier5-src/himi-owner-lite-manage/index.ts +10 -4
  52. package/templates/events-meetups/dev/index.ts +451 -204
  53. package/templates/events-meetups/tier5-src/_shared/collection-contract.ts +4 -1
  54. package/templates/events-meetups/tier5-src/_shared/owner-load-error.ts +7 -1
  55. package/templates/events-meetups/tier5-src/himi-branded-lite-appearance/index.ts +12 -1
  56. package/templates/events-meetups/tier5-src/wix-spaces-app-language/index.ts +4 -2
  57. package/templates/events-meetups-owner/config.ts +1 -1
  58. package/templates/events-meetups-owner/dev/index.ts +247 -9
  59. package/templates/events-meetups-owner/tier5-src/_shared/aria-attachments.ts +179 -0
  60. package/templates/events-meetups-owner/tier5-src/_shared/aria-chat.ts +405 -20
  61. package/templates/events-meetups-owner/tier5-src/_shared/collection-contract.ts +4 -1
  62. package/templates/events-meetups-owner/tier5-src/_shared/owner-bookings.ts +20 -0
  63. package/templates/events-meetups-owner/tier5-src/_shared/owner-load-error.ts +7 -1
  64. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-aria/index.ts +40 -1
  65. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-home/index.ts +2 -12
  66. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-manage/index.ts +10 -4
  67. package/templates/gym-fitness/dev/index.ts +575 -214
  68. package/templates/gym-fitness/tier5-src/_shared/collection-contract.ts +4 -1
  69. package/templates/gym-fitness/tier5-src/_shared/owner-load-error.ts +7 -1
  70. package/templates/gym-fitness/tier5-src/himi-branded-lite-appearance/index.ts +12 -1
  71. package/templates/gym-fitness/tier5-src/himi-store-product/index.ts +36 -5
  72. package/templates/gym-fitness/tier5-src/wix-spaces-app-language/index.ts +4 -2
  73. package/templates/gym-fitness-owner/config.ts +1 -1
  74. package/templates/gym-fitness-owner/dev/index.ts +247 -9
  75. package/templates/gym-fitness-owner/tier5-src/_shared/aria-attachments.ts +179 -0
  76. package/templates/gym-fitness-owner/tier5-src/_shared/aria-chat.ts +405 -20
  77. package/templates/gym-fitness-owner/tier5-src/_shared/collection-contract.ts +4 -1
  78. package/templates/gym-fitness-owner/tier5-src/_shared/owner-bookings.ts +20 -0
  79. package/templates/gym-fitness-owner/tier5-src/_shared/owner-load-error.ts +7 -1
  80. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-aria/index.ts +40 -1
  81. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-home/index.ts +2 -12
  82. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-manage/index.ts +10 -4
  83. package/templates/himi-chat/tier5-src/_shared/owner-load-error.ts +7 -1
  84. package/templates/nonprofit-cause/dev/index.ts +451 -204
  85. package/templates/nonprofit-cause/tier5-src/_shared/collection-contract.ts +4 -1
  86. package/templates/nonprofit-cause/tier5-src/_shared/owner-load-error.ts +7 -1
  87. package/templates/nonprofit-cause/tier5-src/himi-branded-lite-appearance/index.ts +12 -1
  88. package/templates/nonprofit-cause/tier5-src/wix-spaces-app-language/index.ts +4 -2
  89. package/templates/nonprofit-cause-owner/config.ts +1 -1
  90. package/templates/nonprofit-cause-owner/dev/index.ts +247 -9
  91. package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-attachments.ts +179 -0
  92. package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-chat.ts +405 -20
  93. package/templates/nonprofit-cause-owner/tier5-src/_shared/collection-contract.ts +4 -1
  94. package/templates/nonprofit-cause-owner/tier5-src/_shared/owner-bookings.ts +20 -0
  95. package/templates/nonprofit-cause-owner/tier5-src/_shared/owner-load-error.ts +7 -1
  96. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-aria/index.ts +40 -1
  97. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-home/index.ts +2 -12
  98. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-manage/index.ts +10 -4
  99. package/templates/owner-lite/config.ts +1 -1
  100. package/templates/owner-lite/dev/index.ts +247 -9
  101. package/templates/owner-lite/tier5-src/_shared/aria-attachments.ts +179 -0
  102. package/templates/owner-lite/tier5-src/_shared/aria-chat.ts +405 -20
  103. package/templates/owner-lite/tier5-src/_shared/collection-contract.ts +4 -1
  104. package/templates/owner-lite/tier5-src/_shared/owner-bookings.ts +20 -0
  105. package/templates/owner-lite/tier5-src/_shared/owner-load-error.ts +7 -1
  106. package/templates/owner-lite/tier5-src/himi-owner-lite-aria/index.ts +40 -1
  107. package/templates/owner-lite/tier5-src/himi-owner-lite-home/index.ts +2 -12
  108. package/templates/owner-lite/tier5-src/himi-owner-lite-manage/index.ts +10 -4
  109. package/templates/podcast-creator/dev/index.ts +451 -204
  110. package/templates/podcast-creator/tier5-src/_shared/collection-contract.ts +4 -1
  111. package/templates/podcast-creator/tier5-src/_shared/owner-load-error.ts +7 -1
  112. package/templates/podcast-creator/tier5-src/himi-branded-lite-appearance/index.ts +12 -1
  113. package/templates/podcast-creator/tier5-src/wix-spaces-app-language/index.ts +4 -2
  114. package/templates/podcast-creator-owner/config.ts +1 -1
  115. package/templates/podcast-creator-owner/dev/index.ts +247 -9
  116. package/templates/podcast-creator-owner/tier5-src/_shared/aria-attachments.ts +179 -0
  117. package/templates/podcast-creator-owner/tier5-src/_shared/aria-chat.ts +405 -20
  118. package/templates/podcast-creator-owner/tier5-src/_shared/collection-contract.ts +4 -1
  119. package/templates/podcast-creator-owner/tier5-src/_shared/owner-bookings.ts +20 -0
  120. package/templates/podcast-creator-owner/tier5-src/_shared/owner-load-error.ts +7 -1
  121. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-aria/index.ts +40 -1
  122. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-home/index.ts +2 -12
  123. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-manage/index.ts +10 -4
  124. package/templates/portfolio-creative/dev/index.ts +575 -214
  125. package/templates/portfolio-creative/tier5-src/_shared/collection-contract.ts +4 -1
  126. package/templates/portfolio-creative/tier5-src/_shared/owner-load-error.ts +7 -1
  127. package/templates/portfolio-creative/tier5-src/himi-branded-lite-appearance/index.ts +12 -1
  128. package/templates/portfolio-creative/tier5-src/himi-store-product/index.ts +36 -5
  129. package/templates/portfolio-creative/tier5-src/wix-spaces-app-language/index.ts +4 -2
  130. package/templates/portfolio-creative-owner/config.ts +1 -1
  131. package/templates/portfolio-creative-owner/dev/index.ts +247 -9
  132. package/templates/portfolio-creative-owner/tier5-src/_shared/aria-attachments.ts +179 -0
  133. package/templates/portfolio-creative-owner/tier5-src/_shared/aria-chat.ts +405 -20
  134. package/templates/portfolio-creative-owner/tier5-src/_shared/collection-contract.ts +4 -1
  135. package/templates/portfolio-creative-owner/tier5-src/_shared/owner-bookings.ts +20 -0
  136. package/templates/portfolio-creative-owner/tier5-src/_shared/owner-load-error.ts +7 -1
  137. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-aria/index.ts +40 -1
  138. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-home/index.ts +2 -12
  139. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-manage/index.ts +10 -4
  140. package/templates/restaurant/dev/index.ts +451 -204
  141. package/templates/restaurant/tier5-src/_shared/collection-contract.ts +4 -1
  142. package/templates/restaurant/tier5-src/_shared/owner-load-error.ts +7 -1
  143. package/templates/restaurant/tier5-src/himi-branded-lite-appearance/index.ts +12 -1
  144. package/templates/restaurant/tier5-src/wix-spaces-app-language/index.ts +4 -2
  145. package/templates/restaurant-owner/config.ts +1 -1
  146. package/templates/restaurant-owner/dev/index.ts +247 -9
  147. package/templates/restaurant-owner/tier5-src/_shared/aria-attachments.ts +179 -0
  148. package/templates/restaurant-owner/tier5-src/_shared/aria-chat.ts +405 -20
  149. package/templates/restaurant-owner/tier5-src/_shared/collection-contract.ts +4 -1
  150. package/templates/restaurant-owner/tier5-src/_shared/owner-bookings.ts +20 -0
  151. package/templates/restaurant-owner/tier5-src/_shared/owner-load-error.ts +7 -1
  152. package/templates/restaurant-owner/tier5-src/himi-owner-lite-aria/index.ts +40 -1
  153. package/templates/restaurant-owner/tier5-src/himi-owner-lite-home/index.ts +2 -12
  154. package/templates/restaurant-owner/tier5-src/himi-owner-lite-manage/index.ts +10 -4
  155. package/templates/retail-store/dev/index.ts +575 -214
  156. package/templates/retail-store/tier5-src/_shared/collection-contract.ts +4 -1
  157. package/templates/retail-store/tier5-src/_shared/owner-load-error.ts +7 -1
  158. package/templates/retail-store/tier5-src/himi-branded-lite-appearance/index.ts +12 -1
  159. package/templates/retail-store/tier5-src/himi-store-product/index.ts +36 -5
  160. package/templates/retail-store/tier5-src/wix-spaces-app-language/index.ts +4 -2
  161. package/templates/retail-store-owner/config.ts +1 -1
  162. package/templates/retail-store-owner/dev/index.ts +247 -9
  163. package/templates/retail-store-owner/tier5-src/_shared/aria-attachments.ts +179 -0
  164. package/templates/retail-store-owner/tier5-src/_shared/aria-chat.ts +405 -20
  165. package/templates/retail-store-owner/tier5-src/_shared/collection-contract.ts +4 -1
  166. package/templates/retail-store-owner/tier5-src/_shared/owner-bookings.ts +20 -0
  167. package/templates/retail-store-owner/tier5-src/_shared/owner-load-error.ts +7 -1
  168. package/templates/retail-store-owner/tier5-src/himi-owner-lite-aria/index.ts +40 -1
  169. package/templates/retail-store-owner/tier5-src/himi-owner-lite-home/index.ts +2 -12
  170. package/templates/retail-store-owner/tier5-src/himi-owner-lite-manage/index.ts +10 -4
  171. package/templates/yoga-studio/dev/index.ts +451 -204
  172. package/templates/yoga-studio/tier5-src/_shared/collection-contract.ts +4 -1
  173. package/templates/yoga-studio/tier5-src/_shared/owner-load-error.ts +7 -1
  174. package/templates/yoga-studio/tier5-src/himi-branded-lite-appearance/index.ts +12 -1
  175. package/templates/yoga-studio/tier5-src/wix-spaces-app-language/index.ts +4 -2
  176. package/templates/yoga-studio-owner/config.ts +1 -1
  177. package/templates/yoga-studio-owner/dev/index.ts +247 -9
  178. package/templates/yoga-studio-owner/tier5-src/_shared/aria-attachments.ts +179 -0
  179. package/templates/yoga-studio-owner/tier5-src/_shared/aria-chat.ts +405 -20
  180. package/templates/yoga-studio-owner/tier5-src/_shared/collection-contract.ts +4 -1
  181. package/templates/yoga-studio-owner/tier5-src/_shared/owner-bookings.ts +20 -0
  182. package/templates/yoga-studio-owner/tier5-src/_shared/owner-load-error.ts +7 -1
  183. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-aria/index.ts +40 -1
  184. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-home/index.ts +2 -12
  185. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-manage/index.ts +10 -4
@@ -29,8 +29,11 @@ import {
29
29
  ARIA_ORIGIN,
30
30
  abortAssistantMessageGeneration,
31
31
  ariaAssistantConfig,
32
+ attachmentsOf,
32
33
  conversationIdOf,
33
34
  createConversation,
35
+ createFileUpload,
36
+ genieUploadRef,
34
37
  functionCallsOf,
35
38
  getConversationMessages,
36
39
  getConversations,
@@ -39,11 +42,26 @@ import {
39
42
  sendFunctionCallResults,
40
43
  sendMessage,
41
44
  triggerAssistantMessage,
45
+ uploadTargetUrl,
42
46
  userMessageIdOf,
47
+ type Attachment,
43
48
  type ConversationMessage,
44
49
  type ConversationSummary,
45
50
  type SendMessageResponse,
46
51
  } from "@himalaya/wix-genie-chat";
52
+ import {
53
+ ARIA_MAX_ATTACHMENTS,
54
+ ARIA_MAX_IMAGE_BYTES,
55
+ attachmentLine,
56
+ chipIn,
57
+ imageFileName,
58
+ normalizeMime,
59
+ parseDataUri,
60
+ refusalFor,
61
+ sendBlocker,
62
+ type AriaAttachmentChip,
63
+ type AriaAttachmentOutcome,
64
+ } from "./aria-attachments.js";
47
65
  import { runClientToolExchange, type AriaClientTools } from "./aria-client-tools.js";
48
66
  import { proseOfParts, scanEnvelopes, stripHidden } from "./aria-widgets.js";
49
67
  import { isWebUrl, stripMarkdown } from "./aria-widget-model.js";
@@ -91,6 +109,11 @@ export interface AriaBubble {
91
109
  link1Label: string; link1Url: string; link1Hidden: boolean;
92
110
  link2Label: string; link2Url: string; link2Hidden: boolean;
93
111
  link3Label: string; link3Url: string; link3Hidden: boolean;
112
+ /** The files attached to this turn, as one line ("📎 a.jpg, b.png"), and its
113
+ * collapse flag. Present on EVERY bubble for the same reason the link slots
114
+ * are: a row missing the field does not substitute. */
115
+ attachText: string;
116
+ attachHidden: boolean;
94
117
  }
95
118
 
96
119
  /** An empty-state suggestion card: emoji + title + hint, and the text that is
@@ -127,6 +150,10 @@ export interface AriaChatState {
127
150
  /** Text of the optimistic user bubble awaiting reconciliation with the server
128
151
  * transcript. Empty when nothing is in flight. */
129
152
  _pending: string;
153
+ /** The file names the in-flight message was sent with, so the optimistic
154
+ * bubble keeps its attachment line across every tick until the server's own
155
+ * copy of the turn replaces it. */
156
+ _pendingAttach: string[];
130
157
  /** Server USER-turn COUNT at the moment the in-flight message was sent. The
131
158
  * optimistic bubble is dropped once the server holds more than this.
132
159
  *
@@ -185,6 +212,17 @@ export interface AriaChatState {
185
212
  * every time the transcript is, which is what makes "one active widget"
186
213
  * true by construction rather than by remembering to clear it. */
187
214
  widget: WidgetView;
215
+ /** The photos waiting in the composer to go with the next message.
216
+ *
217
+ * A PROJECTION, not a field the reducers maintain: the worker holds the chips
218
+ * in one place and every action's result is stamped with them on the way out
219
+ * (`gated`). An upload finishes on its own clock, so its outcome arrives while
220
+ * other actions are mid-await holding older snapshots; a field each reducer
221
+ * spread along would let any of those snapshots put a finished upload back to
222
+ * "Uploading…", or drop a photo picked during a send. */
223
+ attachments: AriaAttachmentChip[];
224
+ /** Collapses the composer's attachment strip while it is empty. */
225
+ attachmentsHidden: boolean;
188
226
  /** Transient host directives — a poll cadence to install, and a navigation to
189
227
  * perform. The runtime strips both before storing the state, so they are
190
228
  * never persisted and never re-fire when the state is spread. */
@@ -246,13 +284,53 @@ export interface AriaChatLabels {
246
284
  * all — it carries no Wix app binding to sign the chat-service call with.
247
285
  * See `isWixAccountUnreachable`, and note what it is NOT: a signed-out user. */
248
286
  unavailable?: Resolvable<string>;
287
+ /** Attachment copy — see `AriaChatOptions.attachments`. Chip states: */
288
+ attachUploading?: Resolvable<string>;
289
+ attachFailed?: Resolvable<string>;
290
+ /** Refusals, shown in the neutral status line. Nothing went wrong, so none of
291
+ * them is red; each one says what to do instead. */
292
+ attachTooMany?: (max: number) => string;
293
+ attachUnsupported?: Resolvable<string>;
294
+ attachTooLarge?: (maxMb: number) => string;
295
+ /** Send refused while a photo is still uploading, or failed to. */
296
+ attachWaitUploading?: Resolvable<string>;
297
+ attachWaitFailed?: Resolvable<string>;
298
+ /** Send refused with photos and no words. The service requires message text
299
+ * on every turn, so a photo alone is not a sendable message. */
300
+ attachNeedsText?: Resolvable<string>;
249
301
  /** Shown when a client-tool exchange broke down (see `clientTools`) and the
250
302
  * turn cannot finish. The underlying reason is a protocol fault the owner can
251
303
  * do nothing about, so it is not what they are shown. */
252
304
  toolFailed?: Resolvable<string>;
253
305
  }
254
306
 
307
+ /** What the composer needs from the device to attach a photo. Supplied by the
308
+ * app's adapter, because both are capabilities the SCREEN has to declare — the
309
+ * shared core imports neither, so a consumer without attachments (the clone)
310
+ * keeps a declared-capability list with no camera and no upload in it.
311
+ *
312
+ * Both ports must REJECT (or resolve not-ok) on failure. A port that swallows
313
+ * an error and resolves as if it had worked would put a photo on the message
314
+ * that the service never received. */
315
+ export interface AriaAttachmentPorts {
316
+ /** Let the owner pick (or take) one photo. Resolves the image as a `data:`
317
+ * URI, or null when they cancelled. */
318
+ pick(source: "library" | "camera"): Promise<{ dataUri: string; name?: string; mimeType?: string } | null>;
319
+ /** PUT `base64` to a pre-signed url with `contentType`, carrying NO user
320
+ * credentials — the url authorizes itself. */
321
+ put(url: string, base64: string, contentType: string): Promise<{ ok: boolean; status: number }>;
322
+ /** Hand an upload's outcome back to the service as a NEW action
323
+ * (`attachmentSettled`). A fresh dispatch rather than a return value, because
324
+ * the upload outlives the action that started it — which must return at once
325
+ * so the chip paints while the bytes are still moving. */
326
+ settled(outcome: AriaAttachmentOutcome): void;
327
+ }
328
+
255
329
  export interface AriaChatOptions {
330
+ /** Photo attachments in the composer. Omit it and the attach actions are
331
+ * inert — which is also what the descriptor does, since it paints no attach
332
+ * control unless the app passes the matching actions. */
333
+ attachments?: AriaAttachmentPorts;
256
334
  /** Mount the generic card for an unrecognised kind in this `WorkerSubtree`
257
335
  * slot.
258
336
  *
@@ -328,7 +406,7 @@ export interface AriaChatOptions {
328
406
  * its new BOOLEAN there on iOS and Android, and the native marshaller merges
329
407
  * args over params — so anything reading it must cope with a non-string.
330
408
  * `optionValue` is the key a caller should use when it means the option. */
331
- export type AriaActionArg = string | { message?: string; text?: string; value?: unknown; optionValue?: string; id?: string; url?: string; intent?: string } | undefined;
409
+ export type AriaActionArg = string | { message?: string; text?: string; value?: unknown; optionValue?: string; id?: string; url?: string; intent?: string; source?: string; ok?: unknown; ref?: string } | undefined;
332
410
 
333
411
  /** The first of these fields that is actually a non-empty string.
334
412
  *
@@ -580,9 +658,24 @@ export function withSpoken(bubbles: AriaBubble[]): AriaBubble[] {
580
658
  : bubbles.map((b) => (typeof b.spoken === "string" ? b : { ...b, spoken: spokenOf(b.who, b.content) }));
581
659
  }
582
660
 
583
- /** A greeting or optimistic bubble: no links, all slots defaulted. */
584
- export function plainBubble(id: string, role: string, who: string, content: string): AriaBubble {
585
- return { id, role, who, content, notUser: role !== "USER", notAria: role === "USER", spoken: spokenOf(who, content), ...linkFieldsOf("", role) };
661
+ /** A bubble's words and its attachment line, with its collapse flag.
662
+ *
663
+ * A turn that is ONLY attachments carries the line as its content instead of
664
+ * beside it: a screen that draws the owner's words in a padded bubble would
665
+ * otherwise paint an empty bubble above the file names, and the row's
666
+ * `spoken` label, built from the content, would announce the author and
667
+ * nothing else. */
668
+ function withAttachments(content: string, names: readonly string[]): { content: string; attachText: string; attachHidden: boolean } {
669
+ const attachText = attachmentLine(names);
670
+ if (attachText && !content.trim()) return { content: attachText, attachText: "", attachHidden: true };
671
+ return { content, attachText, attachHidden: !attachText };
672
+ }
673
+
674
+ /** A greeting or optimistic bubble: no links, all slots defaulted. `attached`
675
+ * names the files an optimistic USER bubble is being sent with. */
676
+ export function plainBubble(id: string, role: string, who: string, content: string, attached: readonly string[] = []): AriaBubble {
677
+ const a = withAttachments(content, attached);
678
+ return { id, role, who, notUser: role !== "USER", notAria: role === "USER", spoken: spokenOf(who, a.content), ...linkFieldsOf("", role), ...a };
586
679
  }
587
680
 
588
681
  /** Server transcript → bubbles. USER text is literal; ASSISTANT replies are
@@ -619,8 +712,16 @@ export function toBubbles(
619
712
  // can keep exactly the same text and still render differently (a held
620
713
  // back envelope is released), so text alone would serve the stale bubble.
621
714
  const writing = role !== "USER" && stillWriting(m);
622
- const build = () => bubbleOf(id, role, who, raw, writing);
623
- const b = memo ? memo.row(id, raw, `${role}\u0000${who}\u0000${writing ? "w" : ""}`, build) : build();
715
+ // The OWNER's attachments only. An assistant row carrying the field is
716
+ // not something the service is known to send, and naming files on it
717
+ // would claim Aria sent them.
718
+ const attached = role === "USER" ? attachmentsOf(m).map((a) => a.fileName) : [];
719
+ const build = () => bubbleOf(id, role, who, raw, writing, attached);
720
+ // Attachments are part of the key: they are not in the text, so a row
721
+ // keyed on text alone could serve a bubble naming the wrong files.
722
+ const b = memo
723
+ ? memo.row(id, raw, `${role}\u0000${who}\u0000${writing ? "w" : ""}\u0000${attached.join("\u0001")}`, build)
724
+ : build();
624
725
  if (b) out.push(b);
625
726
  }
626
727
  } finally {
@@ -643,7 +744,9 @@ export function assistantProse(raw: string, closed = true): string {
643
744
  export type BubbleMemo = RowMemo<AriaBubble | null>;
644
745
  export const createBubbleMemo = (): BubbleMemo => createRowMemo<AriaBubble | null>();
645
746
 
646
- function bubbleOf(id: string, role: string, who: string, raw: string, writing: boolean): AriaBubble | null {
747
+ function bubbleOf(
748
+ id: string, role: string, who: string, raw: string, writing: boolean, attached: readonly string[] = [],
749
+ ): AriaBubble | null {
647
750
  // Only assistant turns carry envelopes; a user's own text is literal, and
648
751
  // scanning it would let an owner who typed `<<…>>` erase their own message.
649
752
  // HIDDEN spans go first: the service marks text as transcript-only that
@@ -655,16 +758,20 @@ function bubbleOf(id: string, role: string, who: string, raw: string, writing: b
655
758
  // painted as raw JSON until the row finishes.
656
759
  const prose = role === "USER" ? raw : assistantProse(raw, !writing);
657
760
  const content = role === "USER" ? prose : stripMarkdown(prose);
658
- if (!content || !content.trim()) return null;
761
+ // A turn that is only attachments still happened, and dropping it would hide
762
+ // what the owner sent: `withAttachments` makes the file line its content.
763
+ // Empty prose AND no attachments is the placeholder row.
764
+ const a = withAttachments(content ?? "", attached);
765
+ if (!a.content.trim()) return null;
659
766
  return {
660
767
  id,
661
768
  role,
662
769
  who,
663
- content,
664
770
  notUser: role !== "USER",
665
771
  notAria: role === "USER",
666
- spoken: spokenOf(who, content),
772
+ spoken: spokenOf(who, a.content),
667
773
  ...linkFieldsOf(prose, role),
774
+ ...a,
668
775
  };
669
776
  }
670
777
 
@@ -715,13 +822,14 @@ export function argText(arg: AriaActionArg): string {
715
822
  * vanish mid-turn. */
716
823
  export function reconcile(
717
824
  server: AriaBubble[], pending: string, pendingAfter: number, youName = "You", pendingId = "",
825
+ pendingAttached: readonly string[] = [],
718
826
  ): { messages: AriaBubble[]; pending: string } {
719
827
  if (!pending) return { messages: server, pending: "" };
720
828
  if (countRole(server, "USER") > pendingAfter) return { messages: server, pending: "" };
721
829
  // `pendingId` is empty only for state persisted before ids were minted; a
722
830
  // fixed fallback still keeps that one row stable across ticks.
723
831
  const id = pendingId || `${LOCAL_ID_PREFIX}pending`;
724
- return { messages: [...server, plainBubble(id, "USER", youName, pending)], pending };
832
+ return { messages: [...server, plainBubble(id, "USER", youName, pending, pendingAttached)], pending };
725
833
  }
726
834
 
727
835
  /** How many turns of one role the transcript holds. Counted over BUBBLES rather
@@ -1054,6 +1162,19 @@ export interface AriaChatCore {
1054
1162
  widgetPick(s: AriaChatState, arg?: AriaActionArg): AriaChatState;
1055
1163
  /** Answer one QUESTIONNAIRE question. */
1056
1164
  widgetAnswer(s: AriaChatState, arg?: AriaActionArg): AriaChatState;
1165
+ /** Pick (or take) a photo and start uploading it. Returns as soon as the chip
1166
+ * exists — the upload reports back through `attachmentSettled`. The source is
1167
+ * the arg (`"camera"`, or `{ source: "camera" }`); anything else is the
1168
+ * library. */
1169
+ attach(s: AriaChatState, arg?: AriaActionArg): Promise<AriaChatState>;
1170
+ /** An upload's outcome, dispatched by the upload itself. A chip that has
1171
+ * since been removed or sent is not resurrected. */
1172
+ attachmentSettled(s: AriaChatState, arg?: AriaActionArg): AriaChatState;
1173
+ /** Drop one chip. The upload, if still running, is left to finish — there is
1174
+ * no cancel on the transfer — and its outcome is then ignored. */
1175
+ removeAttachment(s: AriaChatState, arg?: AriaActionArg): AriaChatState;
1176
+ /** Re-send a failed chip's bytes from the copy it already holds. */
1177
+ retryAttachment(s: AriaChatState, arg?: AriaActionArg): AriaChatState;
1057
1178
  }
1058
1179
 
1059
1180
  const POLL_ID = "aria-turn";
@@ -1089,6 +1210,28 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1089
1210
  const unavailableText = () => res(
1090
1211
  o.labels?.unavailable ?? `${assistant()} can't reach your Wix account from here.`,
1091
1212
  );
1213
+ const attachUploadingText = () => res(o.labels?.attachUploading ?? "Uploading…");
1214
+ const attachFailedText = () => res(o.labels?.attachFailed ?? "Couldn't upload");
1215
+ const chipLabels = () => ({ uploading: attachUploadingText(), failed: attachFailedText() });
1216
+ const refusalText = (why: "type" | "size" | "count"): string =>
1217
+ why === "count"
1218
+ ? o.labels?.attachTooMany?.(ARIA_MAX_ATTACHMENTS) ?? `You can attach up to ${ARIA_MAX_ATTACHMENTS} photos.`
1219
+ : why === "size"
1220
+ ? o.labels?.attachTooLarge?.(ARIA_MAX_IMAGE_BYTES / (1024 * 1024))
1221
+ ?? `That photo is too large to attach (up to ${ARIA_MAX_IMAGE_BYTES / (1024 * 1024)} MB).`
1222
+ : res(o.labels?.attachUnsupported ?? "That file type can't be attached.");
1223
+ const waitUploadingText = () => res(o.labels?.attachWaitUploading ?? "Wait for your photo to finish uploading.");
1224
+ const waitFailedText = () => res(o.labels?.attachWaitFailed ?? "Retry or remove the photo that didn't upload.");
1225
+ const needsTextText = () => res(o.labels?.attachNeedsText ?? "Add a message to send with your photo.");
1226
+ /** Clear the status line if what it says is an attachment notice, and only
1227
+ * then. Every notice is advice about the composer's photos, so once the owner
1228
+ * acts on them it is stale — but the same line also carries "Aria is
1229
+ * typing…", which a photo edit must never erase. */
1230
+ const withoutNotice = <T extends { statusText: string }>(s: T): T => {
1231
+ const notices = [waitUploadingText(), waitFailedText(), needsTextText(),
1232
+ refusalText("type"), refusalText("size"), refusalText("count")];
1233
+ return s.statusText && notices.includes(s.statusText) ? { ...s, statusText: "" } : s;
1234
+ };
1092
1235
  const toolFailedText = () => res(o.labels?.toolFailed ?? `${assistant()} couldn't finish that. Try again.`);
1093
1236
  const promptList = () => res(o.prompts);
1094
1237
  const siteOf = o.siteId ?? (() => "");
@@ -1098,10 +1241,98 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1098
1241
 
1099
1242
  const initialState: AriaChatState = {
1100
1243
  status: "loading", statusText: "", error: "", input: "", copyNote: "", replyActionsHidden: true,
1101
- messages: [], prompts: [], _convId: "", _siteId: "", _pending: "",
1244
+ messages: [], prompts: [], _convId: "", _siteId: "", _pending: "", _pendingAttach: [],
1102
1245
  _pendingAfter: 0, _pendingId: "", _replyAfter: 0, _srvUsers: 0, _srvAssistants: 0,
1103
1246
  _abortId: "", _turnId: "", stopHidden: true, ok: false,
1104
1247
  widget: NO_WIDGET,
1248
+ attachments: [], attachmentsHidden: true,
1249
+ };
1250
+
1251
+ /** The composer's photos — the ONE copy. See `AriaChatState.attachments` for
1252
+ * why the state's field is only ever a projection of this. */
1253
+ let chips: AriaAttachmentChip[] = [];
1254
+ /** Whether `chips` has been seeded from a rehydrated state yet. A cold start
1255
+ * hands the first action a PERSISTED state whose chips this worker has never
1256
+ * seen; the latch starts empty and would otherwise wipe them. */
1257
+ let chipsSeeded = false;
1258
+ /** Mints chip ids. Never derived from the file: two photos named
1259
+ * `IMG_0001.jpg` are two chips. */
1260
+ let chipSeq = 0;
1261
+ /** Each chip's full image as a `data:` URI, keyed by chip id: the bytes an
1262
+ * upload, or a retry, sends. Kept HERE and not in the chip, because every chip
1263
+ * is projected into the service state, which rides every web frame, and the
1264
+ * contained web runtime refuses a frame over 4 MiB. With up to 10 photos of up
1265
+ * to 8 MB each, the chips alone would exceed that, and the action would fail
1266
+ * before anything rendered. Pruned to the live chips in `gated`. */
1267
+ const chipBytes = new Map<string, string>();
1268
+ /** A picked image this small doubles as its own chip thumbnail; a larger one
1269
+ * shows the chip without a preview. Ten of these stay well under a frame. */
1270
+ const THUMB_INLINE_MAX = 200_000;
1271
+
1272
+ const seedChips = (s: AriaChatState): void => {
1273
+ if (chipsSeeded) return;
1274
+ chipsSeeded = true;
1275
+ const persisted = Array.isArray(s.attachments) ? s.attachments : [];
1276
+ // An upload does not survive the process that ran it, so a chip persisted
1277
+ // mid-upload would say "Uploading…" forever. It still holds its bytes, so it
1278
+ // comes back as FAILED — with the retry control — rather than being dropped.
1279
+ chips = persisted
1280
+ .filter((c) => c && typeof c.id === "string")
1281
+ .map((c) => (c.state === "uploading" ? chipIn(c, "failed", chipLabels()) : c));
1282
+ for (const c of chips) {
1283
+ const n = Number(/^att-(\d+)$/.exec(c.id)?.[1] ?? 0);
1284
+ if (n > chipSeq) chipSeq = n;
1285
+ // A rehydrated chip's bytes are whatever it kept inline. A large photo kept
1286
+ // none, so its retry fails honestly rather than sending nothing.
1287
+ if (c.thumbUrl && !chipBytes.has(c.id)) chipBytes.set(c.id, c.thumbUrl);
1288
+ }
1289
+ };
1290
+
1291
+ /** Is Send refused while photos are pending, and what does the owner see?
1292
+ * Shared by a typed send and a suggested-prompt tap, so neither can send past
1293
+ * a photo that is still uploading or failed, or drop the photos off a turn. */
1294
+ const attachmentRefusal = (s: AriaChatState, content: string): AriaChatState | null => {
1295
+ if (!chips.length) return null;
1296
+ const blocker = sendBlocker(chips);
1297
+ if (blocker === "uploading") return { ...s, statusText: waitUploadingText(), error: "" };
1298
+ if (blocker === "failed") return { ...s, statusText: waitFailedText(), error: "" };
1299
+ if (!content) return { ...s, statusText: needsTextText(), error: "" };
1300
+ return null;
1301
+ };
1302
+
1303
+ /** Keep a "wait for your photo" notice true to the chips as they are NOW. An
1304
+ * upload that FAILED while Send was held on "Wait for your photo to finish
1305
+ * uploading" must say to retry or remove it instead, and one that landed,
1306
+ * with nothing else pending, clears it. Any other status line (the typing
1307
+ * line above all) is left alone. */
1308
+ const refreshWaitNotice = (s: AriaChatState): AriaChatState => {
1309
+ const waiting = [waitUploadingText(), waitFailedText()];
1310
+ if (!s.statusText || !waiting.includes(s.statusText)) return s;
1311
+ const blocker = sendBlocker(chips);
1312
+ const next = blocker === "uploading" ? waitUploadingText() : blocker === "failed" ? waitFailedText() : "";
1313
+ return next === s.statusText ? s : { ...s, statusText: next };
1314
+ };
1315
+
1316
+ /** Send one chip's bytes: mint the upload, PUT the bytes, report back. Detached
1317
+ * on purpose — see `AriaAttachmentPorts.settled`. Any failure, at either
1318
+ * step, reports `ok: false`; nothing is retried behind the owner's back. */
1319
+ const startUpload = (chip: AriaAttachmentChip): void => {
1320
+ const ports = o.attachments;
1321
+ if (!ports) return;
1322
+ const run = async (): Promise<AriaAttachmentOutcome> => {
1323
+ const parsed = parseDataUri(chipBytes.get(chip.id) ?? chip.thumbUrl);
1324
+ if (!parsed) return { id: chip.id, ok: false };
1325
+ const minted = await createFileUpload(chip.name, chip.mimeType);
1326
+ const id = (minted?.upload?.id ?? "").trim();
1327
+ const target = uploadTargetUrl(minted?.upload?.uploadUrl ?? "", chip.name);
1328
+ if (!id || !target) return { id: chip.id, ok: false };
1329
+ const sent = await ports.put(target, parsed.base64, chip.mimeType);
1330
+ return sent?.ok ? { id: chip.id, ok: true, ref: genieUploadRef(id) } : { id: chip.id, ok: false };
1331
+ };
1332
+ run().then(
1333
+ (outcome) => ports.settled(outcome),
1334
+ () => ports.settled({ id: chip.id, ok: false }),
1335
+ );
1105
1336
  };
1106
1337
 
1107
1338
  /** One create at a time, per site.
@@ -1545,12 +1776,17 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1545
1776
  const withDraft = (next: AriaChatState, since: number): AriaChatState =>
1546
1777
  draftSeq === since || draft === next.input ? next : { ...next, input: draft };
1547
1778
 
1548
- async function send(s: AriaChatState, content: string): Promise<AriaChatState> {
1779
+ async function send(s: AriaChatState, content: string, attached: readonly AriaAttachmentChip[] = []): Promise<AriaChatState> {
1549
1780
  // A new turn is a turn boundary: any tick still parked on the PREVIOUS
1550
1781
  // turn's transcript read must stand down rather than write its snapshot over
1551
1782
  // this one. Bump before the first await so an in-flight tick is already stale
1552
1783
  // by the time it resolves.
1553
1784
  turnSeq += 1;
1785
+ // Only chips whose bytes have LANDED travel; `submit` refuses to get here
1786
+ // with any other kind, and this filter is the second lock on that door.
1787
+ const riding = attached.filter((c) => c.state === "ready" && !!c.ref);
1788
+ const attachments: Attachment[] = riding.map((c) => ({ fileName: c.name, mimeType: c.mimeType, url: c.ref }));
1789
+ const attachedNames = riding.map((c) => c.name);
1554
1790
  // Optimistic first: the owner's message and the typing line are part of the
1555
1791
  // state this action RETURNS, so on the `_poll` path they paint immediately.
1556
1792
  // Its id is the LOCAL SEND ID, minted here and carried as `_pendingId`, the
@@ -1572,7 +1808,7 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1572
1808
  localId = minted.id;
1573
1809
  }
1574
1810
  const optimistic: AriaBubble[] = [
1575
- ...s.messages.filter((b) => b.id !== localId), plainBubble(localId, "USER", you(), content),
1811
+ ...s.messages.filter((b) => b.id !== localId), plainBubble(localId, "USER", you(), content, attachedNames),
1576
1812
  ];
1577
1813
  const base: AriaChatState = {
1578
1814
  // `copyNote: ""` — a Copy confirmation belongs to the moment, and a new
@@ -1584,7 +1820,7 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1584
1820
  // on a stale turn id would look for an answer that already exists. Both
1585
1821
  // are replaced the moment this send knows its own id.
1586
1822
  _abortId: "", _turnId: "",
1587
- statusText: typingText(), messages: optimistic, _pending: content, _pendingId: localId,
1823
+ statusText: typingText(), messages: optimistic, _pending: content, _pendingId: localId, _pendingAttach: attachedNames,
1588
1824
  // Mark the turn against the LAST COUNTS WE SAW, not against our own
1589
1825
  // message list — the greeting is a local bubble the server never had, so
1590
1826
  // counting `messages` would mark the turn one assistant reply ahead and
@@ -1683,9 +1919,21 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1683
1919
  abortId = stalled.userMessageId;
1684
1920
  answer = await triggerAssistantMessage(convId, stalled.userMessageId, { origin });
1685
1921
  } else {
1686
- answer = await sendMessage(convId, content, { origin });
1922
+ answer = await sendMessage(convId, content, {
1923
+ origin,
1924
+ // Omitted rather than `[]` when there are none, so a plain text turn
1925
+ // is byte-for-byte what it was before attachments existed.
1926
+ ...(attachments.length ? { attachments } : {}),
1927
+ });
1687
1928
  abortId = userMessageIdOf(answer);
1688
1929
  }
1930
+ // The turn is stored WITH its photos, so they leave the composer — by id,
1931
+ // against the live latch, because a photo picked while this send was in
1932
+ // the air is not part of it and must stay.
1933
+ if (riding.length) {
1934
+ const gone = new Set(riding.map((c) => c.id));
1935
+ chips = chips.filter((c) => !gone.has(c.id));
1936
+ }
1689
1937
  // A SECOND boundary bump, and the load-bearing one. The bump at entry only
1690
1938
  // retires a tick that was already parked when the owner tapped Send. A tick
1691
1939
  // dispatched DURING the two round trips above read the post-bump epoch, so
@@ -1844,9 +2092,19 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1844
2092
  // `_pending` rather than `turnInFlight` — see `regenerate` for why `status`
1845
2093
  // is not trusted to have been cleared.
1846
2094
  const replyActionsHidden = !(lastReply(s) && !s._pending);
1847
- const out = s.stopHidden === hidden && s.replyActionsHidden === replyActionsHidden
2095
+ const actioned = s.stopHidden === hidden && s.replyActionsHidden === replyActionsHidden
1848
2096
  ? s
1849
2097
  : { ...s, stopHidden: hidden, replyActionsHidden };
2098
+ // The attachment projection rides the same exit, for the same reason: one
2099
+ // place that every action's result passes through, so no reducer can hand
2100
+ // back a stale copy of the chips. Seeded first, so the very first action of
2101
+ // a cold start stamps the PERSISTED chips rather than an empty latch.
2102
+ seedChips(actioned);
2103
+ for (const id of [...chipBytes.keys()]) if (!chips.some((c) => c.id === id)) chipBytes.delete(id);
2104
+ const attachmentsHidden = chips.length === 0;
2105
+ const out = actioned.attachments === chips && actioned.attachmentsHidden === attachmentsHidden
2106
+ ? actioned
2107
+ : { ...actioned, attachments: chips, attachmentsHidden };
1850
2108
  // Every state the runtime commits passes through here (an `undefined`
1851
2109
  // result commits nothing and skips it), so this is the newest committed
1852
2110
  // state — what `copyMessage` puts its note on when other actions landed
@@ -1858,6 +2116,21 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1858
2116
  return out;
1859
2117
  };
1860
2118
 
2119
+ /** Which chip an action names. Accepts the bare id and the object form, as
2120
+ * every other action here does. */
2121
+ const chipIdOf = (arg?: AriaActionArg): string =>
2122
+ typeof arg === "string" ? arg.trim() : firstString(arg?.id, arg?.optionValue, arg?.value);
2123
+
2124
+ /** Replace one chip in the latch. Returns false when it no longer exists —
2125
+ * removed, sent, or discarded by ➕ — which every caller treats as "nothing to
2126
+ * do" rather than bringing it back. */
2127
+ const replaceChip = (id: string, next: (c: AriaAttachmentChip) => AriaAttachmentChip): boolean => {
2128
+ const at = chips.findIndex((c) => c.id === id);
2129
+ if (at < 0) return false;
2130
+ chips = [...chips.slice(0, at), next(chips[at]), ...chips.slice(at + 1)];
2131
+ return true;
2132
+ };
2133
+
1861
2134
  /** Ask the service how an async job is getting on.
1862
2135
  *
1863
2136
  * Folded into the existing `tick` rather than armed as a SECOND `_poll`, and
@@ -1973,7 +2246,12 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1973
2246
  initialState,
1974
2247
 
1975
2248
  load: async (s) => {
2249
+ seedChips(s);
1976
2250
  const site = siteOf();
2251
+ // An upload is minted under the business whose signed instance asked for
2252
+ // it, exactly as a conversation is. Photos picked for one business must not
2253
+ // ride a message to another, so a switch drops them with the conversation.
2254
+ if (s._siteId && s._siteId !== site) chips = [];
1977
2255
  // Every branch below that puts a DIFFERENT conversation on screen abandons
1978
2256
  // whatever turn was in flight — a business switch mid-turn does exactly
1979
2257
  // that — so it re-arms the ladder slow, as `newChat` does. Otherwise the
@@ -2062,9 +2340,14 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
2062
2340
 
2063
2341
  submit: async (s, arg) => {
2064
2342
  const content = argText(arg) || (s.input ?? "").trim();
2343
+ // Read from the latch, never from `s`: see `AriaChatState.attachments`.
2344
+ // Each refusal leaves the draft AND the photos exactly as they were — the
2345
+ // owner fixes the one thing named and taps Send again.
2346
+ const refused = attachmentRefusal(s, content);
2347
+ if (refused) return refused;
2065
2348
  if (!content) return { ...s, statusText: "" };
2066
2349
  if (duplicate(s, content)) return s;
2067
- return send(s, content);
2350
+ return send(s, content, chips);
2068
2351
  },
2069
2352
 
2070
2353
  // A suggested-prompt chip submits its own text through the SAME path a typed
@@ -2074,8 +2357,13 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
2074
2357
  submitPrompt: async (s, arg) => {
2075
2358
  const msg = argText(arg);
2076
2359
  if (!msg) return s;
2360
+ // Staged photos go with the prompt, under the same rules as Send: a chip
2361
+ // that bypassed them would send the words and strand the photos in the
2362
+ // composer, or send past one that has not finished uploading.
2363
+ const refused = attachmentRefusal(s, msg);
2364
+ if (refused) return refused;
2077
2365
  if (duplicate(s, msg)) return s;
2078
- return send(s, msg);
2366
+ return send(s, msg, chips);
2079
2367
  },
2080
2368
 
2081
2369
  newChat: async (s) => {
@@ -2083,6 +2371,10 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
2083
2371
  // A fresh conversation is a turn boundary too: a tick parked on the OLD
2084
2372
  // conversation's transcript must not reconcile it over the new greeting.
2085
2373
  turnSeq += 1;
2374
+ // ➕ discards the draft (`input: ""` below), and the photos are part of the
2375
+ // draft. An upload still running settles into nothing: its chip is gone.
2376
+ chipsSeeded = true;
2377
+ chips = [];
2086
2378
  // ➕ can land mid-turn, and it abandons that turn: `status` goes back to
2087
2379
  // `ready`, so `tick`'s `wasFast` is false from here on and the FAST
2088
2380
  // ladder the send armed would never be slowed again. A worker cannot
@@ -2151,7 +2443,7 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
2151
2443
  // reply go unnoticed and pinned the ladder fast forever. Safe to return
2152
2444
  // `s` here: the guard above proved this tick is still current.
2153
2445
  if (!raw.length) return s;
2154
- const { messages, pending } = reconcile(server, s._pending, s._pendingAfter, you(), s._pendingId);
2446
+ const { messages, pending } = reconcile(server, s._pending, s._pendingAfter, you(), s._pendingId, s._pendingAttach ?? []);
2155
2447
  // …but still never PAINT nothing over something. `messages` is only
2156
2448
  // adopted when it has content: a transcript that renders empty leaves
2157
2449
  // the screen as it was (the greeting, or the last good turn) while the
@@ -2371,6 +2663,95 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
2371
2663
  return s;
2372
2664
  },
2373
2665
 
2666
+ attach: async (s, arg) => {
2667
+ const ports = o.attachments;
2668
+ if (!ports) return s;
2669
+ seedChips(s);
2670
+ // Refused BEFORE the picker opens when there is no room: presenting a
2671
+ // picker whose result will be thrown away wastes the owner's time.
2672
+ if (chips.length >= ARIA_MAX_ATTACHMENTS) return { ...s, statusText: refusalText("count"), error: "" };
2673
+ const raw = typeof arg === "string" ? arg : firstString(arg?.source, arg?.value);
2674
+ const source = raw.trim().toLowerCase() === "camera" ? "camera" : "library";
2675
+ // The picker is open for as long as the owner takes, and a poll, a reply or
2676
+ // another action can commit a NEWER state meanwhile. Returning `{ ...s }`
2677
+ // afterwards would put this pre-picker snapshot back over it: an older
2678
+ // transcript, a `_pending` already answered, a reply that just landed
2679
+ // hidden again. So the result is built on the newest committed state, the
2680
+ // same way `copyMessage` puts its note there (see `gated`).
2681
+ const at = commits;
2682
+ const latest = (): AriaChatState => (commits !== at && committed ? committed : s);
2683
+ let picked: Awaited<ReturnType<AriaAttachmentPorts["pick"]>>;
2684
+ try {
2685
+ picked = await ports.pick(source);
2686
+ } catch {
2687
+ // A picker that could not open is not the owner's fault and there is no
2688
+ // file to talk about; say nothing rather than something misleading.
2689
+ return latest();
2690
+ }
2691
+ if (!picked || !picked.dataUri) return latest(); // cancelled
2692
+ const parsed = parseDataUri(picked.dataUri);
2693
+ const mimeType = normalizeMime(picked.mimeType || parsed?.mimeType || "");
2694
+ // Re-checked after the await: other picks may have landed meanwhile.
2695
+ const refused = refusalFor(parsed, mimeType, chips.length);
2696
+ if (refused) return { ...latest(), statusText: refusalText(refused), error: "" };
2697
+ chipSeq += 1;
2698
+ const id = `att-${chipSeq}`;
2699
+ chipBytes.set(id, picked.dataUri);
2700
+ const chip = chipIn({
2701
+ id,
2702
+ name: imageFileName(mimeType, chipSeq, picked.name),
2703
+ mimeType,
2704
+ // Only a small image rides the state as its own thumbnail; see `chipBytes`.
2705
+ thumbUrl: picked.dataUri.length <= THUMB_INLINE_MAX ? picked.dataUri : "",
2706
+ }, "uploading", chipLabels());
2707
+ chips = [...chips, chip];
2708
+ startUpload(chip);
2709
+ // Clear a refusal left over from an earlier pick: this one worked. The
2710
+ // typing line is not touched, so picking mid-turn does not hide it.
2711
+ return { ...withoutNotice(latest()), error: "" };
2712
+ },
2713
+
2714
+ attachmentSettled: (s, arg) => {
2715
+ const id = chipIdOf(arg);
2716
+ if (!id) return s;
2717
+ const ok = typeof arg === "object" && !!arg && (arg.ok === true || arg.ok === "true");
2718
+ const ref = typeof arg === "object" && arg ? (arg.ref ?? "").trim() : "";
2719
+ // Only an UPLOADING chip can settle. A retry re-mints the chip's id (see
2720
+ // `retryAttachment`), so a late answer from an earlier attempt names an id
2721
+ // that no longer exists and cannot decide the attempt now running.
2722
+ const current = chips.find((c) => c.id === id);
2723
+ if (!current || current.state !== "uploading") return s;
2724
+ replaceChip(id, (c) => chipIn(c, ok && ref ? "ready" : "failed", chipLabels(), ref));
2725
+ // A "wait for your photo" notice follows the chips: a failure turns
2726
+ // "…finish uploading" into "retry or remove…", and a success clears it
2727
+ // once nothing else is pending.
2728
+ return refreshWaitNotice(s);
2729
+ },
2730
+
2731
+ removeAttachment: (s, arg) => {
2732
+ const id = chipIdOf(arg);
2733
+ if (!id || !chips.some((c) => c.id === id)) return s;
2734
+ chips = chips.filter((c) => c.id !== id);
2735
+ return refreshWaitNotice(withoutNotice(s));
2736
+ },
2737
+
2738
+ retryAttachment: (s, arg) => {
2739
+ const id = chipIdOf(arg);
2740
+ const current = chips.find((c) => c.id === id);
2741
+ if (!current || current.state !== "failed") return s;
2742
+ // A NEW id for the new attempt, in the same place in the strip. Keeping the
2743
+ // old one would let the first attempt's outcome, if it is still in the air,
2744
+ // settle this one.
2745
+ chipSeq += 1;
2746
+ const again = chipIn({ ...current, id: `att-${chipSeq}` }, "uploading", chipLabels());
2747
+ // The bytes follow the chip to its new id; the old entry is pruned in `gated`.
2748
+ const bytes = chipBytes.get(id);
2749
+ if (bytes) chipBytes.set(again.id, bytes);
2750
+ replaceChip(id, () => again);
2751
+ startUpload(again);
2752
+ return withoutNotice(s);
2753
+ },
2754
+
2374
2755
  // Abandon the reply being generated. Three things happen, and only the first
2375
2756
  // is a network call:
2376
2757
  //
@@ -2533,6 +2914,10 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
2533
2914
  widgetRespond: (s, arg) => impl.widgetRespond(s, arg).then(gated),
2534
2915
  widgetPrimary: (s) => impl.widgetPrimary(s).then(gated),
2535
2916
  widgetSecondary: (s) => impl.widgetSecondary(s).then(gated),
2917
+ attach: (s, arg) => impl.attach(s, arg).then(gated),
2918
+ attachmentSettled: (s, arg) => gated(impl.attachmentSettled(s, arg)),
2919
+ removeAttachment: (s, arg) => gated(impl.removeAttachment(s, arg)),
2920
+ retryAttachment: (s, arg) => gated(impl.retryAttachment(s, arg)),
2536
2921
  };
2537
2922
  }
2538
2923