diff --git a/packages/atomic-react/src/components/search/components.ts b/packages/atomic-react/src/components/search/components.ts
index bad5cb1176b..409f046f5f0 100644
--- a/packages/atomic-react/src/components/search/components.ts
+++ b/packages/atomic-react/src/components/search/components.ts
@@ -1,4 +1,5 @@
import {
+ AtomicAiConversationToggle as LitAtomicAiConversationToggle,
AtomicAriaLive as LitAtomicAriaLive,
AtomicComponentError as LitAtomicComponentError,
AtomicExternal as LitAtomicExternal,
@@ -41,6 +42,12 @@ import {
import {createComponent} from '@lit/react';
import React from 'react';
+export const AtomicAiConversationToggle = createComponent({
+ tagName: 'atomic-ai-conversation-toggle',
+ react: React,
+ elementClass: LitAtomicAiConversationToggle,
+});
+
export const AtomicAriaLive = createComponent({
tagName: 'atomic-aria-live',
react: React,
diff --git a/packages/atomic/dev/examples/multiturn-conversation.html b/packages/atomic/dev/examples/multiturn-conversation.html
new file mode 100644
index 00000000000..e78c28ef3d0
--- /dev/null
+++ b/packages/atomic/dev/examples/multiturn-conversation.html
@@ -0,0 +1,58 @@
+
+
+
+
+
+ Coveo Multi-turn conversation
+
+
+
+
+
+
+
+
+
+ Multi-turn conversation example coming soon!
+
+
diff --git a/packages/atomic/dev/header.js b/packages/atomic/dev/header.js
index a7f0601d434..5dc69ee13f4 100644
--- a/packages/atomic/dev/header.js
+++ b/packages/atomic/dev/header.js
@@ -19,6 +19,10 @@ const links = [
{href: '/examples/genqa.html', label: 'Gen Q&A'},
{href: '/examples/tabs.html', label: 'Tabs'},
{href: '/examples/commerce-website/homepage.html', label: 'Commerce Website'},
+ {
+ href: '/examples/multiturn-conversation.html',
+ label: 'Multi-turn conversation',
+ },
];
const header = document.createElement('header');
diff --git a/packages/atomic/scripts/generate-component.mjs b/packages/atomic/scripts/generate-component.mjs
index 7bf86d08e4a..04ee9e4100c 100644
--- a/packages/atomic/scripts/generate-component.mjs
+++ b/packages/atomic/scripts/generate-component.mjs
@@ -1,6 +1,7 @@
import {execSync} from 'node:child_process';
+import {existsSync} from 'node:fs';
+import {mkdir, readFile, writeFile} from 'node:fs/promises';
import path from 'node:path';
-import fs from 'fs-extra';
import handlebars from 'handlebars';
const capitalize = (str) => str.charAt(0).toUpperCase() + str.slice(1);
@@ -48,12 +49,12 @@ async function generateFiles(name, outputDir) {
outputPaths.push(outputPath);
// Does not overwrite existing files
- if (await fs.pathExists(outputPath)) {
+ if (existsSync(outputPath)) {
console.log(`Skipped (already exists): ${outputPath}`);
continue;
}
- const templateContent = await fs.readFile(templatePath, 'utf8');
+ const templateContent = await readFile(templatePath, 'utf8');
const compiled = handlebars.compile(templateContent);
const content = compiled({
name,
@@ -63,8 +64,8 @@ async function generateFiles(name, outputDir) {
githubPath,
});
- await fs.ensureDir(path.dirname(outputPath));
- await fs.writeFile(outputPath, content, 'utf8');
+ await mkdir(path.dirname(outputPath), {recursive: true});
+ await writeFile(outputPath, content, 'utf8');
console.log(`Created: ${outputPath}`);
}
execSync(`npx @biomejs/biome check --write ${outputPaths.join(' ')}`);
diff --git a/packages/atomic/src/components/common/ai-conversation-modal/modal.ts b/packages/atomic/src/components/common/ai-conversation-modal/modal.ts
new file mode 100644
index 00000000000..f54cef61a94
--- /dev/null
+++ b/packages/atomic/src/components/common/ai-conversation-modal/modal.ts
@@ -0,0 +1,85 @@
+import type {i18n} from 'i18next';
+import {html} from 'lit';
+import {localizedString} from '@/src/directives/localized-string';
+import type {FunctionalComponentWithChildren} from '@/src/utils/functional-component-utils';
+import CloseIcon from '../../../images/close.svg';
+import {ATOMIC_MODAL_EXPORT_PARTS} from '../atomic-modal/export-parts';
+import '../atomic-modal/atomic-modal';
+import {renderButton} from '../button';
+import '../atomic-icon/atomic-icon';
+
+interface AIConversationModalProps {
+ host: HTMLElement;
+ i18n: i18n;
+ onClose(): void;
+ title: string;
+ isOpen: boolean;
+ openButton?: HTMLElement;
+ boundary?: 'page' | 'element';
+ scope?: HTMLElement;
+}
+
+export const renderAIConversationModal: FunctionalComponentWithChildren<
+ AIConversationModalProps
+> =
+ ({props}) =>
+ (children) => {
+
+ const renderHeader = () => {
+ return html`
+
+
${props.title}
+ ${renderButton({
+ props: {
+ style: 'text-transparent',
+ class: 'grid place-items-center',
+ part: 'close-button',
+ onClick: props.onClose,
+ ariaLabel: props.i18n.t('close'),
+ },
+ })(
+ html`
`
+ )}
+
+ `;
+ };
+
+ const renderFooter = () => {
+ return html`
+
+ ${renderButton({
+ props: {
+ style: 'primary',
+ part: 'footer-button',
+ class: 'flex w-full justify-center p-3 text-lg',
+ onClick: props.onClose,
+ },
+ })(html`
+
+ ${'Close'}
+
+ `)}
+
+ `;
+ };
+
+ return html`
+ {}}
+ exportparts=${ATOMIC_MODAL_EXPORT_PARTS}
+ .boundary=${props.boundary ?? 'page'}
+ .scope=${props.scope}
+ >
+ ${renderHeader()} ${children} ${renderFooter()}
+
+ `;
+ };
diff --git a/packages/atomic/src/components/common/atomic-citation-container/atomic-citation-container.mdx b/packages/atomic/src/components/common/atomic-citation-container/atomic-citation-container.mdx
new file mode 100644
index 00000000000..991e103a7cc
--- /dev/null
+++ b/packages/atomic/src/components/common/atomic-citation-container/atomic-citation-container.mdx
@@ -0,0 +1,22 @@
+import {Meta} from '@storybook/addon-docs/blocks';
+import * as AtomicCitationListStories from './atomic-citation-container.new.stories';
+import {AtomicDocTemplate} from '../../../../../storybook-utils/documentation/atomic-doc-template';
+
+
+
+
+
+The `atomic-citation-list` component displays a list of citations for a generated answer.
+
+## Usage
+
+```html
+
+```
+
+
\ No newline at end of file
diff --git a/packages/atomic/src/components/common/atomic-citation-container/atomic-citation-container.new.stories.tsx b/packages/atomic/src/components/common/atomic-citation-container/atomic-citation-container.new.stories.tsx
new file mode 100644
index 00000000000..a9d60253011
--- /dev/null
+++ b/packages/atomic/src/components/common/atomic-citation-container/atomic-citation-container.new.stories.tsx
@@ -0,0 +1,53 @@
+import type {Meta, StoryObj as Story} from '@storybook/web-components-vite';
+import {html} from 'lit';
+import {parameters} from '@/storybook-utils/common/common-meta-parameters';
+import './atomic-citation-container.js';
+import type {GeneratedAnswerCitation} from '@coveo/headless';
+import type {AtomicCitationContainer} from './atomic-citation-container.js';
+
+const sampleCitations: GeneratedAnswerCitation[] = [
+ {
+ id: '1',
+ title: "What's a query pipeline? | Coveo Platform",
+ uri: 'https://docs.coveo.com/en/query-pipeline',
+ permanentid: 'doc-query-pipeline-123',
+ source: 'Coveo Documentation',
+ clickUri: 'https://docs.coveo.com/en/query-pipeline',
+ },
+ {
+ id: '2',
+ title:
+ 'Manage the basic configuration of a query pipeline | Coveo Platform',
+ uri: 'https://docs.coveo.com/en/manage-query-pipeline',
+ permanentid: 'doc-manage-pipeline-456',
+ source: 'Coveo Training',
+ clickUri: 'https://docs.coveo.com/en/manage-query-pipeline',
+ },
+];
+
+const meta: Meta = {
+ component: 'atomic-citation-container',
+ title: 'Common/Citation Container',
+ id: 'atomic-citation-container',
+
+ render: (args) => {
+ const element = document.createElement(
+ 'atomic-citation-container'
+ ) as AtomicCitationContainer;
+ element.citations = args.citations;
+ return element;
+ },
+ parameters: {
+ ...parameters,
+ },
+ args: {
+ citations: sampleCitations,
+ },
+};
+
+export default meta;
+
+export const Default: Story = {
+ name: 'atomic-citation-container',
+ decorators: [(story) => html`${story()}`],
+};
diff --git a/packages/atomic/src/components/common/atomic-citation-container/atomic-citation-container.ts b/packages/atomic/src/components/common/atomic-citation-container/atomic-citation-container.ts
new file mode 100644
index 00000000000..4cae7e5033a
--- /dev/null
+++ b/packages/atomic/src/components/common/atomic-citation-container/atomic-citation-container.ts
@@ -0,0 +1,41 @@
+import type {GeneratedAnswerCitation} from '@coveo/headless';
+import {html, LitElement} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
+import {withTailwindStyles} from '@/src/decorators/with-tailwind-styles.js';
+import '../atomic-citation-list/atomic-citation-list';
+
+/**
+ * The `atomic-citation-container` component displays a list of citations for a generated answer in the context of a multiturn conversation.
+ *
+ * @part citation-list - The container for the citation list
+ * @part citation-item - Each individual citation item
+ * @part citation-title - The title of the citation
+ * @part citation-source - The source label of the citation
+ * @part citation-link - The clickable link element
+ */
+@customElement('atomic-citation-container')
+@withTailwindStyles
+export class AtomicCitationContainer extends LitElement {
+ /**
+ * The array of citations to display
+ */
+ @property({type: Array, attribute: false})
+ citations: GeneratedAnswerCitation[] = [];
+
+ render() {
+ // Guard against null/undefined citations
+
+ return html`
+
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'atomic-citation-container': AtomicCitationContainer;
+ }
+}
diff --git a/packages/atomic/src/components/common/atomic-citation-list/atomic-citation-list.mdx b/packages/atomic/src/components/common/atomic-citation-list/atomic-citation-list.mdx
new file mode 100644
index 00000000000..eb7076f92f8
--- /dev/null
+++ b/packages/atomic/src/components/common/atomic-citation-list/atomic-citation-list.mdx
@@ -0,0 +1,22 @@
+import {Meta} from '@storybook/addon-docs/blocks';
+import * as AtomicCitationListStories from './atomic-citation-list.new.stories';
+import {AtomicDocTemplate} from '../../../../../storybook-utils/documentation/atomic-doc-template';
+
+
+
+
+
+The `atomic-citation-list` component displays a list of citations for a generated answer.
+
+## Usage
+
+```html
+
+```
+
+
\ No newline at end of file
diff --git a/packages/atomic/src/components/common/atomic-citation-list/atomic-citation-list.new.stories.tsx b/packages/atomic/src/components/common/atomic-citation-list/atomic-citation-list.new.stories.tsx
new file mode 100644
index 00000000000..c1ad2d7420d
--- /dev/null
+++ b/packages/atomic/src/components/common/atomic-citation-list/atomic-citation-list.new.stories.tsx
@@ -0,0 +1,53 @@
+import type {Meta, StoryObj as Story} from '@storybook/web-components-vite';
+import {html} from 'lit';
+import {parameters} from '@/storybook-utils/common/common-meta-parameters';
+import type {AtomicCitationList} from './atomic-citation-list';
+import './atomic-citation-list';
+import type {GeneratedAnswerCitation} from '@coveo/headless';
+
+const sampleCitations: GeneratedAnswerCitation[] = [
+ {
+ id: '1',
+ title: "What's a query pipeline? | Coveo Platform",
+ uri: 'https://docs.coveo.com/en/query-pipeline',
+ permanentid: 'doc-query-pipeline-123',
+ source: 'Coveo Documentation',
+ clickUri: 'https://docs.coveo.com/en/query-pipeline',
+ },
+ {
+ id: '2',
+ title:
+ 'Manage the basic configuration of a query pipeline | Coveo Platform',
+ uri: 'https://docs.coveo.com/en/manage-query-pipeline',
+ permanentid: 'doc-manage-pipeline-456',
+ source: 'Coveo Training',
+ clickUri: 'https://docs.coveo.com/en/manage-query-pipeline',
+ },
+];
+
+const meta: Meta = {
+ component: 'atomic-citation-list',
+ title: 'Common/Citation List',
+ id: 'atomic-citation-list',
+
+ render: (args) => {
+ const element = document.createElement(
+ 'atomic-citation-list'
+ ) as AtomicCitationList;
+ element.citations = args.citations;
+ return element;
+ },
+ parameters: {
+ ...parameters,
+ },
+ args: {
+ citations: sampleCitations,
+ },
+};
+
+export default meta;
+
+export const Default: Story = {
+ name: 'atomic-citation-list',
+ decorators: [(story) => html`${story()}`],
+};
diff --git a/packages/atomic/src/components/common/atomic-citation-list/atomic-citation-list.ts b/packages/atomic/src/components/common/atomic-citation-list/atomic-citation-list.ts
new file mode 100644
index 00000000000..558ef036aea
--- /dev/null
+++ b/packages/atomic/src/components/common/atomic-citation-list/atomic-citation-list.ts
@@ -0,0 +1,128 @@
+import type {GeneratedAnswerCitation} from '@coveo/headless';
+import {css, html, LitElement} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
+import {withTailwindStyles} from '@/src/decorators/with-tailwind-styles.js';
+
+/**
+ * The `atomic-citation-list` component displays a list of citations for a generated answer in the context of a multiturn conversation.
+ *
+ * @part citation-list - The container for the citation list
+ * @part citation-item - Each individual citation item
+ * @part citation-title - The title of the citation
+ * @part citation-source - The source label of the citation
+ * @part citation-link - The clickable link element
+ */
+@customElement('atomic-citation-list')
+@withTailwindStyles
+export class AtomicCitationList extends LitElement {
+ static styles = css`
+ :host {
+ display: block;
+ font-family:
+ system-ui,
+ -apple-system,
+ sans-serif;
+ }
+
+ .citation-list {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ padding: 1.5rem;
+ background-color: #efeff9;
+ border-radius: 1rem;
+ }
+
+ .citation-list-title {
+ font-size: 1.125rem;
+ font-weight: 600;
+ color: #2e2e2e;
+ margin: 0 0 1rem 0;
+ }
+
+ .citation-items {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ }
+
+ .citation-item {
+ padding-top: 0.5rem;
+ padding-bottom: 0.5rem;
+ }
+
+ .citation-link {
+ text-decoration: none;
+ color: inherit;
+ display: block;
+ }
+
+ .citation-link:hover .citation-title {
+ text-decoration: underline;
+ }
+
+ .citation-source {
+ font-size: 0.875rem;
+ color: #828282;
+ margin-bottom: 0.25rem;
+ }
+
+ .citation-title {
+ font-size: 1rem;
+ font-weight: 500;
+ color: #2e2e2e;
+ }
+ `;
+
+ /**
+ * The array of citations to display
+ */
+ @property({type: Array, attribute: false})
+ citations: GeneratedAnswerCitation[] = [];
+
+ render() {
+ // Guard against null/undefined citations
+ const citationsToRender = this.citations || [];
+
+ return html`
+
+
Citations
+
+ ${citationsToRender.map((citation) => this.renderCitation(citation))}
+
+
+ `;
+ }
+
+ private renderCitation(citation: GeneratedAnswerCitation) {
+ const citationUrl = citation.clickUri || citation.uri;
+
+ return html`
+
+
+
+ ${citation.source}
+
+
+ ${citation.title}
+
+
+
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'atomic-citation-list': AtomicCitationList;
+ }
+}
diff --git a/packages/atomic/src/components/common/generated-answer/atomic-generated-content.tsx b/packages/atomic/src/components/common/generated-answer/atomic-generated-content.tsx
new file mode 100644
index 00000000000..865bdb24e18
--- /dev/null
+++ b/packages/atomic/src/components/common/generated-answer/atomic-generated-content.tsx
@@ -0,0 +1,40 @@
+import { FunctionalComponent } from "@/src/utils/functional-component-utils";
+import { html } from "lit";
+import { when } from "lit/directives/when.js";
+import { renderGeneratedTextContent } from "./generated-content/generated-text-content";
+
+interface GeneratedContentContainerProps {
+ answer?: string;
+ answerContentFormat?: string;
+ isStreaming: boolean;
+}
+
+export const renderAtomicGeneratedContent: FunctionalComponent<
+ GeneratedContentContainerProps
+> = ({ props }) => {
+ const isMarkdown = props.answerContentFormat === "text/markdown";
+ return html`
+
+ ${when(
+ isMarkdown,
+ () => html`
+
+ `,
+
+ () =>
+ renderGeneratedTextContent({
+ props: {
+ answer: props.answer,
+ isStreaming: props.isStreaming,
+ },
+ })
+ )}
+
+
+ `;
+};
diff --git a/packages/atomic/src/components/common/generated-answer/generated-content-container.tsx b/packages/atomic/src/components/common/generated-answer/generated-content-container.tsx
index a08f38db169..525968e328b 100644
--- a/packages/atomic/src/components/common/generated-answer/generated-content-container.tsx
+++ b/packages/atomic/src/components/common/generated-answer/generated-content-container.tsx
@@ -1,6 +1,6 @@
-import {FunctionalComponent, h} from '@stencil/core';
-import {GeneratedMarkdownContent} from './generated-content/generated-markdown-content';
-import {GeneratedTextContent} from './generated-content/generated-text-content';
+import { FunctionalComponent, h } from "@stencil/core";
+import { GeneratedMarkdownContent } from "./generated-content/stencil-generated-markdown-content";
+import { GeneratedTextContent } from "./generated-content/stencil-generated-text-content";
interface GeneratedContentContainerProps {
answer?: string;
@@ -16,7 +16,7 @@ export const GeneratedContentContainer: FunctionalComponent<
> = (props, children) => {
return (
- {props.answerContentFormat === 'text/markdown' ? (
+ {props.answerContentFormat === "text/markdown" ? (
+ `;
+ }
+}
diff --git a/packages/atomic/src/components/common/generated-answer/generated-content/generated-text-content.ts b/packages/atomic/src/components/common/generated-answer/generated-content/generated-text-content.ts
new file mode 100644
index 00000000000..b08a80afca0
--- /dev/null
+++ b/packages/atomic/src/components/common/generated-answer/generated-content/generated-text-content.ts
@@ -0,0 +1,22 @@
+import {html} from 'lit';
+import type {FunctionalComponent} from '@/src/utils/functional-component-utils';
+
+interface GeneratedTextContentProps {
+ answer?: string;
+ isStreaming: boolean;
+}
+
+export const renderGeneratedTextContent: FunctionalComponent<
+ GeneratedTextContentProps
+> = ({props}) => {
+ return html`
+
+ ${props.answer}
+
+ `;
+};
diff --git a/packages/atomic/src/components/common/generated-answer/generated-content/generated-markdown-content.tsx b/packages/atomic/src/components/common/generated-answer/generated-content/stencil-generated-markdown-content.tsx
similarity index 100%
rename from packages/atomic/src/components/common/generated-answer/generated-content/generated-markdown-content.tsx
rename to packages/atomic/src/components/common/generated-answer/generated-content/stencil-generated-markdown-content.tsx
diff --git a/packages/atomic/src/components/common/generated-answer/generated-content/generated-text-content.tsx b/packages/atomic/src/components/common/generated-answer/generated-content/stencil-generated-text-content.tsx
similarity index 100%
rename from packages/atomic/src/components/common/generated-answer/generated-content/generated-text-content.tsx
rename to packages/atomic/src/components/common/generated-answer/generated-content/stencil-generated-text-content.tsx
diff --git a/packages/atomic/src/components/common/index.ts b/packages/atomic/src/components/common/index.ts
index 6653e078a2b..f8cf380c190 100644
--- a/packages/atomic/src/components/common/index.ts
+++ b/packages/atomic/src/components/common/index.ts
@@ -1,5 +1,7 @@
// Auto-generated file
export {AtomicAriaLive} from './atomic-aria-live/atomic-aria-live.js';
+export {AtomicCitationContainer} from './atomic-citation-container/atomic-citation-container.js';
+export {AtomicCitationList} from './atomic-citation-list/atomic-citation-list.js';
export {AtomicComponentError} from './atomic-component-error/atomic-component-error.js';
export {AtomicFacetDateInput} from './atomic-facet-date-input/atomic-facet-date-input.js';
export {AtomicFacetPlaceholder} from './atomic-facet-placeholder/atomic-facet-placeholder.js';
diff --git a/packages/atomic/src/components/common/lazy-index.ts b/packages/atomic/src/components/common/lazy-index.ts
index 47a94102b45..52a7a4a10c6 100644
--- a/packages/atomic/src/components/common/lazy-index.ts
+++ b/packages/atomic/src/components/common/lazy-index.ts
@@ -2,6 +2,10 @@
export default {
'atomic-aria-live': async () =>
await import('./atomic-aria-live/atomic-aria-live.js'),
+ 'atomic-citation-container': async () =>
+ await import('./atomic-citation-container/atomic-citation-container.js'),
+ 'atomic-citation-list': async () =>
+ await import('./atomic-citation-list/atomic-citation-list.js'),
'atomic-component-error': async () =>
await import('./atomic-component-error/atomic-component-error.js'),
'atomic-facet-date-input': async () =>
diff --git a/packages/atomic/src/components/common/result-children/show-hide-button.tsx b/packages/atomic/src/components/common/result-children/show-hide-button.tsx
index 026c13e668a..0dc04300b1f 100644
--- a/packages/atomic/src/components/common/result-children/show-hide-button.tsx
+++ b/packages/atomic/src/components/common/result-children/show-hide-button.tsx
@@ -29,9 +29,7 @@ export const ShowHideButton: FunctionalComponent = ({
onClick={() => {
if (moreResultsAvailable) {
loadFullCollection();
- toggleShowInitialChildren();
}
-
toggleShowInitialChildren();
}}
>
diff --git a/packages/atomic/src/components/insight/result-templates/atomic-insight-result-children/atomic-insight-result-children.tsx b/packages/atomic/src/components/insight/result-templates/atomic-insight-result-children/atomic-insight-result-children.tsx
index 06d62aa461e..762c4263335 100644
--- a/packages/atomic/src/components/insight/result-templates/atomic-insight-result-children/atomic-insight-result-children.tsx
+++ b/packages/atomic/src/components/insight/result-templates/atomic-insight-result-children/atomic-insight-result-children.tsx
@@ -64,7 +64,7 @@ export class AtomicResultChildren
@State()
private foldedResultListState!: InsightFoldedResultListState;
@State()
- private showInitialChildren = false;
+ private showInitialChildren = true;
/**
* Whether to inherit templates defined in a parent atomic-result-children. Only works for the second level of child nesting.
diff --git a/packages/atomic/src/components/search/atomic-ai-conversation-toggle/atomic-ai-conversation-toggle.mdx b/packages/atomic/src/components/search/atomic-ai-conversation-toggle/atomic-ai-conversation-toggle.mdx
new file mode 100644
index 00000000000..47703668fb2
--- /dev/null
+++ b/packages/atomic/src/components/search/atomic-ai-conversation-toggle/atomic-ai-conversation-toggle.mdx
@@ -0,0 +1,19 @@
+import { Meta } from '@storybook/addon-docs/blocks';
+import * as AtomicAiConversationToggleStories from './atomic-ai-conversation-toggle.new.stories';
+import { AtomicDocTemplate } from '../../../../storybook-utils/documentation/atomic-doc-template';
+
+
+
+
+
+// TODO: Add usage instructions here.
+
+// * See atomic-product-link.mdx for typical template component.
+// * See atomic-commerce-pager.mdx for typical non-template component.
+
+
diff --git a/packages/atomic/src/components/search/atomic-ai-conversation-toggle/atomic-ai-conversation-toggle.new.stories.tsx b/packages/atomic/src/components/search/atomic-ai-conversation-toggle/atomic-ai-conversation-toggle.new.stories.tsx
new file mode 100644
index 00000000000..becad94ca3c
--- /dev/null
+++ b/packages/atomic/src/components/search/atomic-ai-conversation-toggle/atomic-ai-conversation-toggle.new.stories.tsx
@@ -0,0 +1,35 @@
+// import {wrapInCommerceInterface} from '@/storybook-utils/commerce/commerce-interface-wrapper';
+// import {wrapInInsightInterface} from '@/storybook-utils/insight/insight-interface-wrapper';
+import type {Meta, StoryObj as Story} from '@storybook/web-components-vite';
+import {getStorybookHelpers} from '@wc-toolkit/storybook-helpers';
+import {parameters} from '@/storybook-utils/common/common-meta-parameters';
+import {wrapInSearchInterface} from '@/storybook-utils/search/search-interface-wrapper';
+const {events, args, argTypes, template} = getStorybookHelpers('atomic-ai-conversation-toggle', {
+ excludeCategories: ['methods'],
+});
+
+// Wrap it in whatever interface/component you need
+const {decorator, play} = wrapInSearchInterface();
+// const {decorator, play} = wrapInCommerceInterface();
+// const {decorator, play} = wrapInInsightInterface();
+
+const meta: Meta = {
+ component: 'atomic-ai-conversation-toggle',
+ title: 'TODO/Ai Conversation Toggle',
+ id: 'atomic-ai-conversation-toggle',
+ render: (args) => template(args),
+ decorators: [decorator],
+ parameters: {
+ ...parameters,
+ actions: {
+ handles: events,
+ },
+ },
+ args,
+ argTypes,
+ play,
+};
+
+export default meta;
+
+export const Default: Story = {};
diff --git a/packages/atomic/src/components/search/atomic-ai-conversation-toggle/atomic-ai-conversation-toggle.spec.ts b/packages/atomic/src/components/search/atomic-ai-conversation-toggle/atomic-ai-conversation-toggle.spec.ts
new file mode 100644
index 00000000000..cc005a102a0
--- /dev/null
+++ b/packages/atomic/src/components/search/atomic-ai-conversation-toggle/atomic-ai-conversation-toggle.spec.ts
@@ -0,0 +1,326 @@
+import {buildQuerySummary, type QuerySummaryState} from '@coveo/headless';
+import {html, type TemplateResult} from 'lit';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {within} from 'shadow-dom-testing-library';
+import {beforeEach, describe, expect, it, type MockInstance, vi} from 'vitest';
+import {page} from 'vitest/browser';
+import {renderInAtomicSearchInterface} from '@/vitest-utils/testing-helpers/fixtures/atomic/search/atomic-search-interface-fixture';
+import {buildFakeSearchEngine} from '@/vitest-utils/testing-helpers/fixtures/headless/search/engine';
+import {buildFakeSummary} from '@/vitest-utils/testing-helpers/fixtures/headless/search/summary-controller';
+import type {AtomicAiConversationToggle} from './atomic-ai-conversation-toggle';
+import './atomic-ai-conversation-toggle';
+
+vi.mock('@coveo/headless', {spy: true});
+
+describe('atomic-ai-conversation-toggle', () => {
+ const mockedEngine = buildFakeSearchEngine();
+ let mockedQuerySummary: ReturnType;
+
+ const renderAtomicAiConversationToggle = async ({
+ props = {},
+ slottedContent,
+ querySummaryState = {hasQuery: true, query: 'test query'},
+ }: {
+ props?: Partial<{entityToGreet: string; isVulcan: boolean}>;
+ slottedContent?: TemplateResult;
+ querySummaryState?: Partial;
+ } = {}) => {
+ mockedQuerySummary = buildFakeSummary({state: querySummaryState});
+
+ vi.mocked(buildQuerySummary).mockImplementation(() => mockedQuerySummary);
+
+ const {element} =
+ await renderInAtomicSearchInterface({
+ template: html`${ifDefined(slottedContent)}
+ `,
+ selector: 'atomic-ai-conversation-toggle',
+ bindings: (bindings) => {
+ bindings.engine = mockedEngine;
+ return bindings;
+ },
+ });
+
+ return {
+ element,
+ toggleRevealQueryButton: () => page.getByRole('button'),
+ parts: (element: AtomicAiConversationToggle) => {
+ const qs = (part: string) =>
+ element.shadowRoot?.querySelector(`[part="${part}"]`);
+ return {
+ container: qs('container'),
+ message: qs('message'),
+ queryContainer: qs('query-container'),
+ toggleRevealQueryButton: qs('toggle-reveal-query-button'),
+ query: qs('query'),
+ };
+ },
+ };
+ };
+
+ describe('#initialize', () => {
+ it('should not set the error when using the default props', async () => {
+ const {element} = await renderAtomicAiConversationToggle();
+
+ expect(element.error).toBeUndefined();
+ });
+
+ it('should not set the error when receiving valid props', async () => {
+ const {element} = await renderAtomicAiConversationToggle({
+ props: {
+ entityToGreet: 'Mr Spock',
+ isVulcan: true,
+ },
+ });
+
+ expect(element.error).toBeUndefined();
+ });
+
+ it('should set the error when received entityToGreet is an empty string', async () => {
+ const {element} = await renderAtomicAiConversationToggle({
+ props: {
+ entityToGreet: '',
+ },
+ });
+
+ expect(element.error).toBeInstanceOf(Error);
+ expect(element.error.message).toContain(
+ 'entityToGreet: value is an empty string.'
+ );
+ });
+
+ it('should set the querySummary with buildQuerySummary', async () => {
+ const {element} = await renderAtomicAiConversationToggle();
+
+ expect(buildQuerySummary).toHaveBeenCalledWith(mockedEngine);
+ expect(element.querySummary).toBe(mockedQuerySummary);
+ });
+
+ it('should bind the querySummaryState to the state of query summary controller', async () => {
+ const {element} = await renderAtomicAiConversationToggle({
+ querySummaryState: {hasQuery: true, query: 'test new query'},
+ });
+
+ expect(element.querySummaryState.hasQuery).toBe(true);
+ expect(element.querySummaryState.query).toBe('test new query');
+ });
+
+ it('should add an "atomic/redden" event listener to the element', async () => {
+ const {element} = await renderAtomicAiConversationToggle();
+ const addEventListenerSpy = vi.spyOn(element, 'addEventListener');
+
+ element.initialize();
+
+ expect(addEventListenerSpy).toHaveBeenCalledExactlyOnceWith(
+ 'atomic/redden',
+ expect.any(Function)
+ );
+ });
+ });
+
+ describe('when the "atomic/redden" event is dispatched', () => {
+ let consoleLogSpy: MockInstance;
+
+ beforeEach(() => {
+ // Mock the console to avoid polluting the test logs.
+ consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
+ });
+
+ it('should add the "text-red-600" class to the message paragraph', async () => {
+ const {element, parts} = await renderAtomicAiConversationToggle();
+ expect(parts(element).message?.classList.contains('text-red-600')).toBe(
+ false
+ );
+
+ element?.dispatchEvent(new CustomEvent('atomic/redden'));
+
+ expect(parts(element).message?.classList.contains('text-red-600')).toBe(
+ true
+ );
+ });
+
+ it('should log to the console', async () => {
+ const {element} = await renderAtomicAiConversationToggle();
+
+ element?.dispatchEvent(new CustomEvent('atomic/redden'));
+
+ expect(consoleLogSpy).toHaveBeenCalledWith(
+ 'The greeting has been reddened!'
+ );
+ });
+ });
+
+ describe('before updating (#willUpdate)', () => {
+ it('should not set the error when entityToGreet has not been updated', async () => {
+ const {element} = await renderAtomicAiConversationToggle();
+
+ element.isVulcan = true;
+ await element.updateComplete;
+
+ expect(element.error).toBeUndefined();
+ });
+
+ it('should not set the error when entityToGreet has been updated to a valid value', async () => {
+ const {element} = await renderAtomicAiConversationToggle();
+
+ element.entityToGreet = 'Mr LaForge';
+ await element.updateComplete;
+
+ expect(element.error).toBeUndefined();
+ });
+
+ it('should set the error when entityToGreet has been updated to an invalid value', async () => {
+ const {element} = await renderAtomicAiConversationToggle();
+
+ element.entityToGreet = '';
+ await element.updateComplete;
+
+ expect(element.error).toBeInstanceOf(Error);
+ expect(element.error.message).toContain(
+ 'entityToGreet: value is an empty string.'
+ );
+ });
+ });
+
+ describe('when rendering (#render)', () => {
+ it('should render every part except the query initially', async () => {
+ const {element, parts} = await renderAtomicAiConversationToggle();
+ const partsElements = parts(element);
+
+ await expect.element(partsElements.container!).toBeInTheDocument();
+ await expect.element(partsElements.message!).toBeInTheDocument();
+ await expect.element(partsElements.query!).not.toBeInTheDocument();
+ await expect.element(partsElements.queryContainer!).toBeInTheDocument();
+ await expect
+ .element(partsElements.toggleRevealQueryButton!)
+ .toBeInTheDocument();
+ });
+
+ it('should render content in the "before" slot before the message part', async () => {
+ const {element, parts} = await renderAtomicAiConversationToggle({
+ slottedContent: html`Before
`,
+ });
+ const slot = parts(element).container?.querySelector(
+ 'slot[name="before"]'
+ ) as HTMLSlotElement;
+
+ expect(within(slot).getByShadowText('Before')?.tagName).toBe('DIV');
+ expect(within(slot).getByShadowText('Before').classList).toContain(
+ 'test-class'
+ );
+ });
+
+ it('should render content in the default slot between the message part and the query container part', async () => {
+ const {element, parts} = await renderAtomicAiConversationToggle({
+ slottedContent: html`Default
`,
+ });
+ const slot = parts(element).container?.querySelector(
+ 'slot:not([name])'
+ ) as HTMLSlotElement;
+
+ expect(within(slot).getByShadowText('Default')?.tagName).toBe('DIV');
+ expect(within(slot).getByShadowText('Default').classList).toContain(
+ 'test-class'
+ );
+ });
+
+ it('should render content in the "after" slot after the query container part', async () => {
+ const {element, parts} = await renderAtomicAiConversationToggle({
+ slottedContent: html`After
`,
+ });
+ const slot = parts(element).container?.querySelector(
+ 'slot[name="after"]'
+ ) as HTMLSlotElement;
+
+ expect(within(slot).getByShadowText('After')?.tagName).toBe('DIV');
+ expect(within(slot).getByShadowText('After').classList).toContain(
+ 'test-class'
+ );
+ });
+
+ it('should render the Vulcan greeting when isVulcan is true', async () => {
+ const {element, parts} = await renderAtomicAiConversationToggle({
+ props: {isVulcan: true},
+ });
+
+ expect(parts(element).message).toHaveTextContent('🖖 Mr LaForge!');
+ });
+
+ it('should render the non-Vulcan greeting when isVulcan is false', async () => {
+ const {element, parts} = await renderAtomicAiConversationToggle({
+ props: {isVulcan: false},
+ });
+ const partsElements = parts(element);
+
+ expect(partsElements.message).toHaveTextContent('👋 Mr LaForge!');
+ });
+
+ it('should render the button with the correct style', async () => {
+ const {element, parts} = await renderAtomicAiConversationToggle();
+
+ expect(parts(element).toggleRevealQueryButton).toHaveClass('btn-primary');
+ });
+
+ it('should render the button with the correct text when the query is not revealed', async () => {
+ const {element, parts} = await renderAtomicAiConversationToggle();
+
+ expect(parts(element).toggleRevealQueryButton).toHaveTextContent(
+ 'Show more'
+ );
+ });
+
+ it('should render the button with the correct text when the query is revealed', async () => {
+ const {element, parts, toggleRevealQueryButton} =
+ await renderAtomicAiConversationToggle();
+
+ await toggleRevealQueryButton().click();
+
+ expect(parts(element).toggleRevealQueryButton).toHaveTextContent(
+ 'Show less'
+ );
+ });
+
+ it('should enable the button when there is a query', async () => {
+ const {element, parts} = await renderAtomicAiConversationToggle({
+ querySummaryState: {hasQuery: true, query: 'test query'},
+ });
+
+ expect(parts(element).toggleRevealQueryButton).toBeEnabled();
+ });
+
+ it('should disable the button when there is no query', async () => {
+ const {element, parts} = await renderAtomicAiConversationToggle({
+ querySummaryState: {hasQuery: false, query: ''},
+ });
+
+ expect(parts(element).toggleRevealQueryButton).toBeDisabled();
+ });
+
+ it('should render the query part with the correct content when the button is clicked', async () => {
+ const {element, parts, toggleRevealQueryButton} =
+ await renderAtomicAiConversationToggle();
+ expect(parts(element).query).not.toBeInTheDocument();
+
+ await toggleRevealQueryButton().click();
+
+ expect(parts(element).query).toBeInTheDocument();
+ expect(parts(element).query).toHaveTextContent('test query');
+ });
+ });
+
+ describe('when removed from the DOM (#disconnectedCallback)', () => {
+ it('should remove the "atomic/redden" event listener from the element', async () => {
+ const {element} = await renderAtomicAiConversationToggle();
+ const removeEventListenerSpy = vi.spyOn(element!, 'removeEventListener');
+
+ element.remove();
+
+ expect(removeEventListenerSpy).toHaveBeenCalledExactlyOnceWith(
+ 'atomic/redden',
+ expect.any(Function)
+ );
+ });
+ });
+});
diff --git a/packages/atomic/src/components/search/atomic-ai-conversation-toggle/atomic-ai-conversation-toggle.ts b/packages/atomic/src/components/search/atomic-ai-conversation-toggle/atomic-ai-conversation-toggle.ts
new file mode 100644
index 00000000000..abfadcdda00
--- /dev/null
+++ b/packages/atomic/src/components/search/atomic-ai-conversation-toggle/atomic-ai-conversation-toggle.ts
@@ -0,0 +1,200 @@
+import { Schema, StringValue } from "@coveo/bueno";
+
+import { type CSSResultGroup, css, html, LitElement, nothing } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import { renderButton } from "@/src/components/common/button";
+import { ValidatePropsController } from "@/src/components/common/validate-props-controller/validate-props-controller";
+import type { Bindings } from "@/src/components/search/atomic-search-interface/interfaces";
+import { bindingGuard } from "@/src/decorators/binding-guard";
+import { bindings } from "@/src/decorators/bindings";
+import { errorGuard } from "@/src/decorators/error-guard";
+import type { InitializableComponent } from "@/src/decorators/types";
+import { withTailwindStyles } from "@/src/decorators/with-tailwind-styles.js";
+import "@/src/components/common/atomic-icon/atomic-icon";
+import "@/src/components/common/atomic-modal/atomic-modal";
+import { ATOMIC_MODAL_EXPORT_PARTS } from "../../common/atomic-modal/export-parts";
+import { booleanConverter } from "@/src/converters/boolean-converter";
+import { renderAIConversationModal } from "../../common/ai-conversation-modal/modal";
+
+/**
+ * The `atomic-ai-conversation-toggle` component displays a simple greeting message and
+ * allows the user to show or hide the current query.
+ *
+ * @part container - The container for the entire component.
+ * @part message - The greeting message itself.
+ * @part query-container - The container for the button and query.
+ * @part toggle-reveal-query-button - The button to show or hide the query.
+ * @part query - The query.
+ *
+ * @slot - Content to display between the message and the the query container.
+ * @slot before - Content to display before the message.
+ * @slot after - Content to display after the query container.
+ *
+ * @remarks
+ * This is a template meant to be used as a starting point for creating or
+ * migrating components. It demonstrates common patterns and good practices.
+ */
+@customElement("atomic-ai-conversation-toggle")
+@bindings()
+@withTailwindStyles
+export class AtomicAiConversationToggle
+ extends LitElement
+ implements InitializableComponent
+{
+ /**
+ * The element that opens the modal when clicked.
+ */
+ @property({ attribute: "open-button", type: Object })
+ openButton?: HTMLElement;
+
+ /**
+ * The tooltip text for the AI Conversation Toggle button.
+ */
+ @property({ type: String, attribute: "tooltip" })
+ tooltip: string = "AI Mode";
+
+ /**
+ * The label text for the AI Conversation Toggle button.
+ */
+ @property({ type: String, attribute: "label" }) label: string = "AI Mode";
+ /**
+ * Whether the modal is open.
+ */
+ @property({
+ type: Boolean,
+ converter: booleanConverter,
+ reflect: true,
+ attribute: "is-open",
+ })
+ isOpen = false;
+
+ @state() public bindings!: Bindings;
+ @state() public error!: Error;
+
+ static styles: CSSResultGroup = css`
+ :host {
+ overflow: hidden;
+ }
+ `;
+
+ // ==========================================================================
+ // Standard custom element lifecycle methods
+ // ==========================================================================
+ /*
+ Override standard custom element lifecycle methods in this section.
+
+ Use the following order:
+ 1. `constructor`
+ 2. `connectedCallback`
+ 3. `disconnectedCallback`
+ 4. `adoptedCallback`
+ 5. `attributeChangedCallback`
+
+ Remove this multiline comment when no longer needed.
+ */
+
+ constructor() {
+ super();
+
+ new ValidatePropsController(
+ this,
+ () => ({ tooltip: this.tooltip, label: this.label }),
+ new Schema({
+ tooltip: new StringValue({
+ emptyAllowed: false,
+ }),
+ label: new StringValue({
+ emptyAllowed: false,
+ }),
+ })
+ );
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ }
+
+ // ==========================================================================
+ // Public methods
+ // ==========================================================================
+ /*
+ Declare public non-lifecycle methods in this section.
+
+ For initializable components, declare the `initialize` method first.
+
+ Declare other public methods in alphabetical order.
+
+ Remove this multiline comment when no longer needed.
+ */
+
+ public initialize() {}
+
+ // ==========================================================================
+ // Lit reactive update lifecycle methods
+ // ==========================================================================
+ /*
+ Override Lit reactive update lifecycle method in this section.
+
+ Use the following order:
+ 1. `shouldUpdate`
+ 2. `willUpdate`
+ 3. `update`
+ 4. `render`
+ 5. `firstUpdated`
+ 6. `updated`
+
+ Remove this multiline comment when no longer needed.
+ */
+
+ @bindingGuard()
+ @errorGuard()
+ render() {
+ return html`${renderAIConversationModal({
+ props: {
+ i18n: this.bindings.i18n,
+ host: this,
+ isOpen: this.isOpen,
+ onClose: () => {
+ this.isOpen = false;
+ },
+ title: "✨ AI MODE!",
+ openButton: this.openButton,
+ },
+ })(html``)}
+ ${renderButton({
+ props: {
+ style: "primary",
+ class: "relative p-3",
+ part: `atomic-ai-conversation-toggle-button`,
+ title: this.tooltip,
+ onClick: () => {
+ this.isOpen = !this.isOpen;
+ },
+ },
+ })(html`
+
+ ${this.label}
+ `)}`;
+ }
+ // ==========================================================================
+ // Private methods
+ // ==========================================================================
+ /*
+ Declare private methods in this section in any order.
+
+ Remove this multiline comment when no longer needed.
+ */
+ private handleCloseAIConversation() {
+ this.isOpen = false;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "atomic-ai-conversation-toggle": AtomicAiConversationToggle;
+ }
+}
diff --git a/packages/atomic/src/components/search/atomic-ai-conversation-toggle/e2e/atomic-ai-conversation-toggle.e2e.ts b/packages/atomic/src/components/search/atomic-ai-conversation-toggle/e2e/atomic-ai-conversation-toggle.e2e.ts
new file mode 100644
index 00000000000..5e4e7d08da8
--- /dev/null
+++ b/packages/atomic/src/components/search/atomic-ai-conversation-toggle/e2e/atomic-ai-conversation-toggle.e2e.ts
@@ -0,0 +1,12 @@
+import {expect, test} from './fixture';
+
+test.describe('atomic-ai-conversation-toggle', () => {
+ test.beforeEach(async ({aiConversationToggle}) => {
+ await aiConversationToggle.load();
+ });
+
+ // Add your tests here
+ test('should render properly', async ({aiConversationToggle}) => {
+ // TODO: Add test implementation
+ });
+});
diff --git a/packages/atomic/src/components/search/atomic-ai-conversation-toggle/e2e/fixture.ts b/packages/atomic/src/components/search/atomic-ai-conversation-toggle/e2e/fixture.ts
new file mode 100644
index 00000000000..adb73a5e321
--- /dev/null
+++ b/packages/atomic/src/components/search/atomic-ai-conversation-toggle/e2e/fixture.ts
@@ -0,0 +1,14 @@
+import {test as base} from '@playwright/test';
+import {AtomicAiConversationTogglePageObject} from './page-object';
+
+type Fixtures = {
+ aiConversationToggle: AtomicAiConversationTogglePageObject;
+};
+
+export const test = base.extend({
+ aiConversationToggle: async ({page}, use) => {
+ await use(new AtomicAiConversationTogglePageObject(page));
+ },
+});
+
+export {expect} from '@playwright/test';
diff --git a/packages/atomic/src/components/search/atomic-ai-conversation-toggle/e2e/page-object.ts b/packages/atomic/src/components/search/atomic-ai-conversation-toggle/e2e/page-object.ts
new file mode 100644
index 00000000000..7048815edfd
--- /dev/null
+++ b/packages/atomic/src/components/search/atomic-ai-conversation-toggle/e2e/page-object.ts
@@ -0,0 +1,8 @@
+import type {Page} from '@playwright/test';
+import {BasePageObject} from '@/playwright-utils/lit-base-page-object';
+
+export class AtomicAiConversationTogglePageObject extends BasePageObject {
+ constructor(page: Page) {
+ super(page, 'atomic-ai-conversation-toggle');
+ }
+}
diff --git a/packages/atomic/src/components/search/index.ts b/packages/atomic/src/components/search/index.ts
index fb9f17b729a..35c7850ca09 100644
--- a/packages/atomic/src/components/search/index.ts
+++ b/packages/atomic/src/components/search/index.ts
@@ -1,4 +1,5 @@
// Auto-generated file
+export {AtomicAiConversationToggle} from './atomic-ai-conversation-toggle/atomic-ai-conversation-toggle.js';
export {AtomicExternal} from './atomic-external/atomic-external.js';
export {AtomicFacet} from './atomic-facet/atomic-facet.js';
export {AtomicHtml} from './atomic-html/atomic-html.js';
diff --git a/packages/atomic/src/components/search/lazy-index.ts b/packages/atomic/src/components/search/lazy-index.ts
index d17971ce8e8..f0d27f82f9a 100644
--- a/packages/atomic/src/components/search/lazy-index.ts
+++ b/packages/atomic/src/components/search/lazy-index.ts
@@ -1,5 +1,9 @@
// Auto-generated file
export default {
+ 'atomic-ai-conversation-toggle': async () =>
+ await import(
+ './atomic-ai-conversation-toggle/atomic-ai-conversation-toggle.js'
+ ),
'atomic-external': async () =>
await import('./atomic-external/atomic-external.js'),
'atomic-facet': async () => await import('./atomic-facet/atomic-facet.js'),
diff --git a/packages/atomic/src/components/search/result-lists/atomic-result-children/atomic-result-children.tsx b/packages/atomic/src/components/search/result-lists/atomic-result-children/atomic-result-children.tsx
index 3a87ffa2a8c..87ed0b8171c 100644
--- a/packages/atomic/src/components/search/result-lists/atomic-result-children/atomic-result-children.tsx
+++ b/packages/atomic/src/components/search/result-lists/atomic-result-children/atomic-result-children.tsx
@@ -77,7 +77,7 @@ export class AtomicResultChildren implements InitializableComponent {
@State()
private foldedResultListState!: FoldedResultListState;
@State()
- private showInitialChildren = false;
+ private showInitialChildren = true;
/**
* Whether to inherit templates defined in a parent atomic-result-children. Only works for the second level of child nesting.
diff --git a/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/color-parser.spec.ts b/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/color-parser.spec.ts
new file mode 100644
index 00000000000..a87888cde7a
--- /dev/null
+++ b/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/color-parser.spec.ts
@@ -0,0 +1,25 @@
+import {describe, expect, it} from 'vitest';
+import {ColorParser} from './color-parser';
+
+describe('ColorParser', () => {
+ it('should return white for non-rgb input and inverted black', () => {
+ const parser = new ColorParser('foo');
+
+ expect(parser.rgb()).toBe('rgb(255, 255, 255)');
+ expect(parser.rgbInverted()).toBe('rgb(0, 0, 0)');
+ expect(parser.rgbSaturated()).toBe('rgb(255, 255, 255)');
+ });
+
+ it('should parse rgb string and invert correctly', () => {
+ const parser = new ColorParser('rgb(10,20,30)');
+
+ expect(parser.rgb()).toBe('rgb(10, 20, 30)');
+ expect(parser.rgbInverted()).toBe('rgb(245, 235, 225)');
+ });
+
+ it('should saturate color by doubling saturation (clamped to 1)', () => {
+ const parser = new ColorParser('rgb(100, 150, 200)');
+
+ expect(parser.rgbSaturated()).toBe('rgb(0, 100, 200)');
+ });
+});
diff --git a/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/color-parser.ts b/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/color-parser.ts
new file mode 100644
index 00000000000..f5297c07c69
--- /dev/null
+++ b/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/color-parser.ts
@@ -0,0 +1,44 @@
+import {hsvToRgb, rgbToHsv} from '@/src/utils/color-utils';
+
+/**
+ * @internal
+ */
+export class ColorParser {
+ private static readonly REGEX =
+ /\s*rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)/i;
+
+ private red: number = 255;
+ private green: number = 255;
+ private blue: number = 255;
+
+ constructor(private color: string) {
+ const rgb = this.color.match(ColorParser.REGEX);
+ if (rgb) {
+ this.red = parseInt(rgb[1], 10);
+ this.green = parseInt(rgb[2], 10);
+ this.blue = parseInt(rgb[3], 10);
+ }
+ }
+
+ public rgb(): string {
+ return this.rgbString(this.red, this.green, this.blue);
+ }
+
+ public rgbInverted(): string {
+ return this.rgbString(255 - this.red, 255 - this.green, 255 - this.blue);
+ }
+
+ public rgbSaturated(): string {
+ const {h, s, v} = rgbToHsv(this.red, this.green, this.blue);
+ let newSaturation = s * 2;
+ if (newSaturation > 1) {
+ newSaturation = 1;
+ }
+ const {r, g, b} = hsvToRgb(h, newSaturation, v);
+ return this.rgbString(r, g, b);
+ }
+
+ private rgbString(r: number, g: number, b: number): string {
+ return `rgb(${r}, ${g}, ${b})`;
+ }
+}
diff --git a/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/iframe-parser.spec.ts b/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/iframe-parser.spec.ts
new file mode 100644
index 00000000000..846dd9cc237
--- /dev/null
+++ b/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/iframe-parser.spec.ts
@@ -0,0 +1,101 @@
+import type {TermsToHighlight} from '@coveo/headless';
+import {afterEach, beforeEach, describe, expect, it} from 'vitest';
+import {unsafeHTMLElement} from '@/vitest-utils/testing-helpers/testing-utils/unsafe-html-element';
+import {getWordsHighlights, HIGHLIGHT_PREFIX} from './iframe-parser';
+
+describe('getWordsHighlights', () => {
+ const baseColor = 'rgb(10, 20, 30)';
+ const stemmingInfo: TermsToHighlight = {
+ keyword: ['keyword', 'keywords'],
+ test: ['test', 'tests'],
+ };
+
+ describe('with no iframe', () => {
+ it('should return an empty object', () => {
+ const result = getWordsHighlights(stemmingInfo);
+
+ expect(result).toEqual({});
+ });
+ });
+
+ describe('with an iframe', () => {
+ let iframe: HTMLIFrameElement;
+ let iframeDoc: Document;
+ let container: HTMLDivElement;
+
+ beforeEach(() => {
+ // Create a per-test container in the real document so iframe.contentDocument is available,
+ // but keep it isolated and remove it in afterEach to avoid polluting the global document.
+ container = document.createElement('div');
+ document.body.appendChild(container);
+
+ iframe = document.createElement('iframe') as HTMLIFrameElement;
+ container.appendChild(iframe);
+
+ iframeDoc = (iframe.contentDocument ||
+ iframe.contentWindow?.document) as Document;
+ });
+
+ afterEach(() => {
+ container?.parentNode?.removeChild(container);
+ container = null!;
+ iframe = null!;
+ iframeDoc = null!;
+ });
+
+ describe('with no tagged words', () => {
+ it('should return an empty object', () => {
+ const result = getWordsHighlights(stemmingInfo, iframe);
+
+ expect(result).toEqual({});
+ });
+ });
+
+ describe('with tagged words', () => {
+ function createTaggedWordElement(id: string, text: string): HTMLElement {
+ return unsafeHTMLElement(
+ `${text}`,
+ iframeDoc
+ );
+ }
+
+ function appendTaggedWordElement(id: string, text: string) {
+ const el = createTaggedWordElement(id, text);
+ iframeDoc.body.appendChild(el);
+ }
+
+ beforeEach(() => {
+ appendTaggedWordElement('1', 'keyword');
+ appendTaggedWordElement('1', 'keyword');
+ appendTaggedWordElement('2', 'test');
+ });
+
+ it('should find and group highlights correctly', () => {
+ const highlights = getWordsHighlights(stemmingInfo, iframe);
+
+ expect(Object.keys(highlights)).toContain('1');
+ expect(Object.keys(highlights)).toContain('2');
+ expect(highlights['1'].occurrences).toBe(2);
+ expect(highlights['2'].occurrences).toBe(1);
+ });
+ });
+
+ describe('with invalid keyword identifier', () => {
+ let element: HTMLElement;
+
+ beforeEach(() => {
+ element = unsafeHTMLElement(
+ ``,
+ iframeDoc
+ );
+ iframeDoc.body.appendChild(element);
+ });
+
+ it('should throw an error', () => {
+ expect(() => getWordsHighlights(stemmingInfo, iframe)).toThrow(
+ 'Invalid keyword identifier for quickview'
+ );
+ });
+ });
+ });
+});
diff --git a/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/iframe-parser.ts b/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/iframe-parser.ts
new file mode 100644
index 00000000000..e23bd3db504
--- /dev/null
+++ b/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/iframe-parser.ts
@@ -0,0 +1,84 @@
+import type {TermsToHighlight} from '@coveo/headless';
+import {QuickviewWordHighlight} from './quickview-word-highlight';
+import {TextParser} from './text-parser';
+
+export const HIGHLIGHT_PREFIX = 'CoveoHighlight';
+
+function findHighlightedElements(iframe?: HTMLIFrameElement): HTMLElement[] {
+ const root = iframe?.contentDocument?.body;
+ if (!root) return [];
+
+ // Collect tagged word elements as well as elements whose id starts with
+ // the highlight prefix. Tagged words (``) must be
+ // considered so that invalid identifiers (e.g. id="invalid") are
+ // discovered and cause the appropriate error in the parser.
+ const selector = `[id^="${HIGHLIGHT_PREFIX}"]`;
+ const query = root.querySelectorAll(selector);
+
+ return Array.from(query);
+}
+
+/**
+ * @internal
+ */
+export class IframeParser {
+ private textParser = new TextParser(this.stemmingInfoFromIndex);
+ private wordsHighlights: Record = {};
+
+ constructor(
+ private stemmingInfoFromIndex: TermsToHighlight,
+ private iframe?: HTMLIFrameElement
+ ) {}
+
+ public parse(): Record {
+ const elements = findHighlightedElements(this.iframe);
+ for (const element of elements) {
+ const identifier = this.parseIdentifier(element);
+ if (!identifier) {
+ throw new Error('Invalid keyword identifier for quickview');
+ }
+
+ const text = this.textParser.parse(element);
+ if (!text) {
+ continue;
+ }
+
+ const alreadyScannedKeyword = this.wordsHighlights[identifier];
+ if (alreadyScannedKeyword) {
+ alreadyScannedKeyword.addElement(element);
+ continue;
+ }
+
+ const color = element.style.backgroundColor;
+ const highlight = new QuickviewWordHighlight(
+ identifier,
+ text,
+ color,
+ element
+ );
+ this.wordsHighlights[identifier] = highlight;
+ }
+
+ return this.wordsHighlights;
+ }
+
+ private parseIdentifier(element: HTMLElement): string | null {
+ const parts = element.id
+ .substring(HIGHLIGHT_PREFIX.length + 1)
+ .match(/^([0-9]+)\.([0-9]+)\.([0-9]+)$/);
+
+ if (!parts || parts.length <= 3) {
+ return null;
+ }
+
+ return parts[1];
+ }
+}
+
+export const getWordsHighlights = (
+ stemmingInfoFromIndex: TermsToHighlight,
+ iframe?: HTMLIFrameElement
+) => {
+ const parser = new IframeParser(stemmingInfoFromIndex, iframe);
+ return parser.parse();
+};
diff --git a/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/quickview-word-highlight.spec.ts b/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/quickview-word-highlight.spec.ts
new file mode 100644
index 00000000000..2966e977f33
--- /dev/null
+++ b/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/quickview-word-highlight.spec.ts
@@ -0,0 +1,177 @@
+import {beforeEach, describe, expect, it} from 'vitest';
+import {QuickviewWordHighlight} from './quickview-word-highlight';
+
+describe('QuickviewWordHighlight', () => {
+ const baseColor = 'rgb(10, 20, 30)';
+ const focusedColor = 'rgb(245, 235, 225)';
+ const previewBorderColor = 'rgb(0, 15, 30)';
+
+ let testDoc: Document;
+
+ beforeEach(() => {
+ testDoc = document.implementation.createHTMLDocument();
+ });
+
+ function createElement(): HTMLElement {
+ const element = testDoc.createElement('div');
+ element.style.backgroundColor = baseColor;
+ return element;
+ }
+
+ function createHighlight(
+ id: string,
+ text: string,
+ el?: HTMLElement
+ ): QuickviewWordHighlight {
+ const element = el ?? createElement();
+ return new QuickviewWordHighlight(id, text, baseColor, element);
+ }
+
+ function expectHighlighted(element: HTMLElement) {
+ expect(element.style.color).toBe(baseColor);
+ expect(element.style.backgroundColor).toBe(focusedColor);
+ }
+
+ function expectNotHighlighted(element: HTMLElement) {
+ expect(element.style.color).toBe('');
+ expect(element.style.backgroundColor).toBe(baseColor);
+ }
+
+ describe('with one element', () => {
+ let element: HTMLElement;
+ let highlight: QuickviewWordHighlight;
+
+ beforeEach(() => {
+ highlight = createHighlight('1', 'keyword');
+ element = highlight.elements[0];
+ });
+
+ it('should initialize properties correctly', () => {
+ expect(highlight.occurrences).toBe(1);
+ expect(highlight.indexIdentifier).toBe('1');
+ expect(highlight.text).toBe('keyword');
+ expect(highlight.color).toBe(baseColor);
+ });
+
+ it('should compute color properties correctly', () => {
+ expect(highlight.focusedColor).toBe(focusedColor);
+ expect(highlight.previewBorderColor).toBe(previewBorderColor);
+ });
+
+ describe('when no navigation has occurred', () => {
+ it('should not apply any highlighting styles', () => {
+ expectNotHighlighted(element);
+ });
+ });
+
+ describe('when navigating forward', () => {
+ beforeEach(() => {
+ highlight.navigateForward();
+ });
+
+ it('should apply highlighting styles to the focused element', () => {
+ expectHighlighted(element);
+ });
+
+ describe('when navigating forward again', () => {
+ beforeEach(() => {
+ highlight.navigateForward();
+ });
+
+ it('should reapply highlighting styles to the focused element', () => {
+ expectHighlighted(element);
+ });
+ });
+
+ describe('when navigating backward', () => {
+ beforeEach(() => {
+ highlight.navigateBackward();
+ });
+
+ it('should reapply highlighting styles to the focused element', () => {
+ expectHighlighted(element);
+ });
+ });
+ });
+
+ describe('when navigating backward from initial state', () => {
+ beforeEach(() => {
+ highlight.navigateBackward();
+ });
+
+ it('should apply highlighting styles to the focused element', () => {
+ expect(element.style.color).toBe(baseColor);
+ expect(element.style.backgroundColor).toBe(focusedColor);
+ });
+ });
+ });
+
+ describe('with two elements', () => {
+ let element1: HTMLElement;
+ let element2: HTMLElement;
+ let highlight: QuickviewWordHighlight;
+
+ beforeEach(() => {
+ element1 = createElement();
+ element2 = createElement();
+ highlight = createHighlight('1', 'keyword', element1);
+ highlight.addElement(element2);
+ });
+
+ it('should track elements and occurrences', () => {
+ expect(highlight.occurrences).toBe(2);
+ expect(highlight.elements).toEqual([element1, element2]);
+ });
+
+ describe('when no navigation has occurred', () => {
+ it('should not apply any highlighting styles to either element', () => {
+ expectNotHighlighted(element1);
+ expectNotHighlighted(element2);
+ });
+ });
+
+ describe('when navigating forward', () => {
+ beforeEach(() => {
+ highlight.navigateForward();
+ });
+
+ it('should apply highlighting styles to the first element only', () => {
+ expectHighlighted(element1);
+ expectNotHighlighted(element2);
+ });
+
+ describe('when navigating forward again', () => {
+ beforeEach(() => {
+ highlight.navigateForward();
+ });
+
+ it('should apply highlighting styles to the second element only', () => {
+ expectNotHighlighted(element1);
+ expectHighlighted(element2);
+ });
+
+ describe('when navigating forward a third time', () => {
+ beforeEach(() => {
+ highlight.navigateForward();
+ });
+
+ it('should loop and apply highlighting to the first element again', () => {
+ expectHighlighted(element1);
+ expectNotHighlighted(element2);
+ });
+ });
+ });
+ });
+
+ describe('when navigating backward from initial state', () => {
+ beforeEach(() => {
+ highlight.navigateBackward();
+ });
+
+ it('should apply highlighting styles to the last element (loop backward)', () => {
+ expectNotHighlighted(element1);
+ expectHighlighted(element2);
+ });
+ });
+ });
+});
diff --git a/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/quickview-word-highlight.ts b/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/quickview-word-highlight.ts
new file mode 100644
index 00000000000..b660df08ab4
--- /dev/null
+++ b/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/quickview-word-highlight.ts
@@ -0,0 +1,68 @@
+import {ColorParser} from './color-parser';
+
+export {getWordsHighlights, HIGHLIGHT_PREFIX} from './iframe-parser';
+
+export class QuickviewWordHighlight {
+ public readonly focusedColor: string;
+ public readonly previewBorderColor: string;
+ public readonly elements: HTMLElement[] = [];
+
+ private currentNavigationPosition = -1;
+
+ constructor(
+ public readonly indexIdentifier: string,
+ public readonly text: string,
+ public readonly color: string,
+ element: HTMLElement
+ ) {
+ this.addElement(element);
+
+ const colorParser = new ColorParser(color);
+ this.focusedColor = colorParser.rgbInverted();
+ this.previewBorderColor = colorParser.rgbSaturated();
+ }
+
+ public addElement(element: HTMLElement) {
+ this.elements.push(element);
+ }
+
+ public get occurrences() {
+ return this.elements.length;
+ }
+
+ public navigateForward() {
+ this.currentNavigationPosition++;
+ if (this.currentNavigationPosition >= this.elements.length) {
+ this.currentNavigationPosition = 0;
+ }
+ this.highlightNavigation();
+ this.putElementIntoView();
+ return this.elements[this.currentNavigationPosition];
+ }
+
+ public navigateBackward() {
+ this.currentNavigationPosition--;
+ if (this.currentNavigationPosition < 0) {
+ this.currentNavigationPosition = this.elements.length - 1;
+ }
+ this.highlightNavigation();
+ this.putElementIntoView();
+ return this.elements[this.currentNavigationPosition];
+ }
+
+ private highlightNavigation() {
+ const currentElement = this.elements[this.currentNavigationPosition];
+ const otherElements = this.elements.filter((el) => el !== currentElement);
+ currentElement.style.color = this.color;
+ currentElement.style.backgroundColor = this.focusedColor;
+ otherElements.forEach((element) => {
+ element.style.color = '';
+ element.style.backgroundColor = this.color;
+ });
+ }
+
+ private putElementIntoView() {
+ const element = this.elements[this.currentNavigationPosition];
+ element.scrollIntoView();
+ }
+}
diff --git a/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/quickview-word-highlight.tsx b/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/quickview-word-highlight.tsx
deleted file mode 100644
index e2bfaa66761..00000000000
--- a/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/quickview-word-highlight.tsx
+++ /dev/null
@@ -1,211 +0,0 @@
-import {TermsToHighlight} from '@coveo/headless';
-import {hsvToRgb, rgbToHsv} from '../../../../utils/color-utils';
-
-export const HIGHLIGHT_PREFIX = 'CoveoHighlight';
-export class QuickviewWordHighlight {
- public text: string;
- public indexIdentifier: string;
- public occurrences = 0;
- public color: string;
- public focusedColor: string;
- public previewBorderColor: string;
- public elements: HTMLElement[] = [];
-
- private currentNavigationPosition = -1;
-
- constructor(
- private stemmingInfoFromIndex: TermsToHighlight,
- keywordElementInIframe: HTMLElement
- ) {
- const parsed = this.parseKeywordIdentifier(keywordElementInIframe);
- if (!parsed) {
- throw 'Invalid keyword identifier for quickview';
- }
-
- this.text = this.getText(keywordElementInIframe);
- this.indexIdentifier = `${parsed.keywordIdentifier}`;
- this.color = keywordElementInIframe.style.backgroundColor;
- this.focusedColor = this.computeInvertedColor();
- this.previewBorderColor = this.computeSaturatedColor();
-
- this.addElement(keywordElementInIframe);
- }
-
- public addElement(keywordElementInIframe: HTMLElement) {
- this.occurrences++;
- this.elements.push(keywordElementInIframe);
- }
-
- public navigateForward() {
- this.currentNavigationPosition++;
- if (this.currentNavigationPosition >= this.elements.length) {
- this.currentNavigationPosition = 0;
- }
- this.highlightNavigation();
- this.putElementIntoView();
- return this.elements[this.currentNavigationPosition];
- }
-
- public navigateBackward() {
- this.currentNavigationPosition--;
- if (this.currentNavigationPosition < 0) {
- this.currentNavigationPosition = this.elements.length - 1;
- }
- this.highlightNavigation();
- this.putElementIntoView();
- return this.elements[this.currentNavigationPosition];
- }
-
- private isTaggedWord(element: HTMLElement) {
- return element.nodeName.toLowerCase() === 'coveotaggedword';
- }
-
- private highlightNavigation() {
- const currentElement = this.elements[this.currentNavigationPosition];
- const otherElements = this.elements.filter((el) => el !== currentElement);
- currentElement.style.color = this.color;
- currentElement.style.backgroundColor = this.focusedColor;
- otherElements.forEach((element) => {
- element.style.color = '';
- element.style.backgroundColor = this.color;
- });
- }
-
- private putElementIntoView() {
- const element = this.elements[this.currentNavigationPosition];
- element.scrollIntoView();
- }
-
- private getText(element: HTMLElement) {
- const innerTextOfHTMLElement = this.getHighlightedInnerText(element);
- return this.resolveOriginalTerm(innerTextOfHTMLElement).trim();
- }
-
- private resolveOriginalTerm(highlight: string): string {
- // First try to find either an exact match between the highlight and the original non-stemmed keyword.
- // Otherwise try to find a match between the highlight and the stemming keyword expansions
- // If nothing is found (which should not normally happen...), simply return the highlight keyword as is.
-
- const found = Object.keys(this.stemmingInfoFromIndex).find(
- (originalTerm) => {
- const originalTermMatch =
- originalTerm.toLowerCase() === highlight.toLowerCase();
- if (originalTermMatch) {
- return true;
- }
- const stemmingExpansions = this.stemmingInfoFromIndex[originalTerm];
- if (!stemmingExpansions) {
- return false;
- }
-
- const stemmingExpansionMatch = stemmingExpansions.find(
- (stemmingExpansion) =>
- stemmingExpansion.toLowerCase() === highlight.toLowerCase()
- );
- return stemmingExpansionMatch;
- }
- );
- return found || highlight;
- }
-
- private getHighlightedInnerText(element: HTMLElement): string {
- if (!this.isTaggedWord(element)) {
- return this.getTextOfHTMLElement(element);
- }
-
- const children = Array.from(element.children) as HTMLElement[];
- if (children.length >= 1) {
- return this.getTextOfHTMLElement(children[0]);
- }
-
- return '';
- }
-
- private parseKeywordIdentifier(element: HTMLElement) {
- const parts = element.id
- .substring(HIGHLIGHT_PREFIX.length + 1)
- .match(/^([0-9]+)\.([0-9]+)\.([0-9]+)$/);
-
- if (!parts || parts.length <= 3) {
- return null;
- }
-
- return {
- keywordIdentifier: parts[1],
- keywordTermPart: parseInt(parts[3], 10),
- };
- }
-
- private getTextOfHTMLElement(el: HTMLElement) {
- return el.innerText || el.textContent || '';
- }
-
- private computeInvertedColor() {
- const {r, g, b} = this.extractRgb();
- return `rgb(${255 - r}, ${255 - g}, ${255 - b})`;
- }
-
- private computeSaturatedColor() {
- const {r, g, b} = this.extractRgb();
- const {h, s, v} = rgbToHsv(r, g, b);
- let newSaturation = s * 2;
- if (newSaturation > 1) {
- newSaturation = 1;
- }
- const {
- r: rSaturated,
- g: gSaturated,
- b: bSaturated,
- } = hsvToRgb(h, newSaturation, v);
- return `rgb(${rSaturated}, ${gSaturated}, ${bSaturated})`;
- }
-
- private extractRgb() {
- const rgbExtracted = this.color.match(/\d+/g);
- if (!rgbExtracted) {
- return {r: 255, g: 255, b: 255};
- }
-
- return {
- r: parseInt(rgbExtracted[0], 10),
- g: parseInt(rgbExtracted[1], 10),
- b: parseInt(rgbExtracted[2], 10),
- };
- }
-}
-
-export const getWordsHighlights = (
- stemmingInfoFromIndex: TermsToHighlight,
- iframe?: HTMLIFrameElement
-) => {
- const wordsHighlights: Record = {};
- if (!iframe) {
- return wordsHighlights;
- }
-
- iframe.contentDocument?.body
- .querySelectorAll(`[id^="${HIGHLIGHT_PREFIX}"]`)
- .forEach((el) => {
- const wordHTMLElementToHighlight = el as HTMLElement;
-
- const wordHighlight = new QuickviewWordHighlight(
- stemmingInfoFromIndex,
- wordHTMLElementToHighlight
- );
-
- if (!wordHighlight.text) {
- return;
- }
-
- const alreadyScannedKeyword =
- wordsHighlights[wordHighlight.indexIdentifier];
-
- if (alreadyScannedKeyword) {
- alreadyScannedKeyword.addElement(wordHTMLElementToHighlight);
- } else {
- wordsHighlights[wordHighlight.indexIdentifier] = wordHighlight;
- }
- });
-
- return wordsHighlights;
-};
diff --git a/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/text-parser.spec.ts b/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/text-parser.spec.ts
new file mode 100644
index 00000000000..d3cc1eea63b
--- /dev/null
+++ b/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/text-parser.spec.ts
@@ -0,0 +1,48 @@
+import type {TermsToHighlight} from '@coveo/headless';
+import {describe, expect, it} from 'vitest';
+import {TextParser} from './text-parser';
+
+describe('TextParser', () => {
+ function createElement(tagName: string, innerText: string = ''): HTMLElement {
+ const element = document.createElement(tagName);
+ element.innerText = innerText;
+ return element;
+ }
+
+ it('should return inner text for non-tagged elements (trimmed)', () => {
+ const parser = new TextParser({} as TermsToHighlight);
+ const el = createElement('div', ' Cat ');
+
+ expect(parser.parse(el)).toBe('Cat');
+ });
+
+ it('should resolve exact original term (case-insensitive)', () => {
+ const parser = new TextParser({Hello: []} as TermsToHighlight);
+ const el = createElement('div', 'hello');
+
+ expect(parser.parse(el)).toBe('Hello');
+ });
+
+ it('should resolve stemming expansion to the original term', () => {
+ const parser = new TextParser({dogs: ['dog', 'doggy']} as TermsToHighlight);
+ const el = createElement('div', 'DOG');
+
+ expect(parser.parse(el)).toBe('dogs');
+ });
+
+ it('should use the first child inner text for tagged elements', () => {
+ const parser = new TextParser({orig: ['x']} as TermsToHighlight);
+ const tag = createElement('coveotaggedword');
+ const span = createElement('span', 'x');
+ tag.appendChild(span);
+
+ expect(parser.parse(tag)).toBe('orig');
+ });
+
+ it('should return empty string when tagged element has no children', () => {
+ const parser = new TextParser({} as TermsToHighlight);
+ const tag = createElement('coveotaggedword');
+
+ expect(parser.parse(tag)).toBe('');
+ });
+});
diff --git a/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/text-parser.ts b/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/text-parser.ts
new file mode 100644
index 00000000000..67f67a2ff7e
--- /dev/null
+++ b/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/text-parser.ts
@@ -0,0 +1,61 @@
+import type {TermsToHighlight} from '@coveo/headless';
+
+/**
+ * @internal
+ */
+export class TextParser {
+ constructor(private stemmingInfoFromIndex: TermsToHighlight) {}
+
+ public parse(element: HTMLElement) {
+ const innerTextOfHTMLElement = this.getHighlightedInnerText(element);
+ return this.resolveOriginalTerm(innerTextOfHTMLElement).trim();
+ }
+
+ private isTaggedWord(element: HTMLElement) {
+ return element.nodeName.toLowerCase() === 'coveotaggedword';
+ }
+
+ private getHighlightedInnerText(element: HTMLElement): string {
+ if (!this.isTaggedWord(element)) {
+ return this.getTextOfHTMLElement(element);
+ }
+
+ const children = Array.from(element.children) as HTMLElement[];
+ if (children.length >= 1) {
+ return this.getTextOfHTMLElement(children[0]);
+ }
+
+ return '';
+ }
+
+ private getTextOfHTMLElement(el: HTMLElement) {
+ return el.innerText || el.textContent || '';
+ }
+
+ private resolveOriginalTerm(highlight: string): string {
+ // First try to find either an exact match between the highlight and the original non-stemmed keyword.
+ // Otherwise try to find a match between the highlight and the stemming keyword expansions
+ // If nothing is found (which should not normally happen...), simply return the highlight keyword as is.
+
+ const found = Object.keys(this.stemmingInfoFromIndex).find(
+ (originalTerm) => {
+ const originalTermMatch =
+ originalTerm.toLowerCase() === highlight.toLowerCase();
+ if (originalTermMatch) {
+ return true;
+ }
+ const stemmingExpansions = this.stemmingInfoFromIndex[originalTerm];
+ if (!stemmingExpansions) {
+ return false;
+ }
+
+ const stemmingExpansionMatch = stemmingExpansions.find(
+ (stemmingExpansion) =>
+ stemmingExpansion.toLowerCase() === highlight.toLowerCase()
+ );
+ return stemmingExpansionMatch;
+ }
+ );
+ return found || highlight;
+ }
+}
diff --git a/packages/atomic/vitest-utils/testing-helpers/testing-utils/unsafe-html-element.ts b/packages/atomic/vitest-utils/testing-helpers/testing-utils/unsafe-html-element.ts
new file mode 100644
index 00000000000..a97f627db6b
--- /dev/null
+++ b/packages/atomic/vitest-utils/testing-helpers/testing-utils/unsafe-html-element.ts
@@ -0,0 +1,16 @@
+import {unsafeHTML} from 'lit/directives/unsafe-html.js';
+import {html, render} from 'lit-html';
+
+export function unsafeHTMLElement(
+ raw: string,
+ doc: Document = document
+): HTMLElement {
+ const container = doc.createElement('div');
+ const template = html`${unsafeHTML(raw)}`;
+ render(template, container);
+ const element = container.firstElementChild as HTMLElement | null;
+ if (!element) {
+ throw new Error('Failed to create element from HTML string');
+ }
+ return element;
+}
diff --git a/packages/headless/src/api/knowledge/stream-answer-api-state.ts b/packages/headless/src/api/knowledge/stream-answer-api-state.ts
index cc703d5ff8f..fa475e9aab1 100644
--- a/packages/headless/src/api/knowledge/stream-answer-api-state.ts
+++ b/packages/headless/src/api/knowledge/stream-answer-api-state.ts
@@ -5,6 +5,7 @@ import type {
GetAnswerQuerySection,
InsightCaseContextSection,
InsightConfigurationSection,
+ MultiTurnConversationSection,
TabSection,
} from '../../state/state-sections.js';
@@ -17,4 +18,5 @@ export type StreamAnswerAPIState = {
Partial &
GeneratedAnswerSection &
Partial &
- Partial;
+ Partial &
+ Partial;
diff --git a/packages/headless/src/api/knowledge/stream-answer-api.ts b/packages/headless/src/api/knowledge/stream-answer-api.ts
index 6787283e2c6..22dc3ed6f3d 100644
--- a/packages/headless/src/api/knowledge/stream-answer-api.ts
+++ b/packages/headless/src/api/knowledge/stream-answer-api.ts
@@ -12,6 +12,12 @@ import {
logGeneratedAnswerStreamEnd,
} from '../../features/generated-answer/generated-answer-analytics-actions.js';
import type {AnswerApiQueryParams} from '../../features/generated-answer/generated-answer-request.js';
+import {
+ addAnswer,
+ updateActiveAnswer,
+ updateActiveAnswerMessage,
+ updateCitationsToActiveAnswer,
+} from '../../features/multi-turn-conversation/multi-turn-conversation-actions.js';
import {fetchEventSource} from '../../utils/fetch-event-source/fetch.js';
import type {EventSourceMessage} from '../../utils/fetch-event-source/parse.js';
import {getOrganizationEndpoint} from '../platform-client.js';
@@ -102,6 +108,14 @@ export const updateCacheWithEvent = (
const message: Required = JSON.parse(event.data);
if (message.finishReason === 'ERROR' && message.errorMessage) {
handleError(draft, message);
+ const errorMessage = message.errorMessage || 'Unknown error occurred';
+ dispatch(
+ updateActiveAnswer({
+ isStreaming: false,
+ isLoading: false,
+ error: errorMessage,
+ })
+ );
}
const parsedPayload: StreamPayload = message.payload.length
@@ -112,6 +126,13 @@ export const updateCacheWithEvent = (
case 'genqa.headerMessageType':
if (parsedPayload.contentFormat) {
handleHeaderMessage(draft, parsedPayload);
+ dispatch(
+ updateActiveAnswer({
+ isStreaming: true,
+ isLoading: false,
+ answerContentFormat: parsedPayload.contentFormat,
+ })
+ );
dispatch(setAnswerContentFormat(parsedPayload.contentFormat));
}
break;
@@ -119,16 +140,23 @@ export const updateCacheWithEvent = (
if (parsedPayload.textDelta) {
handleMessage(draft, parsedPayload);
dispatch(updateMessage({textDelta: parsedPayload.textDelta}));
+ dispatch(
+ updateActiveAnswerMessage({textDelta: parsedPayload.textDelta})
+ );
}
break;
case 'genqa.citationsType':
if (parsedPayload.citations) {
handleCitations(draft, parsedPayload);
dispatch(updateCitations({citations: parsedPayload.citations}));
+ dispatch(updateCitationsToActiveAnswer(parsedPayload.citations));
}
break;
case 'genqa.endOfStreamType':
handleEndOfStream(draft, parsedPayload);
+ dispatch(
+ updateActiveAnswer({isStreaming: false, cannotAnswer: !draft.generated})
+ );
dispatch(
logGeneratedAnswerStreamEnd(parsedPayload.answerGenerated ?? false)
);
@@ -137,6 +165,69 @@ export const updateCacheWithEvent = (
}
};
+const updateCacheWithEventBeta = (
+ event: EventSourceMessage,
+ draft: GeneratedAnswerStream,
+ dispatch: ThunkDispatch
+) => {
+ const message: Required = JSON.parse(event.data);
+ if (message.finishReason === 'ERROR' && message.errorMessage) {
+ handleError(draft, message);
+ const errorMessage = message.errorMessage || 'Unknown error occurred';
+ dispatch(
+ updateActiveAnswer({
+ isStreaming: false,
+ isLoading: false,
+ error: errorMessage,
+ })
+ );
+ }
+
+ const parsedPayload: StreamPayload = message.payload.length
+ ? JSON.parse(message.payload)
+ : {};
+
+ switch (message.payloadType) {
+ case 'genqa.headerMessageType':
+ if (parsedPayload.contentFormat) {
+ handleHeaderMessage(draft, parsedPayload);
+ dispatch(
+ updateActiveAnswer({
+ isStreaming: true,
+ isLoading: false,
+ answerContentFormat: parsedPayload.contentFormat,
+ })
+ );
+ // dispatch(setAnswerContentFormat(parsedPayload.contentFormat));
+ }
+ break;
+ case 'genqa.messageType':
+ if (parsedPayload.textDelta) {
+ handleMessage(draft, parsedPayload);
+ dispatch(
+ updateActiveAnswerMessage({textDelta: parsedPayload.textDelta})
+ );
+ }
+ break;
+ case 'genqa.citationsType':
+ if (parsedPayload.citations) {
+ handleCitations(draft, parsedPayload);
+ dispatch(updateCitationsToActiveAnswer(parsedPayload.citations));
+ }
+ break;
+ case 'genqa.endOfStreamType':
+ handleEndOfStream(draft, parsedPayload);
+ dispatch(
+ updateActiveAnswer({isStreaming: false, cannotAnswer: !draft.generated})
+ );
+ // dispatch(
+ // logGeneratedAnswerStreamEnd(parsedPayload.answerGenerated ?? false)
+ // );
+ // dispatch(logGeneratedAnswerResponseLinked());
+ break;
+ }
+};
+
export const buildAnswerEndpoint = (
platformEndpoint: string,
organizationId: string,
@@ -157,17 +248,20 @@ export const answerApi = answerSlice.injectEndpoints({
overrideExisting: true,
endpoints: (builder) => ({
getAnswer: builder.query({
- queryFn: () => ({
- data: {
- contentFormat: undefined,
- answer: undefined,
- citations: undefined,
- error: undefined,
- generated: false,
- isStreaming: true,
- isLoading: true,
- },
- }),
+ queryFn: (args, {dispatch}) => {
+ dispatch(addAnswer({prompt: args.q!}));
+ return {
+ data: {
+ contentFormat: undefined,
+ answer: undefined,
+ citations: undefined,
+ error: undefined,
+ generated: false,
+ isStreaming: true,
+ isLoading: true,
+ },
+ };
+ },
serializeQueryArgs: ({endpointName, queryArgs}) => {
// RTK Query serialize our endpoints and they're serialized state arguments as the key in the store.
// Keys must match, because if anything in the query changes, it's not the same query anymore.
@@ -236,6 +330,89 @@ export const answerApi = answerSlice.injectEndpoints({
});
},
}),
+ getNewTurn: builder.query({
+ queryFn: (args, {dispatch}) => {
+ dispatch(addAnswer({prompt: args.q!}));
+ return {
+ data: {
+ contentFormat: undefined,
+ answer: undefined,
+ citations: undefined,
+ error: undefined,
+ generated: false,
+ isStreaming: true,
+ isLoading: true,
+ },
+ };
+ },
+ serializeQueryArgs: ({endpointName, queryArgs}) => {
+ // RTK Query serialize our endpoints and they're serialized state arguments as the key in the store.
+ // Keys must match, because if anything in the query changes, it's not the same query anymore.
+ // Analytics data is excluded entirely as it contains volatile fields that change during streaming.
+ const {analytics: _analytics, ...queryArgsWithoutAnalytics} = queryArgs;
+
+ // Standard RTK key, with analytics excluded
+ return `${endpointName}(${JSON.stringify(queryArgsWithoutAnalytics)})`;
+ },
+ async onCacheEntryAdded(
+ args,
+ {getState, cacheDataLoaded, updateCachedData, dispatch}
+ ) {
+ await cacheDataLoaded;
+ /**
+ * createApi has to be called prior to creating the redux store and is used as part of the store setup sequence.
+ * It cannot use the inferred state used by Redux, thus the casting.
+ * https://redux-toolkit.js.org/rtk-query/usage-with-typescript#typing-dispatch-and-getstate
+ */
+ const {configuration, generatedAnswer, insightConfiguration} =
+ getState() as unknown as StreamAnswerAPIState;
+ const {organizationId, environment, accessToken} = configuration;
+ const platformEndpoint = getOrganizationEndpoint(
+ organizationId,
+ environment
+ );
+ const answerEndpoint = buildAnswerEndpoint(
+ platformEndpoint,
+ organizationId,
+ generatedAnswer.answerConfigurationId!,
+ insightConfiguration?.insightId
+ );
+
+ await fetchEventSource(answerEndpoint, {
+ method: 'POST',
+ body: JSON.stringify(args),
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ 'Accept-Encoding': '*',
+ },
+ fetch,
+ onopen: async (res) => {
+ const answerId = res.headers.get('x-answer-id');
+ if (answerId) {
+ updateCachedData((draft) => {
+ draft.answerId = answerId;
+ dispatch(updateActiveAnswer({answerId}));
+ });
+ }
+ },
+ onmessage: (event) => {
+ updateCachedData((draft) => {
+ updateCacheWithEventBeta(event, draft, dispatch);
+ });
+ },
+ onerror: (error) => {
+ throw error;
+ },
+ onclose: () => {
+ updateCachedData((draft) => {
+ dispatch(updateActiveAnswer({cannotAnswer: !draft.generated}));
+ });
+ },
+ });
+ },
+ }),
}),
});
@@ -243,6 +420,10 @@ export const fetchAnswer = (fetchAnswerParams: AnswerApiQueryParams) => {
return answerApi.endpoints.getAnswer.initiate(fetchAnswerParams);
};
+export const fetchNewTurn = (fetchAnswerParams: AnswerApiQueryParams) => {
+ return answerApi.endpoints.getNewTurn.initiate(fetchAnswerParams);
+};
+
// Select answer state from RTK endpoint state
export const selectAnswer = (state: StreamAnswerAPIState) => {
const params = selectAnswerApiQueryParams(state);
diff --git a/packages/headless/src/controllers/knowledge/generated-answer/headless-answerapi-generated-answer.ts b/packages/headless/src/controllers/knowledge/generated-answer/headless-answerapi-generated-answer.ts
index b6b57d1186c..08fbba07905 100644
--- a/packages/headless/src/controllers/knowledge/generated-answer/headless-answerapi-generated-answer.ts
+++ b/packages/headless/src/controllers/knowledge/generated-answer/headless-answerapi-generated-answer.ts
@@ -95,7 +95,7 @@ const parseEvaluationArguments = ({
question: query,
});
-const subscribeToSearchRequest = (
+export const subscribeToSearchRequest = (
engine: SearchEngine
) => {
let lastRequestId = '';
@@ -205,5 +205,8 @@ function loadAnswerApiReducers(
QuerySection & {answer: ReturnType}
> {
engine.addReducers({[answerApi.reducerPath]: answerApi.reducer, query});
+ engine.addReducers({
+ [answerEvaluation.reducerPath]: answerEvaluation.reducer,
+ });
return true;
}
diff --git a/packages/headless/src/controllers/knowledge/generated-answer/headless-multiturn-conversation.ts b/packages/headless/src/controllers/knowledge/generated-answer/headless-multiturn-conversation.ts
new file mode 100644
index 00000000000..bc28d44078c
--- /dev/null
+++ b/packages/headless/src/controllers/knowledge/generated-answer/headless-multiturn-conversation.ts
@@ -0,0 +1,89 @@
+import {answerApi} from '../../../api/knowledge/stream-answer-api.js';
+import type {StreamAnswerAPIState} from '../../../api/knowledge/stream-answer-api-state.js';
+import {warnIfUsingNextAnalyticsModeForServiceFeature} from '../../../app/engine.js';
+import type {InsightEngine} from '../../../app/insight-engine/insight-engine.js';
+import type {SearchEngine} from '../../../app/search-engine/search-engine.js';
+import {
+ generateAnswerForPrompt,
+ updateAnswerConfigurationId,
+ updateResponseFormat,
+} from '../../../features/generated-answer/generated-answer-actions.js';
+import {generatedAnswerReducer as generatedAnswer} from '../../../features/generated-answer/generated-answer-slice.js';
+import {multiTurnReducer as multiTurnConversation} from '../../../features/multi-turn-conversation/multi-turn-conversation-slice.js';
+import type {MultiTurnConversationState as MultiTurnConversationStateInterface} from '../../../features/multi-turn-conversation/multi-turn-conversation-state.js';
+import type {
+ GeneratedAnswerSection,
+ MultiTurnConversationSection,
+ QuerySection,
+} from '../../../state/state-sections.js';
+import {loadReducerError} from '../../../utils/errors.js';
+import {
+ buildController,
+ type Controller,
+} from '../../controller/headless-controller.js';
+import {subscribeToSearchRequest} from './headless-answerapi-generated-answer.js';
+
+export interface MultiturnConversation extends Controller {
+ generateAnswerForPrompt(prompt: string): void;
+ subscribeToSearchRequest(): void;
+ state: MultiTurnConversationStateInterface;
+}
+
+export interface MultiturnConversationProps {
+ answerConfigurationId?: string;
+}
+
+export interface MultiturnConversationState
+ extends MultiTurnConversationStateInterface {}
+
+export function buildMultiturnConversation(
+ engine: SearchEngine | InsightEngine,
+ props: MultiturnConversationProps = {}
+): MultiturnConversation {
+ if (!loadAnswerApiReducers(engine)) {
+ throw loadReducerError;
+ }
+ warnIfUsingNextAnalyticsModeForServiceFeature(
+ engine.state.configuration.analytics.analyticsMode
+ );
+
+ const controller = buildController(engine);
+ const getState = () => engine.state;
+ engine.dispatch(updateAnswerConfigurationId(props.answerConfigurationId!));
+ engine.dispatch(
+ updateResponseFormat({
+ contentFormat: ['text/markdown', 'text/plain'],
+ })
+ );
+
+ return {
+ ...controller,
+ get state() {
+ return {
+ ...getState().multiTurnConversation,
+ };
+ },
+ subscribeToSearchRequest() {
+ subscribeToSearchRequest(engine as SearchEngine);
+ },
+ generateAnswerForPrompt(prompt) {
+ engine.dispatch(generateAnswerForPrompt(prompt));
+ },
+ };
+}
+
+function loadAnswerApiReducers(
+ engine: SearchEngine | InsightEngine
+): engine is SearchEngine<
+ GeneratedAnswerSection &
+ QuerySection & {
+ answer: ReturnType;
+ } & MultiTurnConversationSection
+> {
+ engine.addReducers({
+ [answerApi.reducerPath]: answerApi.reducer,
+ multiTurnConversation,
+ generatedAnswer,
+ });
+ return true;
+}
diff --git a/packages/headless/src/features/generated-answer/generated-answer-actions.ts b/packages/headless/src/features/generated-answer/generated-answer-actions.ts
index 2388c2d982a..b938e94d5b1 100644
--- a/packages/headless/src/features/generated-answer/generated-answer-actions.ts
+++ b/packages/headless/src/features/generated-answer/generated-answer-actions.ts
@@ -16,7 +16,10 @@ import type {
GeneratedAnswerStreamEventData,
} from '../../api/generated-answer/generated-answer-event-payload.js';
import type {GeneratedAnswerStreamRequest} from '../../api/generated-answer/generated-answer-request.js';
-import {fetchAnswer} from '../../api/knowledge/stream-answer-api.js';
+import {
+ fetchAnswer,
+ fetchNewTurn,
+} from '../../api/knowledge/stream-answer-api.js';
import type {StreamAnswerAPIState} from '../../api/knowledge/stream-answer-api-state.js';
import type {AsyncThunkOptions} from '../../app/async-thunk-options.js';
import type {SearchThunkExtraArguments} from '../../app/search-thunk-extra-arguments.js';
@@ -331,9 +334,8 @@ export const streamAnswer = createAsyncThunk<
* instead of the regular search pipeline.
*
* Flow:
- * 1. Reset the current generated answer state.
- * 2. Construct the Answer API query parameters based on the current state.
- * 3. Fetch a new answer from the Answer API using the provided configuration.
+ * 1. Construct the Answer API query parameters based on the current state.
+ * 2. Fetch a new answer from the Answer API using the provided configuration.
*/
export const generateAnswer = createAsyncThunk<
void,
@@ -361,3 +363,31 @@ export const generateAnswer = createAsyncThunk<
}
}
);
+
+export const generateAnswerForPrompt = createAsyncThunk<
+ void,
+ string,
+ AsyncThunkOptions
+>(
+ 'generatedAnswer/generateAnswer',
+ async (
+ prompt: string,
+ {getState, dispatch, extra: {navigatorContext, logger}}
+ ) => {
+ const state = getState() as StreamAnswerAPIState;
+ if (state.generatedAnswer.answerConfigurationId) {
+ const answerApiQueryParams = constructAnswerAPIQueryParams(
+ {...state, query: {q: prompt, enableQuerySyntax: false}},
+ navigatorContext
+ );
+ // Important to remove previous line when un-commenting the next line
+ // dispatch(setAnswerApiQueryParams(answerApiQueryParams));
+ await dispatch(fetchNewTurn(answerApiQueryParams));
+ } else {
+ logger.warn(
+ '[WARNING] Missing answerConfigurationId in engine configuration. ' +
+ 'The generateAnswer action requires an answer configuration ID to use CRGA with the Answer API.'
+ );
+ }
+ }
+);
diff --git a/packages/headless/src/features/multi-turn-conversation/multi-turn-conversation-actions.ts b/packages/headless/src/features/multi-turn-conversation/multi-turn-conversation-actions.ts
new file mode 100644
index 00000000000..7ce39934ee5
--- /dev/null
+++ b/packages/headless/src/features/multi-turn-conversation/multi-turn-conversation-actions.ts
@@ -0,0 +1,46 @@
+import {StringValue} from '@coveo/bueno';
+import {createAction} from '@reduxjs/toolkit';
+import type {
+ GeneratedAnswerCitation,
+ GeneratedAnswerMessagePayload,
+} from '../../api/generated-answer/generated-answer-event-payload.js';
+import {validatePayload} from '../../utils/validate-payload.js';
+import type {GeneratedContentFormat} from '../generated-answer/generated-response-format.js';
+
+const stringValue = new StringValue({required: true});
+
+export const resetConversation = createAction<{conversationId: string}>(
+ 'multiTurn/resetConversation'
+);
+
+export const addAnswer = createAction<{prompt: string}>('multiTurn/addAnswer');
+
+export const updateActiveAnswerMessage = createAction(
+ 'multiTurn/updateActiveAnswerMessage',
+ (payload: GeneratedAnswerMessagePayload) =>
+ validatePayload(payload, {
+ textDelta: stringValue,
+ })
+);
+
+export const updateActiveAnswer = createAction<
+ Partial<{
+ answer: string;
+ prompt: string;
+ citations: GeneratedAnswerCitation[];
+ isStreaming: boolean;
+ isLoading: boolean;
+ error: string | null;
+ cannotAnswer: boolean;
+ answerId: string;
+ answerContentFormat: GeneratedContentFormat;
+ }>
+>('multiTurn/updateActiveAnswer');
+
+export const updateCitationsToActiveAnswer = createAction<
+ GeneratedAnswerCitation[]
+>('multiTurn/updateCitationsToActiveAnswer');
+
+export const markActiveAnswerComplete = createAction(
+ 'multiTurn/markActiveAnswerComplete'
+);
diff --git a/packages/headless/src/features/multi-turn-conversation/multi-turn-conversation-slice.ts b/packages/headless/src/features/multi-turn-conversation/multi-turn-conversation-slice.ts
new file mode 100644
index 00000000000..0c7b910645e
--- /dev/null
+++ b/packages/headless/src/features/multi-turn-conversation/multi-turn-conversation-slice.ts
@@ -0,0 +1,72 @@
+import {createReducer} from '@reduxjs/toolkit';
+import {
+ addAnswer,
+ markActiveAnswerComplete,
+ resetConversation,
+ updateActiveAnswer,
+ updateActiveAnswerMessage,
+ updateCitationsToActiveAnswer,
+} from './multi-turn-conversation-actions.js';
+import {
+ type ConversationTurn,
+ getMultiTurnConversationInitialState,
+ type MultiTurnConversationState,
+} from './multi-turn-conversation-state.js';
+
+export const multiTurnReducer = createReducer(
+ getMultiTurnConversationInitialState(),
+ (builder) => {
+ builder
+ .addCase(resetConversation, (state, action) => {
+ state.conversationId = action.payload.conversationId;
+ state.answers = [];
+ })
+ .addCase(addAnswer, (state, action) => {
+ const newAnswer: ConversationTurn = {
+ answer: '',
+ prompt: action.payload.prompt,
+ citations: [],
+ isStreaming: false,
+ isLoading: true,
+ error: null,
+ cannotAnswer: false,
+ answerContentFormat: 'text/plain',
+ };
+ state.answers.push(newAnswer);
+ })
+ .addCase(updateActiveAnswerMessage, (state, {payload}) => {
+ const index = state.answers.length - 1;
+ if (index >= 0) {
+ state.answers[index] = {
+ ...state.answers[index],
+ isLoading: false,
+ isStreaming: true,
+ error: null,
+ answer: (state.answers[index].answer || '') + payload.textDelta,
+ };
+ }
+ })
+ .addCase(updateActiveAnswer, (state, action) => {
+ const index = state.answers.length - 1;
+ if (index >= 0) {
+ state.answers[index] = {
+ ...state.answers[index],
+ ...action.payload,
+ };
+ }
+ })
+ .addCase(updateCitationsToActiveAnswer, (state, action) => {
+ const index = state.answers.length - 1;
+ if (index >= 0) {
+ state.answers[index].citations = action.payload;
+ }
+ })
+ .addCase(markActiveAnswerComplete, (state) => {
+ const index = state.answers.length - 1;
+ if (index >= 0) {
+ state.answers[index].isLoading = false;
+ state.answers[index].isStreaming = false;
+ }
+ });
+ }
+);
diff --git a/packages/headless/src/features/multi-turn-conversation/multi-turn-conversation-state.ts b/packages/headless/src/features/multi-turn-conversation/multi-turn-conversation-state.ts
new file mode 100644
index 00000000000..1ebab3c5b42
--- /dev/null
+++ b/packages/headless/src/features/multi-turn-conversation/multi-turn-conversation-state.ts
@@ -0,0 +1,26 @@
+import type {GeneratedAnswerCitation} from '../../api/generated-answer/generated-answer-event-payload.js';
+import type {GeneratedContentFormat} from '../generated-answer/generated-response-format.js';
+
+export interface ConversationTurn {
+ answer: string;
+ prompt: string;
+ citations: GeneratedAnswerCitation[];
+ isStreaming: boolean;
+ isLoading: boolean;
+ error: string | null;
+ cannotAnswer: boolean;
+ answerId?: string;
+ answerContentFormat: GeneratedContentFormat;
+}
+
+export interface MultiTurnConversationState {
+ conversationId: string;
+ answers: ConversationTurn[];
+}
+
+export function getMultiTurnConversationInitialState(): MultiTurnConversationState {
+ return {
+ conversationId: '',
+ answers: [],
+ };
+}
diff --git a/packages/headless/src/index.ts b/packages/headless/src/index.ts
index b8f15d65256..adf26b0520c 100644
--- a/packages/headless/src/index.ts
+++ b/packages/headless/src/index.ts
@@ -251,6 +251,12 @@ export type {
InstantResultsState,
} from './controllers/instant-results/instant-results.js';
export {buildInstantResults} from './controllers/instant-results/instant-results.js';
+export type {
+ MultiturnConversation,
+ MultiturnConversationProps,
+ MultiturnConversationState,
+} from './controllers/knowledge/generated-answer/headless-multiturn-conversation.js';
+export {buildMultiturnConversation} from './controllers/knowledge/generated-answer/headless-multiturn-conversation.js';
export type {
Pager,
PagerInitialState,
diff --git a/packages/headless/src/insight.index.ts b/packages/headless/src/insight.index.ts
index a10b14fd9a2..7e60f25be69 100644
--- a/packages/headless/src/insight.index.ts
+++ b/packages/headless/src/insight.index.ts
@@ -320,6 +320,12 @@ export type {
export {buildUserActions} from './controllers/insight/user-actions/headless-user-actions.js';
export type {InsightInterface} from './controllers/insight-interface/insight-interface.js';
export {buildInsightInterface} from './controllers/insight-interface/insight-interface.js';
+export type {
+ MultiturnConversation,
+ MultiturnConversationProps,
+ MultiturnConversationState,
+} from './controllers/knowledge/generated-answer/headless-multiturn-conversation.js';
+export {buildMultiturnConversation} from './controllers/knowledge/generated-answer/headless-multiturn-conversation.js';
export * from './features/analytics/generic-analytics-actions-loader.js';
export * from './features/analytics/insight-analytics-actions-loader.js';
export * from './features/attached-results/attached-results-actions-loader.js';
diff --git a/packages/headless/src/state/insight-app-state.ts b/packages/headless/src/state/insight-app-state.ts
index 88afdc9764a..28e60bf0b66 100644
--- a/packages/headless/src/state/insight-app-state.ts
+++ b/packages/headless/src/state/insight-app-state.ts
@@ -15,6 +15,7 @@ import type {
InsightConfigurationSection,
InsightInterfaceSection,
InsightUserActionsSection,
+ MultiTurnConversationSection,
NumericFacetSection,
PaginationSection,
QuerySection,
@@ -66,4 +67,5 @@ export type InsightAppState = InsightSearchParametersState &
ContextSection &
InsightUserActionsSection &
TriggerSection &
- RecentQueriesSection;
+ RecentQueriesSection &
+ MultiTurnConversationSection;
diff --git a/packages/headless/src/state/search-app-state.ts b/packages/headless/src/state/search-app-state.ts
index 561160f9166..0b01386e704 100644
--- a/packages/headless/src/state/search-app-state.ts
+++ b/packages/headless/src/state/search-app-state.ts
@@ -19,6 +19,7 @@ import type {
GeneratedAnswerSection,
HistorySection,
InstantResultSection,
+ MultiTurnConversationSection,
NumericFacetSection,
PaginationSection,
PipelineSection,
@@ -78,4 +79,5 @@ export type SearchAppState = SearchParametersState &
RecentQueriesSection &
ExcerptLengthSection &
GeneratedAnswerSection &
- InstantResultSection;
+ InstantResultSection &
+ MultiTurnConversationSection;
diff --git a/packages/headless/src/state/state-sections.ts b/packages/headless/src/state/state-sections.ts
index e992bac55ca..77e1ff04dff 100644
--- a/packages/headless/src/state/state-sections.ts
+++ b/packages/headless/src/state/state-sections.ts
@@ -54,6 +54,7 @@ import type {InsightConfigurationState} from '../features/insight-configuration/
import type {InsightInterfaceState} from '../features/insight-interface/insight-interface-state.js';
import type {UserActionsState} from '../features/insight-user-actions/insight-user-actions-state.js';
import type {InstantResultsState} from '../features/instant-results/instant-results-state.js';
+import type {MultiTurnConversationState} from '../features/multi-turn-conversation/multi-turn-conversation-state.js';
import type {PaginationState} from '../features/pagination/pagination-state.js';
import type {QueryState} from '../features/query/query-state.js';
import type {QuerySetState} from '../features/query-set/query-set-state.js';
@@ -70,6 +71,13 @@ import type {StaticFilterSetState} from '../features/static-filter-set/static-fi
import type {TabSetState} from '../features/tab-set/tab-set-state.js';
import type {TriggerState} from '../features/triggers/triggers-state.js';
+export interface MultiTurnConversationSection {
+ /**
+ * The state of the multi-turn conversation.
+ */
+ multiTurnConversation: MultiTurnConversationState;
+}
+
export interface QuerySection {
/**
* The expressions that constitute the current query.
diff --git a/packages/headless/src/test/mock-insight-state.ts b/packages/headless/src/test/mock-insight-state.ts
index 5b0d629d328..747db947b5a 100644
--- a/packages/headless/src/test/mock-insight-state.ts
+++ b/packages/headless/src/test/mock-insight-state.ts
@@ -15,6 +15,7 @@ import {getGeneratedAnswerInitialState} from '../features/generated-answer/gener
import {getInsightConfigurationInitialState} from '../features/insight-configuration/insight-configuration-state.js';
import {getInsightInterfaceInitialState} from '../features/insight-interface/insight-interface-state.js';
import {getInsightUserActionsInitialState} from '../features/insight-user-actions/insight-user-actions-state.js';
+import {getMultiTurnConversationInitialState} from '../features/multi-turn-conversation/multi-turn-conversation-state.js';
import {getPaginationInitialState} from '../features/pagination/pagination-state.js';
import {getQueryInitialState} from '../features/query/query-state.js';
import {getQuerySetInitialState} from '../features/query-set/query-set-state.js';
@@ -65,6 +66,7 @@ export function buildMockInsightState(
insightUserActions: getInsightUserActionsInitialState(),
triggers: getTriggerInitialState(),
recentQueries: getRecentQueriesInitialState(),
+ multiTurnConversation: getMultiTurnConversationInitialState(),
...config,
};
}
diff --git a/packages/headless/src/test/mock-state.ts b/packages/headless/src/test/mock-state.ts
index 14dee456da5..746f7bc7bb6 100644
--- a/packages/headless/src/test/mock-state.ts
+++ b/packages/headless/src/test/mock-state.ts
@@ -20,6 +20,7 @@ import {getFoldingInitialState} from '../features/folding/folding-state.js';
import {getGeneratedAnswerInitialState} from '../features/generated-answer/generated-answer-state.js';
import {getHistoryInitialState} from '../features/history/history-state.js';
import {getInstantResultsInitialState} from '../features/instant-results/instant-results-state.js';
+import {getMultiTurnConversationInitialState} from '../features/multi-turn-conversation/multi-turn-conversation-state.js';
import {getPaginationInitialState} from '../features/pagination/pagination-state.js';
import {getPipelineInitialState} from '../features/pipeline/pipeline-state.js';
import {getQueryInitialState} from '../features/query/query-state.js';
@@ -86,6 +87,7 @@ export function createMockState(
excerptLength: getExcerptLengthInitialState(),
automaticFacetSet: getAutomaticFacetSetInitialState(),
generatedAnswer: getGeneratedAnswerInitialState(),
+ multiTurnConversation: getMultiTurnConversationInitialState(),
...config,
};
}