@wix/himalaya-cli 0.1250.0 → 0.1252.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 (63) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/dev-server/src/wix-member-login.d.ts +6 -0
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/state/src/index.d.ts +2 -0
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/serve-runtime/src/wix-bridge.d.ts +144 -0
  4. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/serve-storage/src/sha256.d.ts +13 -0
  5. package/dist/cli.mjs +554 -284
  6. package/dist/preview-assets.generated.bin +0 -0
  7. package/guides/sdk-reference.json +1 -1
  8. package/package.json +2 -2
  9. package/templates/beauty-salon-owner/dev/index.ts +668 -87
  10. package/templates/beauty-salon-owner/tier5-src/_shared/aria-chat.ts +271 -45
  11. package/templates/beauty-salon-owner/tier5-src/_shared/aria-widget-model.ts +3 -0
  12. package/templates/beauty-salon-owner/tier5-src/_shared/aria-widget-view.ts +333 -18
  13. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-aria/index.ts +7 -0
  14. package/templates/community-club-owner/dev/index.ts +668 -87
  15. package/templates/community-club-owner/tier5-src/_shared/aria-chat.ts +271 -45
  16. package/templates/community-club-owner/tier5-src/_shared/aria-widget-model.ts +3 -0
  17. package/templates/community-club-owner/tier5-src/_shared/aria-widget-view.ts +333 -18
  18. package/templates/community-club-owner/tier5-src/himi-owner-lite-aria/index.ts +7 -0
  19. package/templates/events-meetups-owner/dev/index.ts +668 -87
  20. package/templates/events-meetups-owner/tier5-src/_shared/aria-chat.ts +271 -45
  21. package/templates/events-meetups-owner/tier5-src/_shared/aria-widget-model.ts +3 -0
  22. package/templates/events-meetups-owner/tier5-src/_shared/aria-widget-view.ts +333 -18
  23. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-aria/index.ts +7 -0
  24. package/templates/gym-fitness-owner/dev/index.ts +668 -87
  25. package/templates/gym-fitness-owner/tier5-src/_shared/aria-chat.ts +271 -45
  26. package/templates/gym-fitness-owner/tier5-src/_shared/aria-widget-model.ts +3 -0
  27. package/templates/gym-fitness-owner/tier5-src/_shared/aria-widget-view.ts +333 -18
  28. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-aria/index.ts +7 -0
  29. package/templates/nonprofit-cause-owner/dev/index.ts +668 -87
  30. package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-chat.ts +271 -45
  31. package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-widget-model.ts +3 -0
  32. package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-widget-view.ts +333 -18
  33. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-aria/index.ts +7 -0
  34. package/templates/owner-lite/dev/index.ts +754 -173
  35. package/templates/owner-lite/tier5-src/_shared/aria-chat.ts +271 -45
  36. package/templates/owner-lite/tier5-src/_shared/aria-widget-model.ts +3 -0
  37. package/templates/owner-lite/tier5-src/_shared/aria-widget-view.ts +333 -18
  38. package/templates/owner-lite/tier5-src/himi-owner-lite-aria/index.ts +7 -0
  39. package/templates/podcast-creator-owner/dev/index.ts +668 -87
  40. package/templates/podcast-creator-owner/tier5-src/_shared/aria-chat.ts +271 -45
  41. package/templates/podcast-creator-owner/tier5-src/_shared/aria-widget-model.ts +3 -0
  42. package/templates/podcast-creator-owner/tier5-src/_shared/aria-widget-view.ts +333 -18
  43. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-aria/index.ts +7 -0
  44. package/templates/portfolio-creative-owner/dev/index.ts +668 -87
  45. package/templates/portfolio-creative-owner/tier5-src/_shared/aria-chat.ts +271 -45
  46. package/templates/portfolio-creative-owner/tier5-src/_shared/aria-widget-model.ts +3 -0
  47. package/templates/portfolio-creative-owner/tier5-src/_shared/aria-widget-view.ts +333 -18
  48. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-aria/index.ts +7 -0
  49. package/templates/restaurant-owner/dev/index.ts +668 -87
  50. package/templates/restaurant-owner/tier5-src/_shared/aria-chat.ts +271 -45
  51. package/templates/restaurant-owner/tier5-src/_shared/aria-widget-model.ts +3 -0
  52. package/templates/restaurant-owner/tier5-src/_shared/aria-widget-view.ts +333 -18
  53. package/templates/restaurant-owner/tier5-src/himi-owner-lite-aria/index.ts +7 -0
  54. package/templates/retail-store-owner/dev/index.ts +668 -87
  55. package/templates/retail-store-owner/tier5-src/_shared/aria-chat.ts +271 -45
  56. package/templates/retail-store-owner/tier5-src/_shared/aria-widget-model.ts +3 -0
  57. package/templates/retail-store-owner/tier5-src/_shared/aria-widget-view.ts +333 -18
  58. package/templates/retail-store-owner/tier5-src/himi-owner-lite-aria/index.ts +7 -0
  59. package/templates/yoga-studio-owner/dev/index.ts +668 -87
  60. package/templates/yoga-studio-owner/tier5-src/_shared/aria-chat.ts +271 -45
  61. package/templates/yoga-studio-owner/tier5-src/_shared/aria-widget-model.ts +3 -0
  62. package/templates/yoga-studio-owner/tier5-src/_shared/aria-widget-view.ts +333 -18
  63. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-aria/index.ts +7 -0
