@constructor-io/constructorio-ui-asa 1.5.0 → 1.6.1

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.
@@ -26,8 +26,8 @@ function getAnnouncement(messages, translations) {
26
26
  }
27
27
  return '';
28
28
  }
29
- const Chat = (0, react_1.forwardRef)(({ onClose, className, initialSuggestions, termsText, onProductClick, onViewMore, onAddToCart, aspectRatio, currency, normalizeItem, componentOverrides, translations, initialThreadId, }, ref) => {
30
- const { messages, sendMessage, isStreaming, clearHistory } = (0, useAsaResults_1.default)({
29
+ const Chat = (0, react_1.forwardRef)(({ onClose, className, initialSuggestions, termsText, onProductClick, onViewMore, onAddToCart, aspectRatio, currency, normalizeItem, componentOverrides, translations, initialThreadId, showStopButton = false, }, ref) => {
30
+ const { messages, sendMessage, isStreaming, abort, clearHistory } = (0, useAsaResults_1.default)({
31
31
  initialThreadId,
32
32
  });
33
33
  const chatViewRef = (0, react_1.useRef)(null);
@@ -36,6 +36,7 @@ const Chat = (0, react_1.forwardRef)(({ onClose, className, initialSuggestions,
36
36
  const isModal = typeof onClose === 'function';
37
37
  const announcement = getAnnouncement(messages, translations);
38
38
  (0, react_1.useImperativeHandle)(ref, () => ({
39
+ abort,
39
40
  clearHistory,
40
41
  }));
41
42
  (0, react_1.useEffect)(() => {
@@ -59,7 +60,7 @@ const Chat = (0, react_1.forwardRef)(({ onClose, className, initialSuggestions,
59
60
  react_1.default.createElement(WelcomeScreen_1.default, { suggestions: initialSuggestions, onSend: sendMessage, onClose: onClose, termsText: termsText, translations: translations, componentOverrides: componentOverrides === null || componentOverrides === void 0 ? void 0 : componentOverrides.welcomeScreen }))) : (react_1.default.createElement("div", { className: 'cio-asa-chat-view cio-asa-chat-view--chat', ref: chatViewRef },
60
61
  react_1.default.createElement(ChatHeader_1.default, { onClose: onClose, translations: translations, componentOverrides: componentOverrides === null || componentOverrides === void 0 ? void 0 : componentOverrides.header }),
61
62
  react_1.default.createElement(ChatMessageList_1.default, { messages: messages, onProductClick: onProductClick, onViewMore: onViewMore, onAddToCart: onAddToCart, aspectRatio: aspectRatio, currency: currency, normalizeItem: normalizeItem, addToCartText: (0, translate_1.default)('CioAsa.results.addToCart', translations), viewMoreText: (0, translate_1.default)('CioAsa.results.viewMore', translations), aiMessageOverrides: componentOverrides === null || componentOverrides === void 0 ? void 0 : componentOverrides.aiMessage, userMessageOverrides: componentOverrides === null || componentOverrides === void 0 ? void 0 : componentOverrides.userMessage, resultsBlockOverrides: componentOverrides === null || componentOverrides === void 0 ? void 0 : componentOverrides.resultsBlock, translations: translations, onSend: sendMessage, isStreaming: isStreaming }),
62
- react_1.default.createElement(ChatInput_1.default, { onSubmit: sendMessage, isDisabled: isStreaming, translations: translations, componentOverrides: componentOverrides === null || componentOverrides === void 0 ? void 0 : componentOverrides.input }))))));
63
+ react_1.default.createElement(ChatInput_1.default, { onSubmit: sendMessage, isDisabled: isStreaming, isStreaming: isStreaming, onAbort: abort, showStopButton: showStopButton, translations: translations, componentOverrides: componentOverrides === null || componentOverrides === void 0 ? void 0 : componentOverrides.input }))))));
63
64
  });
64
65
  Chat.displayName = 'Chat';
65
66
  exports.default = Chat;
@@ -81,3 +81,14 @@
81
81
  cursor: default;
82
82
  opacity: 0.3;
83
83
  }
84
+
85
+ /* Replaces the send button while a reply streams; always actionable, so it never
86
+ inherits the disabled styling above. */
87
+ .cio-asa-chat-input__stop {
88
+ cursor: pointer;
89
+ opacity: 1;
90
+ }
91
+
92
+ .cio-asa-chat-input__stop:hover rect {
93
+ fill-opacity: 1;
94
+ }
@@ -6,25 +6,30 @@ const constructorio_ui_components_1 = require("@constructor-io/constructorio-ui-
6
6
  const icons_1 = require("../icons");
7
7
  const translate_1 = tslib_1.__importDefault(require("../../utils/translate"));
8
8
  const useMessageInput_1 = tslib_1.__importDefault(require("../../hooks/useMessageInput"));
9
- function ChatInput({ onSubmit, isDisabled = false, translations, componentOverrides, }) {
9
+ function ChatInput({ onSubmit, isDisabled = false, isStreaming = false, onAbort, showStopButton = false, translations, componentOverrides, }) {
10
10
  const { value, setValue, handleSubmit, handleKeyDown } = (0, useMessageInput_1.default)({
11
11
  onSend: onSubmit,
12
12
  isDisabled,
13
13
  submitOnEnterOnly: true,
14
14
  });
15
+ // The stop button only makes sense when there is something to stop and a way to do it.
16
+ const canAbort = isStreaming && showStopButton && typeof onAbort === 'function';
15
17
  const renderProps = {
16
18
  value,
17
19
  onChange: setValue,
18
20
  onSubmit: handleSubmit,
19
21
  placeholder: (0, translate_1.default)('CioAsa.input.placeholder', translations),
20
22
  isDisabled,
23
+ isStreaming,
24
+ onAbort: onAbort !== null && onAbort !== void 0 ? onAbort : (() => { }),
21
25
  };
22
26
  return (react_1.default.createElement(constructorio_ui_components_1.RenderPropsWrapper, { override: componentOverrides === null || componentOverrides === void 0 ? void 0 : componentOverrides.reactNode, props: renderProps },
23
27
  react_1.default.createElement("div", { className: 'cio-asa-chat-input-wrapper' },
24
28
  react_1.default.createElement("div", { className: 'cio-asa-chat-input' },
25
29
  react_1.default.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": (0, translate_1.default)('CioAsa.input.ariaLabel', translations) }),
26
- react_1.default.createElement("button", { type: 'button', className: 'cio-asa-chat-input__send', onClick: handleSubmit, disabled: isDisabled || !value.trim(), "aria-label": (0, translate_1.default)('CioAsa.input.sendAriaLabel', translations) },
27
- react_1.default.createElement(icons_1.SendArrowIcon, null))))));
30
+ canAbort ? (react_1.default.createElement("button", { type: 'button', className: 'cio-asa-chat-input__send cio-asa-chat-input__stop', onClick: onAbort, "aria-label": (0, translate_1.default)('CioAsa.input.stopAriaLabel', translations) },
31
+ react_1.default.createElement(icons_1.StopIcon, null))) : (react_1.default.createElement("button", { type: 'button', className: 'cio-asa-chat-input__send', onClick: handleSubmit, disabled: isDisabled || !value.trim(), "aria-label": (0, translate_1.default)('CioAsa.input.sendAriaLabel', translations) },
32
+ react_1.default.createElement(icons_1.SendArrowIcon, null)))))));
28
33
  }
