/* eslint-disable @lwc/lwc/no-document-query */ import { LightningElement, api } from "lwc"; import { waitUntilResolved } from "dxUtils/async"; const HOSTED_MIAW_UI_TAG = "page-builder-miaw-ui"; const SCRIPT_SRC = "https://a.sfdcstatic.com/digital/@sfdc-www-emu/page-builder-miaw-ui/v1-stable/page-builder-miaw-ui.js"; const DEFINE_TIMEOUT_MS = 60000; const WHEN_DEFINED_TIMEOUT_MESSAGE = `MIAW UI embed (${HOSTED_MIAW_UI_TAG}) did not register within ${ DEFINE_TIMEOUT_MS / 1000 }s. ` + "Confirm the script loads (network tab), your origin is allowed, and CSP permits this script."; let scriptLoaded: Promise | null = null; async function ensureMiawScriptAndDefinition(): Promise { if (customElements.get(HOSTED_MIAW_UI_TAG)) { return; } let script = document.querySelector( `script[src="${SCRIPT_SRC}"]` ) as HTMLScriptElement | null; if (!script) { script = document.createElement("script"); script.type = "module"; script.src = SCRIPT_SRC; await new Promise((resolve, reject) => { script!.addEventListener("load", () => resolve(), { once: true }); script!.addEventListener( "error", () => reject( new Error( `Failed to load MIAW UI embed script (${SCRIPT_SRC}). Check status code and referrer rules.` ) ), { once: true } ); document.head.appendChild(script!); }); } await waitUntilResolved( customElements.whenDefined(HOSTED_MIAW_UI_TAG), DEFINE_TIMEOUT_MS, WHEN_DEFINED_TIMEOUT_MESSAGE ); } /** Loads the MIAW UI script if it is not already loaded. This occurs only once per module instance. */ function loadMiawUiScript(): Promise { if (!scriptLoaded) { scriptLoaded = ensureMiawScriptAndDefinition(); } return scriptLoaded; } function dropCookie(cookieDomain: string) { const { hostname, protocol } = window.location; const onCookieDomain = !!cookieDomain && (hostname === cookieDomain || hostname.endsWith(`.${cookieDomain}`)); document.cookie = [ "show_agent=true", "Path=/", "Max-Age=2592000", "SameSite=Lax", protocol === "https:" ? "Secure" : null, onCookieDomain ? `Domain=${cookieDomain}` : null ] .filter(Boolean) .join("; "); } export default class AgentMiawUi extends LightningElement { private static readonly SIDEBAR_OPEN_ATTR = "data-xsf-agent-sidebar-open"; private static readonly AGENT_ROOT_SELECTOR = '[data-testid="agent-root"]'; private static readonly SIDEBAR_OPEN_CLASS = "view-sidebar"; /** Salesforce org id (15- or 18-character Id). */ @api orgId!: string; /** Messaging endpoint host URL (e.g. https://org62.my.salesforce-scrt.com). */ @api messagingUrl!: string; @api deploymentDevName = "page_builder_miaw_ui"; @api richComponentVersion = "v1-stable"; @api isOnDigitalDomain = "true"; @api routingAttributes?: string; /** * JSON array of suggested prompt strings for the MIAW UI (e.g. * `["Show me an Agentforce demo","Help me build a business case"]`); forwarded as `prompts` on the embed. */ @api prompts?: string; /** When set, forwarded to the embed as `welcome-text` (greeting / headline copy). */ @api welcomeText?: string; @api agentforceEnv?: string; private _hideAgentConditionally = false; /** * When set, mounting of the agent is gated behind the `showAgent=true` query param * or `show_agent=true` cookie opt-in; otherwise (and by default), gating is off. * * COERCION: this component is consumed as raw, server-rendered HTML on an LWR page — * not inside another LWC's template — so LWC's presence→boolean attribute convention * does NOT apply automatically. Native attribute reflection runs instead, and LWC's * attributeChangedCallback assigns the raw attribute STRING to this property * (`this[prop] = newValue`), so a bare `hide-agent-conditionally` arrives as `""`, not * boolean `true`. This setter restores HTML boolean-attribute semantics by source: * - boolean (LWC property API / tests): honored as-is, so `false` turns gating off. * - string (native attribute reflection): PRESENCE means true regardless of value — * `""`, `"true"`, and even `"false"` all enable gating, exactly like `disabled="false"` * is still disabled. Only absence (null/undefined, i.e. the attribute removed) is false. */ @api get hideAgentConditionally(): boolean { return this._hideAgentConditionally; } set hideAgentConditionally(value: boolean | string | null | undefined) { this._hideAgentConditionally = typeof value === "boolean" ? value : value !== null && value !== undefined; } /** * Apex domain that the opt-in cookie targets (covers subdomains); applied only when the * current host matches it, so local dev / preview hosts fall back to a host-only cookie. */ @api cookieDomain?: string; /** After first mount attempt is scheduled, we do not run it again for this instance. */ private hasRendered = false; private sidebarStateObserver: MutationObserver | null = null; private sidebarStateTargetSelector = "#main-content"; renderedCallback(): void { if (!this.hasRendered) { this.hasRendered = true; if (this.shouldMount()) { this.mountMiawHost(); } } } disconnectedCallback(): void { this.sidebarStateObserver?.disconnect(); this.sidebarStateObserver = null; } /** * Decide whether the agent should mount. If conditional gating is off, always mount. * If conditional gating of the agent is on, return `true` only when the user has opted in * via the `showAgent=true` query param or the `show_agent=true` cookie. (When only the * param is present, persists a sticky opt-in cookie (~30 days) as a side effect so the * choice carries across pages and subdomains.) * * CROSS-SITE CONTRACT: the param key/value, cookie token, Max-Age, SameSite, Secure and * Domain-scoping logic are MIRRORED in architect-sf, since both serve pages for Architect. * Changing these values requires a coordinated change in all repos. */ private shouldMount(): boolean { if (!this.hideAgentConditionally) { return true; } // When the agent should be conditionally hidden, we check for special query param or // cookie required for actually mounting/rendering it. const hasParam = new URLSearchParams(window.location.search).get("showAgent") === "true"; const hasCookie = /(?:^|;\s*)show_agent=true(?:;|$)/.test( document.cookie ); // If the user doesn't have the cookie yet but does have the param, set the cookie if (hasParam && !hasCookie) { dropCookie(this.cookieDomain || ""); } return hasParam || hasCookie; } private async mountMiawHost(): Promise { try { await loadMiawUiScript(); const container = this.template.querySelector(".miaw-host"); if (!container) { return; } const el = document.createElement(HOSTED_MIAW_UI_TAG); el.setAttribute("org-id", this.orgId); el.setAttribute("messaging-url", this.messagingUrl); el.setAttribute("deployment-dev-name", this.deploymentDevName); el.setAttribute("input-variant", "mini-sidebar"); el.setAttribute("is-on-digital-domain", this.isOnDigitalDomain); el.setAttribute( "rich-component-version", this.richComponentVersion ); if (this.routingAttributes) { el.setAttribute("routing-attributes", this.routingAttributes); } if (this.prompts) { el.setAttribute("prompts", this.prompts); } if (this.welcomeText) { el.setAttribute("welcome-text", this.welcomeText); } el.setAttribute("env", this.agentforceEnv || "dev"); container.appendChild(el); this.bridgeSidebarState(el); } catch (e) { console.error(e); } } private setSidebarOpenState(isOpen: boolean): void { const targets = document.querySelectorAll( this.sidebarStateTargetSelector ); targets.forEach((target) => { target.setAttribute( AgentMiawUi.SIDEBAR_OPEN_ATTR, isOpen ? "true" : "false" ); }); } private bridgeSidebarState(el: Element): void { const host = el as HTMLElement; const root = host.shadowRoot?.querySelector( AgentMiawUi.AGENT_ROOT_SELECTOR ) as HTMLElement | null; if (!root) { window.requestAnimationFrame(() => this.bridgeSidebarState(el)); return; } const isOpen = () => root.classList.contains(AgentMiawUi.SIDEBAR_OPEN_CLASS); this.setSidebarOpenState(isOpen()); this.sidebarStateObserver?.disconnect(); this.sidebarStateObserver = new MutationObserver(() => { this.setSidebarOpenState(isOpen()); }); this.sidebarStateObserver.observe(root, { attributes: true, attributeFilter: ["class"] }); } }