@coveo/quantic 3.39.1 → 3.41.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 (48) hide show
  1. package/force-app/main/default/classes/HeadlessControllerTest.cls +1 -0
  2. package/force-app/main/default/classes/InsightControllerTest.cls +1 -0
  3. package/force-app/main/default/classes/RecommendationsControllerTest.cls +1 -1
  4. package/force-app/main/default/classes/SampleTokenProvider.cls +1 -0
  5. package/force-app/main/default/classes/SampleTokenProviderTest.cls +1 -0
  6. package/force-app/main/default/labels/CustomLabels.labels-meta.xml +77 -0
  7. package/force-app/main/default/lwc/quanticGeneratedAnswer/__tests__/quanticGeneratedAnswer.test.js +54 -0
  8. package/force-app/main/default/lwc/quanticGeneratedAnswer/quanticGeneratedAnswer.js +26 -3
  9. package/force-app/main/default/lwc/quanticGeneratedAnswer/templates/generatedAnswer.css +5 -0
  10. package/force-app/main/default/lwc/quanticGeneratedAnswer/templates/generatedAnswer.html +3 -1
  11. package/force-app/main/default/lwc/quanticGeneratedAnswerBody/__tests__/quanticGeneratedAnswerBody.test.js +341 -0
  12. package/force-app/main/default/lwc/quanticGeneratedAnswerBody/quanticGeneratedAnswerBody.js +148 -0
  13. package/force-app/main/default/lwc/quanticGeneratedAnswerBody/quanticGeneratedAnswerBody.js-meta.xml +5 -0
  14. package/force-app/main/default/lwc/quanticGeneratedAnswerBody/templates/answer.css +3 -0
  15. package/force-app/main/default/lwc/quanticGeneratedAnswerBody/templates/answer.html +53 -0
  16. package/force-app/main/default/lwc/quanticGeneratedAnswerBody/templates/cannotAnswer.html +7 -0
  17. package/force-app/main/default/lwc/quanticGeneratedAnswerBody/templates/error.html +7 -0
  18. package/force-app/main/default/lwc/quanticGeneratedAnswerContent/__tests__/quanticGeneratedAnswerContent.test.js +269 -0
  19. package/force-app/main/default/lwc/quanticGeneratedAnswerContent/quanticGeneratedAnswerContent.js +136 -0
  20. package/force-app/main/default/lwc/quanticGeneratedAnswerContent/templates/generatedMarkdownContent.css +10 -0
  21. package/force-app/main/default/lwc/quanticGeneratedAnswerStreamOfThought/__tests__/quanticGeneratedAnswerStreamOfThought.test.js +348 -0
  22. package/force-app/main/default/lwc/quanticGeneratedAnswerStreamOfThought/quanticGeneratedAnswerStreamOfThought.css +17 -0
  23. package/force-app/main/default/lwc/quanticGeneratedAnswerStreamOfThought/quanticGeneratedAnswerStreamOfThought.js +163 -0
  24. package/force-app/main/default/lwc/quanticGeneratedAnswerStreamOfThought/quanticGeneratedAnswerStreamOfThought.js-meta.xml +5 -0
  25. package/force-app/main/default/lwc/quanticGeneratedAnswerStreamOfThought/templates/collapsedSummary.css +1 -0
  26. package/force-app/main/default/lwc/quanticGeneratedAnswerStreamOfThought/templates/collapsedSummary.html +32 -0
  27. package/force-app/main/default/lwc/quanticGeneratedAnswerStreamOfThought/templates/streamOfThought.css +1 -0
  28. package/force-app/main/default/lwc/quanticGeneratedAnswerStreamOfThought/templates/streamOfThought.html +65 -0
  29. package/force-app/main/default/lwc/quanticGeneratedAnswerThread/__tests__/quanticGeneratedAnswerThread.test.js +285 -0
  30. package/force-app/main/default/lwc/quanticGeneratedAnswerThread/quanticGeneratedAnswerThread.css +47 -0
  31. package/force-app/main/default/lwc/quanticGeneratedAnswerThread/quanticGeneratedAnswerThread.html +67 -0
  32. package/force-app/main/default/lwc/quanticGeneratedAnswerThread/quanticGeneratedAnswerThread.js +93 -0
  33. package/force-app/main/default/lwc/quanticGeneratedAnswerThread/quanticGeneratedAnswerThread.js-meta.xml +5 -0
  34. package/force-app/main/default/lwc/quanticThreadItem/quanticThreadItem.css +0 -4
  35. package/force-app/main/default/lwc/quanticThreadItem/quanticThreadItem.html +1 -1
  36. package/force-app/main/default/lwc/quanticUtils/__tests__/markdownUtils.test.js +38 -0
  37. package/force-app/main/default/lwc/quanticUtils/markdownUtils.js +18 -0
  38. package/force-app/main/default/staticresources/coveoheadless/case-assist/headless.js +6 -6
  39. package/force-app/main/default/staticresources/coveoheadless/definitions/api/commerce/common/pagination.d.ts +1 -1
  40. package/force-app/main/default/staticresources/coveoheadless/definitions/controllers/core/generated-answer/headless-core-generated-answer.d.ts +1 -0
  41. package/force-app/main/default/staticresources/coveoheadless/definitions/controllers/core/generated-answer/headless-core-interactive-citation.d.ts +3 -3
  42. package/force-app/main/default/staticresources/coveoheadless/definitions/controllers/generated-answer/interactive-citation-analytics-client.d.ts +3 -0
  43. package/force-app/main/default/staticresources/coveoheadless/definitions/features/analytics/analytics-utils.d.ts +3 -2
  44. package/force-app/main/default/staticresources/coveoheadless/definitions/features/generated-answer/generated-answer-analytics-actions.d.ts +2 -0
  45. package/force-app/main/default/staticresources/coveoheadless/headless.js +17 -17
  46. package/force-app/main/default/staticresources/coveoheadless/insight/headless.js +7 -7
  47. package/force-app/main/default/staticresources/coveoheadless/recommendation/headless.js +14 -14
  48. package/package.json +2 -2