@@ -72,8 +72,19 @@ import {
72
72
  import { runClientToolExchange, type AriaClientTools } from "./aria-client-tools.js";
73
73
  import { proseOfParts, scanEnvelopes, stripHidden } from "./aria-widgets.js";
74
74
  import { isWebUrl, stripMarkdown } from "./aria-widget-model.js";
75
- import { NO_WIDGET, activeWidgetOf, asyncAdvanced, type SubtreeSlot, type WidgetOptionRow, type WidgetView }
76
- from "./aria-widget-view.js";
75
+ import {
76
+ DEFAULT_QUESTIONNAIRE_COPY,
77
+ NO_WIDGET,
78
+ activeWidgetOf,
79
+ asyncAdvanced,
80
+ questionnaireAnswers,
81
+ questionnaireSynced,
82
+ sameQuestionnaire,
83
+ type QuestionnaireCopy,
84
+ type SubtreeSlot,
85
+ type WidgetOptionRow,
86
+ type WidgetView,
87
+ } from "./aria-widget-view.js";
77
88
  import { substituteSelection } from "./aria-widget-model.js";
78
89
  import { MENTION_PAGE_SIZE, allowlisted, emptyMentionModel, mentionView,
79
90
  type MentionItemRow, type MentionLabels, type MentionModel, type MentionProvider, type MentionView } from "./aria-mentions.js";
@@ -326,6 +337,15 @@ export interface AriaChatLabels {
326
337
  selectionLimit?: (max: number) => string;
327
338
  /** Shown under a questionnaire while a required answer is missing. */
328
339
  answersIncomplete?: string;
340
+ /** A questionnaire's place, e.g. "2 of 3". Only shown with two or more
341
+ * questions. */
342
+ questionProgress?: (current: number, total: number) => string;
343
+ /** A questionnaire's submit, when the payload's own button has no label. */
344
+ questionnaireSubmit?: Resolvable<string>;
345
+ /** Shown when the owner taps an option whose destination is a screen of the
346
+ * native Wix app that this app does not have, and the payload gave no
347
+ * explanation of its own. */
348
+ notSupported?: Resolvable<string>;
329
349
  /** Shown instead of a raw transport error when this HOST cannot reach Wix at
330
350
  * all — it carries no Wix app binding to sign the chat-service call with.
331
351
  * See `isWixAccountUnreachable`, and note what it is NOT: a signed-out user. */
@@ -349,6 +369,10 @@ export interface AriaChatLabels {
349
369
  /** Shown when one provider's items could not be fetched. Takes the
350
370
  * category's label. */
351
371
  mentionItemsFailed?: (category: string) => string;
372
+ /** Shown when a response widget's item provider cannot be queried. */
373
+ widgetItemsFailed?: Resolvable<string>;
374
+ /** Shown when this host cannot reach Wix's item provider service. */
375
+ widgetItemsUnavailable?: Resolvable<string>;
352
376
  /** Attachment copy — see `AriaChatOptions.attachments`. Chip states: */
353
377
  attachUploading?: Resolvable<string>;
354
378
  attachFailed?: Resolvable<string>;
@@ -509,7 +533,7 @@ export interface AriaChatOptions {
509
533
  * its new BOOLEAN there on iOS and Android, and the native marshaller merges
510
534
  * args over params — so anything reading it must cope with a non-string.
511
535
  * `optionValue` is the key a caller should use when it means the option. */
512
- 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;
536
+ export type AriaActionArg = string | { message?: string; text?: string; value?: unknown; optionValue?: string; id?: string; url?: string; intent?: string; section?: string; step?: string; source?: string; ok?: unknown; ref?: string } | undefined;
513
537
 
514
538
  /** The first of these fields that is actually a non-empty string.
515
539
  *
@@ -1291,8 +1315,13 @@ export interface AriaChatCore {
1291
1315
  * first. The mode is known where the payload is, so the choice belongs
1292
1316
  * here. */
1293
1317
  widgetPick(s: AriaChatState, arg?: AriaActionArg): AriaChatState;
1294
- /** Answer one QUESTIONNAIRE question. */
1318
+ /** Type into a QUESTIONNAIRE question's free-text field. `id` names the
1319
+ * question; the text rides the control's own `value`. */
1295
1320
  widgetAnswer(s: AriaChatState, arg?: AriaActionArg): AriaChatState;
1321
+ /** Move through a QUESTIONNAIRE one question at a time: `step` is `"next"`
1322
+ * or `"back"`. Next refuses while the question on screen is required and
1323
+ * unanswered — the same rule its greyed twin shows. */
1324
+ widgetStep(s: AriaChatState, arg?: AriaActionArg): AriaChatState;
1296
1325
  /** The composer's mic: open the recognizer and show "Listening…". Returns at
1297
1326
  * once; the transcript arrives later through `dictated`. */
1298
1327
  dictate(s: AriaChatState): AriaChatState;
@@ -1361,6 +1390,15 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1361
1390
  o.labels?.answersIncomplete ?? "Answer the required questions to continue.";
1362
1391
  const limitReachedText = (max: number) =>
1363
1392
  o.labels?.selectionLimit?.(max) ?? `You can choose up to ${max}.`;
1393
+ const questionnaireCopy = (): QuestionnaireCopy => ({
1394
+ progress: o.labels?.questionProgress ?? DEFAULT_QUESTIONNAIRE_COPY.progress,
1395
+ incomplete: answersIncompleteText(),
1396
+ submit: o.labels?.questionnaireSubmit
1397
+ ? res(o.labels.questionnaireSubmit)
1398
+ : DEFAULT_QUESTIONNAIRE_COPY.submit,
1399
+ });
1400
+ const notSupportedText = () =>
1401
+ (o.labels?.notSupported ? res(o.labels.notSupported) : "") || "That isn't available in this app yet.";
1364
1402
  // States the fact and stops. It deliberately gives no instruction, because
1365
1403
  // there is none the owner could follow: they are signed in, and the thing that
1366
1404
  // is missing is the app's own Wix binding. "Here" is doing real work — it says
@@ -1372,6 +1410,8 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1372
1410
  o.labels?.dictationEmpty ?? "Didn't catch that. Check microphone access and try again.",
1373
1411
  );
1374
1412
  const dictationFailedText = () => res(o.labels?.dictationFailed ?? "Dictation isn't available right now.");
1413
+ const widgetItemsFailedText = () => res(o.labels?.widgetItemsFailed ?? "Couldn't load these items.");
1414
+ const widgetItemsUnavailableText = () => res(o.labels?.widgetItemsUnavailable ?? "Items aren't available here.");
1375
1415
  const attachUploadingText = () => res(o.labels?.attachUploading ?? "Uploading…");
1376
1416
  const attachFailedText = () => res(o.labels?.attachFailed ?? "Couldn't upload");
1377
1417
  const chipLabels = () => ({ uploading: attachUploadingText(), failed: attachFailedText() });
@@ -1989,16 +2029,119 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1989
2029
  * from what the service sent. */
1990
2030
  const carrySelection = (next: WidgetView, prev?: WidgetView): WidgetView => {
1991
2031
  if (!prev || prev.hidden || next.hidden) return next;
2032
+ // A new assistant row can ask the exact same questionnaire again. Its
2033
+ // shape is not enough to identify the ask; only carry edits while the
2034
+ // transcript row itself is unchanged.
2035
+ if (!next.sourceMessageId || prev.sourceMessageId !== next.sourceMessageId) return next;
1992
2036
  if (prev.kind !== next.kind) return next;
2037
+ // A questionnaire carries MORE than a selection — every answer, typed text
2038
+ // and which question is on screen — and its `options` are only the CURRENT
2039
+ // question's choices, so the comparison below would call an owner on
2040
+ // question two a different ask and throw all of it away on the next poll.
2041
+ if (next.kind === "QUESTIONNAIRE") {
2042
+ return sameQuestionnaire(prev, next)
2043
+ ? questionnaireSynced({ ...next, questions: prev.questions }, prev.questionIndex,
2044
+ questionnaireCopy(), !prev.answersTextHidden)
2045
+ : next;
2046
+ }
2047
+ // ITEMS_SELECTION's payload supplies a provider key, not option rows. Keep
2048
+ // the fetched page (and its terminal error state) as the transcript is
2049
+ // re-read; otherwise every poll would replace it with the empty loading
2050
+ // placeholder and issue the same provider request again.
2051
+ if (next.kind === "ITEMS_SELECTION" && prev.providerKey === next.providerKey && !prev.itemsLoading) {
2052
+ return reselect({ ...next, options: prev.options, itemsLoading: false, itemsLoadingHidden: true,
2053
+ itemsError: prev.itemsError, itemsErrorHidden: prev.itemsErrorHidden,
2054
+ itemsEmpty: prev.itemsEmpty }, prev.options);
2055
+ }
1993
2056
  if (prev.options.length !== next.options.length) return next;
1994
2057
  for (let i = 0; i < next.options.length; i++) {
1995
2058
  if (prev.options[i].value !== next.options[i].value) return next;
1996
2059
  }
1997
- if (!prev.options.some((o, i) => o.selected !== next.options[i].selected)) return next;
2060
+ // The line explaining an option that could not open is the answer to a
2061
+ // tap, and a poll is not a reason to take it back.
2062
+ const kept = prev.notice ? { ...next, notice: prev.notice, noticeHidden: prev.noticeHidden } : next;
2063
+ if (!prev.options.some((o, i) => o.selected !== next.options[i].selected)) return kept;
1998
2064
  const options = next.options.map((o, i) => ({ ...o, selected: prev.options[i].selected }));
1999
- return reselect({ ...next, options }, options);
2065
+ return reselect({ ...kept, options }, options);
2066
+ };
2067
+
2068
+ /** Fetch the provider-backed rows after a transcript reveals an
2069
+ * ITEMS_SELECTION. This runs in the worker, through the same Wix API
2070
+ * capability used by the mention picker. A provider failure is shown inside
2071
+ * the widget and never turns into an empty list that looks like success. */
2072
+ const withWidgetItems = async (s: AriaChatState): Promise<AriaChatState> => {
2073
+ const w = s.widget;
2074
+ if (w.kind !== "ITEMS_SELECTION" || !w.itemsLoading || !w.providerKey) return s;
2075
+
2076
+ let options: WidgetOptionRow[] = [];
2077
+ let error = "";
2078
+ try {
2079
+ const installed = await listInstalledProviders();
2080
+ const provider = (installed.providers ?? []).find((candidate) =>
2081
+ typeof candidate?.key === "string" && candidate.key.trim() === w.providerKey);
2082
+ if (!provider) {
2083
+ error = widgetItemsFailedText();
2084
+ } else {
2085
+ const pageSize = 50;
2086
+ let offset = 0;
2087
+ let cursor = "";
2088
+ const seenCursors = new Set<string>();
2089
+ const items: ReturnType<typeof itemsOf> = [];
2090
+ for (;;) {
2091
+ const query: ItemsQuery = provider.paginationMode === "CURSOR"
2092
+ ? { cursorPaging: { limit: pageSize, ...(cursor ? { cursor } : {}) } }
2093
+ : provider.paginationMode === "OFFSET" ? { paging: { limit: pageSize, offset } } : {};
2094
+ const response = await queryItems(w.providerKey, query);
2095
+ const page = itemsOf(response);
2096
+ items.push(...page);
2097
+
2098
+ // Providers expose their paging state with the page. Follow every
2099
+ // advertised page so an owner can select an item beyond the first 50.
2100
+ // Stop on an empty raw page or a repeated cursor to avoid looping on
2101
+ // malformed provider metadata.
2102
+ const rawCount = response?.items?.length ?? 0;
2103
+ const hasNext = response?.pagingMetadata?.hasNext;
2104
+ if (provider.paginationMode === "CURSOR") {
2105
+ const next = response?.pagingMetadata?.cursors?.next ?? "";
2106
+ if (!next || hasNext === false || rawCount === 0 || seenCursors.has(next)) break;
2107
+ seenCursors.add(next);
2108
+ cursor = next;
2109
+ } else if (provider.paginationMode === "OFFSET") {
2110
+ if (hasNext !== true || rawCount === 0) break;
2111
+ offset += pageSize;
2112
+ } else {
2113
+ break;
2114
+ }
2115
+ }
2116
+ options = items.map((item, i) => {
2117
+ const pictureUrl = isWixMediaHost(hostOf(item.imageUrl)) ? item.imageUrl : "";
2118
+ return {
2119
+ id: `o${i}`, value: item.id, title: item.title, subtitle: item.subtitle,
2120
+ subtitleHidden: !item.subtitle, text: "", textHidden: true,
2121
+ pictureUrl, pictureHidden: !pictureUrl, buttonLabel: "", buttonHidden: true,
2122
+ listText: "", listHidden: true, selected: false, respondText: "",
2123
+ actionUrl: "", actionScreenId: "", notSupportedText: "",
2124
+ };
2125
+ });
2126
+ }
2127
+ } catch (e) {
2128
+ error = isWixAccountUnreachable(e) || String((e as Error)?.message ?? e).includes("absolute_urls_not_allowed")
2129
+ ? widgetItemsUnavailableText() : widgetItemsFailedText();
2130
+ }
2131
+ const loaded = {
2132
+ ...w, options, itemsLoading: false, itemsLoadingHidden: true,
2133
+ itemsError: error, itemsErrorHidden: !error, itemsEmpty: options.length === 0,
2134
+ };
2135
+ return { ...s, widget: reselect(loaded, options) };
2000
2136
  };
2001
2137
 
2138
+ /** The questionnaire's derived fields in THIS app's words. `viewOf` derives
2139
+ * them in the stock copy, having none of its own. */
2140
+ const localized = (w: WidgetView): WidgetView =>
2141
+ w.kind === "QUESTIONNAIRE"
2142
+ ? questionnaireSynced(w, w.questionIndex, questionnaireCopy(), !w.answersTextHidden)
2143
+ : w;
2144
+
2002
2145
  const observed = (msgs: ConversationMessage[], prev?: WidgetView) => ({
2003
2146
  _srvUsers: countTurns(msgs, "USER"),
2004
2147
  _srvAssistants: countTurns(msgs, "ASSISTANT"),
@@ -2015,15 +2158,15 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
2015
2158
  // envelopes into live interactive cards. The transcript path already did
2016
2159
  // both; the widget path did neither.
2017
2160
  widget: carrySelection(
2018
- activeWidgetOf(
2161
+ localized(activeWidgetOf(
2019
2162
  // A draft offers no controls: the agent has not finished asking, and
2020
2163
  // an answer tapped now would start a second turn on top of this one.
2021
2164
  // An empty newest row is NO_WIDGET — the placeholder rule.
2022
- msgs.map((m) => ({ role: m.role, content: stillWriting(m) ? "" : stripHidden(messageText(m)) })),
2165
+ msgs.map((m) => ({ id: m.id, role: m.role, content: stillWriting(m) ? "" : stripHidden(messageText(m)) })),
2023
2166
  disclosedLabel,
2024
2167
  o.subtree,
2025
2168
  (url) => isWixMediaHost(hostOf(url)),
2026
- ),
2169
+ )),
2027
2170
  prev,
2028
2171
  ),
2029
2172
  });
@@ -2082,7 +2225,7 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
2082
2225
  seen = raw;
2083
2226
  const failed = takeToolFailure(convId);
2084
2227
  if (failed) {
2085
- return { ...s, status: "ready", _convId: convId, messages: server, input: "", _pending: "", _abortId: "", ...observed(raw, s.widget), ok: false, ...toolFailureSlots(failed) };
2228
+ return withWidgetItems({ ...s, status: "ready", _convId: convId, messages: server, input: "", _pending: "", _abortId: "", ...observed(raw, s.widget), ok: false, ...toolFailureSlots(failed) });
2086
2229
  }
2087
2230
  // Not over while the agent is waiting on a client tool from us — and this
2088
2231
  // loop has no clock of its own, so re-reading meanwhile would spend its
@@ -2094,10 +2237,10 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
2094
2237
  // the last BUBBLE stalled this loop on exactly that reply, the same way it
2095
2238
  // stalled `tick`.
2096
2239
  if (toolReplied(convId, raw) ?? repliedTo(raw, s._turnId) ?? countSettledAnswers(raw) > s._replyAfter) {
2097
- return { ...s, status: "ready", _convId: convId, messages: bubbles, prompts: [], input: "", statusText: "", error: "", _pending: "", ...observed(raw, s.widget), ok: false };
2240
+ return withWidgetItems({ ...s, status: "ready", _convId: convId, messages: bubbles, prompts: [], input: "", statusText: "", error: "", _pending: "", ...observed(raw, s.widget), ok: false });
2098
2241
  }
2099
2242
  }
2100
- return { ...s, status: "ready", _convId: convId, messages: server, input: "", statusText: slowText(), _pending: "", ...observed(seen, s.widget), ok: false };
2243
+ return withWidgetItems({ ...s, status: "ready", _convId: convId, messages: server, input: "", statusText: slowText(), _pending: "", ...observed(seen, s.widget), ok: false });
2101
2244
  }
2102
2245
 
2103
2246
  /** Send + wait, by whichever transport this consumer configured. */
@@ -2600,6 +2743,20 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
2600
2743
  * selection and the payload — and a toggle that updated two of the three is
2601
2744
  * the shape of bug that leaves a button enabled over an empty selection. */
2602
2745
  const reselect = (w: WidgetView, options: WidgetOptionRow[]): WidgetView => {
2746
+ // A questionnaire's `options` are the question ON SCREEN's choices, so a
2747
+ // selection is written back into that question and everything re-derived
2748
+ // from the answers — the Next gate and the submit gate included.
2749
+ if (w.kind === "QUESTIONNAIRE") {
2750
+ const i = w.questionIndex;
2751
+ const questions = w.questions.map((q, j) => {
2752
+ if (j !== i) return q;
2753
+ // A single-select takes ONE answer: choosing a choice replaces what
2754
+ // was typed, as typing replaces the choice (`widgetAnswer`).
2755
+ const picked = options.some((c) => c.selected);
2756
+ return { ...q, choices: options, freeText: q.type === "single-select" && picked ? "" : q.freeText };
2757
+ });
2758
+ return questionnaireSynced({ ...w, questions }, i, questionnaireCopy(), true);
2759
+ }
2603
2760
  const chosen = options.filter((o) => o.selected).length;
2604
2761
  const limitReached = w.maxSelected > 0 && chosen >= w.maxSelected;
2605
2762
  // Only says anything once the owner is actually against the limit. A line
@@ -2658,8 +2815,8 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
2658
2815
  // `copyNote: ""` — a resumed transcript ends in a reply, so the row is
2659
2816
  // shown at once; a note carried over would say "Copied" beside an
2660
2817
  // answer from a different conversation that nobody copied.
2661
- return leaving({ ...s, status: "ready", _convId: resumeId, _siteId: site, _pending: "", statusText: "", error: "", copyNote: "",
2662
- messages: resumed, ...observed(raw, s.widget), prompts: promptsFor(resumed, promptList()) });
2818
+ return leaving(await withWidgetItems({ ...s, status: "ready", _convId: resumeId, _siteId: site, _pending: "", statusText: "", error: "", copyNote: "",
2819
+ messages: resumed, ...observed(raw, s.widget), prompts: promptsFor(resumed, promptList()) }));
2663
2820
  } catch { /* expired or unreachable — fall through to a fresh one */ }
2664
2821
  }