29
34
  exports.default = ChatInput;
30
35
  ChatInput.displayName = 'ChatInput';
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const tslib_1 = require("tslib");
4
+ const react_1 = tslib_1.__importDefault(require("react"));
5
+ const CioAsaProvider_1 = tslib_1.__importDefault(require("../CioAsaProvider/CioAsaProvider"));
6
+ const Chat_1 = tslib_1.__importDefault(require("../Chat/Chat"));
7
+ /**
8
+ * The whole agent in one component: the provider and the chat UI composed together.
9
+ *
10
+ * Consumers that need to place `Chat` inside their own tree, or render several
11
+ * components against one client, should compose `CioAsaProvider` and `Chat`
12
+ * directly. This is the single-entry-point convenience wrapper, and it is what the
13
+ * standalone browser bundle (`src/bundled.jsx`) mounts.
14
+ */
15
+ const CioAsa = react_1.default.forwardRef((props, ref) => {
16
+ const {
17
+ // Provider configuration — everything else belongs to Chat.
18
+ apiKey, cioClient, staticRequestConfigs, formatters, urlHelpers, callbacks, section } = props, chatProps = tslib_1.__rest(props, ["apiKey", "cioClient", "staticRequestConfigs", "formatters", "urlHelpers", "callbacks", "section"]);
19
+ return (react_1.default.createElement(CioAsaProvider_1.default, { apiKey: apiKey, cioClient: cioClient, staticRequestConfigs: staticRequestConfigs, formatters: formatters, urlHelpers: urlHelpers, callbacks: callbacks, section: section },
20
+ react_1.default.createElement(Chat_1.default, Object.assign({}, chatProps, { ref: ref }))));
21
+ });
22
+ CioAsa.displayName = 'CioAsa';
23
+ exports.default = CioAsa;
@@ -0,0 +1,9 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const tslib_1 = require("tslib");
4
+ const react_1 = tslib_1.__importDefault(require("react"));
5
+ function StopIcon() {
6
+ return (react_1.default.createElement("svg", { width: '10', height: '10', viewBox: '0 0 10 10', fill: 'none', xmlns: 'http://www.w3.org/2000/svg', "aria-hidden": 'true', focusable: 'false' },
7
+ react_1.default.createElement("rect", { x: '0.5', y: '0.5', width: '9', height: '9', rx: '1.5', fill: '#0F1324', fillOpacity: '0.7' })));
8
+ }
9
+ exports.default = StopIcon;
@@ -3,13 +3,15 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.ArrowRightIcon = exports.SendPlaneIcon = exports.SendArrowIcon = exports.ChatBubbleLightIcon = exports.ChatBubbleDarkIcon = void 0;
6
+ exports.ArrowRightIcon = exports.SendPlaneIcon = exports.StopIcon = exports.SendArrowIcon = exports.ChatBubbleLightIcon = exports.ChatBubbleDarkIcon = void 0;
7
7
  var ChatBubbleDarkIcon_1 = require("./ChatBubbleDarkIcon");
