@wix/himalaya-cli 0.940.0 → 0.941.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 +32 -0
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-genie-chat.mjs +22 -1
  3. package/dist/cli.mjs +0 -2
  4. package/guides/sdk-reference.json +19 -1
  5. package/guides/tier-5-add-a-screen.md +113 -0
  6. package/package.json +2 -2
  7. package/templates/beauty-salon-owner/dev/index.ts +57 -0
  8. package/templates/beauty-salon-owner/tier5-src/_shared/aria-chat.ts +367 -16
  9. package/templates/beauty-salon-owner/tier5-src/_shared/aria-widgets.ts +45 -0
  10. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-aria/index.ts +15 -1
  11. package/templates/community-club-owner/dev/index.ts +57 -0
  12. package/templates/community-club-owner/tier5-src/_shared/aria-chat.ts +367 -16
  13. package/templates/community-club-owner/tier5-src/_shared/aria-widgets.ts +45 -0
  14. package/templates/community-club-owner/tier5-src/himi-owner-lite-aria/index.ts +15 -1
  15. package/templates/events-meetups-owner/dev/index.ts +57 -0
  16. package/templates/events-meetups-owner/tier5-src/_shared/aria-chat.ts +367 -16
  17. package/templates/events-meetups-owner/tier5-src/_shared/aria-widgets.ts +45 -0
  18. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-aria/index.ts +15 -1
  19. package/templates/gym-fitness-owner/dev/index.ts +57 -0
  20. package/templates/gym-fitness-owner/tier5-src/_shared/aria-chat.ts +367 -16
  21. package/templates/gym-fitness-owner/tier5-src/_shared/aria-widgets.ts +45 -0
  22. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-aria/index.ts +15 -1
  23. package/templates/nonprofit-cause-owner/dev/index.ts +57 -0
  24. package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-chat.ts +367 -16
  25. package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-widgets.ts +45 -0
  26. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-aria/index.ts +15 -1
  27. package/templates/owner-lite/dev/index.ts +57 -0
  28. package/templates/owner-lite/tier5-src/_shared/aria-chat.ts +367 -16
  29. package/templates/owner-lite/tier5-src/_shared/aria-widgets.ts +45 -0
  30. package/templates/owner-lite/tier5-src/himi-owner-lite-aria/index.ts +15 -1
  31. package/templates/podcast-creator-owner/dev/index.ts +57 -0
  32. package/templates/podcast-creator-owner/tier5-src/_shared/aria-chat.ts +367 -16
  33. package/templates/podcast-creator-owner/tier5-src/_shared/aria-widgets.ts +45 -0
  34. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-aria/index.ts +15 -1
  35. package/templates/portfolio-creative-owner/dev/index.ts +57 -0
  36. package/templates/portfolio-creative-owner/tier5-src/_shared/aria-chat.ts +367 -16
  37. package/templates/portfolio-creative-owner/tier5-src/_shared/aria-widgets.ts +45 -0
  38. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-aria/index.ts +15 -1
  39. package/templates/restaurant-owner/dev/index.ts +57 -0
  40. package/templates/restaurant-owner/tier5-src/_shared/aria-chat.ts +367 -16
  41. package/templates/restaurant-owner/tier5-src/_shared/aria-widgets.ts +45 -0
  42. package/templates/restaurant-owner/tier5-src/himi-owner-lite-aria/index.ts +15 -1
  43. package/templates/retail-store-owner/dev/index.ts +57 -0
  44. package/templates/retail-store-owner/tier5-src/_shared/aria-chat.ts +367 -16
  45. package/templates/retail-store-owner/tier5-src/_shared/aria-widgets.ts +45 -0
  46. package/templates/retail-store-owner/tier5-src/himi-owner-lite-aria/index.ts +15 -1
  47. package/templates/yoga-studio-owner/dev/index.ts +57 -0
  48. package/templates/yoga-studio-owner/tier5-src/_shared/aria-chat.ts +367 -16
  49. package/templates/yoga-studio-owner/tier5-src/_shared/aria-widgets.ts +45 -0
  50. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-aria/index.ts +15 -1
@@ -2,8 +2,9 @@
2
2
  //
3
3
  // Aria is a REAL Wix product surface (the Genie chat-service behind the Owner
4
4
  // app's Aria tab), so every app that mounts it wants the same conversation
5
- // machine: greet, send optimistically, wait for the agent, normalize the reply,
6
- // surface its action links, and remember the conversation. Only the trimmings
5
+ // machine: greet, send optimistically, wait for the agent, abandon that wait
6
+ // when the owner has heard enough, normalize the reply, surface its action
7
+ // links, and remember the conversation. Only the trimmings
7
8
  // differ per app — the service NAME (which `$service:` paths and `_poll.action`
8
9
  // are built from), the greeting copy, the suggested prompts, how a link opens,
9
10
  // and the storage namespace.
@@ -26,6 +27,7 @@
26
27
  import { navigate, type NavDirective, type PollDirective } from "@himalaya/state";