2665
2822
  // Opening a screen runs `load` more than once — the bundle self-kicks at
@@ -2864,8 +3021,10 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
2864
3021
  // AFTER the transcript rebuild, so a widget that has just been retired
2865
3022
  // by a new answer is never polled for.
2866
3023
  const withJob = { ...next, widget: await withJobProgress(next.widget) };
3024
+ const withItems = await withWidgetItems(withJob);
3025
+ if (turnSeq !== seq || abortingEpochs.has(seq)) return undefined;
2867
3026
  // Slow the ladder the moment the turn is over; leave it alone otherwise.
2868
- return ended && wasFast ? cadence(withJob, o.poll.idleMs, o.poll.fastMs) : withJob;
3027
+ return ended && wasFast ? cadence(withItems, o.poll.idleMs, o.poll.fastMs) : withItems;
2869
3028
  } catch {
2870
3029
  // A read that THREW is exactly as stale as one that resolved: if a turn
2871
3030
  // boundary moved on while it was in flight, returning `s` merges the
@@ -2909,28 +3068,49 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
2909
3068
  },
2910
3069
 
2911
3070
  widgetAnswer: (s, arg) => {
2912
- const qid = (typeof arg === "string" ? arg : (arg?.id ?? "")).trim();
2913
- const answer = typeof arg === "string" ? "" : firstString(arg?.value, arg?.text);
2914
- if (!qid) return s;
2915
- const questions = s.widget.questions.map((q) =>
2916
- q.id === qid
2917
- ? { ...q, answer, answerHidden: !answer, missing: q.required && !answer }
2918
- : q);
2919
- if (questions === s.widget.questions) return s;
2920
- const incomplete = questions.some((q) => q.missing);
2921
- return {
2922
- ...s,
2923
- widget: {
2924
- ...s.widget,
2925
- questions,
2926
- answersIncomplete: incomplete,
2927
- // Only says anything while something is actually missing.
2928
- answersText: incomplete ? answersIncompleteText() : "",
2929
- answersTextHidden: !incomplete,
2930
- primaryDisabled: s.widget.submitting || incomplete,
2931
- primaryEnabled: !(s.widget.submitting || incomplete),
2932
- },
2933
- };
3071
+ const w = s.widget;
3072
+ if (w.kind !== "QUESTIONNAIRE") return s;
3073
+ const qid = (typeof arg === "string" ? arg : firstString(arg?.id)).trim();
3074
+ const i = w.questions.findIndex((q) => q.id === qid);
3075
+ // Text for a question that offers no field to type it into is not an
3076
+ // answer the owner gave; it cannot satisfy a required choice.
3077
+ if (i < 0 || w.questions[i].freeHidden) return s;
3078
+ // VERBATIM, not trimmed: this runs on every keystroke, and trimming here
3079
+ // would eat the space between two words the moment it is typed. The
3080
+ // answer is trimmed where it is READ (`questionnaireSynced`).
3081
+ const text = typeof arg === "string" ? "" : firstStringRaw(arg?.value, arg?.text);
3082
+ const questions = w.questions.map((q, j) => {
3083
+ if (j !== i) return q;
3084
+ // Typing into a single-select's "something else" IS the answer, so it
3085
+ // takes the place of any choice — one question, one answer.
3086
+ const replaces = q.type === "single-select" && !!text.trim();
3087
+ return {
3088
+ ...q,
3089
+ freeText: text,
3090
+ choices: replaces ? q.choices.map((c) => (c.selected ? { ...c, selected: false } : c)) : q.choices,
3091
+ };
3092
+ });
3093
+ // Re-derived for the question the owner is LOOKING AT, whichever one the
3094
+ // text arrived for — a late keystroke from the previous question must not
3095
+ // flip the screen back to it.
3096
+ return { ...s, widget: questionnaireSynced({ ...w, questions }, w.questionIndex, questionnaireCopy(), true) };
3097
+ },
3098
+
3099
+ widgetStep: (s, arg) => {
3100
+ const w = s.widget;
3101
+ if (w.kind !== "QUESTIONNAIRE" || w.submitting) return s;
3102
+ const step = typeof arg === "string" ? arg.trim() : firstString(arg?.step, arg?.value, arg?.id);
3103
+ const current = w.questions[w.questionIndex];
3104
+ if (!current) return s;
3105
+ if (step === "back") {
3106
+ if (w.questionIndex === 0) return s;
3107
+ return { ...s, widget: questionnaireSynced(w, w.questionIndex - 1, questionnaireCopy(), true) };
3108
+ }
3109
+ if (step !== "next") return s;
3110
+ // The same rule the greyed Next shows. Refused here as well, because a
3111
+ // descriptor flag is a picture of the rule, not the rule.
3112
+ if (current.missing || w.questionIndex >= w.questions.length - 1) return s;
3113
+ return { ...s, widget: questionnaireSynced(w, w.questionIndex + 1, questionnaireCopy(), true) };
2934
3114
  },