8
8
  Object.defineProperty(exports, "ChatBubbleDarkIcon", { enumerable: true, get: function () { return __importDefault(ChatBubbleDarkIcon_1).default; } });
9
9
  var ChatBubbleLightIcon_1 = require("./ChatBubbleLightIcon");
10
10
  Object.defineProperty(exports, "ChatBubbleLightIcon", { enumerable: true, get: function () { return __importDefault(ChatBubbleLightIcon_1).default; } });
11
11
  var SendArrowIcon_1 = require("./SendArrowIcon");
12
12
  Object.defineProperty(exports, "SendArrowIcon", { enumerable: true, get: function () { return __importDefault(SendArrowIcon_1).default; } });
13
+ var StopIcon_1 = require("./StopIcon");
14
+ Object.defineProperty(exports, "StopIcon", { enumerable: true, get: function () { return __importDefault(StopIcon_1).default; } });
13
15
  var SendPlaneIcon_1 = require("./SendPlaneIcon");
14
16
  Object.defineProperty(exports, "SendPlaneIcon", { enumerable: true, get: function () { return __importDefault(SendPlaneIcon_1).default; } });
15
17
  var ArrowRightIcon_1 = require("./ArrowRightIcon");
@@ -14,6 +14,9 @@ function useAsaResults(options) {
14
14
  const readerRef = (0, react_1.useRef)(null);
15
15
  const threadIdRef = (0, react_1.useRef)((_a = options === null || options === void 0 ? void 0 : options.initialThreadId) !== null && _a !== void 0 ? _a : null);
16
16
  const idCounterRef = (0, react_1.useRef)(0);
17
+ // The assistant message the in-flight stream is writing into, so `abort` can
18
+ // settle it without having to guess which message that is.
19
+ const activeAssistantIdRef = (0, react_1.useRef)(null);
17
20
  const contextValue = (0, useCioAsaContext_1.useCioAsaContext)();
18
21
  if (!contextValue) {
19
22
  throw new Error('useAsaResults must be used within a CioAsaProvider.');
@@ -34,6 +37,21 @@ function useAsaResults(options) {
34
37
  callbacksRef.current = callbacks;
35
38
  const staticRequestConfigsRef = (0, react_1.useRef)(staticRequestConfigs);
36
39
  staticRequestConfigsRef.current = staticRequestConfigs;
40
+ /**
41
+ * Stops the in-flight stream. The kill switch is raised *before* cancelling because
42
+ * cancelling makes the pending `reader.read()` resolve `{ done: true }` — identical
43
+ * to a stream that ended on its own. Without the flag already set, the read loop
44
+ * would take the end-of-stream branch and report a load that never finished.
45
+ *
46
+ * Deliberately touches no state, so the unmount cleanup can share it.
47
+ */
48
+ const teardownStream = (0, react_1.useCallback)(() => {
49
+ killSwitchRef.current = true;
50
+ if (readerRef.current) {
51
+ readerRef.current.cancel();
52
+ readerRef.current = null;
53
+ }
54
+ }, []);
37
55
  const nextMessageId = (0, react_1.useCallback)(() => {
38
56
  idCounterRef.current += 1;
39
57
  return `msg-${idCounterRef.current}-${Date.now()}`;
@@ -59,6 +77,7 @@ function useAsaResults(options) {
59
77
  status: 'loading',
60
78
  intent,
61
79
  };
80
+ activeAssistantIdRef.current = assistantMessage.id;
62
81
  setMessages((prev) => [...prev, userMessage, assistantMessage]);
63
82
  setIsStreaming(true);
64
83
  isStreamingRef.current = true;
@@ -159,30 +178,56 @@ function useAsaResults(options) {
159
178
  if (readerRef.current === reader) {
160
179
  reader.cancel();
161
180
  readerRef.current = null;
181
+ activeAssistantIdRef.current = null;
162
182
  setIsStreaming(false);
163
183
  isStreamingRef.current = false;
164
184
  }
165
185
  }
166
186
  }))();
167
187
  }, [cioClient, domain, nextMessageId]);
