@constructor-io/constructorio-ui-asa 1.8.0 → 1.10.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 (43) hide show
  1. package/README.md +8 -210
  2. package/dist/constructorio-ui-asa-bundled.js +79 -21
  3. package/lib/cjs/components/Button/Button.js +22 -3
  4. package/lib/cjs/components/Chat/AiMessage.css +52 -0
  5. package/lib/cjs/components/Chat/AiMessage.js +6 -2
  6. package/lib/cjs/components/Chat/Chat.js +18 -4
  7. package/lib/cjs/components/Chat/ChatInput.js +3 -3
  8. package/lib/cjs/hooks/asaStreamHandlers.js +1 -1
  9. package/lib/cjs/hooks/persistence/backgroundTurns.js +35 -0
  10. package/lib/cjs/hooks/persistence/useChatPersistence.js +35 -10
  11. package/lib/cjs/hooks/useAsaResults.js +54 -26
  12. package/lib/cjs/hooks/useAsaTracking.js +31 -16
  13. package/lib/cjs/index.js +3 -1
  14. package/lib/cjs/utils/chatThreads.js +15 -1
  15. package/lib/cjs/utils/localStoragePersistence.js +110 -19
  16. package/lib/cjs/utils/markdown.js +60 -0
  17. package/lib/cjs/version.js +1 -1
  18. package/lib/mjs/components/Button/Button.js +26 -3
  19. package/lib/mjs/components/Chat/AiMessage.css +52 -0
  20. package/lib/mjs/components/Chat/AiMessage.js +6 -2
  21. package/lib/mjs/components/Chat/Chat.js +18 -4
  22. package/lib/mjs/components/Chat/ChatInput.js +3 -3
  23. package/lib/mjs/hooks/asaStreamHandlers.js +1 -1
  24. package/lib/mjs/hooks/persistence/backgroundTurns.js +35 -1
  25. package/lib/mjs/hooks/persistence/useChatPersistence.js +35 -11
  26. package/lib/mjs/hooks/useAsaResults.js +53 -24
  27. package/lib/mjs/hooks/useAsaTracking.js +40 -16
  28. package/lib/mjs/index.js +1 -0
  29. package/lib/mjs/utils/chatThreads.js +12 -0
  30. package/lib/mjs/utils/localStoragePersistence.js +106 -14
  31. package/lib/mjs/utils/markdown.js +53 -0
  32. package/lib/mjs/version.js +1 -1
  33. package/lib/types/components/Button/Button.d.ts +3 -2
  34. package/lib/types/components/Chat/Chat.d.ts +13 -1
  35. package/lib/types/hooks/persistence/backgroundTurns.d.ts +6 -0
  36. package/lib/types/hooks/persistence/useChatPersistence.d.ts +4 -0
  37. package/lib/types/hooks/useAsaTracking.d.ts +4 -2
  38. package/lib/types/index.d.ts +3 -2
  39. package/lib/types/types.d.ts +57 -8
  40. package/lib/types/utils/chatThreads.d.ts +4 -0
  41. package/lib/types/utils/markdown.d.ts +8 -0
  42. package/lib/types/version.d.ts +1 -1
  43. package/package.json +6 -2
@@ -75,22 +75,33 @@ const emptyStored = () => ({
75
75
  threads: {},
76
76
  deleted: {},
77
77
  });