@@ -1,6 +1,12 @@
1
+ import {
2
+ registerComponentForInit,
3
+ initializeWithHeadless,
4
+ getHeadlessBundle,
5
+ } from 'c/quanticHeadlessLoader';
1
6
  import {
2
7
  transformMarkdownToHtml,
3
8
  loadMarkdownDependencies,
9
+ LinkUtils,
4
10
  } from 'c/quanticUtils';
5
11
  import {LightningElement, api} from 'lwc';
6
12
  // @ts-ignore
@@ -8,6 +14,21 @@ import generatedMarkdownContentTemplate from './templates/generatedMarkdownConte
8
14
  // @ts-ignore
9
15
  import generatedTextContentTemplate from './templates/generatedTextContent.html';
10
16
 
17
+ /** @typedef {import("coveo").SearchEngine} SearchEngine */
18
+ /** @typedef {import("coveo").InsightEngine} InsightEngine */
19
+
20
+ const INLINE_LINK_ICON = `
21
+ <svg
22
+ class="slds-icon answer-content__link-icon-svg"
23
+ aria-hidden="true"
24
+ focusable="false"
25
+ viewBox="0 0 52 52"
26
+ >
27
+ <path d="M48.5 2h-15c-.8 0-1.5.7-1.5 1.5v3c0 .8.7 1.5 1.5 1.5h6.4L22.1 25.8c-.6.6-.6 1.5 0 2.1l2.1 2.1c.6.6 1.5.6 2.1 0L44 12.3v6.2c0 .8.7 1.5 1.5 1.5h3c.8 0 1.5-.7 1.5-1.5v-15c0-.8-.7-1.5-1.5-1.5z"></path>
28
+ <path d="M38 28.5V46H8V16h17.5c.8 0 1.5-.7 1.5-1.5v-3c0-.8-.7-1.5-1.5-1.5H6c-2.2 0-4 1.8-4 4v34c0 2.2 1.8 4 4 4h34c2.2 0 4-1.8 4-4V28.5c0-.8-.7-1.5-1.5-1.5h-3c-.8 0-1.5.7-1.5 1.5z"></path>
29
+ </svg>
30
+ `;
31
+
11
32
  /**
12
33
  * The `QuanticGeneratedAnswerContent` component displays the generated answer content.
13
34
  * @category Internal
@@ -16,6 +37,18 @@ import generatedTextContentTemplate from './templates/generatedTextContent.html'
16
37
  * <c-quantic-generated-answer-content answer-content-format={answerContentFormat} answer={answer} is-streaming={isStreaming}></c-quantic-generated-answer-content>
17
38
  */
