@wix/himalaya-cli 0.970.0 → 0.972.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 (50) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/genie-chat/src/index.d.ts +12 -0
  2. package/dist/cli.mjs +196 -161
  3. package/dist/preview-assets.generated.bin +0 -0
  4. package/guides/sdk-reference.json +7 -1
  5. package/guides/tier-5-add-a-screen.md +8 -4
  6. package/package.json +2 -2
  7. package/templates/beauty-salon-owner/config.ts +1 -1
  8. package/templates/beauty-salon-owner/dev/index.ts +79 -1
  9. package/templates/beauty-salon-owner/tier5-src/_shared/aria-chat.ts +238 -11
  10. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-aria/index.ts +15 -0
  11. package/templates/community-club-owner/config.ts +1 -1
  12. package/templates/community-club-owner/dev/index.ts +79 -1
  13. package/templates/community-club-owner/tier5-src/_shared/aria-chat.ts +238 -11
  14. package/templates/community-club-owner/tier5-src/himi-owner-lite-aria/index.ts +15 -0
  15. package/templates/events-meetups-owner/config.ts +1 -1
  16. package/templates/events-meetups-owner/dev/index.ts +79 -1
  17. package/templates/events-meetups-owner/tier5-src/_shared/aria-chat.ts +238 -11
  18. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-aria/index.ts +15 -0
  19. package/templates/gym-fitness-owner/config.ts +1 -1
  20. package/templates/gym-fitness-owner/dev/index.ts +79 -1
  21. package/templates/gym-fitness-owner/tier5-src/_shared/aria-chat.ts +238 -11
  22. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-aria/index.ts +15 -0
  23. package/templates/nonprofit-cause-owner/config.ts +1 -1
  24. package/templates/nonprofit-cause-owner/dev/index.ts +79 -1
  25. package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-chat.ts +238 -11
  26. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-aria/index.ts +15 -0
  27. package/templates/owner-lite/config.ts +1 -1
  28. package/templates/owner-lite/dev/index.ts +79 -1
  29. package/templates/owner-lite/tier5-src/_shared/aria-chat.ts +238 -11
  30. package/templates/owner-lite/tier5-src/himi-owner-lite-aria/index.ts +15 -0
  31. package/templates/podcast-creator-owner/config.ts +1 -1
  32. package/templates/podcast-creator-owner/dev/index.ts +79 -1
  33. package/templates/podcast-creator-owner/tier5-src/_shared/aria-chat.ts +238 -11
  34. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-aria/index.ts +15 -0
  35. package/templates/portfolio-creative-owner/config.ts +1 -1
  36. package/templates/portfolio-creative-owner/dev/index.ts +79 -1
  37. package/templates/portfolio-creative-owner/tier5-src/_shared/aria-chat.ts +238 -11
  38. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-aria/index.ts +15 -0
  39. package/templates/restaurant-owner/config.ts +1 -1
  40. package/templates/restaurant-owner/dev/index.ts +79 -1
  41. package/templates/restaurant-owner/tier5-src/_shared/aria-chat.ts +238 -11
  42. package/templates/restaurant-owner/tier5-src/himi-owner-lite-aria/index.ts +15 -0
  43. package/templates/retail-store-owner/config.ts +1 -1
  44. package/templates/retail-store-owner/dev/index.ts +79 -1
  45. package/templates/retail-store-owner/tier5-src/_shared/aria-chat.ts +238 -11
  46. package/templates/retail-store-owner/tier5-src/himi-owner-lite-aria/index.ts +15 -0
  47. package/templates/yoga-studio-owner/config.ts +1 -1
  48. package/templates/yoga-studio-owner/dev/index.ts +79 -1
  49. package/templates/yoga-studio-owner/tier5-src/_shared/aria-chat.ts +238 -11
  50. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-aria/index.ts +15 -0
