@constructor-io/constructorio-ui-asa 1.4.0 → 1.6.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 (39) hide show
  1. package/README.md +1 -1
  2. package/dist/constructorio-ui-asa-bundled.js +1 -1
  3. package/lib/cjs/components/Chat/Chat.js +5 -4
  4. package/lib/cjs/components/Chat/ChatInput.css +11 -0
  5. package/lib/cjs/components/Chat/ChatInput.js +8 -3
  6. package/lib/cjs/components/Chat/ChatMessageList.js +7 -3
  7. package/lib/cjs/components/Chat/FollowUpRefinement.css +62 -0
  8. package/lib/cjs/components/Chat/FollowUpRefinement.js +30 -0
  9. package/lib/cjs/components/icons/StopIcon.js +9 -0
  10. package/lib/cjs/components/icons/index.js +3 -1
  11. package/lib/cjs/hooks/asaStreamHandlers.js +11 -1
  12. package/lib/cjs/hooks/useAsaResults.js +62 -13
  13. package/lib/cjs/styles.css +1 -0
  14. package/lib/cjs/utils/translate.js +2 -0
  15. package/lib/cjs/version.js +1 -1
  16. package/lib/mjs/components/Chat/Chat.js +5 -4
  17. package/lib/mjs/components/Chat/ChatInput.css +11 -0
  18. package/lib/mjs/components/Chat/ChatInput.js +9 -4
  19. package/lib/mjs/components/Chat/ChatMessageList.js +7 -3
  20. package/lib/mjs/components/Chat/FollowUpRefinement.css +62 -0
  21. package/lib/mjs/components/Chat/FollowUpRefinement.js +26 -0
  22. package/lib/mjs/components/icons/StopIcon.js +5 -0
  23. package/lib/mjs/components/icons/index.js +1 -0
  24. package/lib/mjs/hooks/asaStreamHandlers.js +13 -0
  25. package/lib/mjs/hooks/useAsaResults.js +62 -14
  26. package/lib/mjs/styles.css +1 -0
  27. package/lib/mjs/utils/translate.js +2 -0
  28. package/lib/mjs/version.js +1 -1
  29. package/lib/types/components/Chat/Chat.d.ts +13 -0
  30. package/lib/types/components/Chat/ChatInput.d.ts +11 -1
  31. package/lib/types/components/Chat/ChatMessageList.d.ts +6 -2
  32. package/lib/types/components/Chat/FollowUpRefinement.d.ts +14 -0
  33. package/lib/types/components/icons/StopIcon.d.ts +2 -0
  34. package/lib/types/components/icons/index.d.ts +1 -0
  35. package/lib/types/hooks/asaStreamHandlers.d.ts +1 -0
  36. package/lib/types/index.d.ts +1 -1
  37. package/lib/types/types.d.ts +35 -3
  38. package/lib/types/version.d.ts +1 -1
  39. package/package.json +3 -3
@@ -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;
@@ -142,6 +161,10 @@ function useAsaResults(options) {
142
161
  fireLoadStart();
143
162
  (0, asaStreamHandlers_1.handleMessage)(data, assistantMessage.id, setMessages);
144
163
  }
164
+ else if (type === 'follow_up_refinement') {
165
+ fireLoadStart();
166
+ (0, asaStreamHandlers_1.handleFollowUpRefinement)(data, assistantMessage.id, setMessages);
167
+ }
145
168
  else if (type === 'server_error') {
146
169
  (0, asaStreamHandlers_1.handleServerError)(assistantMessage.id, setMessages);
147
170
  break;
@@ -155,30 +178,56 @@ function useAsaResults(options) {
155
178
  if (readerRef.current === reader) {
156
179
  reader.cancel();
157
180
  readerRef.current = null;
181
+ activeAssistantIdRef.current = null;
158
182
  setIsStreaming(false);
159
183
  isStreamingRef.current = false;
160
184
  }
161
185
  }
162
186
  }))();
163
187
  }, [cioClient, domain, nextMessageId]);
164
- (0, react_1.useEffect)(() => () => {
165
- killSwitchRef.current = true;
166
- if (readerRef.current) {
167
- readerRef.current.cancel();
168
- 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
+ }));
169
221
  }