27
28
  import {
28
29
  ARIA_ORIGIN,
30
+ abortAssistantMessageGeneration,
29
31
  ariaAssistantConfig,
30
32
  conversationIdOf,
31
33
  createConversation,
@@ -35,10 +37,11 @@ import {
35
37
  messagesOf,
36
38
  sendMessage,
37
39
  triggerAssistantMessage,
40
+ userMessageIdOf,
38
41
  type ConversationMessage,
39
42
  type ConversationSummary,
40
43
  } from "@himalaya/wix-genie-chat";
41
- import { proseOfParts, scanEnvelopes } from "./aria-widgets.js";
44
+ import { proseOfParts, scanEnvelopes, stripHidden } from "./aria-widgets.js";
42
45
 
43
46
  // ---------------------------------------------------------------------------
44
47
  // Shapes
@@ -105,10 +108,37 @@ export interface AriaChatState {
105
108
  * stores those words under any normalisation this client cannot match left
106
109
  * "Aria is typing…" on screen forever, beside the answer. */
107
110
  _replyAfter: number;
111
+ /** The id the CURRENT turn's user message was stored under, when the service
112
+ * named one.
113
+ *
114
+ * The turn is over when an assistant message sits AFTER this one in the
115
+ * transcript — which is exact, where a count is not. Counting says "the
116
+ * server holds more assistant turns than it did when we sent", and that is
117
+ * satisfiable by an answer to a DIFFERENT turn: stop a turn, let the agent
118
+ * finish writing anyway, send again before a tick observes it, and the old
119
+ * answer ends the new turn. Ending a turn early clears the typing line and
120
+ * hides Stop while the agent is still writing.
121
+ *
122
+ * Empty when the service named no id, and the count rule is the fallback —
123
+ * the same rule, with the same imprecision, that applied before. */
124
+ _turnId: string;
108
125
  /** The last counts actually observed from the server, so the two marks above
109
126
  * can be taken at send time without an extra round trip. */
110
127
  _srvUsers: number;
111
128
  _srvAssistants: number;
129
+ /** The id of the user turn whose reply is being generated — the ONLY handle
130
+ * the abort route accepts. It arrives with the send response and there is no
131
+ * route that looks it up afterwards, so a turn whose send did not name one
132
+ * simply cannot be stopped. Empty whenever nothing is abortable. */
133
+ _abortId: string;
134
+ /** Gate for the descriptor's Stop control, in the polarity the renderers want
135
+ * (a `hiddenPath` HIDES when true). Derived from the turn state after every
136
+ * action rather than maintained by each one — see `gated`.
137
+ *
138
+ * True unless a reply is outstanding AND we hold the handle to abort it: a
139
+ * Stop that cannot stop anything is worse than no Stop, because the owner
140
+ * taps it, the answer arrives anyway, and the control is what they blame. */
141
+ stopHidden: boolean;
112
142
  /** Intentionally never true: a `form` submit that returns `ok` makes the host
113
143
  * POP the screen. A chat composer must stay put, so the whole surface leaves
114
144
  * this false and the bound message list simply re-renders. */
@@ -155,6 +185,9 @@ export interface AriaChatLabels {
155
185
  typing?: Resolvable<string>;
156
186
  /** Status line when the wait ran out without a reply. */
157
187
  slow?: Resolvable<string>;
188
+ /** Status line after the owner stopped a reply. Sits in the NEUTRAL slot:
189
+ * they asked for it, so nothing went wrong. */
190
+ stopped?: Resolvable<string>;
158
191
  /** Shown instead of a raw transport error when this HOST cannot reach Wix at
159
192
  * all — it carries no Wix app binding to sign the chat-service call with.
160
193
  * See `isWixAccountUnreachable`, and note what it is NOT: a signed-out user. */
@@ -442,7 +475,11 @@ export function toBubbles(msgs: ConversationMessage[], assistantName: string, yo
442
475
  const role = m.role || "ASSISTANT";
443
476
  // Only assistant turns carry envelopes; a user's own text is literal, and
444
477
  // scanning it would let an owner who typed `<<…>>` erase their own message.
445
- const prose = role === "USER" ? raw : proseOfParts(scanEnvelopes(raw, 0, true).parts);
478
+ // HIDDEN spans go first: the service marks text as transcript-only that
479
+ // way, and an aborted turn consists of nothing else. Then envelopes, then
480
+ // the markdown stripper — each stage handing on only what a reader should
481
+ // actually see.
482
+ const prose = role === "USER" ? raw : proseOfParts(scanEnvelopes(stripHidden(raw), 0, true).parts);
446
483
  return {
447
484
  id: m.id || `m${i}`,
448
485
  role,
@@ -508,6 +545,51 @@ export function countRole(bubbles: AriaBubble[], role: string): number {
508
545
  return n;
509
546
  }
510
547
 
548
+ /** Has the turn stored under `turnId` been answered?
549
+ *
550
+ * EXACT, where counting is not. The count rule — "the server holds more
551
+ * assistant turns than it did when we sent" — is satisfiable by an answer to a
552
+ * DIFFERENT turn, and Stop is what makes that reachable: abort a turn, let the
553
+ * agent finish writing it anyway, send again before a tick has observed the
554
+ * late answer, and that answer ends the NEW turn. The typing line clears and
555
+ * Stop disappears while the agent is still writing.
556
+ *
557
+ * Position in the transcript is what settles it: an answer to this turn comes
558
+ * after this turn. An empty `turnId` — a service that named none — falls back
559
+ * to the caller's count rule, which is exactly what applied before. */
560
+ /** The assistant text that is actually ADDRESSED TO THE OWNER.
561
+ *
562
+ * `<HIDDEN>…</HIDDEN>` is how the service leaves notes for itself — including
563
+ * the note it replaces an aborted turn with — and `toBubbles` strips it, so a
564
+ * row made only of it paints nothing. Judging "did the turn get an answer?" on
565
+ * the RAW text therefore ends the turn on a row the owner never sees: after a
566
+ * Stop it erases the "Stopped." line, and mid-turn it clears the typing line and
567
+ * hides Stop while the agent is still writing.
568
+ *
569
+ * A widget envelope (`<<{"widget":…}>>`) is deliberately NOT stripped here. That
570
+ * IS an answer with nothing to say in prose — the case `countTurns` counts raw
571
+ * messages for in the first place — so it must keep reading as non-empty. */
572
+ function answerText(m: ConversationMessage): string {
573
+ return stripHidden(messageText(m)).trim();
574
+ }
575
+
576
+ export function repliedTo(msgs: ConversationMessage[], turnId: string): boolean | null {
577
+ if (!turnId) return null;
578
+ const at = msgs.findIndex((m) => m.id === turnId);
579
+ // NOT FOUND is "cannot attribute", not "not answered". A service is free to
580
+ // normalise, truncate or simply not keep the owner's turn, and one that drops
581
+ // it would otherwise leave the answer on screen under a permanent "Aria is
582
+ // typing…" with no recovery but leaving the screen — which is the exact defect
583
+ // the count rule was introduced to fix. So: fall back to it.
584
+ if (at < 0) return null;
585
+ for (let i = at + 1; i < msgs.length; i++) {
586
+ const m = msgs[i];
587
+ if ((m.role || "ASSISTANT") !== "ASSISTANT") continue;
588
+ if (answerText(m)) return true;
589
+ }
590
+ return false;
591
+ }
592
+
511
593
  /** How many turns of one role the SERVER holds — the mark the turn is judged
512
594
  * against. Counted over RAW messages, gated on the raw text being non-empty.
513
595
  *
@@ -531,7 +613,9 @@ export function countTurns(msgs: ConversationMessage[], role: string): number {
531
613
  let n = 0;
532
614
  for (const m of msgs) {
533
615
  if ((m.role || "ASSISTANT") !== role) continue;
534
- if (messageText(m).trim()) n++;
616
+ // USER rows carry no HIDDEN notes, so `answerText` is a no-op on them and
617
+ // the two roles stay counted by one rule.
618
+ if (answerText(m)) n++;
535
619
  }
536
620
  return n;
537
621
  }
@@ -585,7 +669,7 @@ export function turnAnswered(msgs: ConversationMessage[], userMessageId: string)
585
669
  // exactly these two, and the two scanners read the same transcript: if they
586
670
  // disagree, one of them ends the turn while the other leaves the ladder
587
671
  // running at the fast cadence.
588
- if (role === "ASSISTANT" && messageText(m).trim()) return true;
672
+ if (role === "ASSISTANT" && answerText(m)) return true;
589
673
  }
590
674
  return false;
591
675
  }
@@ -666,8 +750,21 @@ export interface AriaChatCore {
666
750
  * draft never reaches the worker cannot be emptied on send — the owner's
667
751
  * sent message just sits there, one tap away from being sent twice. */
668
752
  typing(s: AriaChatState, arg?: AriaActionArg): AriaChatState;
669
- tick(s: AriaChatState): Promise<AriaChatState>;
753
+ /** Resolves to the next state, or `undefined` to leave the state UNTOUCHED. A
754
+ * tick whose transcript read predates a turn boundary (a Stop, a fresh send,
755
+ * or a new chat) must not write its snapshot back — the runtime merges a
756
+ * returned object over the CURRENT state, so writing a stale in-progress
757
+ * snapshot would resurrect or clobber the turn that boundary just changed.
758
+ * `undefined` SPECIFICALLY — not `null`, not `false`. Only `undefined` is
759
+ * read as "no opinion" by every host; the web host assigns anything else it
760
+ * is handed, so `return null` would store `null` as the service state. */
761
+ tick(s: AriaChatState): Promise<AriaChatState | undefined>;
670
762
  openLink(s: AriaChatState, arg?: AriaActionArg): Promise<AriaChatState>;
763
+ /** Abandon the reply being generated for the turn in flight. Resolves to
764
+ * `undefined` to leave the state UNTOUCHED, on `tick`'s terms: a boundary
765
+ * that moved during the abort makes this snapshot stale, and committing it
766
+ * would end a turn the owner has since started. */
767
+ stop(s: AriaChatState): Promise<AriaChatState | undefined>;
671
768
  }
672
769
 
673
770
  const POLL_ID = "aria-turn";
@@ -687,6 +784,7 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
687
784
  const you = () => res(o.labels?.you ?? "You");
688
785
  const typingText = () => res(o.labels?.typing ?? `${assistant()} is typing…`);
689
786
  const slowText = () => res(o.labels?.slow ?? `${assistant()} is taking a while…`);
787
+ const stoppedText = () => res(o.labels?.stopped ?? "Stopped.");
690
788
  // States the fact and stops. It deliberately gives no instruction, because
691
789
  // there is none the owner could follow: they are signed in, and the thing that
692
790
  // is missing is the app's own Wix binding. "Here" is doing real work — it says
@@ -703,7 +801,8 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
703
801
  const initialState: AriaChatState = {
704
802
  status: "loading", statusText: "", error: "", input: "",
705
803
  messages: [], prompts: [], _convId: "", _siteId: "", _pending: "",
706
- _pendingAfter: 0, _replyAfter: 0, _srvUsers: 0, _srvAssistants: 0, ok: false,
804
+ _pendingAfter: 0, _replyAfter: 0, _srvUsers: 0, _srvAssistants: 0,
805
+ _abortId: "", _turnId: "", stopHidden: true, ok: false,
707
806
  };
708
807
 
709
808
  /** One create at a time, per site.
@@ -759,6 +858,63 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
759
858
  * `turnAnswered` — rather than anything inferred from local counts. */
760
859
  let stalledTurn: { convId: string; content: string; userMessageId: string } | null = null;
761
860
 
861
+ /** A monotonic turn epoch, bumped by every TURN BOUNDARY: a successful `stop`,
862
+ * the start of a `send`, and a `newChat`.
863
+ *
864
+ * A latch for the same reason `inFlightSend` is one: reducers are pure
865
+ * functions of the state handed to them, so a `tick` that is already awaiting
866
+ * `getConversationMessages` when the turn changes underneath it holds a
867
+ * snapshot from BEFORE that change. The action cannot see that from its own
868
+ * argument; this counter is the only place the overlap is visible.
869
+ *
870
+ * It is not enough to bump on `stop` alone. A Stop followed by a fresh send
871
+ * starts a NEW turn, and the parked tick — still carrying the old turn's
872
+ * snapshot — would then clear the new turn's pending mark and Stop control,
873
+ * leaving it running with nothing to stop it. Bumping on the send too makes
874
+ * the parked tick recognise it is stale and stand down. */
875
+ let turnSeq = 0;
876
+
877
+ /** Turn epochs with an abort in the air, and how many. Covers the window
878
+ * `turnSeq` cannot: `stop` bumps the epoch only AFTER the service accepts
879
+ * the abort (a refused one leaves the turn running), so a tick resolving
880
+ * during the request passes the epoch check, is applied, and is then
881
+ * overwritten by `stop`'s pre-await snapshot — an answer the service already
882
+ * wrote vanishing under "Stopped." until the next slow poll.
883
+ *
884
+ * Discarding such a tick is free: `tick` rebuilds wholesale, so the next one
885
+ * re-reads it in full, on both the accepted and the refused outcome.
886
+ *
887
+ * Keyed by EPOCH, not counted flat: a flat counter also fenced ticks of a
888
+ * turn started after the Stop — which submit deliberately allows — stalling
889
+ * the new turn until the old abort settled. Staleness is relative to the
890
+ * aborted turn alone. A multiset because nothing serialises two overlapping
891
+ * stops on one epoch. */
892
+ const abortingEpochs = new Map<number, number>();
893
+
894
+ /** The composer's draft, and a count of edits to it.
895
+ *
896
+ * `typing` is not a turn boundary, so `turnSeq` never covered it: an owner
897
+ * drafting while `stop`'s abort was in flight had it overwritten by the
898
+ * pre-await snapshot. Returning only the fields `stop` owns is the obvious
899
+ * fix and is WRONG on this platform — a reducer must return a whole state,
900
+ * because the hosts disagree about what a patch means. That is general
901
+ * Tier-5 knowledge rather than Aria's, so it lives in the tier-5 guide
902
+ * (`himi docs tier-5-add-a-screen`, "A reducer must return a WHOLE state").
903
+ *
904
+ * The counter is what keeps `withDraft` honest — see it for why. */
905
+ let draft = "";
906
+ let draftSeq = 0;
907
+
908
+ /** Raise the fence for one abort of `seq`; returns the lowering half. */
909
+ const fenceEpoch = (seq: number): (() => void) => {
910
+ abortingEpochs.set(seq, (abortingEpochs.get(seq) ?? 0) + 1);
911
+ return () => {
912
+ const left = (abortingEpochs.get(seq) ?? 1) - 1;
913
+ if (left > 0) abortingEpochs.set(seq, left);
914
+ else abortingEpochs.delete(seq);
915
+ };
916
+ };
917
+
762
918
  /** Refuse a send that is already on its way. State half + latch half. */
763
919
  const duplicate = (s: AriaChatState, content: string): boolean =>
764
920
  isDuplicateSend(s, content) || inFlightSend === content;
@@ -826,7 +982,7 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
826
982
  // widget-only reply is, even though it leaves no bubble to inspect. Asking
827
983
  // the last BUBBLE stalled this loop on exactly that reply, the same way it
828
984
  // stalled `tick`.
829
- if (countTurns(raw, "ASSISTANT") > s._replyAfter) {
985
+ if (repliedTo(raw, s._turnId) ?? countTurns(raw, "ASSISTANT") > s._replyAfter) {
830
986
  return { ...s, status: "ready", _convId: convId, messages: bubbles, prompts: [], input: "", statusText: "", error: "", _pending: "", ...observed(raw), ok: false };
831
987
  }
832
988
  }
@@ -834,12 +990,34 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
834
990
  }
835
991
 
836
992
  /** Send + wait, by whichever transport this consumer configured. */
993
+ /** Re-apply the composer's draft to a state built from a pre-await snapshot,
994
+ * but ONLY when the owner typed during that await (`since` is the counter
995
+ * read before it).
996
+ *
997
+ * Re-applying whenever the latch merely DIFFERS is wrong twice over: the
998
+ * latch still holds the text of the message just sent, so a Stop moments
999
+ * after a send would put it back in the composer ready to send twice; and
1000
+ * it would make every one of the five places that deliberately set
1001
+ * `input: ""` responsible for clearing a latch. */
1002
+ const withDraft = (next: AriaChatState, since: number): AriaChatState =>
1003
+ draftSeq === since || draft === next.input ? next : { ...next, input: draft };
1004
+
837
1005
  async function send(s: AriaChatState, content: string): Promise<AriaChatState> {
1006
+ // A new turn is a turn boundary: any tick still parked on the PREVIOUS
1007
+ // turn's transcript read must stand down rather than write its snapshot over
1008
+ // this one. Bump before the first await so an in-flight tick is already stale
1009
+ // by the time it resolves.
1010
+ turnSeq += 1;
838
1011
  // Optimistic first: the owner's message and the typing line are part of the
839
1012
  // state this action RETURNS, so on the `_poll` path they paint immediately.
840
1013
  const optimistic: AriaBubble[] = [...s.messages, plainBubble(`u${s.messages.length}`, "USER", you(), content)];
841
1014
  const base: AriaChatState = {
842
1015
  ...s, prompts: [], input: "", error: "",
1016
+ // The PREVIOUS turn's handles must not survive into this one: aborting by
1017
+ // a stale id would abandon a reply that has already been read, and waiting
1018
+ // on a stale turn id would look for an answer that already exists. Both
1019
+ // are replaced the moment this send knows its own id.
1020
+ _abortId: "", _turnId: "",
843
1021
  statusText: typingText(), messages: optimistic, _pending: content,
844
1022
  // Mark the turn against the LAST COUNTS WE SAW, not against our own
845
1023
  // message list — the greeting is a local bubble the server never had, so
@@ -913,12 +1091,34 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
913
1091
  if (turnAnswered(seen, stalled.userMessageId)) stalled = null;
914
1092
  } catch { /* unknown — keep the latch, see above */ }
915
1093
  }
1094
+ // The id the turn is STORED under is the only thing the abort route
1095
+ // accepts, and no route hands it back afterwards — so it has to be
1096
+ // captured as the turn is sent, even though everything else about the turn
1097
+ // is read back off the transcript. A service that names none leaves this
1098
+ // turn unstoppable, which `gated` renders as an ABSENT control rather than
1099
+ // a dead one.
1100
+ //
1101
+ // The retry path needs no send to learn it: holding that id is what
1102
+ // `stalledTurn` IS. So a turn recovered from a failed trigger is just as
1103
+ // stoppable as one sent cleanly.
1104
+ let abortId: string;
916
1105
  if (stalled) {
917
1106
  retriedId = stalled.userMessageId;
1107
+ abortId = stalled.userMessageId;
918
1108
  await triggerAssistantMessage(convId, stalled.userMessageId, { origin });
919
1109
  } else {
920
- await sendMessage(convId, content, { origin });
1110
+ abortId = userMessageIdOf(await sendMessage(convId, content, { origin }));
921
1111
  }
1112
+ // A SECOND boundary bump, and the load-bearing one. The bump at entry only
1113
+ // retires a tick that was already parked when the owner tapped Send. A tick
1114
+ // dispatched DURING the two round trips above read the post-bump epoch, so
1115
+ // it matches, and it is carrying the PRE-send snapshot — it would resolve
1116
+ // after this send commits and put `status: "ready"`, an empty `_pending`
1117
+ // and an empty `_abortId` back, taking the typing line and the Stop control
1118
+ // with them while the reply is still being generated. That is the very
1119
+ // clobber the epoch exists to prevent, one window over. `stop` bumps after
1120
+ // its await for this reason; so must this.
1121
+ turnSeq += 1;
922
1122
  // Delivered — whatever was stalled is answered or superseded.
923
1123
  //
924
1124
  // `retriedId` is cleared with it, and that is not redundant: the legacy
@@ -952,9 +1152,15 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
952
1152
  // first reply would never read as new — the same stranded "typing…" this
953
1153
  // turn-marking exists to prevent. Reachable when the owner switches
954
1154
  // business and sends without the screen reloading in between.
955
- const sent: AriaChatState = created
1155
+ const marked: AriaChatState = created
956
1156
  ? { ...base, _pendingAfter: 0, _replyAfter: 0, _srvUsers: 0, _srvAssistants: 0 }
957
1157
  : base;
1158
+ // `_turnId` and `_abortId` are the same id and NOT the same field.
1159
+ // `_abortId` is "can this still be stopped" and a successful stop clears
1160
+ // it; `_turnId` is "which turn are we waiting on" and has to outlive that,
1161
+ // because that is what tells a late answer to the STOPPED turn apart from
1162
+ // the answer to this one.
1163
+ const sent: AriaChatState = { ...marked, _abortId: abortId, _turnId: abortId };
958
1164
  inFlightSend = null;
959
1165
  if (o.poll) {
960
1166
  // Return NOW. The host owns the clock from here; `tick` merges the
@@ -965,6 +1171,10 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
965
1171
  return busyLoop({ ...sent, _convId: convId, _siteId: site }, convId, optimistic);
966
1172
  } catch (e) {
967
1173
  inFlightSend = null;
1174
+ // The same second bump as the success path, for the same reason: a tick
1175
+ // dispatched during the awaits above is carrying the pre-send snapshot, and
1176
+ // this failure state is just as much a commit as a delivered turn.
1177
+ turnSeq += 1;
968
1178
  // A send that failed AFTER the turn was stored hands back the id it stored
969
1179
  // under. Remember it, so the owner tapping Send again triggers that turn
970
1180
  // instead of posting their message a second time. Any other failure clears
@@ -1008,7 +1218,23 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1008
1218
  }
1009
1219
  }
1010
1220
 
1011
- return {
1221
+ /** Is a reply outstanding right now? True from the moment `send` returns its
1222
+ * optimistic state until the transcript holds the answer (or the owner stops
1223
+ * waiting). Read from the state alone — a poll tick and a tap both arrive as
1224
+ * ordinary reducer calls, with nothing else in scope to ask. */
1225
+ const turnInFlight = (s: AriaChatState): boolean => s.status === "sending" || !!s._pending;
1226
+
1227
+ /** Recompute the Stop gate from the turn state. Applied to the result of every
1228
+ * action rather than maintained inside each of them: `stopHidden` is a pure
1229
+ * function of two other fields, and eight reducers each remembering to set it
1230
+ * is eight chances for the control to be shown when there is nothing to stop
1231
+ * — or, worse, hidden while a reply the owner wants to abandon runs on. */
1232
+ const gated = (s: AriaChatState): AriaChatState => {
1233
+ const hidden = !(turnInFlight(s) && !!s._abortId);
1234
+ return s.stopHidden === hidden ? s : { ...s, stopHidden: hidden };
1235
+ };
1236
+
1237
+ const impl: AriaChatCore = {
1012
1238
  initialState,
1013
1239
 
1014
1240
  load: async (s) => {
@@ -1076,6 +1302,11 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1076
1302
 
1077
1303
  typing: (s, arg) => {
1078
1304
  const next = typeof arg === "string" ? arg : (arg?.value ?? arg?.text ?? arg?.message ?? "");
1305
+ // Recorded even when the value is unchanged, because the counter is about
1306
+ // "did an edit happen", and a keystroke that retypes the same character
1307
+ // still happened.
1308
+ draft = next;
1309
+ draftSeq += 1;
1079
1310
  return next === s.input ? s : { ...s, input: next };
1080
1311
  },
1081
1312
 
@@ -1099,6 +1330,9 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1099
1330
 
1100
1331
  newChat: async (s) => {
1101
1332
  const site = siteOf();
1333
+ // A fresh conversation is a turn boundary too: a tick parked on the OLD
1334
+ // conversation's transcript must not reconcile it over the new greeting.
1335
+ turnSeq += 1;
1102
1336
  // ➕ can land mid-turn, and it abandons that turn: `status` goes back to
1103
1337
  // `ready`, so `tick`'s `wasFast` is false from here on and the FAST
1104
1338
  // ladder the send armed would never be slowed again. A worker cannot
@@ -1111,11 +1345,19 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1111
1345
  // NOT `createOnce`: ➕ means "give me a different conversation", so it
1112
1346
  // must never be handed an in-flight one that another caller started.
1113
1347
  const res = await createConversation(assistantConfig, origin);
1348
+ // Bump again now the new conversation exists. The bump above only
1349
+ // retires a tick parked before ➕ was tapped; one dispatched DURING the
1350
+ // create matched the epoch and still holds the OLD conversation's view,
1351
+ // so it would resolve after this and merge `_convId`, the old transcript
1352
+ // and the old observed counts back — the fresh chat snapping back to the
1353
+ // previous one, which reads as ➕ having done nothing.
1354
+ turnSeq += 1;
1114
1355
  return slow({ ...s, status: "ready", _convId: conversationIdOf(res), _siteId: site, input: "", statusText: "", error: "",
1115
- _pending: "", messages: greetingOnly(), ...observed([]), prompts: promptList(), ok: false });
1356
+ _pending: "", _abortId: "", _turnId: "", messages: greetingOnly(), ...observed([]), prompts: promptList(), ok: false });
1116
1357
  } catch (e) {
1358
+ turnSeq += 1; // see above — a failed create commits state too
1117
1359
  return slow({ ...s, status: "ready", _convId: "", _siteId: site, input: "", ...failureSlots(e, unavailableText),
1118
- _pending: "", messages: greetingOnly(), ...observed([]), prompts: promptList(), ok: false });
1360
+ _pending: "", _abortId: "", _turnId: "", messages: greetingOnly(), ...observed([]), prompts: promptList(), ok: false });
1119
1361
  }
1120
1362
  },