2935
3115
 
2936
3116
  // --- Mentions -----------------------------------------------------------
@@ -3061,18 +3241,63 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
3061
3241
  // a send that had already failed.
3062
3242
  if (widgetSending) return s;
3063
3243
 
3064
- const option = w.options.find((o) => o.value === which);
3065
- const template = option
3066
- ? option.respondText
3067
- : which === "secondary" ? w.secondaryRespondText : w.primaryRespondText;
3244
+ // A VISUAL_DISPLAY section's own button ("Edit" beside a row). Named by
3245
+ // its own key, `section`, never by `value`: a section is not an option,
3246
+ // and an option whose value happened to equal a row id must not be able
3247
+ // to answer for it. The clone's positional form can only say `value`, so
3248
+ // a row id there is tried only once no option has claimed it.
3249
+ const sectionId = typeof arg === "object" ? firstString(arg?.section) : "";
3250
+ const option = sectionId ? undefined : w.options.find((o) => o.value === which);
3251
+ const section = sectionId
3252
+ ? w.rows.find((r) => r.id === sectionId)
3253
+ : (option ? undefined : w.rows.find((r) => r.id === which));
3254
+ if (sectionId && !section) return s;
3255
+
3256
+ // An option with a supported URL goes there even when the payload also
3257
+ // includes a respondText fallback. The view removes an unsupported URL
3258
+ // and its fallback before this action can run, so it cannot turn into a
3259
+ // different response. Screen-only options still use respondText when
3260
+ // present; otherwise show why the Wix-native screen cannot open here.
3261
+ if (option?.actionUrl) return openWidgetUrl(s, option.actionUrl);
3262
+
3263
+ // An option whose button goes to a screen of the native Wix app — the
3264
+ // catalogue's "Open store settings". This app has none of those screens,
3265
+ // so a screen-only option must explain the limitation rather than leave
3266
+ // a button that does nothing.
3267
+ if (option && !option.respondText) {
3268
+ if (option.actionScreenId) {
3269
+ // A screen of the native Wix app. This app has none of them, so the
3270
+ // honest thing is to say so — the payload's own words when it sent
3271
+ // some, ours otherwise — rather than leave the tap unanswered.
3272
+ const notice = option.notSupportedText || notSupportedText();
3273
+ return { ...s, widget: { ...w, notice, noticeHidden: !notice } };
3274
+ }
3275
+ return s;
3276
+ }
3277
+
3278
+ const questionnaire = w.kind === "QUESTIONNAIRE" && !option && !section && which !== "secondary";
3279
+ // The submit is gated on the required answers; refused here too, since a
3280
+ // greyed button is only a picture of the rule.
3281
+ if (questionnaire && (w.answersIncomplete || w.questionIndex < w.questions.length - 1)) return s;
3282
+
3283
+ const template = section
3284
+ ? section.respondText
3285
+ : option
3286
+ ? option.respondText
3287
+ : which === "secondary" ? w.secondaryRespondText : w.primaryRespondText;
3068
3288
  if (!template) return s;