168
- (0, react_1.useEffect)(() => () => {
169
- killSwitchRef.current = true;
170
- if (readerRef.current) {
171
- readerRef.current.cancel();
172
- readerRef.current = null;
188
+ (0, react_1.useEffect)(() => () => teardownStream(), [teardownStream]);
189
+ /**
190
+ * Cancels the in-flight request while keeping the conversation: the partial reply is
191
+ * settled as `done` (so the typing indicator stops and streamed text survives) and the
192
+ * thread id is kept, so the next message continues the same conversation rather than
193
+ * starting a new one. Use `clearHistory` to reset instead.
194
+ *
195
+ * A reply that had streamed nothing yet is dropped rather than settled — it would
196
+ * otherwise render as a blank bubble, and a cancelled turn would read as a glitch.
197
+ * "Nothing" means no text, no product groups and no follow-up refinement; any one of
198
+ * them is content worth keeping. The user's own message always stays.
199
+ *
200
+ * No beacon is sent: there is no "aborted" ASA event, and reporting the load as
201
+ * finished would be false. An aborted turn therefore leaves an
202
+ * `assistant_result_load_started` with no matching finished event.
203
+ */
204
+ const abort = (0, react_1.useCallback)(() => {
205
+ if (!isStreamingRef.current)
206
+ return;
207
+ teardownStream();
208
+ setIsStreaming(false);
209
+ isStreamingRef.current = false;
210
+ const abortedId = activeAssistantIdRef.current;
211
+ activeAssistantIdRef.current = null;
212
+ if (abortedId) {
213
+ setMessages((prev) => prev.flatMap((msg) => {
214
+ var _a;
215
+ if (msg.id !== abortedId)
216
+ return [msg];
217
+ if (!msg.text && !((_a = msg.groups) === null || _a === void 0 ? void 0 : _a.length) && !msg.refinement)
218
+ return [];
219
+ return [Object.assign(Object.assign({}, msg), { status: 'done' })];
220
+ }));
173
221
  }
174
- }, []);
222
+ }, [teardownStream]);
175
223
  const clearHistory = (0, react_1.useCallback)(() => {
176
- killSwitchRef.current = true;
177
- if (readerRef.current) {
178
- readerRef.current.cancel();
179
- readerRef.current = null;
180
- }
224
+ teardownStream();
181
225
  threadIdRef.current = null;
226
+ activeAssistantIdRef.current = null;
182
227
  setMessages([]);
183
228
  setIsStreaming(false);
184
229
  isStreamingRef.current = false;
185
- }, []);
186
- return { messages, sendMessage, isStreaming, clearHistory };
230
+ }, [teardownStream]);
231
+ return { messages, sendMessage, isStreaming, abort, clearHistory };
187
232
  }
188
233
  exports.default = useAsaResults;
package/lib/cjs/index.js CHANGED
@@ -3,8 +3,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.ConstructorIOClient = exports.normalizeItemToProduct = exports.useCioAsaContext = exports.useAsaTracking = exports.useAsaResults = exports.Button = exports.ResultsBlock = exports.Chat = exports.CioAsaProvider = void 0;
6
+ exports.ConstructorIOClient = exports.normalizeItemToProduct = exports.useCioAsaContext = exports.useAsaTracking = exports.useAsaResults = exports.Button = exports.ResultsBlock = exports.Chat = exports.CioAsaProvider = exports.CioAsa = void 0;
7
7
  // Components
8
+ var CioAsa_1 = require("./components/CioAsa/CioAsa");
9
+ Object.defineProperty(exports, "CioAsa", { enumerable: true, get: function () { return __importDefault(CioAsa_1).default; } });
8
10
  var CioAsaProvider_1 = require("./components/CioAsaProvider/CioAsaProvider");
9
11
  Object.defineProperty(exports, "CioAsaProvider", { enumerable: true, get: function () { return __importDefault(CioAsaProvider_1).default; } });
10
12
  var Chat_1 = require("./components/Chat/Chat");
@@ -6,6 +6,7 @@ const defaultTranslations = {
6
6
  'CioAsa.input.placeholder': 'Ask a question about this product',
7
7
  'CioAsa.input.ariaLabel': 'Type your message',
8
8
  'CioAsa.input.sendAriaLabel': 'Send message',
9
+ 'CioAsa.input.stopAriaLabel': 'Stop response',
9
10
  'CioAsa.welcome.title': 'Shopping Assistant',
10
11
  'CioAsa.welcome.placeholder': 'Ask anything',
11
12
  'CioAsa.welcome.sendButton': 'Chat',
@@ -1,3 +1,3 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.default = '1.5.0';
3
+ exports.default = '1.6.1';
@@ -22,8 +22,8 @@ function getAnnouncement(messages, translations) {
22
22
  }
23
23
  return '';
24
24
  }
