@wix/himalaya-cli 0.960.0 → 0.962.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 (90) hide show
  1. package/dist/cli.mjs +1 -0
  2. package/package.json +2 -2
  3. package/templates/beauty-salon-owner/config.ts +1 -1
  4. package/templates/beauty-salon-owner/dev/index.ts +3075 -0
  5. package/templates/beauty-salon-owner/tier5-src/_shared/aria-chat.ts +466 -9
  6. package/templates/beauty-salon-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
  7. package/templates/beauty-salon-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
  8. package/templates/beauty-salon-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
  9. package/templates/beauty-salon-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
  10. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
  11. package/templates/community-club-owner/config.ts +1 -1
  12. package/templates/community-club-owner/dev/index.ts +3075 -0
  13. package/templates/community-club-owner/tier5-src/_shared/aria-chat.ts +466 -9
  14. package/templates/community-club-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
  15. package/templates/community-club-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
  16. package/templates/community-club-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
  17. package/templates/community-club-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
  18. package/templates/community-club-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
  19. package/templates/events-meetups-owner/config.ts +1 -1
  20. package/templates/events-meetups-owner/dev/index.ts +3075 -0
  21. package/templates/events-meetups-owner/tier5-src/_shared/aria-chat.ts +466 -9
  22. package/templates/events-meetups-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
  23. package/templates/events-meetups-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
  24. package/templates/events-meetups-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
  25. package/templates/events-meetups-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
  26. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
  27. package/templates/gym-fitness-owner/config.ts +1 -1
  28. package/templates/gym-fitness-owner/dev/index.ts +3075 -0
  29. package/templates/gym-fitness-owner/tier5-src/_shared/aria-chat.ts +466 -9
  30. package/templates/gym-fitness-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
  31. package/templates/gym-fitness-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
  32. package/templates/gym-fitness-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
  33. package/templates/gym-fitness-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
  34. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
  35. package/templates/nonprofit-cause-owner/config.ts +1 -1
  36. package/templates/nonprofit-cause-owner/dev/index.ts +3075 -0
  37. package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-chat.ts +466 -9
  38. package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
  39. package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
  40. package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
  41. package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
  42. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
  43. package/templates/owner-lite/config.ts +1 -1
  44. package/templates/owner-lite/dev/index.ts +3075 -0
  45. package/templates/owner-lite/tier5-src/_shared/aria-chat.ts +466 -9
  46. package/templates/owner-lite/tier5-src/_shared/aria-generic-widget.ts +251 -0
  47. package/templates/owner-lite/tier5-src/_shared/aria-widget-kinds.ts +332 -0
  48. package/templates/owner-lite/tier5-src/_shared/aria-widget-model.ts +331 -0
  49. package/templates/owner-lite/tier5-src/_shared/aria-widget-view.ts +722 -0
  50. package/templates/owner-lite/tier5-src/himi-owner-lite-aria/index.ts +29 -0
  51. package/templates/podcast-creator-owner/config.ts +1 -1
  52. package/templates/podcast-creator-owner/dev/index.ts +3075 -0
  53. package/templates/podcast-creator-owner/tier5-src/_shared/aria-chat.ts +466 -9
  54. package/templates/podcast-creator-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
  55. package/templates/podcast-creator-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
  56. package/templates/podcast-creator-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
  57. package/templates/podcast-creator-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
  58. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
  59. package/templates/portfolio-creative-owner/config.ts +1 -1
  60. package/templates/portfolio-creative-owner/dev/index.ts +3075 -0
  61. package/templates/portfolio-creative-owner/tier5-src/_shared/aria-chat.ts +466 -9
  62. package/templates/portfolio-creative-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
  63. package/templates/portfolio-creative-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
  64. package/templates/portfolio-creative-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
  65. package/templates/portfolio-creative-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
  66. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
  67. package/templates/restaurant-owner/config.ts +1 -1
  68. package/templates/restaurant-owner/dev/index.ts +3075 -0
  69. package/templates/restaurant-owner/tier5-src/_shared/aria-chat.ts +466 -9
  70. package/templates/restaurant-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
  71. package/templates/restaurant-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
  72. package/templates/restaurant-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
  73. package/templates/restaurant-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
  74. package/templates/restaurant-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
  75. package/templates/retail-store-owner/config.ts +1 -1
  76. package/templates/retail-store-owner/dev/index.ts +3075 -0
  77. package/templates/retail-store-owner/tier5-src/_shared/aria-chat.ts +466 -9
  78. package/templates/retail-store-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
  79. package/templates/retail-store-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
  80. package/templates/retail-store-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
  81. package/templates/retail-store-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
  82. package/templates/retail-store-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
  83. package/templates/yoga-studio-owner/config.ts +1 -1
  84. package/templates/yoga-studio-owner/dev/index.ts +3075 -0
  85. package/templates/yoga-studio-owner/tier5-src/_shared/aria-chat.ts +466 -9
  86. package/templates/yoga-studio-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
  87. package/templates/yoga-studio-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
  88. package/templates/yoga-studio-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
  89. package/templates/yoga-studio-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
  90. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
@@ -42,6 +42,10 @@ import {
42
42
  type ConversationSummary,
43
43
  } from "@himalaya/wix-genie-chat";