18
39
  export default class QuanticGeneratedAnswerContent extends LightningElement {
40
+ /**
41
+ * The ID of the engine instance the component registers to.
42
+ * @api
43
+ * @type {string}
44
+ */
45
+ @api engineId;
46
+ /**
47
+ * The unique identifier of the generated answer.
48
+ * @api
49
+ * @type {string}
50
+ */
51
+ @api answerId;
19
52
  /**
20
53
  * If the answer is streaming, it will render a blinking cursor at the end of the answer.
21
54
  * @api
@@ -65,6 +98,32 @@ export default class QuanticGeneratedAnswerContent extends LightningElement {
65
98
  /** @type {'text/plain' | 'text/markdown'} */
66
99
  _answerContentFormat = 'text/plain';
67
100
  _markdownDependenciesLoaded = false;
101
+ /** @type {Array<function>} */
102
+ _inlineLinkBindings = [];
103
+ /** @type {AnyHeadless} */
104
+ headless;
105
+ /** @type {SearchEngine | InsightEngine} */
106
+ engine;
107
+
108
+ connectedCallback() {
109
+ registerComponentForInit(this, this.engineId);
110
+ }
111
+
112
+ renderedCallback() {
113
+ initializeWithHeadless(this, this.engineId, this.initialize);
114
+ }
115
+
116
+ /**
117
+ * @param {SearchEngine | InsightEngine} engine
118
+ */
119
+ initialize = (engine) => {
120
+ this.headless = getHeadlessBundle(this.engineId);
121
+ this.engine = engine;
122
+ };
123
+
124
+ disconnectedCallback() {
125
+ this.cleanUpInlineLinkBindings();
126
+ }
68
127
 
69
128
  loadMarkdownDependencies() {
70
129
  if (
@@ -105,6 +164,7 @@ export default class QuanticGeneratedAnswerContent extends LightningElement {
105
164
  // eslint-disable-next-line @lwc/lwc/no-inner-html
106
165
  answerContainer.innerHTML = newHTMLContent;
107
166
  }
167
+ this.processInlineLinks(answerContainer);
108
168
  }
109
169
  }
110
170
  // Fallback to display answer as text if the Marked library failed to load
@@ -114,6 +174,82 @@ export default class QuanticGeneratedAnswerContent extends LightningElement {
114
174
  this.dispatchEvent(new CustomEvent('quantic__answercontentupdated'));
115
175
  }
116
176
 
177
+ /**
178
+ * Decorates each inline link with an icon and binds analytics, replacing any previous bindings.
179
+ * @param {Element|null} answerContainer
180
+ */
181
+ processInlineLinks(answerContainer) {
182
+ if (!answerContainer) {
183
+ return;
184
+ }
185
+ this.cleanUpInlineLinkBindings();
186
+ /** @type {NodeListOf<HTMLAnchorElement>} */
187
+ const anchors = answerContainer.querySelectorAll(
188
+ 'a[data-answer-inline-link]'
189
+ );
190
+ anchors.forEach((anchor) => {
191
+ anchor.target = '_blank';
192
+ anchor.rel = 'noopener';
193
+ this.bindAnalyticsToInlineLink(anchor);
194
+ this.appendInlineLinkIcon(anchor);
195
+ });
196
+ }
197
+
198
+ /**
199
+ * Appends the external link icon to the given anchor.
200
+ * @param {HTMLAnchorElement} anchor
201
+ */
202
+ appendInlineLinkIcon(anchor) {
203
+ const icon = document.createElement('span');
204
+ icon.classList.add(
205
+ 'slds-icon_container',
206
+ 'slds-icon-utility-new_window',
207
+ 'slds-current-color',
208
+ 'slds-m-left_xxx-small',
209
+ 'answer-content__link-icon'
210
+ );
211
+ // eslint-disable-next-line @lwc/lwc/no-inner-html
212
+ icon.innerHTML = INLINE_LINK_ICON;
213
+ anchor.appendChild(icon);
214
+ }
215
+
216
+ /**
217
+ * Creates an `InteractiveGeneratedAnswerInlineLink` controller for the given anchor
218
+ * and binds analytics event listeners to it.
219
+ * @param {HTMLAnchorElement} anchor
220
+ */
221
+ bindAnalyticsToInlineLink(anchor) {
222
+ if (
223
+ !this.headless?.buildInteractiveGeneratedAnswerInlineLink ||
224
+ !this.engine
225
+ ) {
226
+ return;
227
+ }
228
+ const controller = this.headless.buildInteractiveGeneratedAnswerInlineLink(
229
+ this.engine,
230
+ {
231
+ options: {
232
+ link: {
233
+ linkURL: anchor.getAttribute('href') || '',
234
+ linkText: anchor.textContent?.trim() || '',
235
+ },
236
+ answerId: this.answerId,
237
+ },
238
+ }
239
+ );
240
+ this._inlineLinkBindings.push(
241
+ LinkUtils.bindAnalyticsToLink(anchor, controller)
242
+ );
243
+ }
244
+
245
+ /**
246
+ * Cleans up all inline link bindings.
247
+ */
248
+ cleanUpInlineLinkBindings() {
249
+ this._inlineLinkBindings.forEach((remove) => remove());
250
+ this._inlineLinkBindings = [];
251
+ }
252
+
117
253
  get generatedAnswerContentClass() {
118
254
  return `generated-answer-content__answer ${this.isStreaming ? 'generated-answer-content__answer--streaming' : ''}`;
119
255
  }
@@ -152,3 +152,13 @@ tbody tr td:first-of-type {
152
152
  tbody tr td:last-of-type td {
153
153
  border-bottom: unset;
154
154
  }
155
+
156
+ .answer-content__link-icon-svg {
157
+ width: 0.625rem;
158
+ height: 0.625rem;
159
+ fill: currentColor;
160
+ }
161
+
162
+ .answer-content__link-icon {
163
+ display: inline-flex;
164
+ }
@@ -0,0 +1,348 @@
1
+ import {resolveSteps} from '../quanticGeneratedAnswerStreamOfThought.js';
2
+ import {buildCreateTestComponent, cleanup, flushPromises} from 'c/testUtils';
3
+ import QuanticGeneratedAnswerStreamOfThought from '../quanticGeneratedAnswerStreamOfThought';
4
+
5
+ const selectors = {
6
+ stepItem: '[data-testid="step-item"]',
7
+ spinner: '[data-testid="spinner"]',
8
+ checkmark: '[data-testid="checkmark"]',
9
+ stepLabel: '[data-testid="step-label"]',
10
+ collapseButton: '[data-testid="collapse-button"]',
11
+ collapsedSummary: '[data-testid="collapsed-summary"]',
12
+ collapsedSummaryLabel: '[data-testid="collapsed-summary-label"]',
13
+ chevronUp: '[data-testid="chevron-up"]',
14
+ chevronDown: '[data-testid="chevron-down"]',
15
+ };
16
+
17
+ const createTestComponent = buildCreateTestComponent(
18
+ QuanticGeneratedAnswerStreamOfThought,
19
+ 'c-quantic-generated-answer-stream-of-thought',
20
+ {agentSteps: [], isStreaming: false}
21
+ );
22
+
23
+ describe('#resolveSteps', () => {
24
+ it('should return empty array for empty input', () => {
25
+ expect(resolveSteps([])).toEqual([]);
26
+ });
27
+
28
+ it('should resolve a full sequence correctly', () => {
29
+ expect(
30
+ resolveSteps([
31
+ {name: 'thinking', status: 'completed', startedAt: 0},
32
+ {name: 'searching', status: 'completed', startedAt: 10},
33
+ {name: 'thinking', status: 'completed', startedAt: 20},
34
+ {name: 'answering', status: 'active', startedAt: 30},
35
+ ])
36
+ ).toEqual([
37
+ {name: 'thinking-before-search', status: 'completed'},
38
+ {name: 'searching', status: 'completed'},
39
+ {name: 'thinking-after-search', status: 'completed'},
40
+ {name: 'answering', status: 'active'},
41
+ ]);
42
+ });
43
+
44
+ it('should handle repeated searching steps', () => {
45
+ expect(
46
+ resolveSteps([
47
+ {name: 'thinking', status: 'completed', startedAt: 0},
48
+ {name: 'searching', status: 'completed', startedAt: 10},
49
+ {name: 'thinking', status: 'completed', startedAt: 20},
50
+ {name: 'searching', status: 'completed', startedAt: 30},
51
+ {name: 'thinking', status: 'active', startedAt: 40},
52
+ ])
53
+ ).toEqual([
54
+ {name: 'thinking-before-search', status: 'completed'},
55
+ {name: 'searching', status: 'completed'},
56
+ {name: 'thinking-after-search', status: 'completed'},
57
+ {name: 'searching', status: 'completed'},
58
+ {name: 'thinking-after-search', status: 'active'},
59
+ ]);
60
+ });
61
+ });
62
+
63
+ describe('quantic generated answer stream of thought component', () => {
64
+ afterEach(() => {
65
+ cleanup();
66
+ });
67
+ describe('during streaming', () => {
68
+ it('should render nothing when there are no steps', async () => {
69
+ const element = createTestComponent({
70
+ agentSteps: [],
71
+ isStreaming: true,
72
+ });
73
+ await flushPromises();
74
+
75
+ expect(element.shadowRoot.firstChild).toBeNull();
76
+ });
77
+
78
+ it('should show all steps progressively', async () => {
79
+ const element = createTestComponent({
80
+ agentSteps: [
81
+ {name: 'thinking', status: 'completed'},
82
+ {name: 'searching', status: 'active'},
83
+ ],
84
+ isStreaming: true,
85
+ });
86
+ await flushPromises();
87
+
88
+ const stepItems = element.shadowRoot.querySelectorAll(selectors.stepItem);
89
+ expect(stepItems).toHaveLength(2);
90
+ });
91
+
92
+ it('should render steps in the correct order', async () => {
93
+ const element = createTestComponent({
94
+ agentSteps: [
95
+ {name: 'thinking', status: 'completed', startedAt: 0},
96
+ {name: 'searching', status: 'completed', startedAt: 10},
97
+ {name: 'thinking', status: 'completed', startedAt: 20},
98
+ {name: 'searching', status: 'completed', startedAt: 30},
99
+ {name: 'thinking', status: 'completed', startedAt: 40},
100
+ {name: 'answering', status: 'active', startedAt: 30},
101
+ ],
102
+ isStreaming: true,
103
+ });
104
+ await flushPromises();
105
+
106
+ const stepItems = element.shadowRoot.querySelectorAll(selectors.stepItem);
107
+ const names = Array.from(stepItems).map((el) =>
108
+ el.getAttribute('data-step-name')
109
+ );
110
+ expect(names).toEqual([
111
+ 'thinking-before-search',
112
+ 'searching',
113
+ 'thinking-after-search',
114
+ 'searching',
115
+ 'thinking-after-search',
116
+ 'answering',
117
+ ]);
118
+ });
119
+
120
+ it('should show checkmarks for completed steps and spinners for active steps', async () => {
121
+ const element = createTestComponent({
122
+ agentSteps: [
123
+ {name: 'thinking', status: 'completed'},
124
+ {name: 'searching', status: 'active'},
125
+ ],
126
+ isStreaming: true,
127
+ });
128
+ await flushPromises();
129
+
130
+ const checkmarks = element.shadowRoot.querySelectorAll(
131
+ selectors.checkmark
132
+ );
133
+ const spinners = element.shadowRoot.querySelectorAll(selectors.spinner);
134
+ expect(checkmarks).toHaveLength(1);
135
+ expect(spinners).toHaveLength(1);
136
+ });
137
+
138
+ it('should not show a collapse button', async () => {
139
+ const element = createTestComponent({
140
+ agentSteps: [{name: 'thinking', status: 'active'}],
141
+ isStreaming: true,
142
+ });
143
+ await flushPromises();
144
+
145
+ const collapseButton = element.shadowRoot.querySelector(
146
+ selectors.collapseButton
147
+ );
148
+ const collapsedSummary = element.shadowRoot.querySelector(
149
+ selectors.collapsedSummary
150
+ );
151
+
152
+ expect(collapseButton).toBeNull();
153
+ expect(collapsedSummary).toBeNull();
154
+ });
155
+ });
156
+
157
+ describe('after streaming completes', () => {
158
+ const multipleSteps = [
159
+ {name: 'thinking', status: 'completed'},
160
+ {name: 'searching', status: 'completed'},
161
+ {name: 'thinking', status: 'completed'},
162
+ {name: 'answering', status: 'active'},
163
+ ];
164
+
165
+ it('should auto-collapse to show only the last step', async () => {
166
+ const element = createTestComponent({
167
+ agentSteps: multipleSteps,
168
+ isStreaming: true,
169
+ });
170
+ await flushPromises();
171
+ let collapsedSummary = element.shadowRoot.querySelector(
172
+ selectors.collapsedSummary
173
+ );
174
+ let stepItems = element.shadowRoot.querySelectorAll(selectors.stepItem);
175
+
176
+ expect(collapsedSummary).toBeNull();
177
+ expect(stepItems).toHaveLength(multipleSteps.length);
178
+
179
+ element.isStreaming = false;
180
+ element.agentSteps = [
181
+ {name: 'thinking', status: 'completed'},
182
+ {name: 'searching', status: 'completed'},
183
+ {name: 'thinking', status: 'completed'},
184
+ {name: 'answering', status: 'completed'},
185
+ ];
186
+ await flushPromises();
187
+
188
+ collapsedSummary = element.shadowRoot.querySelector(
189
+ selectors.collapsedSummary
190
+ );
191
+ stepItems = element.shadowRoot.querySelectorAll(selectors.stepItem);
192
+ const summaryLabel = element.shadowRoot.querySelector(
193
+ selectors.collapsedSummaryLabel
194
+ );
195
+ const checkmark = collapsedSummary.querySelector(selectors.checkmark);
196
+
197
+ expect(collapsedSummary).not.toBeNull();
198
+ expect(summaryLabel).not.toBeNull();
199
+ expect(checkmark).not.toBeNull();
200
+ expect(stepItems).toHaveLength(0);
201
+ });
202
+
203
+ it('should have aria-expanded set to false when collapsed', async () => {
204
+ const element = createTestComponent({
205
+ agentSteps: multipleSteps,
206
+ isStreaming: false,
207
+ });
208
+ await flushPromises();
209
+
210
+ const collapsedSummary = element.shadowRoot.querySelector(
211
+ selectors.collapsedSummary
212
+ );
213
+
214
+ expect(collapsedSummary).not.toBeNull();
215
+ expect(collapsedSummary.getAttribute('aria-expanded')).toBe('false');
216
+ });
217
+
218
+ it('should render nothing when there are no steps and not streaming', async () => {
219
+ const element = createTestComponent({
220
+ agentSteps: [],
221
+ isStreaming: false,
222
+ });
223
+ await flushPromises();
224
+
225
+ expect(element.shadowRoot.firstChild).toBeNull();
226
+ });
227
+ });
228
+
229
+ describe('expand/collapse interaction', () => {
230
+ const multipleSteps = [
231
+ {name: 'thinking', status: 'completed'},
232
+ {name: 'searching', status: 'completed'},
233
+ {name: 'thinking', status: 'completed'},
234
+ {name: 'answering', status: 'completed'},
235
+ ];
236
+
237
+ it('should expand to show all steps and collapse button when collapsed summary row is clicked', async () => {
238
+ const element = createTestComponent({
239
+ agentSteps: multipleSteps,
240
+ isStreaming: false,
241
+ });
242
+ await flushPromises();
243
+
244
+ const collapsedSummary = element.shadowRoot.querySelector(
245
+ selectors.collapsedSummary
246
+ );
247
+ expect(collapsedSummary).not.toBeNull();
248
+
249
+ collapsedSummary.click();
250
+ await flushPromises();
251
+
252
+ const stepItems = element.shadowRoot.querySelectorAll(selectors.stepItem);
253
+ const collapseButton = element.shadowRoot.querySelector(
254
+ selectors.collapseButton
255
+ );
256
+ expect(stepItems).toHaveLength(multipleSteps.length);
257
+ expect(collapseButton).not.toBeNull();
258
+ });
259
+
260
+ it('should collapse back when collapse button is clicked', async () => {
261
+ const element = createTestComponent({
262
+ agentSteps: multipleSteps,
263
+ isStreaming: false,
264
+ });
265
+ await flushPromises();
266
+
267
+ const collapsedSummary = element.shadowRoot.querySelector(
268
+ selectors.collapsedSummary
269
+ );
270
+ collapsedSummary.click();
271
+ await flushPromises();
272
+
273
+ const collapseButton = element.shadowRoot.querySelector(
274
+ selectors.collapseButton
275
+ );
276
+ expect(collapseButton).not.toBeNull();
277
+
278
+ collapseButton.click();
279
+ await flushPromises();
280
+
281
+ expect(
282
+ element.shadowRoot.querySelector(selectors.collapsedSummary)
283
+ ).not.toBeNull();
284
+ expect(
285
+ element.shadowRoot.querySelectorAll(selectors.stepItem)
286
+ ).toHaveLength(0);
287
+ expect(
288
+ element.shadowRoot.querySelector(selectors.collapseButton)
289
+ ).toBeNull();
290
+ });
291
+
292
+ it('should set aria-expanded to true when expanded', async () => {
293
+ const element = createTestComponent({
294
+ agentSteps: multipleSteps,
295
+ isStreaming: false,
296
+ });
297
+ await flushPromises();
298
+
299
+ const collapsedSummary = element.shadowRoot.querySelector(
300
+ selectors.collapsedSummary
301
+ );
302
+ collapsedSummary.click();
303
+ await flushPromises();
304
+
305
+ const collapseButton = element.shadowRoot.querySelector(
306
+ selectors.collapseButton
307
+ );
308
+ expect(collapseButton).not.toBeNull();
309
+ expect(collapseButton.getAttribute('aria-expanded')).toBe('true');
310
+ });
311
+
312
+ it('should set aria-expanded to false when collapsed', async () => {
313
+ const element = createTestComponent({
314
+ agentSteps: multipleSteps,
315
+ isStreaming: false,
316
+ });
317
+ await flushPromises();
318
+
319
+ const collapsedSummary = element.shadowRoot.querySelector(
320
+ selectors.collapsedSummary
321
+ );
322
+ expect(collapsedSummary).not.toBeNull();
323
+ expect(collapsedSummary.getAttribute('aria-expanded')).toBe('false');
324
+ });
325
+ });
326
+
327
+ describe('single answering step', () => {
328
+ it('should show the step without a collapse button when only answering exists', async () => {
329
+ const element = createTestComponent({
330
+ agentSteps: [{name: 'answering', status: 'completed'}],
331
+ isStreaming: false,
332
+ });
333
+ await flushPromises();
334
+
335
+ const stepItems = element.shadowRoot.querySelectorAll(selectors.stepItem);
336
+ const collapseButton = element.shadowRoot.querySelector(
337
+ selectors.collapseButton
338
+ );
339
+ const collapsedSummary = element.shadowRoot.querySelector(
340
+ selectors.collapsedSummary
341
+ );
342
+
343
+ expect(stepItems).toHaveLength(1);
344
+ expect(collapseButton).toBeNull();
345
+ expect(collapsedSummary).toBeNull();
346
+ });
347
+ });
348
+ });
@@ -0,0 +1,17 @@
1
+ .stream-of-thought__collapsed-summary,
2
+ .stream-of-thought__collapse-button {
3
+ border: none;
4
+ background: transparent;
5
+ }
6
+
7
+ .stream-of-thought__collapsed-summary:focus-visible,
8
+ .stream-of-thought__collapse-button:focus-visible {
9
+ border-radius: var(--slds-g-sizing-border-4, 0.25rem);
10
+ outline: 2px solid var(--lwc-brandPrimary, #1b96ff);
11
+ outline-offset: 2px;
12
+ }
13
+
14
+ .stream-of-thought__spinner-container {
15
+ width: 0.8rem;
16
+ height: 0.8rem;
17
+ }