25
- const Chat = forwardRef(({ onClose, className, initialSuggestions, termsText, onProductClick, onViewMore, onAddToCart, aspectRatio, currency, normalizeItem, componentOverrides, translations, initialThreadId, }, ref) => {
26
- const { messages, sendMessage, isStreaming, clearHistory } = useAsaResults({
25
+ const Chat = forwardRef(({ onClose, className, initialSuggestions, termsText, onProductClick, onViewMore, onAddToCart, aspectRatio, currency, normalizeItem, componentOverrides, translations, initialThreadId, showStopButton = false, }, ref) => {
26
+ const { messages, sendMessage, isStreaming, abort, clearHistory } = useAsaResults({
27
27
  initialThreadId,
28
28
  });
29
29
  const chatViewRef = useRef(null);
@@ -32,6 +32,7 @@ const Chat = forwardRef(({ onClose, className, initialSuggestions, termsText, on
32
32
  const isModal = typeof onClose === 'function';
33
33
  const announcement = getAnnouncement(messages, translations);
34
34
  useImperativeHandle(ref, () => ({
35
+ abort,
35
36
  clearHistory,
36
37
  }));
37
38
  useEffect(() => {
@@ -55,7 +56,7 @@ const Chat = forwardRef(({ onClose, className, initialSuggestions, termsText, on
55
56
  React.createElement(WelcomeScreen, { suggestions: initialSuggestions, onSend: sendMessage, onClose: onClose, termsText: termsText, translations: translations, componentOverrides: componentOverrides?.welcomeScreen }))) : (React.createElement("div", { className: 'cio-asa-chat-view cio-asa-chat-view--chat', ref: chatViewRef },
56
57
  React.createElement(ChatHeader, { onClose: onClose, translations: translations, componentOverrides: componentOverrides?.header }),
57
58
  React.createElement(ChatMessageList, { messages: messages, onProductClick: onProductClick, onViewMore: onViewMore, onAddToCart: onAddToCart, aspectRatio: aspectRatio, currency: currency, normalizeItem: normalizeItem, addToCartText: translate('CioAsa.results.addToCart', translations), viewMoreText: translate('CioAsa.results.viewMore', translations), aiMessageOverrides: componentOverrides?.aiMessage, userMessageOverrides: componentOverrides?.userMessage, resultsBlockOverrides: componentOverrides?.resultsBlock, translations: translations, onSend: sendMessage, isStreaming: isStreaming }),
58
- React.createElement(ChatInput, { onSubmit: sendMessage, isDisabled: isStreaming, translations: translations, componentOverrides: componentOverrides?.input }))))));
59
+ React.createElement(ChatInput, { onSubmit: sendMessage, isDisabled: isStreaming, isStreaming: isStreaming, onAbort: abort, showStopButton: showStopButton, translations: translations, componentOverrides: componentOverrides?.input }))))));
59
60
  });
60
61
  Chat.displayName = 'Chat';
61
62
  export default Chat;
@@ -81,3 +81,14 @@
81
81
  cursor: default;
82
82
  opacity: 0.3;
83
83
  }
84
+
85
+ /* Replaces the send button while a reply streams; always actionable, so it never
86
+ inherits the disabled styling above. */
87
+ .cio-asa-chat-input__stop {
88
+ cursor: pointer;
89
+ opacity: 1;
90
+ }
91
+
92
+ .cio-asa-chat-input__stop:hover rect {
93
+ fill-opacity: 1;
94
+ }
@@ -1,26 +1,31 @@
1
1
  import React from 'react';
2
2
  import { RenderPropsWrapper } from '@constructor-io/constructorio-ui-components';
3
- import { SendArrowIcon } from '../icons';
3
+ import { SendArrowIcon, StopIcon } from '../icons';
4
4
  import translate from '../../utils/translate';
5
5
  import useMessageInput from '../../hooks/useMessageInput';
6
- export default function ChatInput({ onSubmit, isDisabled = false, translations, componentOverrides, }) {
6
+ export default function ChatInput({ onSubmit, isDisabled = false, isStreaming = false, onAbort, showStopButton = false, translations, componentOverrides, }) {
7
7
  const { value, setValue, handleSubmit, handleKeyDown } = useMessageInput({
8
8
  onSend: onSubmit,
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
14
  const renderProps = {
13
15
  value,
14
16
  onChange: setValue,
15
17
  onSubmit: handleSubmit,
16
18
  placeholder: translate('CioAsa.input.placeholder', translations),
17
19
  isDisabled,
20
+ isStreaming,
21
+ onAbort: onAbort ?? (() => { }),
18
22
  };
19
23
  return (React.createElement(RenderPropsWrapper, { override: componentOverrides?.reactNode, props: renderProps },
20
24
  React.createElement("div", { className: 'cio-asa-chat-input-wrapper' },
21
25
  React.createElement("div", { className: 'cio-asa-chat-input' },
22
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) }),
23
- React.createElement("button", { type: 'button', className: 'cio-asa-chat-input__send', onClick: handleSubmit, disabled: isDisabled || !value.trim(), "aria-label": translate('CioAsa.input.sendAriaLabel', translations) },
24
- React.createElement(SendArrowIcon, null))))));
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) },
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
+ React.createElement(SendArrowIcon, null)))))));
25
30
  }