1121
1363
 
@@ -1126,14 +1368,38 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1126
1368
  tick: async (s) => {
1127
1369
  if (!o.poll || !s._convId) return s;
1128
1370
  const wasFast = s.status === "sending";
1371
+ // Read BEFORE the await, compared after: see `turnSeq`.
1372
+ const seq = turnSeq;
1129
1373
  try {
1130
1374
  const gm = await getConversationMessages(s._convId);
1131
1375
  const raw = messagesOf(gm);
1376
+ // A turn boundary crossed while this read was in flight — a Stop, a fresh
1377
+ // send, or a new chat. This tick's ENTIRE view is stale: `s` is the
1378
+ // pre-boundary snapshot, and the runtime would merge whatever we return
1379
+ // over the CURRENT (post-boundary) state. Writing back would either
1380
+ // resurrect the turn the owner just stopped, or clobber the new turn a
1381
+ // send just started — clearing its pending mark and Stop control and
1382
+ // leaving it running with nothing to stop it. Stand down entirely: a
1383
+ // `undefined` return is a no-op (and only `undefined` — see the type),
1384
+ // the post-boundary state stands, and the
1385
+ // next tick (under the new epoch) reconciles the transcript cleanly.
1386
+ //
1387
+ // This guard sits BEFORE every other return in the block on purpose. The
1388
+ // empty-transcript bail below (`return s`) and the reconciled write both
1389
+ // hand `s` back to the runtime, so each is a stale snapshot the instant a
1390
+ // boundary has moved on — guarding only the reconciled write, as this did
1391
+ // originally, still let a boundary-crossing tick that read an empty
1392
+ // transcript clobber the new turn.
1393
+ // An abort is in the air FOR THIS EPOCH (see `abortingEpochs`), so
1394
+ // `stop` holds a snapshot predating this read and will overwrite
1395
+ // whatever we write. A tick of a NEWER epoch is not stale, not fenced.
1396
+ if (turnSeq !== seq || abortingEpochs.has(seq)) return undefined;
1132
1397
  const server = toBubbles(raw, assistant(), you());
1133
1398
  // A transcript that renders to nothing is not necessarily an empty one:
1134
1399
  // a reply made entirely of widgets leaves no bubble behind. So the bail
1135
1400
  // is on the RAW transcript — bailing on the bubbles is what let such a
1136
- // reply go unnoticed and pinned the ladder fast forever.
1401
+ // reply go unnoticed and pinned the ladder fast forever. Safe to return
1402
+ // `s` here: the guard above proved this tick is still current.
1137
1403
  if (!raw.length) return s;
1138
1404
  const { messages, pending } = reconcile(server, s._pending, s._pendingAfter, you());
1139
1405
  // …but still never PAINT nothing over something. `messages` is only
@@ -1146,7 +1412,8 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1146
1412
  // own words came back is a rendering detail, not the answer arriving.
1147
1413
  // Counted over RAW messages, so a widget-only answer still ends the turn
1148
1414
  // instead of pinning the ladder to its fast rung forever (`countTurns`).
1149
- const replied = countTurns(raw, "ASSISTANT") > s._replyAfter;
1415
+ // Exact when the service named the turn, counted when it did not.
1416
+ const replied = repliedTo(raw, s._turnId) ?? countTurns(raw, "ASSISTANT") > s._replyAfter;
1150
1417
  const next: AriaChatState = {
1151
1418
  ...s,
1152
1419
  status: replied ? "ready" : s.status,
@@ -1159,6 +1426,12 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1159
1426
  // Slow the ladder the moment the turn is over; leave it alone otherwise.
1160
1427
  return replied && wasFast ? cadence(next, o.poll.idleMs, o.poll.fastMs) : next;
1161
1428
  } catch {
1429
+ // A read that THREW is exactly as stale as one that resolved: if a turn
1430
+ // boundary moved on while it was in flight, returning `s` merges the
1431
+ // pre-boundary snapshot over the current turn — the same clobber the
1432
+ // guard above prevents on the success path. Only a still-current tick
1433
+ // may hold the ladder up by keeping the state as it is.
1434
+ if (turnSeq !== seq || abortingEpochs.has(seq)) return undefined;
1162
1435
  return s;
1163
1436
  }
1164
1437
  },
@@ -1174,6 +1447,84 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1174
1447
  if (o.openLink) { try { await o.openLink(url); } catch { /* best-effort */ } }
1175
1448
  return s;
1176
1449
  },