170
- }, []);
222
+ }, [teardownStream]);
171
223
  const clearHistory = (0, react_1.useCallback)(() => {
172
- killSwitchRef.current = true;
173
- if (readerRef.current) {
174
- readerRef.current.cancel();
175
- readerRef.current = null;
176
- }
224
+ teardownStream();
177
225
  threadIdRef.current = null;
226
+ activeAssistantIdRef.current = null;
178
227
  setMessages([]);
179
228
  setIsStreaming(false);
180
229
  isStreamingRef.current = false;
181
- }, []);
182
- return { messages, sendMessage, isStreaming, clearHistory };
230
+ }, [teardownStream]);
231
+ return { messages, sendMessage, isStreaming, abort, clearHistory };
183
232
  }
184
233
  exports.default = useAsaResults;
@@ -6,6 +6,7 @@
6
6
  @import './components/Chat/ChatMessageList.css';
7
7
  @import './components/Chat/UserMessage.css';
8
8
  @import './components/Chat/AiMessage.css';
9
+ @import './components/Chat/FollowUpRefinement.css';
9
10
  @import './components/Chat/TypingIndicator.css';
10
11
  @import './components/Chat/ChatInput.css';
11
12
  @import './components/ResultsBlock/ResultsBlock.css';
@@ -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',
@@ -18,6 +19,7 @@ const defaultTranslations = {
18
19
  'CioAsa.results.viewMore': 'View more products',
19
20
  'CioAsa.results.addToCart': 'Add to cart',
20
21
  'CioAsa.results.saleBadge': 'Sale',
22
+ 'CioAsa.refinement.ariaLabel': 'Refine your results',
21
23
  'CioAsa.error.message': "I can't assist you with that request.",
22
24
  };
23
25
  function translate(key, translations) {
@@ -1,3 +1,3 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.default = '1.4.0';
3
+ exports.default = '1.6.0';
@@ -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(() => {
@@ -54,8 +55,8 @@ const Chat = forwardRef(({ onClose, className, initialSuggestions, termsText, on
54
55
  React.createElement("div", { className: 'cio-asa-chat-body' }, isWelcome ? (React.createElement("div", { className: 'cio-asa-chat-view cio-asa-chat-view--welcome' },
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
- 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 }),
58
- React.createElement(ChatInput, { onSubmit: sendMessage, isDisabled: isStreaming, translations: translations, componentOverrides: componentOverrides?.input }))))));
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 }),
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';
@@ -3,7 +3,8 @@ import translate from '../../utils/translate';
3
3
  import ResultsBlock from '../ResultsBlock/ResultsBlock';
4
4
  import UserMessage from './UserMessage';
5
5
  import AiMessage from './AiMessage';
6
- export default function ChatMessageList({ messages, onProductClick, onViewMore, onAddToCart, aspectRatio, currency, normalizeItem, addToCartText, viewMoreText, aiMessageOverrides, userMessageOverrides, resultsBlockOverrides, translations, }) {
6
+ import FollowUpRefinement from './FollowUpRefinement';
7
+ export default function ChatMessageList({ messages, onProductClick, onViewMore, onAddToCart, aspectRatio, currency, normalizeItem, addToCartText, viewMoreText, aiMessageOverrides, userMessageOverrides, resultsBlockOverrides, translations, onSend, isStreaming = false, }) {
7
8
  const listRef = useRef(null);
8
9
  const isNearBottomRef = useRef(true);
9
10
  const prevMessageCountRef = useRef(messages.length);
@@ -28,14 +29,17 @@ export default function ChatMessageList({ messages, onProductClick, onViewMore,
28
29
  el.scrollTo({ top: el.scrollHeight, behavior: isNewMessage ? 'smooth' : 'auto' });
29
30
  });
30
31
  }, [messages]);