26
31
  ChatInput.displayName = 'ChatInput';
@@ -0,0 +1,20 @@
1
+ import React from 'react';
2
+ import CioAsaProvider from '../CioAsaProvider/CioAsaProvider';
3
+ import Chat from '../Chat/Chat';
4
+ /**
5
+ * The whole agent in one component: the provider and the chat UI composed together.
6
+ *
7
+ * Consumers that need to place `Chat` inside their own tree, or render several
8
+ * components against one client, should compose `CioAsaProvider` and `Chat`
9
+ * directly. This is the single-entry-point convenience wrapper, and it is what the
10
+ * standalone browser bundle (`src/bundled.jsx`) mounts.
11
+ */
12
+ const CioAsa = React.forwardRef((props, ref) => {
13
+ const {
14
+ // Provider configuration — everything else belongs to Chat.
15
+ apiKey, cioClient, staticRequestConfigs, formatters, urlHelpers, callbacks, section, ...chatProps } = props;
16
+ return (React.createElement(CioAsaProvider, { apiKey: apiKey, cioClient: cioClient, staticRequestConfigs: staticRequestConfigs, formatters: formatters, urlHelpers: urlHelpers, callbacks: callbacks, section: section },
17
+ React.createElement(Chat, { ...chatProps, ref: ref })));
18
+ });
19
+ CioAsa.displayName = 'CioAsa';
20
+ export default CioAsa;
@@ -0,0 +1,5 @@
1
+ import React from 'react';
2
+ export default function StopIcon() {
3
+ return (React.createElement("svg", { width: '10', height: '10', viewBox: '0 0 10 10', fill: 'none', xmlns: 'http://www.w3.org/2000/svg', "aria-hidden": 'true', focusable: 'false' },
4
+ React.createElement("rect", { x: '0.5', y: '0.5', width: '9', height: '9', rx: '1.5', fill: '#0F1324', fillOpacity: '0.7' })));
5
+ }
@@ -1,5 +1,6 @@
1
1
  export { default as ChatBubbleDarkIcon } from './ChatBubbleDarkIcon';
2
2
  export { default as ChatBubbleLightIcon } from './ChatBubbleLightIcon';
3
3
  export { default as SendArrowIcon } from './SendArrowIcon';
4
+ export { default as StopIcon } from './StopIcon';
4
5
  export { default as SendPlaneIcon } from './SendPlaneIcon';
5
6
  export { default as ArrowRightIcon } from './ArrowRightIcon';