@@ -89,6 +89,19 @@ export interface AriaChatState {
89
89
  error: string;
90
90
  /** Bound seed for the composer field. */
91
91
  input: string;
92
+ /** Transient result of the last Copy: the confirmation, or the failure.
93
+ *
94
+ * It exists because `writeText` resolves or throws and NOTHING else in the
95
+ * state changes either way — so without this the owner cannot tell a copy
96
+ * that took from one the platform refused. Saying "Copied" when nothing
97
+ * reached the clipboard is a known way for a copy control to lie; this is
98
+ * the field that makes the difference observable. */
99
+ copyNote: string;
100
+ /** Collapses the Copy / Regenerate row. Derived, like `stopHidden`: shown
101
+ * only when the newest bubble is a REPLY (not the owner's message, not the
102
+ * local greeting) and no turn is out. A pure function of other fields, so it
103
+ * is computed once in `gated` rather than maintained by every reducer. */
104
+ replyActionsHidden: boolean;
92
105
  messages: AriaBubble[];
93
106
  /** Non-empty ONLY on the greeting-only empty state. */
94
107
  prompts: AriaPromptChip[];
@@ -199,6 +212,12 @@ export interface AriaChatLabels {
199
212
  /** Status line after the owner stopped a reply. Sits in the NEUTRAL slot:
200
213
  * they asked for it, so nothing went wrong. */
201
214
  stopped?: Resolvable<string>;
215
+ /** Shown after a clipboard write that RESOLVED. */
216
+ copied?: Resolvable<string>;
217
+ /** Shown after a clipboard write that threw, or when the host supplied no
218
+ * clipboard at all. Deliberately distinct from `copied`: the point of the
219
+ * pair is that the owner can tell which of the two happened. */
220
+ copyFailed?: Resolvable<string>;
202
221
  /** Shown under a picker once the owner has chosen as many options as the
203
222
  * service allows. Takes the limit, because "pick up to 3" is the whole of
204
223
  * the information. */
@@ -242,6 +261,13 @@ export interface AriaChatOptions {
242
261
  /** Opens an external URL. Apps whose descriptors carry their own open-url
243
262
  * action (the clone) leave this unset and never invoke `openLink`. */
244
263
  openLink?: (url: string) => Promise<boolean>;
264
+
265
+ /** Put `text` on the system clipboard. Injected rather than imported so the
266
+ * shared core stays free of the SDK, exactly as `openLink` is.
267
+ *
268
+ * It RESOLVES or THROWS — there is no boolean. A host that does not supply
269
+ * it has no clipboard, and `copyMessage` says so rather than pretending. */
270
+ copyText?: (text: string) => Promise<void>;
245
271
  /** Maps an Aria action-link URL to an in-app screen. A hit navigates via a
246
272
  * `_nav` directive — the host applies the same linkable-target check an
247
273
  * authored `himi.open` gets — and a miss falls through to `openLink`. */
@@ -559,7 +585,10 @@ export function toBubbles(msgs: ConversationMessage[], assistantName: string, yo
559
585
  // way, and an aborted turn consists of nothing else. Then envelopes, then
560
586
  // the markdown stripper — each stage handing on only what a reader should
561
587
  // actually see.
562
- const prose = role === "USER" ? raw : proseOfParts(scanEnvelopes(stripHidden(raw), 0, true).parts);
588
+ // A row the service is still writing is scanned as STREAMING: an
589
+ // envelope whose `>>` has not arrived yet is held back rather than
590
+ // painted as raw JSON until the row finishes.
591
+ const prose = role === "USER" ? raw : proseOfParts(scanEnvelopes(stripHidden(raw), 0, !stillWriting(m)).parts);
563
592
  return {
564
593
  id: m.id || `m${i}`,
565
594
  role,
@@ -573,6 +602,16 @@ export function toBubbles(msgs: ConversationMessage[], assistantName: string, yo
573
602
  .filter((b) => b.content && b.content.trim());
574
603
  }
575
604
 
605
+ /** The reply the Copy / Regenerate row acts on: the LAST bubble, when it is an
606
+ * answer. Not "the newest assistant bubble": a widget-only answer leaves no
607
+ * bubble, so the transcript then ends on the owner's question, and searching
608
+ * back past it would offer the previous turn's answer as if it were this one.
609
+ * The greeting is excluded because it is a local bubble, not a reply. */
610
+ function lastReply(s: { messages: AriaBubble[] }): AriaBubble | undefined {
611
+ const last = s.messages[s.messages.length - 1];
612
+ return last && last.notUser && last.id !== GREETING_ID ? last : undefined;
613
+ }
614
+
576
615
  /** Chips are visible only before the owner has said anything. */
577
616
  function promptsFor(msgs: AriaBubble[], prompts: AriaPromptChip[]): AriaPromptChip[] {
578
617
  return msgs.some((b) => b.role === "USER") ? [] : prompts;
@@ -653,6 +692,29 @@ function answerText(m: ConversationMessage): string {
653
692
  return stripHidden(messageText(m)).trim();
654
693
  }
655
694
 
695
+ /** Is the service still WRITING this assistant row?
696
+ *
697
+ * The chat-service stamps every message with a status, and an ASSISTANT row
698
+ * that is `STATUS_PENDING` holds a draft: the agent has started the reply and
699
+ * not finished it. Whatever text it carries is worth PAINTING — it is the only
700
+ * view of a reply in progress a worker has, since the words themselves stream
701
+ * over a socket no host exposes (see the genie-chat client's header) — but it
702
+ * is not the answer, and ending the turn on it is the bug this exists for: the
703
+ * typing line clears, Stop disappears while the agent is still writing, and the
704
+ * ladder drops to its idle rung, so the rest of the reply sits unseen for a
705
+ * whole idle interval. The native Aria app ends a turn only on
706
+ * `STATUS_COMPLETED`, for the same reason.
707
+ *
708
+ * Only an explicit PENDING counts. An absent or unrecognised status is
709
+ * "settled": no capture in this repo has ever shown the field, and a gate that
710
+ * demanded `STATUS_COMPLETED` would hang every turn on a service that simply
711
+ * does not send it. Callers apply it to ASSISTANT rows only: a USER row is
712
+ * counted to retire the optimistic bubble, and holding one back would paint
713
+ * the owner's words twice. */
714
+ function stillWriting(m: ConversationMessage): boolean {
715
+ return m.status === "STATUS_PENDING";
716
+ }
717
+
656
718
  export function repliedTo(msgs: ConversationMessage[], turnId: string): boolean | null {
657
719
  if (!turnId) return null;
658
720
  const at = msgs.findIndex((m) => m.id === turnId);
@@ -665,7 +727,9 @@ export function repliedTo(msgs: ConversationMessage[], turnId: string): boolean
665
727
  for (let i = at + 1; i < msgs.length; i++) {
666
728
  const m = msgs[i];
667
729
  if ((m.role || "ASSISTANT") !== "ASSISTANT") continue;
668
- if (answerText(m)) return true;
730
+ // A row still being written is not the answer yet — keep looking, since
731
+ // the finished answer can land in a later row of the same turn.
732
+ if (answerText(m) && !stillWriting(m)) return true;
669
733
  }
670
734
  return false;
671
735
  }
@@ -700,6 +764,26 @@ export function countTurns(msgs: ConversationMessage[], role: string): number {
700
764
  return n;
701
765
  }
702
766
 
767
+ /** How many FINISHED assistant answers the transcript holds — `countTurns`
768
+ * minus the rows the service is still writing (`stillWriting`).
769
+ *
770
+ * The counted path compares THIS against a mark taken with `countTurns`, and
771
+ * the asymmetry is the point. The mark includes drafts, so a follow-up sent
772
+ * while an earlier reply is still being written is marked AFTER that reply;
773
+ * the earlier reply finishing then leaves this count equal to the mark rather
774
+ * than past it, and cannot end the new turn. Were the mark taken without
775
+ * drafts too, that finish would read as the new turn's answer. And a draft
776
+ * of the turn's OWN answer is not counted here, so it does not end the turn
777
+ * either — the same rule `repliedTo` applies on the exact path. */
778
+ export function countSettledAnswers(msgs: ConversationMessage[]): number {
779
+ let n = 0;
780
+ for (const m of msgs) {
781
+ if ((m.role || "ASSISTANT") !== "ASSISTANT") continue;
782
+ if (answerText(m) && !stillWriting(m)) n++;
783
+ }
784
+ return n;
785
+ }
786
+
703
787
  /** Has the turn stored under `userMessageId` already been answered?
704
788
  *
705
789
  * Per-TURN, and that is the entire point. A COUNT of assistant replies says
@@ -749,6 +833,12 @@ export function turnAnswered(msgs: ConversationMessage[], userMessageId: string)
749
833
  // exactly these two, and the two scanners read the same transcript: if they
750
834
  // disagree, one of them ends the turn while the other leaves the ladder
751
835
  // running at the fast cadence.
836
+ //
837
+ // They DO part on one case, deliberately: a row the service is still
838
+ // writing (`stillWriting`). That does not end a turn, but it IS proof the
839
+ // trigger landed — the agent is answering — so re-triggering the stored id
840
+ // here would have it answer the same turn twice. The ladder never reads
841
+ // this scanner, so the split cannot pin it fast.
752
842
  if (role === "ASSISTANT" && answerText(m)) return true;
753
843
  }
754
844
  return false;
@@ -845,6 +935,24 @@ export interface AriaChatCore {
845
935
  * that moved during the abort makes this snapshot stale, and committing it
846
936
  * would end a turn the owner has since started. */
847
937
  stop(s: AriaChatState): Promise<AriaChatState | undefined>;
938
+
939
+ /** Put one reply's prose on the clipboard. Defaults to the newest assistant
940
+ * bubble, which is the only one the descriptor offers the control on.
941
+ *
942
+ * Copies `bubble.content`, which is ALREADY the normalized prose — the
943
+ * envelope scan and the markdown stripper both ran on the way in
944
+ * (`bubblesFrom`), so the owner gets what they can read rather than the
945
+ * wire text. */
946
+ /** `undefined` when the turn moved on while the write was out — see `stop`. */
947
+ copyMessage(s: AriaChatState, arg?: AriaActionArg): Promise<AriaChatState | undefined>;
948
+
949
+ /** Ask for another answer.
950
+ *
951
+ * NOT a protocol feature: it sends ordinary user text, the same literal the
952
+ * native Aria app sends, through the one send path (`send`). Not through
953
+ * `submit`: with no argument that reads the COMPOSER, so a regenerate would
954
+ * send whatever the owner had half-typed instead of the retry. */
955
+ regenerate(s: AriaChatState): Promise<AriaChatState>;
848
956
  /** Tapping the active widget's primary / secondary footer button.
849
957
  *
850
958
  * The url is read from STATE rather than bound into the action's params.
@@ -880,6 +988,10 @@ export interface AriaChatCore {
880
988
  }
881
989
 
882
990
  const POLL_ID = "aria-turn";
991
+ /** What Regenerate sends. Ordinary user text, matching the native Aria app —
992
+ * the chat-service has no retry verb, and inventing one here would be a
993
+ * protocol we then had to keep. */
994
+ const REGENERATE_TEXT = "Let's try that again.";
883
995
  /** Id of the local greeting bubble. Shared by the minting and the
884
996
  * "is it still the empty state?" test so the two cannot drift apart. */
885
997
  const GREETING_ID = "greeting";
@@ -915,7 +1027,7 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
915
1027
  const localHistory = o.localHistory !== false;
916
1028
 
917
1029
  const initialState: AriaChatState = {
918
- status: "loading", statusText: "", error: "", input: "",
1030
+ status: "loading", statusText: "", error: "", input: "", copyNote: "", replyActionsHidden: true,
919
1031
  messages: [], prompts: [], _convId: "", _siteId: "", _pending: "",
920
1032
  _pendingAfter: 0, _replyAfter: 0, _srvUsers: 0, _srvAssistants: 0,
921
1033
  _abortId: "", _turnId: "", stopHidden: true, ok: false,
@@ -966,6 +1078,10 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
966
1078
  * those. `submitting` stays on the state because the descriptor needs
967
1079
  * something to disable a button with; it is the shadow, not the guard. */
968
1080
  let widgetSending = false;
1081
+ /** A regenerate is on its way. Refuses a double-tap in the window before
1082
+ * `send` has written `_pending` — the same window `widgetSending` next door
1083
+ * closes for two taps of one option. */
1084
+ let regenerating = false;
969
1085
 
970
1086
  /** A turn the server STORED but never got asked to answer.
971
1087
  *
@@ -1093,7 +1209,10 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1093
1209
  // both; the widget path did neither.
1094
1210
  widget: carrySelection(
1095
1211
  activeWidgetOf(
1096
- msgs.map((m) => ({ role: m.role, content: stripHidden(messageText(m)) })),
1212
+ // A draft offers no controls: the agent has not finished asking, and
1213
+ // an answer tapped now would start a second turn on top of this one.
1214
+ // An empty newest row is NO_WIDGET — the placeholder rule.
1215
+ msgs.map((m) => ({ role: m.role, content: stillWriting(m) ? "" : stripHidden(messageText(m)) })),
1097
1216
  disclosedLabel,
1098
1217
  o.subtree,
1099
1218
  (url) => isWixMediaHost(hostOf(url)),
@@ -1158,7 +1277,7 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1158
1277
  // widget-only reply is, even though it leaves no bubble to inspect. Asking
1159
1278
  // the last BUBBLE stalled this loop on exactly that reply, the same way it
1160
1279
  // stalled `tick`.
1161
- if (repliedTo(raw, s._turnId) ?? countTurns(raw, "ASSISTANT") > s._replyAfter) {
1280
+ if (repliedTo(raw, s._turnId) ?? countSettledAnswers(raw) > s._replyAfter) {
1162
1281
  return { ...s, status: "ready", _convId: convId, messages: bubbles, prompts: [], input: "", statusText: "", error: "", _pending: "", ...observed(raw, s.widget), ok: false };
1163
1282
  }
1164
1283
  }
@@ -1188,7 +1307,10 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1188
1307
  // state this action RETURNS, so on the `_poll` path they paint immediately.
1189
1308
  const optimistic: AriaBubble[] = [...s.messages, plainBubble(`u${s.messages.length}`, "USER", you(), content)];
1190
1309
  const base: AriaChatState = {
1191
- ...s, prompts: [], input: "", error: "",
1310
+ // `copyNote: ""` — a Copy confirmation belongs to the moment, and a new
1311
+ // turn ends it. Left in place it would sit under the next reply saying
1312
+ // "Copied" about text the owner copied several turns ago.
1313
+ ...s, prompts: [], input: "", error: "", copyNote: "",
1192
1314
  // The PREVIOUS turn's handles must not survive into this one: aborting by
1193
1315
  // a stale id would abandon a reply that has already been read, and waiting
1194
1316
  // on a stale turn id would look for an answer that already exists. Both
@@ -1410,9 +1532,36 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1410
1532
  * function of two other fields, and eight reducers each remembering to set it
1411
1533
  * is eight chances for the control to be shown when there is nothing to stop
1412
1534
  * — or, worse, hidden while a reply the owner wants to abandon runs on. */
1535
+ let committed: AriaChatState | null = null;
1536
+ let commits = 0;
1537
+ /** A result as the runtime stores it: the one-shot directives it strips on
1538
+ * commit (`_stream`, `_nav`, `_poll`, `_error` — see `applyNext` in the web
1539
+ * host's runtime bootstrap) removed. */
1540
+ const settled = (s: AriaChatState): AriaChatState => {
1541
+ const r = s as AriaChatState & Record<string, unknown>;
1542
+ if (r._stream === undefined && r._nav === undefined && r._poll === undefined && r._error === undefined) return s;
1543
+ const { _stream: _a, _nav: _b, _poll: _c, _error: _d, ...rest } = r;
1544
+ return rest as AriaChatState;
1545
+ };
1413
1546
  const gated = (s: AriaChatState): AriaChatState => {
1414
1547
  const hidden = !(turnInFlight(s) && !!s._abortId);
1415
- return s.stopHidden === hidden ? s : { ...s, stopHidden: hidden };
1548
+ // The reply actions follow the same rule for the same reason: a derived
1549
+ // flag, recomputed on every action's way out, so no reducer can forget it.
1550
+ // `_pending` rather than `turnInFlight` — see `regenerate` for why `status`
1551
+ // is not trusted to have been cleared.
1552
+ const replyActionsHidden = !(lastReply(s) && !s._pending);
1553
+ const out = s.stopHidden === hidden && s.replyActionsHidden === replyActionsHidden
1554
+ ? s
1555
+ : { ...s, stopHidden: hidden, replyActionsHidden };
1556
+ // Every state the runtime commits passes through here (an `undefined`
1557
+ // result commits nothing and skips it), so this is the newest committed
1558
+ // state — what `copyMessage` puts its note on when other actions landed
1559
+ // while its write was out. Kept as the runtime STORES it, i.e. without the
1560
+ // transient directives it strips on commit: re-returning a `_nav` or a
1561
+ // `_poll` from here would navigate, or re-arm the timer, a second time.
1562
+ committed = settled(out);
1563
+ commits += 1;
1564
+ return out;
1416
1565
  };
1417
1566
 
1418
1567
  /** Ask the service how an async job is getting on.
@@ -1550,7 +1699,10 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1550
1699
  const raw = messagesOf(gm);
1551
1700
  const bubbles = toBubbles(raw, assistant(), you());
1552
1701
  const resumed = bubbles.length ? bubbles : greetingOnly();
1553
- return { ...s, status: "ready", _convId: resumeId, _siteId: site, _pending: "", statusText: "", error: "",
1702
+ // `copyNote: ""` — a resumed transcript ends in a reply, so the row is
1703
+ // shown at once; a note carried over would say "Copied" beside an
1704
+ // answer from a different conversation that nobody copied.
1705
+ return { ...s, status: "ready", _convId: resumeId, _siteId: site, _pending: "", statusText: "", error: "", copyNote: "",
1554
1706
  messages: resumed, ...observed(raw, s.widget), prompts: promptsFor(resumed, promptList()) };
1555
1707
  } catch { /* expired or unreachable — fall through to a fresh one */ }
1556
1708
  }
@@ -1649,11 +1801,11 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1649
1801
  // previous one, which reads as ➕ having done nothing.
1650
1802
  turnSeq += 1;
1651
1803
  return slow({ ...s, status: "ready", _convId: conversationIdOf(res), _siteId: site, input: "", statusText: "", error: "",
1652
- _pending: "", _abortId: "", _turnId: "", messages: greetingOnly(), ...observed([]), prompts: promptList(), ok: false });
1804
+ _pending: "", _abortId: "", _turnId: "", copyNote: "", messages: greetingOnly(), ...observed([]), prompts: promptList(), ok: false });
1653
1805
  } catch (e) {
1654
1806
  turnSeq += 1; // see above — a failed create commits state too
1655
1807
  return slow({ ...s, status: "ready", _convId: "", _siteId: site, input: "", ...failureSlots(e, unavailableText),
1656
- _pending: "", _abortId: "", _turnId: "", messages: greetingOnly(), ...observed([]), prompts: promptList(), ok: false });
1808
+ _pending: "", _abortId: "", _turnId: "", copyNote: "", messages: greetingOnly(), ...observed([]), prompts: promptList(), ok: false });
1657
1809
  }