3069
3289
 
3070
3290
  // What the owner actually chose. An option's own button answers with
3071
3291
  // ITSELF even when nothing is ticked — tapping "Select" on a row IS the
3072
- // selection — while a footer button speaks for the accumulated set.
3073
- const selected = option && !option.selected
3074
- ? [option.title || option.value]
3075
- : w.options.filter((o) => o.selected).map((o) => o.title || o.value);
3292
+ // selection — while a footer button speaks for the accumulated set. A
3293
+ // questionnaire speaks for EVERY question, not the one on screen.
3294
+ const selected = section
3295
+ ? []
3296
+ : questionnaire
3297
+ ? [questionnaireAnswers(w)].filter(Boolean)
3298
+ : option && !option.selected
3299
+ ? [option.title || option.value]
3300
+ : w.options.filter((o) => o.selected).map((o) => o.title || o.value);
3076
3301
  const content = substituteSelection(template, selected).trim();
3077
3302
  if (!content) return s;
3078
3303
  if (duplicate(s, content)) return s;
@@ -3468,6 +3693,7 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
3468
3693
  widgetPick: (s, arg) => gated(impl.widgetPick(s, arg)),
3469
3694
  widgetQuery: (s, arg) => gated(impl.widgetQuery(s, arg)),
3470
3695
  widgetAnswer: (s, arg) => gated(impl.widgetAnswer(s, arg)),
3696
+ widgetStep: (s, arg) => gated(impl.widgetStep(s, arg)),
3471
3697
  widgetRespond: (s, arg) => impl.widgetRespond(s, arg).then(gated),
3472
3698
  widgetPrimary: (s) => impl.widgetPrimary(s).then(gated),
3473
3699
  widgetSecondary: (s) => impl.widgetSecondary(s).then(gated),
@@ -107,6 +107,8 @@ export interface WidgetButton {
107
107
  * and this
108
108
  * model must not grow a second URL check. */
109
109
  externalUrl: string;
110
+ /** `mobileAction.screen.id` — a destination the widget footer cannot open. */
111
+ actionScreenId: string;
110
112
  }
111
113
 
112
114
  /** The normalized widget. One shape for all fifteen arms.
@@ -275,6 +277,7 @@ export function buttonAt(o: Record<string, unknown> | null, key: string): Widget
275
277
  // Two spellings for one concept, and both are live in the catalogue:
276
278
  // PREMIUM_UPSELL sends `externalLink`, CHART sends `link`.
277
279
  externalUrl: stringAt(action, "externalLink") || stringAt(action, "link"),
280
+ actionScreenId: stringAt(objectAt(action?.screen), "id"),
278
281
  };
279
282
  }
280
283