@@ -10,6 +10,9 @@ export default function useAsaResults(options) {
10
10
  const readerRef = useRef(null);
11
11
  const threadIdRef = useRef(options?.initialThreadId ?? null);
12
12
  const idCounterRef = useRef(0);
13
+ // The assistant message the in-flight stream is writing into, so `abort` can
14
+ // settle it without having to guess which message that is.
15
+ const activeAssistantIdRef = useRef(null);
13
16
  const contextValue = useCioAsaContext();
14
17
  if (!contextValue) {
15
18
  throw new Error('useAsaResults must be used within a CioAsaProvider.');
@@ -30,6 +33,21 @@ export default function useAsaResults(options) {
30
33
  callbacksRef.current = callbacks;
31
34
  const staticRequestConfigsRef = useRef(staticRequestConfigs);
32
35
  staticRequestConfigsRef.current = staticRequestConfigs;
36
+ /**
37
+ * Stops the in-flight stream. The kill switch is raised *before* cancelling because
38
+ * cancelling makes the pending `reader.read()` resolve `{ done: true }` — identical
39
+ * to a stream that ended on its own. Without the flag already set, the read loop
40
+ * would take the end-of-stream branch and report a load that never finished.
41
+ *
42
+ * Deliberately touches no state, so the unmount cleanup can share it.
43
+ */
44
+ const teardownStream = useCallback(() => {
45
+ killSwitchRef.current = true;
46
+ if (readerRef.current) {
47
+ readerRef.current.cancel();
48
+ readerRef.current = null;
49
+ }
50
+ }, []);
33
51
  const nextMessageId = useCallback(() => {
34
52
  idCounterRef.current += 1;
35
53
  return `msg-${idCounterRef.current}-${Date.now()}`;
@@ -54,6 +72,7 @@ export default function useAsaResults(options) {
54
72
  status: 'loading',
55
73
  intent,
56
74
  };
75
+ activeAssistantIdRef.current = assistantMessage.id;
57
76
  setMessages((prev) => [...prev, userMessage, assistantMessage]);
58
77
  setIsStreaming(true);
59
78
  isStreamingRef.current = true;
@@ -156,29 +175,54 @@ export default function useAsaResults(options) {
156
175
  if (readerRef.current === reader) {
157
176
  reader.cancel();
158
177
  readerRef.current = null;
178
+ activeAssistantIdRef.current = null;
159
179
  setIsStreaming(false);
160
180
  isStreamingRef.current = false;
161
181
  }
162
182
  }
163
183
  })();
164
184
  }, [cioClient, domain, nextMessageId]);
165
- useEffect(() => () => {
166
- killSwitchRef.current = true;
167
- if (readerRef.current) {
168
- readerRef.current.cancel();
169
- readerRef.current = null;
185
+ useEffect(() => () => teardownStream(), [teardownStream]);
186
+ /**
187
+ * Cancels the in-flight request while keeping the conversation: the partial reply is
188
+ * settled as `done` (so the typing indicator stops and streamed text survives) and the
189
+ * thread id is kept, so the next message continues the same conversation rather than
190
+ * starting a new one. Use `clearHistory` to reset instead.
191
+ *
192
+ * A reply that had streamed nothing yet is dropped rather than settled — it would
193
+ * otherwise render as a blank bubble, and a cancelled turn would read as a glitch.
194
+ * "Nothing" means no text, no product groups and no follow-up refinement; any one of
195
+ * them is content worth keeping. The user's own message always stays.
196
+ *
197
+ * No beacon is sent: there is no "aborted" ASA event, and reporting the load as
198
+ * finished would be false. An aborted turn therefore leaves an
199
+ * `assistant_result_load_started` with no matching finished event.
200
+ */
201
+ const abort = useCallback(() => {
202
+ if (!isStreamingRef.current)
203
+ return;
204
+ teardownStream();
205
+ setIsStreaming(false);
206
+ isStreamingRef.current = false;
207
+ const abortedId = activeAssistantIdRef.current;
208
+ activeAssistantIdRef.current = null;
209
+ if (abortedId) {
210
+ setMessages((prev) => prev.flatMap((msg) => {
211
+ if (msg.id !== abortedId)
212
+ return [msg];
213
+ if (!msg.text && !msg.groups?.length && !msg.refinement)
214
+ return [];
215
+ return [{ ...msg, status: 'done' }];
216
+ }));
170
217
  }
171
- }, []);
218
+ }, [teardownStream]);
172
219
  const clearHistory = useCallback(() => {
173
- killSwitchRef.current = true;
174
- if (readerRef.current) {
175
- readerRef.current.cancel();
176
- readerRef.current = null;
177
- }
220
+ teardownStream();
178
221
  threadIdRef.current = null;
222
+ activeAssistantIdRef.current = null;
179
223
  setMessages([]);
180
224
  setIsStreaming(false);
181
225
  isStreamingRef.current = false;
182
- }, []);
183
- return { messages, sendMessage, isStreaming, clearHistory };
226
+ }, [teardownStream]);
227
+ return { messages, sendMessage, isStreaming, abort, clearHistory };
184
228
  }
package/lib/mjs/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // Components
2
+ export { default as CioAsa } from './components/CioAsa/CioAsa';
2
3
  export { default as CioAsaProvider } from './components/CioAsaProvider/CioAsaProvider';
3
4
  export { default as Chat } from './components/Chat/Chat';
4
5
  export { default as ResultsBlock } from './components/ResultsBlock/ResultsBlock';