1658
1810
  },
1659
1811
 
@@ -1709,7 +1861,7 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1709
1861
  // Counted over RAW messages, so a widget-only answer still ends the turn
1710
1862
  // instead of pinning the ladder to its fast rung forever (`countTurns`).
1711
1863
  // Exact when the service named the turn, counted when it did not.
1712
- const replied = repliedTo(raw, s._turnId) ?? countTurns(raw, "ASSISTANT") > s._replyAfter;
1864
+ const replied = repliedTo(raw, s._turnId) ?? countSettledAnswers(raw) > s._replyAfter;
1713
1865
  const next: AriaChatState = {
1714
1866
  ...s,
1715
1867
  status: replied ? "ready" : s.status,
@@ -1972,6 +2124,79 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1972
2124
  return slow(withDraft(
1973
2125
  { ...s, status: "ready", statusText: stoppedText(), error: "", _pending: "", _abortId: "" }, typedAt));
1974
2126
  },
2127
+
2128
+ copyMessage: async (s, arg) => {
2129
+ // Which bubble. The descriptor offers Copy on the newest assistant reply,
2130
+ // so the id is optional and that bubble is the default — but an id IS
2131
+ // accepted, because a row-scoped control passing `$item.id` is the
2132
+ // established shape on this screen (the link rows do exactly that).
2133
+ const wanted = typeof arg === "object" && arg !== null ? String((arg as { id?: string }).id ?? "") : "";
2134
+ const target = wanted
2135
+ ? s.messages.find((m) => m.id === wanted)
2136
+ // The LAST bubble, and only if it is a reply — the same test `gated`
2137
+ // shows the row on. Searching back for the newest reply instead would,
2138
+ // after a widget-only answer (which leaves no bubble), skip over the
2139
+ // owner's question and copy the answer to the turn BEFORE it. The
2140
+ // greeting is excluded because it is a local bubble, not a reply.
2141
+ : lastReply(s);
2142
+ // `content` is ALREADY the prose: `bubblesFrom` scans the widget envelope
2143
+ // out first and then strips markdown, so this is what the owner can read
2144
+ // rather than the wire text.
2145
+ const text = (target?.content ?? "").trim();
2146
+ const failed = () => res(o.labels?.copyFailed ?? "Couldn't copy");
2147
+ if (!text) return { ...s, copyNote: failed() };
2148
+ // No clipboard on this host: say so, rather than report a success that
2149
+ // never happened. Same rule as the throw below.
2150
+ if (!o.copyText) return { ...s, copyNote: failed() };
2151
+ // Read BEFORE the await, compared after: `s` is a pre-await snapshot,
2152
+ // and the runtime commits the whole returned state. Two cases.
2153
+ // - A send or + moved the turn boundary: the note is about a reply that
2154
+ // is gone, so stand down and commit nothing (as `stop` does).
2155
+ // - Anything else landed — a keystroke, a widget selection, a query —
2156
+ // so `{ ...s }` would silently undo it. Put the note on the newest
2157
+ // COMMITTED state instead of on the snapshot.
2158
+ const seq = turnSeq;
2159
+ const at = commits;
2160
+ let note: string;
2161
+ try {
2162
+ await o.copyText(text);
2163
+ note = res(o.labels?.copied ?? "Copied");
2164
+ } catch {
2165
+ // The web Async Clipboard API rejects without a user gesture, and
2166
+ // Android refuses while the app is unfocused. Both land here, and both
2167
+ // are a FAILURE the owner is told about.
2168
+ note = failed();
2169
+ }
2170
+ if (turnSeq !== seq) return undefined;
2171
+ // No separate guard for a resume: `load` resumes only when the screen
2172
+ // holds no live conversation, and a Copy row exists only under a reply
2173
+ // in one — so a resume cannot land while a Copy is out. (The nav input
2174
+ // and site are fixed for a worker's life; a site switch boots a new one.)
2175
+ return { ...(commits !== at && committed ? committed : s), copyNote: note };
2176
+ },
2177
+
2178
+ regenerate: async (s) => {
2179
+ // Refuse a double-tap, and refuse while a reply is still coming — there
2180
+ // is nothing to regenerate until the last one has landed.
2181
+ // `_pending` rather than `turnInFlight`, and the difference is load-bearing.
2182
+ // `turnInFlight` is `status === "sending" || !!_pending`, and `status` can
2183
+ // still read "sending" after a turn has actually closed — a tick whose read
2184
+ // straddled a turn boundary returns `undefined` and writes nothing back, so
2185
+ // the status it would have cleared stays put. Measured: with `turnInFlight`
2186
+ // here, the SECOND regenerate of a conversation was refused even though the
2187
+ // first reply had landed. `_pending` is set by `send` and cleared when the
2188
+ // reply arrives, so it tracks "a turn is out" exactly; the latch above
2189
+ // covers the double-tap that `_pending` is too slow for.
2190
+ if (regenerating || !!s._pending) return s;
2191
+ regenerating = true;
2192
+ try {
2193
+ // `send` directly: it is the one send path, and `submit` with no
2194
+ // argument would read the composer instead of sending the retry.
2195
+ return await send(s, REGENERATE_TEXT);
2196
+ } finally {
2197
+ regenerating = false;
2198
+ }
2199
+ },
1975
2200
  };