31
- return (React.createElement("div", { className: 'cio-asa-chat-message-list', ref: listRef, onScroll: handleScroll, role: 'log', "aria-live": 'off', tabIndex: 0, "aria-label": translate('CioAsa.messageList.ariaLabel', translations) }, messages.map((message) => {
32
+ return (React.createElement("div", { className: 'cio-asa-chat-message-list', ref: listRef, onScroll: handleScroll, role: 'log', "aria-live": 'off', tabIndex: 0, "aria-label": translate('CioAsa.messageList.ariaLabel', translations) }, messages.map((message, index) => {
32
33
  if (message.role === 'user') {
33
34
  return (React.createElement(UserMessage, { key: message.id, text: message.text, translations: translations, componentOverrides: userMessageOverrides }));
34
35
  }
35
36
  const hasGroups = !!message.groups?.length;
37
+ const isLatest = index === messages.length - 1;
38
+ const refinement = onSend ? message.refinement : undefined;
36
39
  return (React.createElement("div", { key: message.id, className: 'cio-asa-ai-message-group' },
37
40
  React.createElement(AiMessage, { message: message, componentOverrides: aiMessageOverrides, translations: translations }),
38
- hasGroups && (React.createElement(ResultsBlock, { groups: message.groups, intent: message.intent, intentResultId: message.intentResultId, threadId: message.threadId, onProductClick: onProductClick, onViewMore: onViewMore, onAddToCart: onAddToCart, aspectRatio: aspectRatio, currency: currency, normalizeItem: normalizeItem, addToCartText: addToCartText, viewMoreText: viewMoreText, saleBadgeText: translate('CioAsa.results.saleBadge', translations), componentOverrides: resultsBlockOverrides }))));
41
+ hasGroups && (React.createElement(ResultsBlock, { groups: message.groups, intent: message.intent, intentResultId: message.intentResultId, threadId: message.threadId, onProductClick: onProductClick, onViewMore: onViewMore, onAddToCart: onAddToCart, aspectRatio: aspectRatio, currency: currency, normalizeItem: normalizeItem, addToCartText: addToCartText, viewMoreText: viewMoreText, saleBadgeText: translate('CioAsa.results.saleBadge', translations), componentOverrides: resultsBlockOverrides })),
42
+ refinement && (React.createElement(FollowUpRefinement, { refinement: refinement, onOptionClick: (option) => onSend(option, 'refinement'), isDisabled: !isLatest || isStreaming, translations: translations, componentOverrides: aiMessageOverrides?.followUpRefinement }))));
39
43
  })));
40
44
  }
41
45
  ChatMessageList.displayName = 'ChatMessageList';
@@ -0,0 +1,62 @@
1
+ .cio-asa-follow-up-refinement {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: 10px;
5
+ width: 100%;
6
+ padding-right: 12px;
7
+ box-sizing: border-box;
8
+ animation: cio-asa-fade-in 0.3s ease;
9
+ }
10
+
11
+ .cio-asa-follow-up-refinement__question {
12
+ margin: 0;
13
+ font-weight: 500;
14
+ font-size: 14px;
15
+ line-height: 20px;
16
+ letter-spacing: -0.005em;
17
+ color: #0F1324;
18
+ }
19
+
20
+ .cio-asa-follow-up-refinement__options {
21
+ display: flex;
22
+ flex-wrap: wrap;
23
+ gap: 8px;
24
+ }
25
+
26
+ .cio-asa-follow-up-refinement__chip {
27
+ display: inline-flex;
28
+ align-items: center;
29
+ justify-content: center;
30
+ padding: 8px 14px;
31
+ background: #ffffff;
32
+ box-shadow: 0px 0.4px 1px rgba(18, 18, 105, 0.13), 0px 0.9px 6px rgba(18, 18, 105, 0.05), 0px 1px 3px rgba(18, 18, 105, 0.05);
33
+ border-radius: 12px;
34
+ border: 1px solid transparent;
35
+ cursor: pointer;
36
+ font-weight: 400;
37
+ font-size: 12px;
38
+ line-height: 20px;
39
+ text-align: center;
40
+ color: #343434;
41
+ box-sizing: border-box;
42
+ transition: background-color 0.2s, box-shadow 0.2s, border-color 0.2s;
43
+ }
44
+
45
+ .cio-asa-follow-up-refinement__chip:hover:not(:disabled) {
46
+ border-color: #ffffff;
47
+ box-shadow: 0px 6px 14px rgba(18, 18, 105, 0.11), 0px 2px 4px rgba(18, 18, 105, 0.0716748), 0px 1px 2px rgba(18, 18, 105, 0.055);
48
+ }
49
+
50
+ .cio-asa-follow-up-refinement__chip:active:not(:disabled) {
51
+ background: #E0E3EB;
52
+ border-color: #DEE0E3;
53
+ box-shadow: inset 1px 1px 2px rgba(18, 18, 105, 0.1), inset 0px 1px 4px rgba(18, 18, 105, 0.05), inset 1px 1px 1px rgba(18, 18, 105, 0.05);
54
+ }
55
+
56
+ .cio-asa-follow-up-refinement__chip:disabled {
57
+ cursor: default;
58
+ background: #F4F5F7;
59
+ border-color: #E9EAEC;
60
+ box-shadow: none;
61
+ color: #6B7080;
62
+ }
@@ -0,0 +1,26 @@
1
+ import React from 'react';
2
+ import { RenderPropsWrapper } from '@constructor-io/constructorio-ui-components';
3
+ import translate from '../../utils/translate';
4
+ export default function FollowUpRefinement({ refinement, onOptionClick, isDisabled = false, translations, componentOverrides, }) {
5
+ const handleOptionClick = (option) => {
6
+ if (isDisabled)
7
+ return;
8
+ onOptionClick(option);
9
+ };
10
+ const renderProps = {
11
+ question: refinement.question,
12
+ options: refinement.options,
13
+ onOptionClick: handleOptionClick,
14
+ isDisabled,
15
+ };
16
+ return (React.createElement(RenderPropsWrapper, { override: componentOverrides?.reactNode, props: renderProps },
17
+ React.createElement("div", { className: [
18
+ 'cio-asa-follow-up-refinement',
19
+ isDisabled && 'cio-asa-follow-up-refinement--disabled',
20
+ ]
21
+ .filter(Boolean)
22
+ .join(' '), role: 'group', "aria-label": translate('CioAsa.refinement.ariaLabel', translations) },
23
+ React.createElement("p", { className: 'cio-asa-follow-up-refinement__question' }, refinement.question),
24
+ React.createElement("div", { className: 'cio-asa-follow-up-refinement__options' }, refinement.options.map((option) => (React.createElement("button", { key: option, type: 'button', className: 'cio-asa-follow-up-refinement__chip', disabled: isDisabled, onClick: () => handleOptionClick(option) }, option)))))));
25
+ }
26
+ FollowUpRefinement.displayName = 'FollowUpRefinement';
@@ -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';
@@ -48,6 +48,19 @@ export function handleMessage(data, assistantId, setMessages) {
48
48
  text: (msg.text || '') + (data?.text || ''),
49
49
  }));