@@ -4,6 +4,7 @@ const defaultTranslations = {
4
4
  'CioAsa.input.placeholder': 'Ask a question about this product',
5
5
  'CioAsa.input.ariaLabel': 'Type your message',
6
6
  'CioAsa.input.sendAriaLabel': 'Send message',
7
+ 'CioAsa.input.stopAriaLabel': 'Stop response',
7
8
  'CioAsa.welcome.title': 'Shopping Assistant',
8
9
  'CioAsa.welcome.placeholder': 'Ask anything',
9
10
  'CioAsa.welcome.sendButton': 'Chat',
@@ -1 +1 @@
1
- export default '1.5.0';
1
+ export default '1.6.1';
@@ -3,9 +3,16 @@ import { ChatComponentOverrides, ResultGroupMeta, Translations } from '../../typ
3
3
  import { Product, NormalizeOptions } from '../../utils/productNormalizer';
4
4
  import { AspectRatio } from '../ResultsBlock/ResultsBlock';
5
5
  export interface ChatHandle {
6
+ /**
7
+ * Cancel the in-flight response, keeping the conversation. The partial reply is kept
8
+ * and the thread is preserved, so the next message continues where it left off.
9
+ * No-op when nothing is streaming.
10
+ */
11
+ abort: () => void;
12
+ /** Cancel any active response, then reset the conversation and the thread. */
6
13
  clearHistory: () => void;
7
14
  }
8
- interface ChatProps {
15
+ export interface ChatProps {
9
16
  /** Called when the close button (✕) is clicked. The consumer controls visibility. */
10
17
  onClose?: () => void;
11
18
  /** Additional CSS class name for the root container */
@@ -35,6 +42,12 @@ interface ChatProps {
35
42
  translations?: Translations;
36
43
  /** Seed the thread id (e.g. loaded from browser storage) to resume a prior conversation. Read once on mount. */
37
44
  initialThreadId?: string;
45
+ /**
46
+ * Whether the input's send button becomes a stop button while a reply streams.
47
+ * Defaults to `false`, so the packaged UI is unchanged unless you opt in. While it is
48
+ * off, cancelling is only reachable via `abort()` on the ref or your own input override.
49
+ */
50
+ showStopButton?: boolean;
38
51
  }
39
52
  declare const Chat: React.ForwardRefExoticComponent<ChatProps & React.RefAttributes<ChatHandle>>;
40
53
  export default Chat;
@@ -3,10 +3,20 @@ import { ChatInputRenderProps, ComponentOverrideProps, Translations } from '../.
3
3
  interface ChatInputProps {
4
4
  onSubmit: (text: string) => void;
5
5
  isDisabled?: boolean;
6
+ /** A reply is streaming: the send button becomes a stop button. */
7
+ isStreaming?: boolean;
8
+ /** Cancel the in-flight reply. */
9
+ onAbort?: () => void;
10
+ /**
11
+ * Whether the built-in stop button replaces send while streaming. Defaults to `false`:
12
+ * the send button stays (disabled). `onAbort` reaches the render props either way, so a
13
+ * consumer can render their own control without enabling this one.
14
+ */
15
+ showStopButton?: boolean;
6
16
  translations?: Translations;
7
17
  componentOverrides?: ComponentOverrideProps<ChatInputRenderProps>;
8
18
  }
9
- declare function ChatInput({ onSubmit, isDisabled, translations, componentOverrides, }: ChatInputProps): React.JSX.Element;
19
+ declare function ChatInput({ onSubmit, isDisabled, isStreaming, onAbort, showStopButton, translations, componentOverrides, }: ChatInputProps): React.JSX.Element;
10
20
  declare namespace ChatInput {
11
21
  var displayName: string;
12
22
  }
@@ -0,0 +1,14 @@
1
+ import React from 'react';
2
+ import { ChatHandle, ChatProps } from '../Chat/Chat';
3
+ import { CioAsaProviderProps } from '../../types';
4
+ export type CioAsaProps = CioAsaProviderProps & ChatProps;
5
+ /**
6
+ * The whole agent in one component: the provider and the chat UI composed together.
7
+ *
8
+ * Consumers that need to place `Chat` inside their own tree, or render several
9
+ * components against one client, should compose `CioAsaProvider` and `Chat`
10
+ * directly. This is the single-entry-point convenience wrapper, and it is what the
11
+ * standalone browser bundle (`src/bundled.jsx`) mounts.
12
+ */
13
+ declare const CioAsa: React.ForwardRefExoticComponent<CioAsaProviderProps & ChatProps & React.RefAttributes<ChatHandle>>;
14
+ export default CioAsa;
@@ -0,0 +1,2 @@
1
+ import React from 'react';
2
+ export default function StopIcon(): React.JSX.Element;
@@ -1,5 +1,6 @@
1
1
  export { default as ChatBubbleDarkIcon } from './ChatBubbleDarkIcon';
2
2
  export { default as ChatBubbleLightIcon } from './ChatBubbleLightIcon';
3
3
  export { default as SendArrowIcon } from './SendArrowIcon';
4
+ export { default as StopIcon } from './StopIcon';
4
5
  export { default as SendPlaneIcon } from './SendPlaneIcon';
5
6
  export { default as ArrowRightIcon } from './ArrowRightIcon';
@@ -1,6 +1,8 @@
1
+ export { default as CioAsa } from './components/CioAsa/CioAsa';
2
+ export type { CioAsaProps } from './components/CioAsa/CioAsa';
1
3
  export { default as CioAsaProvider } from './components/CioAsaProvider/CioAsaProvider';
2
4
  export { default as Chat } from './components/Chat/Chat';
3
- export type { ChatHandle } from './components/Chat/Chat';
5
+ export type { ChatHandle, ChatProps } from './components/Chat/Chat';
4
6
  export { default as ResultsBlock } from './components/ResultsBlock/ResultsBlock';
5
7
  export type { AspectRatio } from './components/ResultsBlock/ResultsBlock';
6
8
  export { default as Button } from './components/Button/Button';