1450
+
1451
+ // Abandon the reply being generated. Three things happen, and only the first
1452
+ // is a network call:
1453
+ //
1454
+ // 1. The service is told to stop generating, addressed by the id of the
1455
+ // user turn that asked. That is the whole of the server-side effect —
1456
+ // there is no "cancel" for a request already in the air.
1457
+ // 2. The ladder is re-armed SLOW. A worker cannot cancel a poll, so a turn
1458
+ // that ends early has to be slowed the same way `newChat` slows one it
1459
+ // interrupted; without this the screen keeps polling at the fast rate
1460
+ // for as long as it stays open.
1461
+ // 3. The transcript is left ALONE. What the owner stopped is the waiting,
1462
+ // not the conversation: their own message was persisted and stays, and
1463
+ // if the service had already finished the answer it lands on the next
1464
+ // tick and is shown. Hiding a reply the service actually produced would
1465
+ // make the transcript disagree with the server for the rest of the
1466
+ // conversation — and the next `tick`, which rebuilds wholesale, would
1467
+ // bring it back anyway.
1468
+ //
1469
+ // A REFUSED abort leaves the turn exactly as it was — status, pending mark
1470
+ // and fast ladder intact — with the reason in the error slot. Reporting
1471
+ // "Stopped." for a generation that is still running is the one outcome worth
1472
+ // going out of the way to avoid: the answer then arrives after the screen
1473
+ // said it would not.
1474
+ stop: async (s) => {
1475
+ if (!turnInFlight(s) || !s._abortId) return s;
1476
+ const slow = (next: AriaChatState): AriaChatState =>
1477
+ o.poll ? cadence(next, o.poll.idleMs, null) : next;
1478
+ // Read BEFORE the await, compared after, exactly as `tick` does: `stop`
1479
+ // holds a pre-await snapshot too, and a `newChat` or `send` dispatched
1480
+ // during the abort moves the boundary under it.
1481
+ const seq = turnSeq;
1482
+ const typedAt = draftSeq;
1483
+ const unfence = fenceEpoch(seq);
1484
+ // Carried out rather than returned from the catch, so the staleness guard
1485
+ // below runs on the refused path as well as the accepted one.
1486
+ let failure: Partial<AriaChatState> | null = null;
1487
+ try {
1488
+ await abortAssistantMessageGeneration(s._abortId);
1489
+ } catch (e) {
1490
+ const slots = failureSlots(e, unavailableText);
1491
+ // Keep the line that says a reply is coming, because one is. The
1492
+ // ordinary failure path BLANKS `statusText` — right when a send failed
1493
+ // and there is no turn, wrong here, where blanking it would tell the
1494
+ // owner the turn ended while the agent kept writing.
1495
+ failure = { ...slots, statusText: slots.statusText || s.statusText };
1496
+ } finally {
1497
+ // Lowered on BOTH paths, and before either return commits: a refused
1498
+ // abort must leave ticks reporting the still-running turn normally.
1499
+ unfence();
1500
+ }
1501
+ // A boundary moved while the abort was in flight (a send, a new chat,
1502
+ // another stop). Committing `s` over the CURRENT state would end the NEW
1503
+ // turn — `status: "ready"`, empty `_abortId`, the old transcript painted
1504
+ // over it. Stand down, as a stale tick does; the abort already happened
1505
+ // at the service, which is the half that could not be taken back.
1506
+ if (turnSeq !== seq) return undefined;
1507
+ if (failure) return withDraft({ ...s, ...failure }, typedAt);
1508
+ // AFTER the abort is accepted, never before: a refused abort leaves the
1509
+ // turn running, and a tick resolving around it must go on reporting that
1510
+ // turn normally.
1511
+ turnSeq += 1;
1512
+ return slow(withDraft(
1513
+ { ...s, status: "ready", statusText: stoppedText(), error: "", _pending: "", _abortId: "" }, typedAt));
1514
+ },
1515
+ };
1516
+
1517
+ // Every action's result goes through the Stop gate on its way out.
1518
+ return {
1519
+ initialState,
1520
+ load: (s) => impl.load(s).then(gated),
1521
+ submit: (s, arg) => impl.submit(s, arg).then(gated),
1522
+ submitPrompt: (s, arg) => impl.submitPrompt(s, arg).then(gated),
1523
+ newChat: (s) => impl.newChat(s).then(gated),
1524
+ typing: (s, arg) => gated(impl.typing(s, arg)),
1525
+ tick: (s) => impl.tick(s).then((r) => (r ? gated(r) : r)),
1526
+ openLink: (s, arg) => impl.openLink(s, arg).then(gated),
1527
+ stop: (s) => impl.stop(s).then((r) => (r ? gated(r) : r)),
1177
1528
  };