50
50
  }
51
+ export function handleFollowUpRefinement(data, assistantId, setMessages) {
52
+ const question = typeof data?.question === 'string' ? data.question.trim() : '';
53
+ const options = Array.isArray(data?.options)
54
+ ? data.options.filter((o) => typeof o === 'string' && o.trim() !== '')
55
+ : [];
56
+ if (question === '' || options.length === 0)
57
+ return;
58
+ updateMessageById(setMessages, assistantId, (msg) => ({
59
+ ...msg,
60
+ status: 'streaming',
61
+ refinement: { question, options },
62
+ }));
63
+ }
51
64
  export function handleServerError(assistantId, setMessages) {
52
65
  updateMessageById(setMessages, assistantId, (msg) => ({
53
66
  ...msg,
@@ -1,6 +1,6 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import { useCioAsaContext } from './useCioAsaContext';
3
- import { handleSearchResult, handleMessage, handleServerError, handleStreamEnd, handleStreamError, } from './asaStreamHandlers';
3
+ import { handleSearchResult, handleMessage, handleFollowUpRefinement, handleServerError, handleStreamEnd, handleStreamError, } from './asaStreamHandlers';
4
4
  import useAsaTracking from './useAsaTracking';
5
5
  export default function useAsaResults(options) {
6
6
  const [messages, setMessages] = useState([]);
@@ -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;
@@ -139,6 +158,10 @@ export default function useAsaResults(options) {
139
158
  fireLoadStart();
140
159
  handleMessage(data, assistantMessage.id, setMessages);
141
160
  }
161
+ else if (type === 'follow_up_refinement') {
162
+ fireLoadStart();
163
+ handleFollowUpRefinement(data, assistantMessage.id, setMessages);
164
+ }
142
165
  else if (type === 'server_error') {
143
166
  handleServerError(assistantMessage.id, setMessages);
144
167
  break;
@@ -152,29 +175,54 @@ export default function useAsaResults(options) {
152
175
  if (readerRef.current === reader) {
153
176
  reader.cancel();
154
177
  readerRef.current = null;
178
+ activeAssistantIdRef.current = null;
155
179
  setIsStreaming(false);
156
180
  isStreamingRef.current = false;
157
181
  }
158
182
  }
159
183
  })();
160
184
  }, [cioClient, domain, nextMessageId]);
161
- useEffect(() => () => {
162
- killSwitchRef.current = true;
163
- if (readerRef.current) {
164
- readerRef.current.cancel();
165
- 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
+ }));
166
217
  }
167
- }, []);
218
+ }, [teardownStream]);
168
219
  const clearHistory = useCallback(() => {
169
- killSwitchRef.current = true;
170
- if (readerRef.current) {
171
- readerRef.current.cancel();
172
- readerRef.current = null;
173
- }
220
+ teardownStream();
174
221
  threadIdRef.current = null;
222
+ activeAssistantIdRef.current = null;
175
223
  setMessages([]);
176
224
  setIsStreaming(false);
177
225
  isStreamingRef.current = false;
178
- }, []);
179
- return { messages, sendMessage, isStreaming, clearHistory };
226
+ }, [teardownStream]);
227
+ return { messages, sendMessage, isStreaming, abort, clearHistory };
180
228
  }