44
44
  import { proseOfParts, scanEnvelopes, stripHidden } from "./aria-widgets.js";
45
+ import { isWebUrl } from "./aria-widget-model.js";
46
+ import { NO_WIDGET, activeWidgetOf, asyncAdvanced, type SubtreeSlot, type WidgetOptionRow, type WidgetView }
47
+ from "./aria-widget-view.js";
48
+ import { substituteSelection } from "./aria-widget-model.js";
45
49
 
46
50
  // ---------------------------------------------------------------------------
47
51
  // Shapes
@@ -143,6 +147,13 @@ export interface AriaChatState {
143
147
  * POP the screen. A chat composer must stay put, so the whole surface leaves
144
148
  * this false and the bound message list simply re-renders. */
145
149
  ok: boolean;
150
+ /** The response widget the assistant is currently asking with, flattened for
151
+ * binding (`aria-widget-view.ts`). `kind: "NONE"` collapses the region.
152
+ *
153
+ * Derived, never accumulated: it is recomputed from the server transcript
154
+ * every time the transcript is, which is what makes "one active widget"
155
+ * true by construction rather than by remembering to clear it. */
156
+ widget: WidgetView;
146
157
  /** Transient host directives — a poll cadence to install, and a navigation to
147
158
  * perform. The runtime strips both before storing the state, so they are
148
159
  * never persisted and never re-fire when the state is spread. */
@@ -188,6 +199,12 @@ export interface AriaChatLabels {
188
199
  /** Status line after the owner stopped a reply. Sits in the NEUTRAL slot:
189
200
  * they asked for it, so nothing went wrong. */
190
201
  stopped?: Resolvable<string>;
202
+ /** Shown under a picker once the owner has chosen as many options as the
203
+ * service allows. Takes the limit, because "pick up to 3" is the whole of
204
+ * the information. */
205
+ selectionLimit?: (max: number) => string;
206
+ /** Shown under a questionnaire while a required answer is missing. */
207
+ answersIncomplete?: string;
191
208
  /** Shown instead of a raw transport error when this HOST cannot reach Wix at
192
209
  * all — it carries no Wix app binding to sign the chat-service call with.
193
210
  * See `isWixAccountUnreachable`, and note what it is NOT: a signed-out user. */
@@ -195,6 +212,19 @@ export interface AriaChatLabels {
195
212
  }
196
213
 
197
214
  export interface AriaChatOptions {
215
+ /** Mount the generic card for an unrecognised kind in this `WorkerSubtree`
216
+ * slot.
217
+ *
218
+ * Omit it and an unrecognised kind degrades to prose, which is what a
219
+ * screen NOT listed in `workerSubtreeScreens` must keep doing — the
220
+ * descriptor validator rejects the node otherwise. */
221
+ subtree?: SubtreeSlot;
222
+
223
+ /** Ask the chat-service for an async job's state — one of `initialized`,
224
+ * `processing`, `finished`, `failed`. Optional: a host that does not supply
225
+ * it renders an ASYNC_TOOL's opening message and never advances it, which is
226
+ * strictly better than showing a spinner that can never stop. */
227
+ asyncJobState?: (jobId: string) => Promise<string>;
198
228
  greeting: Resolvable<string>;
199
229
  prompts: Resolvable<AriaPromptChip[]>;
200
230
  labels?: AriaChatLabels;
@@ -237,7 +267,39 @@ export interface AriaChatOptions {
237
267
  * action passes a POSITIONAL string (`id: "$item.text"` baked into the
238
268
  * descriptor); the standard `tier5.<svc>.<action>` path passes an object. One
239
269
  * normalizer rather than two forks of every action. */
240
- export type AriaActionArg = string | { message?: string; text?: string; value?: string; id?: string; url?: string; intent?: string } | undefined;
270
+ /** `value` is typed `unknown` deliberately: `SelectionControl.onChange` puts
271
+ * its new BOOLEAN there on iOS and Android, and the native marshaller merges
272
+ * args over params — so anything reading it must cope with a non-string.
273
+ * `optionValue` is the key a caller should use when it means the option. */
274
+ export type AriaActionArg = string | { message?: string; text?: string; value?: unknown; optionValue?: string; id?: string; url?: string; intent?: string } | undefined;
275
+
276
+ /** The first of these fields that is actually a non-empty string.
277
+ *
278
+ * `value` is `unknown` because `SelectionControl.onChange` puts its new
279
+ * BOOLEAN there on iOS and Android and the marshaller merges args over
280
+ * params. Every reader therefore has to narrow — `.trim()` on a boolean
281
+ * throws, which is how a mis-wired toggle took the whole action down rather
282
+ * than merely selecting the wrong row. One helper so there is one place to
283
+ * get that right, instead of five `?? ""` chains that each look fine. */
284
+ export function firstString(...candidates: unknown[]): string {
285
+ for (const c of candidates) {
286
+ if (typeof c === "string" && c.trim()) return c.trim();
287
+ }
288
+ return "";
289
+ }
290
+
291
+ /** The first candidate that is a string, KEPT VERBATIM.
292
+ *
293
+ * The trimming sibling above is right for an option value, a query or an
294
+ * answer — all of which are identifiers or searches where surrounding space
295
+ * is noise. It is wrong for anything the owner is still typing: the composer
296
+ * reducer runs on every keystroke, so trimming there eats the space between
297
+ * two words the instant it is typed and turns "hello world" into
298
+ * "helloworld". Only submission normalizes chat text. */
299
+ export function firstStringRaw(...candidates: unknown[]): string {
300
+ for (const c of candidates) if (typeof c === "string") return c;
301
+ return "";
302
+ }
241
303
 
242
304
  // ---------------------------------------------------------------------------
243
305
  // Pure helpers (exported for unit tests)
@@ -378,6 +440,24 @@ export function isWixHost(host: string): boolean {
378
440
  return host === "wix.com" || host.endsWith(".wix.com") || host === "wixsite.com" || host.endsWith(".wixsite.com");
379
441
  }
380
442
 
443
+ /** Hosts an option PICTURE may be loaded from.
444
+ *
445
+ * Deliberately a different list from `isWixHost`, which answers "may a LINK
446
+ * label stay silent about where it goes". `static.wixstatic.com` is where
447
+ * every Wix-hosted image actually lives, so judging pictures by the link list
448
+ * blanked the images the service really sends — the catalogue's MULTI_SELECT
449
+ * thumbnails among them.
450
+ *
451
+ * The two lists must stay separate rather than merge: a CDN is a fine place
452
+ * to fetch a picture from and a poor thing to let a button's label imply. */
453
+ export function isWixMediaHost(host: string): boolean {
454
+ if (!PLAIN_HOST.test(host)) return false;
455
+ return isWixHost(host)
456
+ || host === "wixstatic.com" || host.endsWith(".wixstatic.com")
457
+ || host === "wixmp.com" || host.endsWith(".wixmp.com")
458
+ || host === "wix-pages.com" || host.endsWith(".wix-pages.com");
459
+ }
460
+
381
461
  /** Shown in place of a host when an `http(s)` link's host cannot be
382
462
  * established. Deliberately not domain-shaped: it has to read as "this could
383
463
  * not be checked", never as a site the owner might think they recognize.
@@ -517,7 +597,7 @@ export function isDuplicateSend(s: { _pending: string }, content: string): boole
517
597
  export function argText(arg: AriaActionArg): string {
518
598
  if (typeof arg === "string") return arg.trim();
519
599
  if (!arg) return "";
520
- return (arg.message ?? arg.text ?? arg.value ?? arg.intent ?? arg.id ?? "").trim();
600
+ return firstString(arg.message, arg.text, arg.value, arg.intent, arg.id);
521
601
  }
522
602
 
523
603
  /** Reconcile the optimistic bubble against a freshly fetched server transcript.
@@ -765,6 +845,38 @@ export interface AriaChatCore {
765
845
  * that moved during the abort makes this snapshot stale, and committing it
766
846
  * would end a turn the owner has since started. */
767
847
  stop(s: AriaChatState): Promise<AriaChatState | undefined>;
848
+ /** Tapping the active widget's primary / secondary footer button.
849
+ *
850
+ * The url is read from STATE rather than bound into the action's params.
851
+ * `$item.` is the binding form an action param is built for; the widget
852
+ * region is `$service:`-bound and has no item, and the worker already holds
853
+ * the value anyway — so the descriptor names the action and nothing else,
854
+ * which is also one less place for a model-supplied url to travel through. */
855
+ widgetPrimary(s: AriaChatState): Promise<AriaChatState>;
856
+ widgetSecondary(s: AriaChatState): Promise<AriaChatState>;
857
+ /** Toggle one option of a MULTI_SELECT-style widget. Synchronous: it is a
858
+ * state edit, and making it async would put a turn boundary where there is
859
+ * no network call. */
860
+ widgetToggle(s: AriaChatState, arg?: AriaActionArg): AriaChatState;
861
+ /** Choose exactly one, replacing whatever was chosen (RADIO_BUTTON). */
862
+ widgetSelectOne(s: AriaChatState, arg?: AriaActionArg): AriaChatState;
863
+ /** Send a widget button's `respondText`, with the selection substituted in.
864
+ * Reuses the composer's send — there is deliberately no second send path. */
865
+ widgetRespond(s: AriaChatState, arg?: AriaActionArg): Promise<AriaChatState>;
866
+ /** AUTOCOMPLETE typing. Filters in the worker — there is no autocomplete
867
+ * component to filter for us. */
868
+ widgetQuery(s: AriaChatState, arg?: AriaActionArg): AriaChatState;
869
+ /** Pick a suggestion, honouring the payload's own `selectionType`.
870
+ *
871
+ * A descriptor cannot branch on a value, and the two reducers mean
872
+ * opposite things — `widgetSelectOne` REPLACES the selection — so wiring
873
+ * the row to either one directly is wrong for half the payloads. Tapping a
874
+ * second suggestion under `selectionType: "multi"` silently dropped the
875
+ * first. The mode is known where the payload is, so the choice belongs
876
+ * here. */
877
+ widgetPick(s: AriaChatState, arg?: AriaActionArg): AriaChatState;
878
+ /** Answer one QUESTIONNAIRE question. */
879
+ widgetAnswer(s: AriaChatState, arg?: AriaActionArg): AriaChatState;
768
880
  }
769
881
 
770
882
  const POLL_ID = "aria-turn";
@@ -785,6 +897,10 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
785
897
  const typingText = () => res(o.labels?.typing ?? `${assistant()} is typing…`);
786
898
  const slowText = () => res(o.labels?.slow ?? `${assistant()} is taking a while…`);
787
899
  const stoppedText = () => res(o.labels?.stopped ?? "Stopped.");
900
+ const answersIncompleteText = () =>
901
+ o.labels?.answersIncomplete ?? "Answer the required questions to continue.";
902
+ const limitReachedText = (max: number) =>
903
+ o.labels?.selectionLimit?.(max) ?? `You can choose up to ${max}.`;
788
904
  // States the fact and stops. It deliberately gives no instruction, because
789
905
  // there is none the owner could follow: they are signed in, and the thing that
790
906
  // is missing is the app's own Wix binding. "Here" is doing real work — it says
@@ -803,6 +919,7 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
803
919
  messages: [], prompts: [], _convId: "", _siteId: "", _pending: "",
804
920
  _pendingAfter: 0, _replyAfter: 0, _srvUsers: 0, _srvAssistants: 0,
805
921
  _abortId: "", _turnId: "", stopHidden: true, ok: false,
922
+ widget: NO_WIDGET,
806
923
  };
807
924
 
808
925
  /** One create at a time, per site.
@@ -835,6 +952,21 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
835
952
  * passed in — the only place a concurrent tap is visible is here. */
836
953
  let inFlightSend: string | null = null;
837
954
 
955
+ /** A widget send is in the air.
956
+ *
957
+ * A LATCH, not the `submitting` field beside it, and the distinction is the
958
+ * whole point: a flag set on the state an action RETURNS does not exist
959
+ * until that action resolves, so a second dispatch arriving during the await
960
+ * reads the state from before and sails past. Measured — two different
961
+ * option buttons tapped in the same tick both started a turn.
962
+ *
963
+ * `inFlightSend` next door guards the composer the same way, but keyed by
964
+ * CONTENT: two taps of one option are identical text and it catches them.
965
+ * Two taps of DIFFERENT options are different text, so only this catches
966
+ * those. `submitting` stays on the state because the descriptor needs
967
+ * something to disable a button with; it is the shadow, not the guard. */
968
+ let widgetSending = false;
969
+
838
970
  /** A turn the server STORED but never got asked to answer.
839
971
  *
840
972
  * Sending is two calls: persist, then trigger. When the second fails, the
@@ -921,9 +1053,53 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
921
1053
 
922
1054
  /** Fold a freshly read server transcript into the observed-count marks.
923
1055
  * Takes the RAW messages, not the bubbles — see `countTurns`. */
924
- const observed = (msgs: ConversationMessage[]) => ({
1056
+ /** Carry the owner's SELECTIONS across a rebuild.
1057
+ *
1058
+ * `observed` runs on every transcript rebuild, including __HIMI_APP_NAME__'s idle
1059
+ * poll — so a widget that is still on screen was re-derived from the
1060
+ * payload, and the payload only knows `isPreselected`. An owner could tick
1061
+ * three boxes, wait for the poll, and watch them silently revert before
1062
+ * reaching Continue.
1063
+ *
1064
+ * Reconciled only when it is plainly the SAME ask: same kind, same option
1065
+ * values in the same order. Anything else is a new question and starts
1066
+ * from what the service sent. */
1067
+ const carrySelection = (next: WidgetView, prev?: WidgetView): WidgetView => {
1068
+ if (!prev || prev.hidden || next.hidden) return next;
1069
+ if (prev.kind !== next.kind) return next;
1070
+ if (prev.options.length !== next.options.length) return next;
1071
+ for (let i = 0; i < next.options.length; i++) {
1072
+ if (prev.options[i].value !== next.options[i].value) return next;
1073
+ }
1074
+ if (!prev.options.some((o, i) => o.selected !== next.options[i].selected)) return next;
1075
+ const options = next.options.map((o, i) => ({ ...o, selected: prev.options[i].selected }));
1076
+ return reselect({ ...next, options }, options);
1077
+ };
1078
+
1079
+ const observed = (msgs: ConversationMessage[], prev?: WidgetView) => ({
925
1080
  _srvUsers: countTurns(msgs, "USER"),
926
1081
  _srvAssistants: countTurns(msgs, "ASSISTANT"),
1082
+ // Derived HERE, with the counts, because this is the one expression every
1083
+ // transcript rebuild already spreads — load, resume, tick and both newChat
1084
+ // arms. That is what makes the widget impossible to leave stale: a rebuild
1085
+ // that forgot to clear it would also have to have forgotten the counts,
1086
+ // which the reply-detection depends on. `observed([])` (new chat) yields
1087
+ // NO_WIDGET for free.
1088
+ // Content is normalized HERE, not in the view: `messageText` decodes the
1089
+ // `{ text: … }` shape the service also sends (treated as empty before,
1090
+ // so those widgets never appeared at all), and `stripHidden` removes
1091
+ // `<HIDDEN>` spans — which are transcript-only, and were promoting hidden
1092
+ // envelopes into live interactive cards. The transcript path already did
1093
+ // both; the widget path did neither.
1094
+ widget: carrySelection(
1095
+ activeWidgetOf(
1096
+ msgs.map((m) => ({ role: m.role, content: stripHidden(messageText(m)) })),
1097
+ disclosedLabel,
1098
+ o.subtree,
1099
+ (url) => isWixMediaHost(hostOf(url)),
1100
+ ),
1101
+ prev,
1102
+ ),
927
1103
  });
928
1104
 
929
1105
  const greetingOnly = (): AriaBubble[] => [plainBubble(GREETING_ID, "ASSISTANT", assistant(), res(o.greeting))];
@@ -983,10 +1159,10 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
983
1159
  // the last BUBBLE stalled this loop on exactly that reply, the same way it
984
1160
  // stalled `tick`.
985
1161
  if (repliedTo(raw, s._turnId) ?? countTurns(raw, "ASSISTANT") > s._replyAfter) {
986
- return { ...s, status: "ready", _convId: convId, messages: bubbles, prompts: [], input: "", statusText: "", error: "", _pending: "", ...observed(raw), ok: false };
1162
+ return { ...s, status: "ready", _convId: convId, messages: bubbles, prompts: [], input: "", statusText: "", error: "", _pending: "", ...observed(raw, s.widget), ok: false };
987
1163
  }
988
1164
  }
989
- return { ...s, status: "ready", _convId: convId, messages: server, input: "", statusText: slowText(), _pending: "", ...observed(seen), ok: false };
1165
+ return { ...s, status: "ready", _convId: convId, messages: server, input: "", statusText: slowText(), _pending: "", ...observed(seen, s.widget), ok: false };
990
1166
  }
991
1167
 
992
1168
  /** Send + wait, by whichever transport this consumer configured. */
@@ -1024,6 +1200,11 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1024
1200
  // counting `messages` would mark the turn one assistant reply ahead and
1025
1201
  // the real answer would never register as new.
1026
1202
  _pendingAfter: s._srvUsers, _replyAfter: s._srvAssistants, ok: false,
1203
+ // A new question retires the previous one's controls IMMEDIATELY, rather
1204
+ // than at the next tick. Leaving them up for a second or two invites a
1205
+ // tap that answers a question the owner has already moved past — the same
1206
+ // reason a scrollback widget keeps its prose and loses its controls.
1207
+ widget: NO_WIDGET,
1027
1208
  };
1028
1209
  inFlightSend = content;
1029
1210
  // Held outside the try so a FAILED send still reports the conversation it
@@ -1234,6 +1415,117 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1234
1415
  return s.stopHidden === hidden ? s : { ...s, stopHidden: hidden };
1235
1416
  };