1178
1529
  }
1179
1530
 
@@ -68,6 +68,51 @@ function payloadOf(raw: string): WidgetPayload {
68
68
  return { name, dataJson, configJson, raw };
69
69
  }
70
70
 
71
+ /**
72
+ * Remove every `<HIDDEN>…</HIDDEN>` span.
73
+ *
74
+ * The chat-service uses it for text that belongs to the TRANSCRIPT but not to
75
+ * the screen — machine-facing notes the agent leaves for itself. Aborting a
76
+ * turn produces exactly one such message and nothing else:
77
+ *
78
+ * <HIDDEN>Request was aborted by user.</HIDDEN>
79
+ *
80
+ * (Observed live: abort the turn, re-read the conversation, and that is the
81
+ * assistant message sitting there.) Painting it would put angle-bracketed
82
+ * internals in front of an owner who had just pressed Stop — the same class of
83
+ * leak as the widget envelope this module already lifts out, so it is removed in
84
+ * the same place and before the same markdown stripper.
85
+ *
86
+ * Tolerant of case and of whitespace in the tags, and of an UNCLOSED opener —
87
+ * which is treated as hiding the remainder, because the alternative is printing
88
+ * something the service explicitly marked as not for display.
89
+ */
90
+ const HIDDEN_OPEN = /<\s*HIDDEN\s*>/gi;
91
+ const HIDDEN_CLOSE = /<\s*\/\s*HIDDEN\s*>/gi;
92
+
93
+ export function stripHidden(text: string): string {
94
+ if (!text) return text;
95
+ let out = "";
96
+ let cursor = 0;
97
+ for (;;) {
98
+ // Case- AND whitespace-tolerant, as the docstring promises: the service is
99
+ // free to emit `< HIDDEN >` or `</ hidden >`, and matching only the exact
100
+ // literal `<HIDDEN>` let a spaced variant leak the transcript-only note
101
+ // straight onto the screen — the leak this function exists to stop.
102
+ HIDDEN_OPEN.lastIndex = cursor;
103
+ const openM = HIDDEN_OPEN.exec(text);
104
+ if (!openM) break;
105
+ out += text.slice(cursor, openM.index);
106
+ HIDDEN_CLOSE.lastIndex = openM.index + openM[0].length;
107
+ const closeM = HIDDEN_CLOSE.exec(text);
108
+ // An UNCLOSED opener hides the remainder — the alternative is printing
109
+ // something the service explicitly marked as not for display.
110
+ if (!closeM) return out.trim();
111
+ cursor = closeM.index + closeM[0].length;
112
+ }
113
+ return (out + text.slice(cursor)).trim();
114
+ }
115
+
71
116
  /**
72
117
  * Index of the `>>` that actually TERMINATES the envelope opened at `start`, or
73
118
  * -1 if there is none yet.