78
- function parseStored(raw) {
78
+ /** The record `raw` holds, or `null` when it is not one of this version. */
79
+ function readStored(raw) {
79
80
  var _a;
80
- if (!raw)
81
- return emptyStored();
82
81
  try {
83
82
  const parsed = JSON.parse(raw);
84
83
  if ((parsed === null || parsed === void 0 ? void 0 : parsed.version) !== chatThreads_1.PERSISTED_CHAT_VERSION || !parsed.threads)
85
- return emptyStored();
84
+ return null;
86
85
  const threads = Object.fromEntries(Object.entries(parsed.threads).filter(([, chat]) => (0, chatRecordValidation_1.default)(chat)));
87
86
  const deleted = Object.fromEntries(Object.entries((_a = parsed.deleted) !== null && _a !== void 0 ? _a : {}).filter(([, at]) => typeof at === 'number'));
88
87
  return { version: chatThreads_1.PERSISTED_CHAT_VERSION, threads, deleted };
89
88
  }
90
89
  catch (_b) {
91
- return emptyStored();
90
+ return null;
92
91
  }
93
92
  }
93
+ function parseStored(raw) {
94
+ return (raw && readStored(raw)) || emptyStored();
95
+ }
96
+ function dropExpired(data, cutoff) {
97
+ var _a;
98
+ return {
99
+ version: chatThreads_1.PERSISTED_CHAT_VERSION,
100
+ threads: Object.fromEntries(Object.entries(data.threads).filter(([, chat]) => chat.updatedAt >= cutoff)),
101
+ deleted: Object.fromEntries(Object.entries((_a = data.deleted) !== null && _a !== void 0 ? _a : {}).filter(([, at]) => at >= cutoff)),
102
+ };
103
+ }
104
+ const entryCount = (data) => { var _a; return Object.keys(data.threads).length + Object.keys((_a = data.deleted) !== null && _a !== void 0 ? _a : {}).length; };
94
105
  function readItem(storage, key) {
95
106
  try {
96
107
  return storage.getItem(key);
@@ -99,6 +110,44 @@ function readItem(storage, key) {
99
110
  return null;
100
111
  }
101
112
  }
113
+ /** How often a write is redone when another tab changed the key between its read and its write. */
114
+ const MAX_COMMIT_ATTEMPTS = 3;
115
+ /** Drops expired threads and tombstones under every namespace of `key`, other shoppers' included. */
116
+ function sweepExpired(storage, key, ttlMs) {
117
+ return tslib_1.__awaiter(this, void 0, void 0, function* () {
118
+ const prefix = storageKeyFor(key);
119
+ let keys;
120
+ try {
121
+ keys = Array.from({ length: storage.length }, (_, i) => storage.key(i)).filter((k) => k === prefix || Boolean(k === null || k === void 0 ? void 0 : k.startsWith(`${prefix}:`)));
122
+ }
123
+ catch (_a) {
124
+ return;
125
+ }
126
+ yield Promise.all(keys.map((k) => withStorageLock(k, () => {
127
+ for (let attempt = 0; attempt < MAX_COMMIT_ATTEMPTS; attempt += 1) {
128
+ const raw = readItem(storage, k);
129
+ const stored = raw === null ? null : readStored(raw);
130
+ if (!stored)
131
+ return;
132
+ const kept = dropExpired(stored, Date.now() - ttlMs);
133
+ if (entryCount(kept) === entryCount(stored))
134
+ return;
135
+ if (readItem(storage, k) === raw) {
136
+ try {
137
+ if (entryCount(kept) === 0)
138
+ storage.removeItem(k);
139
+ else
140
+ storage.setItem(k, JSON.stringify(kept));
141
+ }
142
+ catch (_a) {
143
+ /* storage unavailable */
144
+ }
145
+ return;
146
+ }
147
+ }
148
+ })));
149
+ });
150
+ }
102
151
  /**
103
152
  * Deletes every stored conversation of one shopper, or of the guest without `userId`; open tabs
104
153
  * follow. Resolves once the deletion is recorded.
@@ -148,6 +197,23 @@ function clearPersistedConversations(options) {
148
197
  });
149
198
  }
150
199
  exports.clearPersistedConversations = clearPersistedConversations;
200
+ /** The channel abort requests travel on between tabs, or `null` where none can be opened. */
201
+ function openAbortChannel(name, onRequest) {
202
+ if (typeof window === 'undefined' || typeof BroadcastChannel === 'undefined')
203
+ return null;
204
+ try {
205
+ const channel = new BroadcastChannel(name);
206
+ channel.onmessage = ({ data }) => {
207
+ if (typeof (data === null || data === void 0 ? void 0 : data.threadId) !== 'string' || typeof (data === null || data === void 0 ? void 0 : data.messageId) !== 'string')
208
+ return;
209
+ onRequest({ threadId: data.threadId, messageId: data.messageId });
210
+ };
211
+ return channel;
212
+ }
213
+ catch (_a) {
214
+ return null;
215
+ }
216
+ }
151
217
  /** Conversation store on top of `localStorage`; one key per namespace, merged across tabs. */
152
218
  function createLocalStoragePersistence(options = {}) {
153
219
  const { key = chatThreads_1.DEFAULT_PERSISTENCE_KEY, namespace, ttlMs = chatThreads_1.DEFAULT_PERSISTENCE_TTL_MS, maxTurns = Infinity, maxThreads = Infinity, storageArea = 'local', storage: storageOption, } = options;
@@ -156,20 +222,24 @@ function createLocalStoragePersistence(options = {}) {
156
222
  // One sync reads the same key several times; parse and validate the blob once per distinct value.
157
223
  let lastParsed = null;
158
224
  const parse = (raw) => {
159
- var _a;
160
225
  if (!raw)
161
226
  return emptyStored();
162
227
  if ((lastParsed === null || lastParsed === void 0 ? void 0 : lastParsed.raw) !== raw)
163
228
  lastParsed = { raw, data: parseStored(raw) };
164
- const cutoff = Date.now() - ttlMs;
165
- const { data } = lastParsed;
166
- return {
167
- version: chatThreads_1.PERSISTED_CHAT_VERSION,
168
- threads: Object.fromEntries(Object.entries(data.threads).filter(([, chat]) => chat.updatedAt >= cutoff)),
169
- deleted: Object.fromEntries(Object.entries((_a = data.deleted) !== null && _a !== void 0 ? _a : {}).filter(([, at]) => at >= cutoff)),
170
- };
229
+ return dropExpired(lastParsed.data, Date.now() - ttlMs);
230
+ };
231
+ // Other shoppers' records expire only here: their own store may never run again on this device.
232
+ let swept = false;
233
+ const sweepOnce = () => {
234
+ if (swept)
235
+ return;
236
+ swept = true;
237
+ const local = resolveStorage(storageOption, 'local');
238
+ if (local)
239
+ sweepExpired(local, key, ttlMs).catch(() => { });
171
240
  };
172
241
  const read = () => {
242
+ sweepOnce();
173
243
  const storage = resolveStorage(storageOption, storageArea);
174
244
  return parse(storage ? readRaw(storage) : null);
175
245
  };
@@ -191,7 +261,7 @@ function createLocalStoragePersistence(options = {}) {
191
261
  /* ignore */
192
262
  }
193
263
  };
194
- const isEmpty = (data) => { var _a; return Object.keys(data.threads).length === 0 && Object.keys((_a = data.deleted) !== null && _a !== void 0 ? _a : {}).length === 0; };
264
+ const isEmpty = (data) => entryCount(data) === 0;
195
265
  // Retries without tombstones, oldest first: the last thing to shed, see `write`.
196
266
  const tryWriteShedding = (storage, data) => {
197
267
  var _a, _b;
@@ -256,8 +326,8 @@ function createLocalStoragePersistence(options = {}) {
256
326
  tryWriteShedding(storage, others);
257
327
  };
258
328
  // Redo the merge if another tab wrote between our read and this write.
259
- const MAX_COMMIT_ATTEMPTS = 3;
260
329
  const transact = (mutate, priorityThreadId) => {
330
+ sweepOnce();
261
331
  const storage = resolveStorage(storageOption, storageArea);
262
332
  if (!storage)
263
333
  return;
@@ -305,12 +375,34 @@ function createLocalStoragePersistence(options = {}) {
305
375
  }
306
376
  return next;
307
377
  };
378
+ const abortListeners = new Set();
379
+ // Only `localStorage` is shared between tabs; a guest's `sessionStorage` thread streams in this tab alone.
380
+ const channel = storageArea === 'local'
381
+ ? openAbortChannel(`${storageKey}:abort`, (request) => abortListeners.forEach((listener) => listener(request)))
382
+ : null;
383
+ const abortMethods = channel
384
+ ? {
385
+ requestAbort(threadId, messageId) {
386
+ try {
387
+ channel.postMessage({ threadId, messageId });
388
+ }
389
+ catch (_a) {
390
+ /* channel closed */
391
+ }
392
+ },
393
+ subscribeAbort(listener) {
394
+ abortListeners.add(listener);
395
+ return () => {
396
+ abortListeners.delete(listener);
397
+ };
398
+ },
399
+ }
400
+ : {};
308
401
  const retire = (data, staleId) => {
309
402
  const _a = data.threads, _b = staleId, retired = _a[_b], threads = tslib_1.__rest(_a, [typeof _b === "symbol" ? _b : _b + ""]);
310
403
  return Object.assign(Object.assign({}, data), { threads, deleted: Object.assign(Object.assign({}, data.deleted), { [staleId]: Date.now() }) });
311
404
  };
312
- return {
313
- listThreads() {
405
+ return Object.assign({ listThreads() {
314
406
  return tslib_1.__awaiter(this, void 0, void 0, function* () {
315
407
  const now = Date.now();
316
408
  return sortedThreads(read()).map((chat) => ({
@@ -367,8 +459,7 @@ function createLocalStoragePersistence(options = {}) {
367
459
  };
368
460
  window.addEventListener('storage', onStorage);
369
461
  return () => window.removeEventListener('storage', onStorage);
370
- },
371
- };
462
+ } }, abortMethods);
372
463
  }
373
464
  exports.createLocalStoragePersistence = createLocalStoragePersistence;
374
465
  const sharedStores = new Map();
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.markdownToText = exports.renderMarkdown = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const dompurify_1 = tslib_1.__importDefault(require("dompurify"));
6
+ const marked_1 = require("marked");
7
+ // The text comes from an LLM a shopper can steer, so only the tags `marked` outputs are kept.
8
+ // Images, media and SVG would load a URL with no click and could leak data; form controls and
9
+ // `style` could fake a form.
10
+ const purifyConfig = {
11
+ ALLOWED_TAGS: 'p br strong em del a ul ol li blockquote hr code pre table thead tbody tr th td h1 h2 h3 h4 h5 h6'.split(' '),
12
+ ALLOWED_ATTR: ['href', 'tabindex', 'align', 'start'],
13
+ };
14
+ // Text the agent reads (catalog, knowledge base, reviews) could plant a phishing link, so only
15
+ // links to this site, its subdomains, email and phone stay clickable.
16
+ function isOnThisSite(href) {
17
+ try {
18
+ const url = new URL(href, window.location.href);
19
+ if (url.protocol === 'mailto:' || url.protocol === 'tel:')
20
+ return true;
21
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
22
+ return false;
23
+ const site = window.location.hostname.replace(/^www\./, '');
24
+ return url.hostname === site || url.hostname.endsWith(`.${site}`);
25
+ }
26
+ catch (_a) {
27
+ return false;
28
+ }
29
+ }
30
+ /**
31
+ * Turns the agent's markdown into sanitized HTML: paragraphs, bold, links, lists and tables.
32
+ * Links to other sites become plain text. Browser only: without a DOM to sanitize with (server
33
+ * rendering) it returns `''`, so render the plain text instead.
34
+ */
35
+ function renderMarkdown(markdown) {
36
+ if (!markdown || !dompurify_1.default.isSupported)
37
+ return '';
38
+ const html = marked_1.marked.parse(markdown, { async: false, breaks: true })
39
+ // A wide table scrolls (AiMessage.css), so keyboard users need to be able to focus it.
40
+ .replace(/<table>/g, '<table tabindex="0">');
41
+ const doc = new DOMParser().parseFromString(dompurify_1.default.sanitize(html, purifyConfig), 'text/html');
42
+ doc.querySelectorAll('a[href]').forEach((link) => {
43
+ if (!isOnThisSite(link.getAttribute('href')))
44
+ link.replaceWith(...Array.from(link.childNodes));
45
+ });
46
+ return doc.body.innerHTML;
47
+ }
48
+ exports.renderMarkdown = renderMarkdown;
49
+ /** The agent's markdown as plain text, one space between blocks and table cells, for screen readers. */
50
+ function markdownToText(markdown) {
51
+ var _a;
52
+ const html = renderMarkdown(markdown);
53
+ if (!html)
54
+ return markdown;
55
+ // An inert document: nothing in it loads or runs.
56
+ const { body } = new DOMParser().parseFromString(html, 'text/html');
57
+ body.querySelectorAll('p, li, th, td, br').forEach((node) => node.append(' '));
58
+ return ((_a = body.textContent) !== null && _a !== void 0 ? _a : '').replace(/\s+/g, ' ').trim();
59
+ }
60
+ exports.markdownToText = markdownToText;
@@ -1,3 +1,3 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.default = '1.8.0';
3
+ exports.default = '1.10.0';
@@ -1,8 +1,31 @@
1
- import React from 'react';
1
+ import React, { useCallback } from 'react';
2
2
  import { Button as CioButton } from '@constructor-io/constructorio-ui-components';
3
3
  import { ChatBubbleDarkIcon, ChatBubbleLightIcon } from '../icons';
4
- export default function Button({ theme = 'dark', size = 'sm', onClick, label = 'Shopping assistant', }) {
5
- return (React.createElement(CioButton, { className: `cio-asa-button cio-asa-button--${theme} cio-asa-button--${size}`, size: size === 'lg' ? 'default' : 'sm', onClick: onClick },
4
+ import { useCioAsaContext } from '../../hooks/useCioAsaContext';
5
+ import useAsaTracking from '../../hooks/useAsaTracking';
6
+ const AGENT_BUTTON_CLICK_MODE = 'chat';
7
+ export default function Button({ theme = 'dark', size = 'sm', onClick, label = 'Shopping assistant', positionOnPage, pageType, instanceId, }) {
8
+ const context = useCioAsaContext();
9
+ const agentDomain = context?.staticRequestConfigs?.domain;
10
+ const tracking = useAsaTracking({
11
+ tracker: context?.cioClient?.tracker ?? undefined,
12
+ section: context?.section,
13
+ });
14
+ const handleClick = useCallback(() => {
15
+ if (agentDomain) {
16
+ const payload = {
17
+ mode: AGENT_BUTTON_CLICK_MODE,
18
+ agentDomain,
19
+ ...(positionOnPage && { positionOnPage }),
20
+ ...(pageType && { pageType }),
21
+ ...(instanceId && { instanceId }),
22
+ };
23
+ tracking.trackAgentButtonClick(payload);
24
+ context?.callbacks?.onAgentButtonClick?.(payload);
25
+ }
26
+ onClick?.();
27
+ }, [agentDomain, positionOnPage, pageType, instanceId, tracking, context, onClick]);
28
+ return (React.createElement(CioButton, { className: `cio-asa-button cio-asa-button--${theme} cio-asa-button--${size}`, size: size === 'lg' ? 'default' : 'sm', onClick: handleClick },
6
29
  theme === 'dark' ? React.createElement(ChatBubbleDarkIcon, null) : React.createElement(ChatBubbleLightIcon, null),
7
30
  React.createElement("span", { className: 'cio-asa-button__label' }, label)));
8
31
  }
@@ -23,6 +23,9 @@
23
23
  }
24
24
 
25
25
  .cio-asa-ai-message__text {
26
+ width: 100%;
27
+ min-width: 0;
28
+ overflow-wrap: break-word;
26
29
  font-weight: 400;
27
30
  font-size: 14px;
28
31
  line-height: 20px;
@@ -33,3 +36,52 @@
33
36
  .cio-asa-ai-message__bubble--error .cio-asa-ai-message__text {
34
37
  color: #B42318;
35
38
  }
39
+
40
+ /* Agent markdown (see utils/markdown.ts) */
41
+ .cio-asa-ai-message__text > :first-child {
42
+ margin-top: 0;
43
+ }
44
+
45
+ .cio-asa-ai-message__text > :last-child {
46
+ margin-bottom: 0;
47
+ }
48
+
49
+ .cio-asa-ai-message__text p {
50
+ margin: 0 0 8px;
51
+ }
52
+
53
+ .cio-asa-ai-message__text ul,
54
+ .cio-asa-ai-message__text ol {
55
+ margin: 0 0 8px;
56
+ padding-left: 20px;
57
+ }
58
+
59
+ .cio-asa-ai-message__text a {
60
+ color: inherit;
61
+ text-decoration: underline;
62
+ }
63
+
64
+ /* Wide tables scroll inside the bubble instead of widening it. */
65
+ .cio-asa-ai-message__text table {
66
+ display: block;
67
+ max-width: 100%;
68
+ overflow-x: auto;
69
+ margin: 0 0 8px;
70
+ border-collapse: collapse;
71
+ font-size: 12px;
72
+ line-height: 16px;
73
+ }
74
+
75
+ .cio-asa-ai-message__text th,
76
+ .cio-asa-ai-message__text td {
77
+ padding: 6px 8px;
78
+ border: 1px solid #D5D9E2;
79
+ background: #FFFFFF;
80
+ text-align: left;
81
+ vertical-align: top;
82
+ }
83
+
84
+ .cio-asa-ai-message__text th {
85
+ background: #F6F7F9;
86
+ font-weight: 600;
87
+ }
@@ -1,6 +1,7 @@
1
1
  import React from 'react';
2
2
  import { RenderPropsWrapper } from '@constructor-io/constructorio-ui-components';
3
3
  import translate from '../../utils/translate';
4
+ import { renderMarkdown } from '../../utils/markdown';
4
5
  import TypingIndicator from './TypingIndicator';
5
6
  export default function AiMessage({ message, componentOverrides, translations }) {
6
7
  const isLoading = message.status === 'loading';
@@ -11,6 +12,7 @@ export default function AiMessage({ message, componentOverrides, translations })
11
12
  const errorKey = message.interrupted ? 'CioAsa.error.interrupted' : 'CioAsa.error.message';
12
13
  const text = isError ? message.text || translate(errorKey, translations) : message.text || '';
13
14
  const hasText = text.length > 0;
15
+ const html = renderMarkdown(text);
14
16
  const loaderRenderProps = { translations };
15
17
  const textRenderProps = { text };
16
18
  return (React.createElement("div", { className: 'cio-asa-ai-message' },
@@ -22,7 +24,9 @@ export default function AiMessage({ message, componentOverrides, translations })
22
24
  isError && 'cio-asa-ai-message__bubble--error',
23
25
  ]
24
26
  .filter(Boolean)
25
- .join(' '), role: isError ? 'alert' : undefined },
26
- React.createElement("div", { className: 'cio-asa-ai-message__text' }, text))))));
27
+ .join(' '), role: isError ? 'alert' : undefined }, html ? (React.createElement("div", { className: 'cio-asa-ai-message__text',
28
+ // Sanitized by renderMarkdown
29
+ // eslint-disable-next-line react/no-danger
30
+ dangerouslySetInnerHTML: { __html: html } })) : (React.createElement("div", { className: 'cio-asa-ai-message__text' }, text)))))));
27
31
  }