1236
1417
 
1418
+ /** Ask the service how an async job is getting on.
1419
+ *
1420
+ * Folded into the existing `tick` rather than armed as a SECOND `_poll`, and
1421
+ * that is a constraint rather than a preference: `PollDirective` has no stop
1422
+ * verb, so a second timer could be started and never cancelled — it would
1423
+ * outlive the widget, the turn and the conversation, and keep asking about a
1424
+ * job nobody is looking at. Riding the chat's own ladder means the job stops
1425
+ * being polled exactly when the widget stops existing, which is the
1426
+ * behaviour the acceptance list asks for and the only one this directive can
1427
+ * actually deliver.
1428
+ *
1429
+ * It also costs nothing extra: the ladder is already fast while a turn is in
1430
+ * flight, which is when a job is running. */
1431
+ const readJobState = async (jobId: string): Promise<string> => {
1432
+ if (!o.asyncJobState) return "";
1433
+ try {
1434
+ return (await o.asyncJobState(jobId)) || "";
1435
+ } catch {
1436
+ // A failed read is not a failed job. Saying "failed" on one dropped
1437
+ // request would tell the owner their work was lost when it is still
1438
+ // running; the next tick asks again.
1439
+ return "";
1440
+ }
1441
+ };
1442
+
1443
+ /** Advance the active ASYNC_TOOL widget, if there is one with a job to ask
1444
+ * about. Returns the widget unchanged in every other case. */
1445
+ const withJobProgress = async (w: WidgetView): Promise<WidgetView> => {
1446
+ if (w.kind !== "ASYNC_TOOL" || !w.asyncJobId || !w.asyncRunning) return w;
1447
+ const state = await readJobState(w.asyncJobId);
1448
+ if (!state || state === w.asyncState) return w;
1449
+ return asyncAdvanced(w, state);
1450
+ };
1451
+
1452
+ /** Open a widget button's destination.
1453
+ *
1454
+ * Deliberately the SAME path a transcript link takes — `resolveEntity` first
1455
+ * so an entity we can render natively becomes a `_nav` (and gets the host's
1456
+ * linkable-target check), external otherwise. A widget url is no more
1457
+ * trusted than a link inside a reply; both are model output, and giving the
1458
+ * widget its own opener would be a second place to forget that.
1459
+ *
1460
+ * A button with no url does nothing rather than erroring: the descriptor
1461
+ * hides those, so reaching here means the state changed under a tap already
1462
+ * in flight. */
1463
+ /** A widget button's destination is MODEL-INFLUENCED, and reachable by
1464
+ * prompt injection through content this owner does not control. The
1465
+ * transcript's own links are already restricted to `http(s)` by the
1466
+ * extractor that finds them; a widget url arrives straight off the payload
1467
+ * and reached the system opener after nothing but a `trim()`.
1468
+ *
1469
+ * That is a bigger gap than it looks: the opener takes ANY scheme, so a
1470
+ * payload could name `tel:`, `sms:`, a `wix://` deep link, or a custom
1471
+ * scheme registered by some other app on the device. Host disclosure
1472
+ * changes the LABEL; it does not stop the navigation.
1473
+ *
1474
+ * So: an in-app entity first (that path never leaves the app), then
1475
+ * `http(s)` only. Anything else is dropped rather than opened. Dropping is
1476
+ * right rather than cautious — there is no widget in the catalogue whose
1477
+ * button needs a non-web scheme, so anything that asks for one is either a
1478
+ * service change we should implement deliberately or an attack. */
1479
+ const openWidgetUrl = async (s: AriaChatState, url: string): Promise<AriaChatState> => {
1480
+ const target = url.trim();
1481
+ if (!target) return s;
1482
+ const entity = o.resolveEntity?.(target) ?? null;
1483
+ if (entity) return navigate(s, entity);
1484
+ if (!isWebUrl(target)) return s;
1485
+ if (o.openLink) { try { await o.openLink(target); } catch { /* best-effort */ } }
1486
+ return s;
1487
+ };
1488
+
1489
+ /** The value an option action names. Accepts the bare string and the object
1490
+ * form, as every other action here does — the two hosts dispatch differently
1491
+ * and a reducer that only understood one silently did nothing on the other. */
1492
+ /** Which option an action names.
1493
+ *
1494
+ * `optionValue` is read BEFORE `value`, and non-strings are ignored, because
1495
+ * `SelectionControl.onChange` puts its new boolean in `args.value` on iOS
1496
+ * and Android and the native marshaller merges args over params. So a
1497
+ * toggle wired through `value` arrived as `true`/`false` on device while
1498
+ * working on web — and `.trim()` on a boolean THROWS, so the row did not
1499
+ * merely mis-select, it took the action down. Web-only testing cannot see
1500
+ * this; the control's own contract is what gives it away. */
1501
+ const optionValue = (arg?: AriaActionArg): string =>
1502
+ typeof arg === "string" ? arg.trim() : firstString(arg?.optionValue, arg?.value, arg?.id, arg?.text);
1503
+
1504
+ /** Re-derive everything that follows from which options are selected.
1505
+ *
1506
+ * One function rather than three call sites, because `limitReached`,
1507
+ * `primaryDisabled` and the helper line are each a pure function of the
1508
+ * selection and the payload — and a toggle that updated two of the three is
1509
+ * the shape of bug that leaves a button enabled over an empty selection. */
1510
+ const reselect = (w: WidgetView, options: WidgetOptionRow[]): WidgetView => {
1511
+ const chosen = options.filter((o) => o.selected).length;
1512
+ const limitReached = w.maxSelected > 0 && chosen >= w.maxSelected;
1513
+ // Only says anything once the owner is actually against the limit. A line
1514
+ // that is always there is noise; one that appears at the moment of refusal
1515
+ // explains the refusal.
1516
+ const limitText = limitReached ? limitReachedText(w.maxSelected) : "";
1517
+ const disabled = w.submitting || (w.requiresSelection && chosen === 0);
1518
+ return {
1519
+ ...w,
1520
+ options,
1521
+ limitReached,
1522
+ limitText,
1523
+ limitTextHidden: !limitText,
1524
+ primaryDisabled: disabled,
1525
+ primaryEnabled: !disabled,
1526
+ };
1527
+ };
1528
+
1237
1529
  const impl: AriaChatCore = {
1238
1530
  initialState,
1239
1531
 
@@ -1259,7 +1551,7 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1259
1551
  const bubbles = toBubbles(raw, assistant(), you());
1260
1552
  const resumed = bubbles.length ? bubbles : greetingOnly();
1261
1553
  return { ...s, status: "ready", _convId: resumeId, _siteId: site, _pending: "", statusText: "", error: "",
1262
- messages: resumed, ...observed(raw), prompts: promptsFor(resumed, promptList()) };
1554
+ messages: resumed, ...observed(raw, s.widget), prompts: promptsFor(resumed, promptList()) };
1263
1555
  } catch { /* expired or unreachable — fall through to a fresh one */ }