@@ -6,6 +6,7 @@
6
6
  @import './components/Chat/ChatMessageList.css';
7
7
  @import './components/Chat/UserMessage.css';
8
8
  @import './components/Chat/AiMessage.css';
9
+ @import './components/Chat/FollowUpRefinement.css';
9
10
  @import './components/Chat/TypingIndicator.css';
10
11
  @import './components/Chat/ChatInput.css';
11
12
  @import './components/ResultsBlock/ResultsBlock.css';
@@ -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',
@@ -16,6 +17,7 @@ const defaultTranslations = {
16
17
  'CioAsa.results.viewMore': 'View more products',
17
18
  'CioAsa.results.addToCart': 'Add to cart',
18
19
  'CioAsa.results.saleBadge': 'Sale',
20
+ 'CioAsa.refinement.ariaLabel': 'Refine your results',
19
21
  'CioAsa.error.message': "I can't assist you with that request.",
20
22
  };
21
23
  export default function translate(key, translations) {
@@ -1 +1 @@
1
- export default '1.4.0';
1
+ export default '1.6.0';
@@ -3,6 +3,13 @@ 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
15
  interface ChatProps {
@@ -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
  }
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { AiMessageOverrides, ChatMessage, ComponentOverrideProps, ResultGroupMeta, ResultsBlockOverrides, Translations, UserMessageRenderProps } from '../../types';
2
+ import { AiMessageOverrides, AssistantSubmitSource, ChatMessage, ComponentOverrideProps, ResultGroupMeta, ResultsBlockOverrides, Translations, UserMessageRenderProps } from '../../types';
3
3
  import { Product, NormalizeOptions } from '../../utils/productNormalizer';
4
4
  import { AspectRatio } from '../ResultsBlock/ResultsBlock';
5
5
  interface ChatMessageListProps {
@@ -16,8 +16,12 @@ interface ChatMessageListProps {
16
16
  userMessageOverrides?: ComponentOverrideProps<UserMessageRenderProps>;
17
17
  resultsBlockOverrides?: ResultsBlockOverrides;
18
18
  translations?: Translations;
19
+ /** Sends a refinement option as a follow-up message. Chips are hidden when omitted. */
20
+ onSend?: (text: string, source?: AssistantSubmitSource) => void;
21
+ /** Disables refinement chips while a response is streaming. */
22
+ isStreaming?: boolean;
19
23
  }
20
- declare function ChatMessageList({ messages, onProductClick, onViewMore, onAddToCart, aspectRatio, currency, normalizeItem, addToCartText, viewMoreText, aiMessageOverrides, userMessageOverrides, resultsBlockOverrides, translations, }: ChatMessageListProps): React.JSX.Element;
24
+ declare function ChatMessageList({ messages, onProductClick, onViewMore, onAddToCart, aspectRatio, currency, normalizeItem, addToCartText, viewMoreText, aiMessageOverrides, userMessageOverrides, resultsBlockOverrides, translations, onSend, isStreaming, }: ChatMessageListProps): React.JSX.Element;
21
25
  declare namespace ChatMessageList {
22
26
  var displayName: string;
23
27
  }
@@ -0,0 +1,14 @@
1
+ import React from 'react';
2
+ import { ComponentOverrideProps, FollowUpRefinement as FollowUpRefinementData, FollowUpRefinementRenderProps, Translations } from '../../types';
3
+ export interface FollowUpRefinementProps {
4
+ refinement: FollowUpRefinementData;
5
+ onOptionClick: (option: string) => void;
6
+ isDisabled?: boolean;
7
+ translations?: Translations;
8
+ componentOverrides?: ComponentOverrideProps<FollowUpRefinementRenderProps>;
9
+ }
10
+ declare function FollowUpRefinement({ refinement, onOptionClick, isDisabled, translations, componentOverrides, }: FollowUpRefinementProps): React.JSX.Element;
11
+ declare namespace FollowUpRefinement {
12
+ var displayName: string;
13
+ }
14
+ export default FollowUpRefinement;
@@ -0,0 +1,2 @@
1
+ import React from 'react';
2
+ export default function StopIcon(): React.JSX.Element;