28
32
  AiMessage.displayName = 'AiMessage';
@@ -5,6 +5,7 @@ import useFocusTrap from '../../hooks/useFocusTrap';
5
5
  import useLatest from '../../hooks/useLatest';
6
6
  import { normalizeItemToProduct } from '../../utils/productNormalizer';
7
7
  import translate from '../../utils/translate';
8
+ import { markdownToText } from '../../utils/markdown';
8
9
  import ChatHeader from './ChatHeader';
9
10
  import WelcomeScreen from './WelcomeScreen';
10
11
  import ChatMessageList from './ChatMessageList';
@@ -21,11 +22,11 @@ function getAnnouncement(messages, translations) {
21
22
  return `${youSaid}: ${userText}. ${typing}`;
22
23
  }
23
24
  if (lastMessage.status === 'done' && lastMessage.text) {
24
- return `${translate('CioAsa.aiMessage.ariaLabel', translations)}: ${lastMessage.text}`;
25
+ return `${translate('CioAsa.aiMessage.ariaLabel', translations)}: ${markdownToText(lastMessage.text)}`;
25
26
  }
26
27
  return '';
27
28
  }
28
- const Chat = forwardRef(({ onClose, className, initialSuggestions, termsText, onProductClick, onViewMore, onAddToCart, aspectRatio, currency, normalizeItem, componentOverrides, translations, initialThreadId, onThreadsChange, showStopButton = false, }, ref) => {
29
+ const Chat = forwardRef(({ onClose, className, initialSuggestions, termsText, onProductClick, onViewMore, onAddToCart, aspectRatio, currency, normalizeItem, componentOverrides, translations, initialThreadId, initialPrompt, onThreadsChange, showStopButton = false, }, ref) => {
29
30
  const { messages, sendMessage, isStreaming, canAbort, abort, clearHistory, isHydrating, threads, activeThreadId, newThread, switchThread, } = useAsaResults({
30
31
  initialThreadId,
31
32
  normalizeItem: (item) => (normalizeItem ?? normalizeItemToProduct)(item, {
@@ -44,12 +45,25 @@ const Chat = forwardRef(({ onClose, className, initialSuggestions, termsText, on
44
45
  const showChatFrame = showChat || (isHydrating && lastViewRef.current === 'chat');
45
46
  const isModal = typeof onClose === 'function';
46
47
  const announcement = getAnnouncement(messages, translations);
47
- useImperativeHandle(ref, () => ({ abort, clearHistory, newThread, switchThread }), [
48
+ useImperativeHandle(ref, () => ({
48
49
  abort,
49
50
  clearHistory,
50
51
  newThread,
51
52
  switchThread,
52
- ]);
53
+ sendMessage: (text, options) => sendMessage(text, 'external', options),
54
+ }), [abort, clearHistory, newThread, switchThread, sendMessage]);
55
+ // Sent from a timer so StrictMode's mount, unmount, mount cannot cancel the stream it starts.
56
+ const initialPromptRef = useRef(initialPrompt);
57
+ useEffect(() => {
58
+ const prompt = initialPromptRef.current;
59
+ if (isHydrating || !prompt)
60
+ return undefined;
61
+ const timer = setTimeout(() => {
62
+ initialPromptRef.current = undefined;
63
+ sendMessage(prompt, 'external');
64
+ });
65
+ return () => clearTimeout(timer);
66
+ }, [isHydrating, sendMessage]);
53
67
  const hasPersistence = Boolean(useCioAsaContext()?.persistence);
54
68
  const onThreadsChangeRef = useLatest(onThreadsChange);
55
69
  useEffect(() => {
@@ -9,8 +9,7 @@ export default function ChatInput({ onSubmit, isDisabled = false, isStreaming =
9
9
  isDisabled,
10
10
  submitOnEnterOnly: true,
11
11
  });
12
- // The stop button only makes sense when there is something to stop and a way to do it.
13
- const canAbort = isStreaming && showStopButton && typeof onAbort === 'function';
12
+ const canAbort = isStreaming && typeof onAbort === 'function';
14
13
  const renderProps = {
15
14
  value,
16
15
  onChange: setValue,
@@ -18,13 +17,14 @@ export default function ChatInput({ onSubmit, isDisabled = false, isStreaming =
18
17
  placeholder: translate('CioAsa.input.placeholder', translations),
19
18
  isDisabled,
20
19
  isStreaming,
20
+ canAbort,
21
21
  onAbort: onAbort ?? (() => { }),
22
22
  };
23
23
  return (React.createElement(RenderPropsWrapper, { override: componentOverrides?.reactNode, props: renderProps },
24
24
  React.createElement("div", { className: 'cio-asa-chat-input-wrapper' },
25
25
  React.createElement("div", { className: 'cio-asa-chat-input' },
26
26
  React.createElement("input", { type: 'text', className: 'cio-asa-chat-input__field', value: value, onChange: (e) => setValue(e.target.value), onKeyDown: handleKeyDown, placeholder: renderProps.placeholder, disabled: isDisabled, "aria-label": translate('CioAsa.input.ariaLabel', translations) }),
27
- canAbort ? (React.createElement("button", { type: 'button', className: 'cio-asa-chat-input__send cio-asa-chat-input__stop', onClick: onAbort, "aria-label": translate('CioAsa.input.stopAriaLabel', translations) },
27
+ canAbort && showStopButton ? (React.createElement("button", { type: 'button', className: 'cio-asa-chat-input__send cio-asa-chat-input__stop', onClick: onAbort, "aria-label": translate('CioAsa.input.stopAriaLabel', translations) },
28
28
  React.createElement(StopIcon, null))) : (React.createElement("button", { type: 'button', className: 'cio-asa-chat-input__send', onClick: handleSubmit, disabled: isDisabled || !value.trim(), "aria-label": translate('CioAsa.input.sendAriaLabel', translations) },
29
29
  React.createElement(SendArrowIcon, null)))))));
30
30
  }
@@ -45,7 +45,7 @@ export function handleMessage(data, assistantId, setMessages) {
45
45
  updateMessageById(setMessages, assistantId, (msg) => ({
46
46
  ...msg,
47
47
  status: 'streaming',
48
- text: (msg.text || '') + (data?.text || ''),
48
+ text: [msg.text, data?.text].filter(Boolean).join('\n'),
49
49
  }));
50
50
  }
51
51
  export function handleFollowUpRefinement(data, assistantId, setMessages) {
@@ -1,5 +1,5 @@
1
1
  import { prepareSnapshot } from '../../utils/chatSession';
2
- import { normalizeHydratedMessages, saveThreadAndRetireStale } from '../../utils/chatThreads';
2
+ import { normalizeHydratedMessages, saveThreadAndRetireStale, settleCancelledReply, } from '../../utils/chatThreads';
3
3
  const turns = new Set();
4
4
  const listeners = new Set();
5
5
  const notify = () => listeners.forEach((listener) => listener());
@@ -17,6 +17,7 @@ export class BackgroundTurn {
17
17
  ids = new Set();
18
18
  /** Its writes, in order; a chat that takes the turn back writes after them. */
19
19
  writes;
20
+ stopListening;
20
21
  constructor(turn, params) {
21
22
  this.turn = turn;
22
23
  this.store = params.store;
@@ -35,6 +36,9 @@ export class BackgroundTurn {
35
36
  this.save().then(notify);
36
37
  },
37
38
  onDone: () => {
39
+ if (!this.streaming)
40
+ return;
41
+ this.release();
38
42
  this.streaming = false;
39
43
  this.messages = normalizeHydratedMessages(this.messages);
40
44
  this.save().then(() => {
@@ -65,6 +69,34 @@ export class BackgroundTurn {
65
69
  /** Later writes go into `store`. */
66
70
  moveTo(store) {
67
71
  this.store = store;
72
+ if (this.streaming)
73
+ this.listen();
74
+ }
75
+ /** Stops the answer when another tab asks to, through the store it is saved into. */
76
+ listen() {
77
+ this.release();
78
+ this.stopListening = this.store.subscribeAbort?.(({ messageId }) => {
79
+ if (messageId === this.turn.assistantId)
80
+ this.abort();
81
+ });
82
+ }
83
+ release() {
84
+ this.stopListening?.();
85
+ this.stopListening = undefined;
86
+ }
87
+ /** Stops the answer and stores it settled, as the stop button does on screen. */
88
+ abort() {
89
+ if (!this.streaming)
90
+ return;
91
+ this.streaming = false;
92
+ this.release();
93
+ this.turn.cancel();
94
+ this.messages = settleCancelledReply(this.messages, this.turn.assistantId);
95
+ this.save().then(() => {
96
+ if (this.saved)
97
+ turns.delete(this);
98
+ notify();
99
+ });
68
100
  }
69
101
  /** Stores the conversation settled, without awaiting: the page is going away. */
70
102
  flush() {
@@ -85,6 +117,7 @@ let listeningForPagehide = false;
85
117
  export function continueInBackground(turn, params) {
86
118
  const bg = new BackgroundTurn(turn, params);
87
119
  turns.add(bg);
120
+ bg.listen();
88
121
  if (!listeningForPagehide && typeof window !== 'undefined') {
89
122
  listeningForPagehide = true;
90
123
  window.addEventListener('pagehide', () => turns.forEach((t) => t.flush()));
@@ -100,6 +133,7 @@ export function resumeBackgroundTurn(store, threadId, createOwner) {
100
133
  if (!bg)
101
134
  return null;
102
135
  turns.delete(bg);
136
+ bg.release();
103
137
  if (bg.streaming)
104
138
  bg.turn.owner = createOwner(bg.turn);
105
139
  return bg;
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState, } from 'react';
2
- import { findRekeyedThread, getTabId, foreignStreamRemainingMs, isInFlight, mergeMessages, isLocalThreadId, moveThreads, normalizeHydratedMessages, saveThreadAndRetireStale, } from '../../utils/chatThreads';
2
+ import { FOREIGN_ABORT_TIMEOUT_MS, findRekeyedThread, getTabId, foreignStreamRemainingMs, isInFlight, mergeMessages, isLocalThreadId, moveThreads, normalizeHydratedMessages, saveThreadAndRetireStale, } from '../../utils/chatThreads';
3
3
  import { adoptSession, adoptStoredChat, forkSession, isOwnMessage, prepareSnapshot, resetConversation, } from '../../utils/chatSession';
4
4
  import useIsMounted from '../useIsMounted';
5
5
  import useLatest from '../useLatest';
@@ -7,10 +7,12 @@ import useWriteQueue from './useWriteQueue';
7
7
  import useThreadList from './useThreadList';
8
8
  import useForeignStream from './useForeignStream';
9
9
  import { continueInBackground, moveBackgroundTurns, resumeBackgroundTurn, subscribeBackgroundTurns, } from './backgroundTurns';
10
+ /** Whether `store` can both ask another tab to stop an answer and hear such a request. */
11
+ const canAbortAcrossTabs = (store) => Boolean(store?.requestAbort && store.subscribeAbort);
10
12
  const hasUnsavedWork = (session) => session.isStreaming || session.dirty || session.orphanIds.length > 0;
11
13
  /** Restores, saves and syncs the conversation with `store`; inert when `store` is undefined. */
12
14
  export default function useChatPersistence(params) {
13
- const { store, scope, index, session, initialThreadId, messages, setMessages, isStreaming, setIsStreaming, turnRef, cancelStream, } = params;
15
+ const { store, scope, index, session, initialThreadId, messages, setMessages, isStreaming, setIsStreaming, turnRef, cancelStream, abortTurn, } = params;
14
16
  if (store)
15
17
  getTabId();
16
18
  const [isHydrating, setIsHydrating] = useState(Boolean(store));
@@ -58,6 +60,15 @@ export default function useChatPersistence(params) {
58
60
  setIsHydrating(Boolean(store));
59
61
  }
60
62
  }
63
+ /** Settles the answer another tab left in flight, and writes it back. */
64
+ const settleForeign = useCallback((threadId) => {
65
+ if (!mountedRef.current || session.storageThreadId !== threadId)
66
+ return;
67
+ if (session.isStreaming)
68
+ return;
69
+ session.dirty = true;
70
+ setMessages((prev) => normalizeHydratedMessages(prev));
71
+ }, [session, setMessages, mountedRef]);
61
72
  /** Puts a stored conversation on screen and makes it the one this tab continues. */
62
73
  const showStoredChat = useCallback((chat) => {
63
74
  adoptStoredChat(session, chat);
@@ -72,15 +83,18 @@ export default function useChatPersistence(params) {
72
83
  return;
73
84
  }
74
85
  setMessages(chat.messages);
75
- watchForeign(remainingMs, () => {
76
- if (!mountedRef.current || session.storageThreadId !== chat.threadId)
77
- return;
78
- if (session.isStreaming)
79
- return;
80
- session.dirty = true;
81
- setMessages((prev) => normalizeHydratedMessages(prev));
82
- });
83
- }, [session, stopForeign, watchForeign, setMessages, mountedRef]);
86
+ watchForeign(remainingMs, () => settleForeign(chat.threadId));
87
+ }, [session, stopForeign, watchForeign, settleForeign, setMessages]);
88
+ /** Asks the tab streaming the answer on screen to stop it; settles it here if no tab does. */
89
+ const abortForeign = useCallback(() => {
90
+ const { current } = storeRef;
91
+ const threadId = session.storageThreadId;
92
+ const last = messagesRef.current[messagesRef.current.length - 1];
93
+ if (!canAbortAcrossTabs(current) || !session.foreignInFlight || !threadId || !last)
94
+ return;
95
+ current.requestAbort(threadId, last.id);
96
+ watchForeign(FOREIGN_ABORT_TIMEOUT_MS, () => settleForeign(threadId));
97
+ }, [session, storeRef, messagesRef, watchForeign, settleForeign]);
84
98
  /** Writes the conversation as it is now; `settle` finishes an in-flight answer, `sync` skips awaiting. */
85
99
  const persistNow = useCallback((mode) => {
86
100
  const { current } = storeRef;
@@ -320,6 +334,14 @@ export default function useChatPersistence(params) {
320
334
  settleAndPersist();
321
335
  }, [detachTurn, settleAndPersist]);
322
336
  useEffect(() => subscribeBackgroundTurns(refreshThreads), [refreshThreads]);
337
+ useEffect(() => {
338
+ if (!store?.subscribeAbort)
339
+ return undefined;
340
+ return store.subscribeAbort(({ messageId }) => {
341
+ if (turnRef.current?.assistantId === messageId)
342
+ abortTurn();
343
+ });
344
+ }, [store, turnRef, abortTurn]);
323
345
  // Follow changes another tab made to the list or to the thread on screen.
324
346
  const syncFromStore = useCallback(async () => {
325
347
  const { current } = storeRef;
@@ -465,6 +487,8 @@ export default function useChatPersistence(params) {
465
487
  threads,
466
488
  activeThreadId,
467
489
  foreignInFlight,
490
+ canAbortForeign: foreignInFlight && canAbortAcrossTabs(store),
491
+ abortForeign,
468
492
  beginTurn,
469
493
  screenOwner,
470
494
  clearHistory,