1264
1556
  }
1265
1557
  // Opening a screen runs `load` more than once — the bundle self-kicks at
@@ -1301,7 +1593,11 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1301
1593
  },
1302
1594
 
1303
1595
  typing: (s, arg) => {
1304
- const next = typeof arg === "string" ? arg : (arg?.value ?? arg?.text ?? arg?.message ?? "");
1596
+ // Narrowed but NOT trimmed. `value` may be a boolean — assigning that
1597
+ // to the draft would put "true" in the box — but this runs on every
1598
+ // keystroke, so trimming would delete the space between two words as
1599
+ // soon as the owner typed it.
1600
+ const next = typeof arg === "string" ? arg : firstStringRaw(arg?.value, arg?.text, arg?.message);
1305
1601
  // Recorded even when the value is unchanged, because the counter is about
1306
1602
  // "did an edit happen", and a keystroke that retypes the same character
1307
1603
  // still happened.
@@ -1419,12 +1715,16 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1419
1715
  status: replied ? "ready" : s.status,
1420
1716
  messages: painted,
1421
1717
  _pending: pending,
1422
- ...observed(raw),
1718
+ ...observed(raw, s.widget),
1423
1719
  prompts: promptsFor(painted, promptList()),
1424
1720
  statusText: replied ? "" : s.statusText,
1425
1721
  };