1976
2201
 
1977
2202
  // Every action's result goes through the Stop gate on its way out.
@@ -1985,6 +2210,8 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1985
2210
  tick: (s) => impl.tick(s).then((r) => (r ? gated(r) : r)),
1986
2211
  openLink: (s, arg) => impl.openLink(s, arg).then(gated),
1987
2212
  stop: (s) => impl.stop(s).then((r) => (r ? gated(r) : r)),
2213
+ copyMessage: (s, arg) => impl.copyMessage(s, arg).then((r) => (r ? gated(r) : r)),
2214
+ regenerate: (s) => impl.regenerate(s).then(gated),
1988
2215
  // Synchronous, like `typing`: a selection edit makes no network call, so
1989
2216
  // gating it is a plain call rather than a `.then`.
1990
2217
  widgetToggle: (s, arg) => gated(impl.widgetToggle(s, arg)),
@@ -23,6 +23,7 @@
23
23
 
24
24
  import { defineService } from "@himalaya/state";
25
25
  import { open } from "@himalaya/open-url";
26
+ import { writeText } from "@himalaya/clipboard";
26
27
  import {
27
28
  activeSiteId,
28
29
  createAriaChat,
@@ -148,6 +149,11 @@ const core = createAriaChat({
148
149
  typing: () => copyParam("ownerAria.typing", "Aria is typing…"),
149
150
  slow: () => copyParam("ownerAria.slow", "Aria is taking a while…"),
150
151
  stopped: () => copyParam("ownerAria.stopped", "Stopped."),
152
+ // The note beside Copy. Through copyParam for the same reason as the rest:
153
+ // the buttons are localized by the pack, and a note left on the core's
154
+ // English default would be the one line under them that is not.
155
+ copied: () => copyParam("ownerAria.copied", "Copied"),
156
+ copyFailed: () => copyParam("ownerAria.copyFailed", "Couldn't copy"),
151
157
  // Shown when the HOST carries no Wix app binding, so no Wix call can leave
152
158
  // the browser (the web preview). Through copyParam like every other string
153
159
  // here — a default baked into the shared core cannot be localized by this
@@ -167,6 +173,9 @@ const core = createAriaChat({
167
173
  localHistory: false,
168
174
  siteId: activeSiteId,
169
175
  openLink: async (url) => (await open(url, "system_browser")).opened,
176
+ // Resolves or throws — `copyMessage` keys its confirmation on which, so a
177
+ // write the platform refused is never reported as a copy.
178
+ copyText: (text) => writeText(text),
170
179
  resolveEntity: (url) => {
171
180
  const screenId = screenForUrl(url, allowedTargets());
172
181
  return screenId ? { screenId } : null;
@@ -191,6 +200,10 @@ const svc = defineService<AriaChatState, {
191
200
  // new chat) holds a pre-boundary snapshot, and committing it would end the
192
201
  // turn the owner has since started.
193
202
  stop: (s: AriaChatState) => Promise<AriaChatState | undefined>;
203
+ // `undefined` for the same reason again: a write still out when the turn
204
+ // moved on must not commit its pre-await snapshot over the new one.
205
+ copyMessage: (s: AriaChatState, arg?: AriaActionArg) => Promise<AriaChatState | undefined>;
206
+ regenerate: (s: AriaChatState) => Promise<AriaChatState>;
194
207
  widgetPrimary: (s: AriaChatState) => Promise<AriaChatState>;
195
208
  widgetSecondary: (s: AriaChatState) => Promise<AriaChatState>;
196
209
  widgetToggle: (s: AriaChatState, arg?: AriaActionArg) => AriaChatState;
@@ -218,6 +231,8 @@ const svc = defineService<AriaChatState, {
218
231
  // false — so this is only ever dispatched while a reply is outstanding and
219
232
  // the worker is holding the handle that can abort it.
220
233
  stop: core.stop,
234
+ copyMessage: core.copyMessage,
235
+ regenerate: core.regenerate,
221
236
  // The active widget's footer buttons. Named here as well as in the core:
222
237
  // the descriptor dispatches `tier5.<service>.<action>`, and an action the
223
238
  // entry does not export is a control that renders and does nothing.
@@ -37,7 +37,7 @@ export default defineApp({
37
37
  actions: "http://localhost:8190/v1/actions",
38
38
  sync: "http://localhost:8190/v1/sync",
39
39
  },
40
- capabilities: ["live_activity","push","share","secure_storage","open_url","local_database"],
40
+ capabilities: ["live_activity","push","share","secure_storage","open_url","local_database","clipboard"],
41
41
  designTokensPath: "./tokens.json",
42
42
  navigation: {
43
43
  "navKind": "responsive",
@@ -54601,6 +54601,83 @@ const SCREENS = runtimeSetting({
54601
54601
  }
54602
54602
  }
54603
54603
  },
54604
+ {
54605
+ "id": "himi_owner_lite_aria.reply_actions",
54606
+ "body": {
54607
+ "package": "himalaya",
54608
+ "type": "HStack",
54609
+ "props": {
54610
+ "spacing": 4,
54611
+ "children": [
54612
+ {
54613
+ "id": "himi_owner_lite_aria.copy",
54614
+ "body": {
54615
+ "package": "himalaya",
54616
+ "type": "Button",
54617
+ "props": {
54618
+ "title": "Copy",
54619
+ "style": "plain",
54620
+ "size": "sm",
54621
+ "fullWidth": false,
54622
+ "leadingIcon": "copy",
54623
+ "action": {
54624
+ "actionId": "tier5.himiOwnerLiteAria.copyMessage"
54625
+ }
54626
+ }
54627
+ }
54628
+ },
54629
+ {
54630
+ "id": "himi_owner_lite_aria.regenerate",
54631
+ "body": {
54632
+ "package": "himalaya",
54633
+ "type": "Button",
54634
+ "props": {
54635
+ "title": "Regenerate",
54636
+ "style": "plain",
54637
+ "size": "sm",
54638
+ "fullWidth": false,
54639
+ "leadingIcon": "refresh",
54640
+ "action": {
54641
+ "actionId": "tier5.himiOwnerLiteAria.regenerate"
54642
+ }
54643
+ }
54644
+ }
54645
+ },
54646
+ {
54647
+ "id": "",
54648
+ "body": {
54649
+ "package": "himalaya",
54650
+ "type": "Text",
54651
+ "props": {
54652
+ "text": "$service:himiOwnerLiteAria.copyNote",
54653
+ "style": "footnote",
54654
+ "color": "textSecondary",
54655
+ "maxLines": 1,
54656
+ "collapseWhenEmpty": true
54657
+ }
54658
+ }
54659
+ },
54660
+ {
54661
+ "id": "",
54662
+ "body": {
54663
+ "package": "himalaya",
54664
+ "type": "Spacer",
54665
+ "props": {}
54666
+ }
54667
+ }
54668
+ ]
54669
+ }
54670
+ },
54671
+ "modifiers": {
54672
+ "padding": {
54673
+ "leading": 0,
54674
+ "trailing": 0,
54675
+ "top": 0,
54676
+ "bottom": 4
54677
+ },
54678
+ "hiddenPath": "$service:himiOwnerLiteAria.replyActionsHidden"
54679
+ }
54680
+ },
54604
54681
  {
54605
54682
  "id": "himi_owner_lite_aria.prompts.bound",
54606
54683
  "body": {
@@ -57980,7 +58057,8 @@ const SCREENS = runtimeSetting({
57980
58057
  "contentType": "application/javascript+ts-bundle",
57981
58058
  "declaredCapabilities": [
57982
58059
  "wix_api",
57983
- "open_url"
58060
+ "open_url",
58061
+ "clipboard"
57984
58062
  ],
57985
58063
  "minBinaryVersion": "0.1.0",
57986
58064
  "bundleVersion": "0.0.1"