1722
+ // The async job rides this same tick — see `withJobProgress`. Done
1723
+ // AFTER the transcript rebuild, so a widget that has just been retired
1724
+ // by a new answer is never polled for.
1725
+ const withJob = { ...next, widget: await withJobProgress(next.widget) };
1426
1726
  // Slow the ladder the moment the turn is over; leave it alone otherwise.
1427
- return replied && wasFast ? cadence(next, o.poll.idleMs, o.poll.fastMs) : next;
1727
+ return replied && wasFast ? cadence(withJob, o.poll.idleMs, o.poll.fastMs) : withJob;
1428
1728
  } catch {
1429
1729
  // A read that THREW is exactly as stale as one that resolved: if a turn
1430
1730
  // boundary moved on while it was in flight, returning `s` merges the
@@ -1439,6 +1739,153 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1439
1739
  // A tapped action link. An entity we can render natively becomes a `_nav`
1440
1740
  // directive (the host applies the same linkable-target check an authored
1441
1741
  // `himi.open` gets); anything else opens externally.
1742
+ widgetPick: (s, arg) =>
1743
+ s.widget.selectionType === "multi" ? impl.widgetToggle(s, arg) : impl.widgetSelectOne(s, arg),
1744
+
1745
+ widgetQuery: (s, arg) => {
1746
+ const q = typeof arg === "string" ? arg.trim() : firstString(arg?.value, arg?.text);
1747
+ const w = s.widget;
1748
+ const needle = q.toLowerCase();
1749
+ // Match on everything the owner can SEE. Matching only `title` makes a
1750
+ // subtitle that says "Downtown branch" unsearchable, which reads as the
1751
+ // filter being broken rather than as a deliberate scope.
1752
+ const suggestions = !needle
1753
+ ? w.options
1754
+ : w.options.filter((o) =>
1755
+ `${o.title} ${o.subtitle} ${o.text}`.toLowerCase().includes(needle));
1756
+ return {
1757
+ ...s,
1758
+ widget: {
1759
+ ...w,
1760
+ query: q,
1761
+ suggestions,
1762
+ suggestionsEmpty: suggestions.length === 0,
1763
+ // Typed something and got nothing is a different state from having
1764
+ // typed nothing at all, and says something different to the owner.
1765
+ noMatches: !!needle && suggestions.length === 0,
1766
+ },
1767
+ };
1768
+ },
1769
+
1770
+ widgetAnswer: (s, arg) => {
1771
+ const qid = (typeof arg === "string" ? arg : (arg?.id ?? "")).trim();
1772
+ const answer = typeof arg === "string" ? "" : firstString(arg?.value, arg?.text);
1773
+ if (!qid) return s;
1774
+ const questions = s.widget.questions.map((q) =>
1775
+ q.id === qid
1776
+ ? { ...q, answer, answerHidden: !answer, missing: q.required && !answer }
1777
+ : q);
1778
+ if (questions === s.widget.questions) return s;
1779
+ const incomplete = questions.some((q) => q.missing);
1780
+ return {
1781
+ ...s,
1782
+ widget: {
1783
+ ...s.widget,
1784
+ questions,
1785
+ answersIncomplete: incomplete,
1786
+ // Only says anything while something is actually missing.
1787
+ answersText: incomplete ? answersIncompleteText() : "",
1788
+ answersTextHidden: !incomplete,
1789
+ primaryDisabled: s.widget.submitting || incomplete,
1790
+ primaryEnabled: !(s.widget.submitting || incomplete),
1791
+ },
1792
+ };
1793
+ },
1794
+
1795
+ widgetToggle: (s, arg) => {
1796
+ const value = optionValue(arg);
1797
+ if (!value) return s;
1798
+ const current = s.widget.options;
1799
+ const target = current.find((o) => o.value === value);
1800
+ if (!target) return s;
1801
+ // Turning one OFF is always allowed, limit or not: the limit is a ceiling
1802
+ // on how many may be chosen, never a floor, and refusing to deselect
1803
+ // would trap an owner who hit it.
1804
+ if (!target.selected && s.widget.limitReached) return s;
1805
+ const options = current.map((o) => (o.value === value ? { ...o, selected: !o.selected } : o));
1806
+ return { ...s, widget: reselect(s.widget, options) };
1807
+ },
1808
+
1809
+ widgetSelectOne: (s, arg) => {
1810
+ const value = optionValue(arg);
1811
+ if (!value) return s;
1812
+ if (!s.widget.options.some((o) => o.value === value)) return s;
1813
+ // Replaces rather than accumulates, and the limit does not apply: one is
1814
+ // never more than a ceiling of one or more.
1815
+ const options = s.widget.options.map((o) => ({ ...o, selected: o.value === value }));
1816
+ return { ...s, widget: reselect(s.widget, options) };
1817
+ },
1818
+
1819
+ widgetRespond: async (s, arg) => {
1820
+ // Which button. The three that can send are the widget's two footer
1821
+ // buttons and any one option's own button; an option names itself by
1822
+ // value, the footers by keyword.
1823
+ const which = optionValue(arg) || "primary";
1824
+ const w = s.widget;
1825
+ // A second tap while the first send is in the air. The composer refuses
1826
+ // this through `isDuplicateSend` on the CONTENT; a widget button has to
1827
+ // refuse on the ACTION too, because two taps of the same option produce
1828
+ // identical content and the second would otherwise look like a retry of
1829
+ // a send that had already failed.
1830
+ if (widgetSending) return s;
1831
+
1832
+ const option = w.options.find((o) => o.value === which);
1833
+ const template = option
1834
+ ? option.respondText
1835
+ : which === "secondary" ? w.secondaryRespondText : w.primaryRespondText;
1836
+ if (!template) return s;
1837
+
1838
+ // What the owner actually chose. An option's own button answers with
1839
+ // ITSELF even when nothing is ticked — tapping "Select" on a row IS the
1840
+ // selection — while a footer button speaks for the accumulated set.
1841
+ const selected = option && !option.selected
1842
+ ? [option.title || option.value]
1843
+ : w.options.filter((o) => o.selected).map((o) => o.title || o.value);
1844
+ const content = substituteSelection(template, selected).trim();
1845
+ if (!content) return s;
1846
+ if (duplicate(s, content)) return s;
1847
+
1848
+ // Mark in-flight BEFORE the await so a second dispatch sees it. The
1849
+ // widget is not cleared here: `send` paints the owner's message
1850
+ // optimistically and the next transcript rebuild derives the widget
1851
+ // afresh, which retires it. Clearing it here as well would be a second
1852
+ // place deciding when a widget ends.
1853
+ const sending: AriaChatState = {
1854
+ ...s,
1855
+ widget: { ...w, submitting: true, primaryDisabled: true, primaryEnabled: false },
1856
+ };
1857
+ widgetSending = true;
1858
+ let next: AriaChatState;
1859
+ try {
1860
+ next = await send(sending, content);
1861
+ } finally {
1862
+ widgetSending = false;
1863
+ }
1864
+ // A FAILED send keeps the widget and the selection, so the owner can tap
1865
+ // again rather than lose what they picked. `send` reports failure through
1866
+ // the error slot while leaving the turn un-started.
1867
+ return next.error
1868
+ ? { ...next, widget: reselect({ ...w, submitting: false }, w.options) }
1869
+ : next;
1870
+ },
1871
+
1872
+ // A footer button is EITHER a link or a reply, and the view makes it
1873
+ // visible for both: `primaryHidden` is false when there is a label and
1874
+ // either a url or a `respondText`. Opening the url unconditionally
1875
+ // therefore made every response-only footer inert — the whole of
1876
+ // VISUAL_DISPLAY, TODO_PLAN and PREMIUM_UPSELL, which is most of what
1877
+ // these two buttons are for.
1878
+ //
1879
+ // The url wins when both are present: a button that names a destination
1880
+ // should go there, and `respondText` beside it is the fallback the
1881
+ // service sends for clients that cannot open one.
1882
+ widgetPrimary: (s) => s.widget.primaryUrl
1883
+ ? openWidgetUrl(s, s.widget.primaryUrl)
1884
+ : impl.widgetRespond(s, { value: "primary" }),
1885
+ widgetSecondary: (s) => s.widget.secondaryUrl
1886
+ ? openWidgetUrl(s, s.widget.secondaryUrl)
1887
+ : impl.widgetRespond(s, { value: "secondary" }),
1888
+
1442
1889
  openLink: async (s, arg) => {
1443
1890
  const url = typeof arg === "string" ? arg.trim() : (arg?.url ?? arg?.id ?? "").trim();
1444
1891
  if (!url) return s;
@@ -1525,6 +1972,16 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1525
1972
  tick: (s) => impl.tick(s).then((r) => (r ? gated(r) : r)),
1526
1973
  openLink: (s, arg) => impl.openLink(s, arg).then(gated),
1527
1974
  stop: (s) => impl.stop(s).then((r) => (r ? gated(r) : r)),
1975
+ // Synchronous, like `typing`: a selection edit makes no network call, so
1976
+ // gating it is a plain call rather than a `.then`.
1977
+ widgetToggle: (s, arg) => gated(impl.widgetToggle(s, arg)),
1978
+ widgetSelectOne: (s, arg) => gated(impl.widgetSelectOne(s, arg)),
1979
+ widgetPick: (s, arg) => gated(impl.widgetPick(s, arg)),
1980
+ widgetQuery: (s, arg) => gated(impl.widgetQuery(s, arg)),
1981
+ widgetAnswer: (s, arg) => gated(impl.widgetAnswer(s, arg)),
1982
+ widgetRespond: (s, arg) => impl.widgetRespond(s, arg).then(gated),
1983
+ widgetPrimary: (s) => impl.widgetPrimary(s).then(gated),
1984
+ widgetSecondary: (s) => impl.widgetSecondary(s).then(gated),
1528
1985
  };
1529
1986
  }
1530
1987