commit 31e30d5303c38af21488e124eaba4962f4eb4aff parent 6b42b5bdba29be8ade5ccb4f0ee0861b8bcb61fc Author: Giulia Cardieri <gcardieri@mozilla.com> Date: Thu, 8 Jan 2026 16:19:59 +0000 Bug 2001519 - Include chat assistant search handoff component. r:ngrato,standard8 r=search-reviewers,ai-frontend-reviewers,Standard8,ngrato,frontend-codestyle-reviewers Figma (yellow dot): https://www.figma.com/design/5KuePTGmOEUFyCHBHCsGim/AI-Mode-%E2%80%94%C2%A0MVP-Scope-Design?node-id=7568-80098&p=f&m=dev Goal: add a button with the design from Figma that opens the default search engine when clicked and passes the query sent by the assistant. The connection with the real assistant and possible icon updates will be part of a follow-up ticket Differential Revision: https://phabricator.services.mozilla.com/D275302 Diffstat:
15 files changed, 463 insertions(+), 2 deletions(-)
diff --git a/browser/components/DesktopActorRegistry.sys.mjs b/browser/components/DesktopActorRegistry.sys.mjs @@ -226,6 +226,9 @@ let JSWINDOWACTORS = { child: { esModuleURI: "moz-src:///browser/components/aiwindow/ui/actors/AIChatContentChild.sys.mjs", + events: { + "AIChatContent:DispatchSearch": { wantUntrusted: true }, + }, }, allFrames: true, matches: ["about:aichatcontent"], diff --git a/browser/components/aiwindow/ui/actors/AIChatContentChild.sys.mjs b/browser/components/aiwindow/ui/actors/AIChatContentChild.sys.mjs @@ -9,14 +9,44 @@ ChromeUtils.defineESModuleGetters(lazy, {}); * Represents a child actor for getting page data from the browser. */ export class AIChatContentChild extends JSWindowActorChild { - static #EVENT_MAPPINGS = { + static #EVENT_MAPPINGS_FROM_PARENT = { "AIChatContent:DispatchMessage": { event: "aiChatContentActor:message", }, }; + static #VALID_EVENTS_FROM_CONTENT = new Set(["AIChatContent:DispatchSearch"]); + + /** + * Receives event from the content process and sends to the parent. + * + * @param {CustomEvent} event + */ + handleEvent(event) { + if (!AIChatContentChild.#VALID_EVENTS_FROM_CONTENT.has(event.type)) { + console.warn(`AIChatContentChild received unknown event: ${event.type}`); + return; + } + + switch (event.type) { + case "AIChatContent:DispatchSearch": + this.#handleSearchDispatch(event); + break; + + default: + console.warn( + `AIChatContentChild received unknown event: ${event.type}` + ); + } + } + + #handleSearchDispatch(event) { + this.sendAsyncMessage("aiChatContentActor:search", event.detail); + } + async receiveMessage(message) { - const mapping = AIChatContentChild.#EVENT_MAPPINGS[message.name]; + const mapping = + AIChatContentChild.#EVENT_MAPPINGS_FROM_PARENT[message.name]; if (!mapping) { console.warn( diff --git a/browser/components/aiwindow/ui/actors/AIChatContentParent.sys.mjs b/browser/components/aiwindow/ui/actors/AIChatContentParent.sys.mjs @@ -2,6 +2,12 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + AIWindow: + "moz-src:///browser/components/aiwindow/ui/modules/AIWindow.sys.mjs", +}); + /** * JSWindowActor to pass data between AIChatContent singleton and content pages. */ @@ -9,4 +15,26 @@ export class AIChatContentParent extends JSWindowActorParent { dispatchMessageToChatContent(response) { this.sendAsyncMessage("AIChatContent:DispatchMessage", response); } + + receiveMessage({ data, name }) { + switch (name) { + case "aiChatContentActor:search": + this.#handleSearchFromChild(data); + break; + + default: + console.warn(`AIChatContentParent received unknown message: ${name}`); + break; + } + return undefined; + } + + #handleSearchFromChild(data) { + try { + const { topChromeWindow } = this.browsingContext; + lazy.AIWindow.performSearch(data, topChromeWindow); + } catch (e) { + console.warn("Could not perform search from AI Window chat", e); + } + } } diff --git a/browser/components/aiwindow/ui/components/ai-chat-message/ai-chat-message.mjs b/browser/components/aiwindow/ui/components/ai-chat-message/ai-chat-message.mjs @@ -5,6 +5,9 @@ import { html } from "chrome://global/content/vendor/lit.all.mjs"; import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; +// eslint-disable-next-line import/no-unassigned-import +import "chrome://browser/content/aiwindow/components/ai-chat-search-button.mjs"; + /** * A custom element for managing AI Chat Content */ @@ -24,6 +27,24 @@ export class AIChatMessage extends MozLitElement { connectedCallback() { super.connectedCallback(); + this.addEventListener( + "AIWindow:chat-search", + this.handleSearchHandoffEvent.bind(this) + ); + } + + /** + * Handle search handoff events + * + * @param {CustomEvent} event - The custom event containing the search query. + */ + handleSearchHandoffEvent(event) { + const e = new CustomEvent("AIChatContent:DispatchSearch", { + detail: event.detail, + bubbles: true, + composed: true, + }); + this.dispatchEvent(e); } render() { @@ -38,6 +59,11 @@ export class AIChatMessage extends MozLitElement { <!-- TODO: Add markdown parsing here --> ${this.message} </div> + <!-- TODO: update props based on assistant response --> + <ai-chat-search-button + query="Ada Lovelace" + label="Ada Lovelace" + ></ai-chat-search-button> </article> `; } diff --git a/browser/components/aiwindow/ui/components/ai-chat-search-button/ai-chat-search-button.css b/browser/components/aiwindow/ui/components/ai-chat-search-button/ai-chat-search-button.css @@ -0,0 +1,24 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +.ai-chat-search-button { + max-width: 100%; + --button-background-color: color-mix(in srgb, var(--color-white) 80%, transparent); + --button-background-color-hover: var(--color-purple-0); + --button-border: var(--border-width) solid var(--color-purple-0); + --button-border-color-hover: color-mix(in srgb, var(--color-violet-10) 50%, transparent); + --button-border-radius: var(--border-radius-medium); + --button-font-size-small: var(--font-size-large); + --button-font-weight: normal; + --button-padding: var(--space-xsmall) var(--space-small); + --button-text-color: color-mix(in srgb, var(--color-gray-100) 69%, transparent); + --button-text-color-hover: var(--color-gray-100); + + &::part(moz-button-label) { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} diff --git a/browser/components/aiwindow/ui/components/ai-chat-search-button/ai-chat-search-button.mjs b/browser/components/aiwindow/ui/components/ai-chat-search-button/ai-chat-search-button.mjs @@ -0,0 +1,62 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { html } from "chrome://global/content/vendor/lit.all.mjs"; +import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; + +// eslint-disable-next-line import/no-unassigned-import +import "chrome://global/content/elements/moz-button.mjs"; + +/** + * AI Chat Assistant Search Button. + * + * @property {string} label - button label passed by the chat assistant + * @property {string} query - search query passed by the chat assistant + * @property {string} engineIcon - default search engine icon + */ +export class AIChatSearchButton extends MozLitElement { + static properties = { + label: { type: String }, + query: { type: String }, + engineIcon: { + type: String, + }, + }; + + constructor() { + super(); + this.engineIcon = "chrome://global/skin/icons/search-glass.svg"; + this.label = "Search"; + } + + /** + * Triggers the search event. + * + * @param {string} query + */ + createSearchQuery(query) { + const event = new CustomEvent("AIWindow:chat-search", { + detail: query, + bubbles: true, + composed: true, + }); + this.dispatchEvent(event); + } + + render() { + return html`<link + rel="stylesheet" + href="chrome://browser/content/aiwindow/components/ai-chat-search-button.css" + /><moz-button + id="ai-chat-search-button" + class="ai-chat-search-button" + iconSrc=${this.engineIcon} + size="small" + @click=${_e => this.createSearchQuery(this.query)} + > + ${this.label} + </moz-button>`; + } +} +customElements.define("ai-chat-search-button", AIChatSearchButton); diff --git a/browser/components/aiwindow/ui/components/ai-chat-search-button/ai-chat-search-button.stories.mjs b/browser/components/aiwindow/ui/components/ai-chat-search-button/ai-chat-search-button.stories.mjs @@ -0,0 +1,31 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { html } from "chrome://global/content/vendor/lit.all.mjs"; +import "chrome://browser/content/aiwindow/components/ai-chat-search-button.mjs"; + +export default { + title: "Domain-specific UI Widgets/AI Window/Chat Search Button", + component: "ai-chat-search-button", + argTypes: { + engineIcon: "", + label: "Ada Lovelace", + query: "Ada Lovelace", + }, +}; + +const Template = ({ engineIcon, label, query }) => html` + <ai-chat-search-button + .label=${label} + .query=${query} + .engineIcon=${engineIcon} + ></ai-chat-search-button> +`; + +export const Default = Template.bind({}); +Default.args = { + engineIcon: "chrome://global/skin/icons/more.svg" /* placeholder icon */, + label: "Ada Lovelace", + query: "Ada Lovelace", +}; diff --git a/browser/components/aiwindow/ui/jar.mn b/browser/components/aiwindow/ui/jar.mn @@ -10,6 +10,8 @@ browser.jar: content/browser/aiwindow/components/ai-chat-content.css (components/ai-chat-content/ai-chat-content.css) content/browser/aiwindow/components/ai-chat-message.mjs (components/ai-chat-message/ai-chat-message.mjs) content/browser/aiwindow/components/ai-chat-message.css (components/ai-chat-message/ai-chat-message.css) + content/browser/aiwindow/components/ai-chat-search-button.mjs (components/ai-chat-search-button/ai-chat-search-button.mjs) + content/browser/aiwindow/components/ai-chat-search-button.css (components/ai-chat-search-button/ai-chat-search-button.css) content/browser/aiwindow/components/ai-window.mjs (components/ai-window/ai-window.mjs) content/browser/aiwindow/components/ai-window.css (components/ai-window/ai-window.css) content/browser/aiwindow/ai-window-content.css (content/ai-window-content.css) diff --git a/browser/components/aiwindow/ui/modules/AIWindow.sys.mjs b/browser/components/aiwindow/ui/modules/AIWindow.sys.mjs @@ -15,6 +15,8 @@ ChromeUtils.defineESModuleGetters(lazy, { AIWindowMenu: "moz-src:///browser/components/aiwindow/ui/modules/AIWindowMenu.sys.mjs", + + SearchUIUtils: "moz-src:///browser/components/search/SearchUIUtils.sys.mjs", }); /** @@ -166,4 +168,35 @@ export const AIWindow = { get newTabURL() { return AIWINDOW_URL; }, + + /** + * Performs a search in the default search engine with + * passed query in the current tab. + * + * @param {string} query + * @param {Window} window + */ + async performSearch(query, window) { + let engine = null; + try { + engine = await Services.search.getDefault(); + } catch (error) { + console.error(`Failed to get default search engine:`, error); + } + + const triggeringPrincipal = + Services.scriptSecurityManager.getSystemPrincipal(); + + await lazy.SearchUIUtils.loadSearch({ + window, + searchText: query, + where: "current", + usePrivate: false, + triggeringPrincipal, + policyContainer: null, + engine, + searchUrlType: null, + sapSource: "aiwindow_assistant", + }); + }, }; diff --git a/browser/components/aiwindow/ui/test/browser/browser.toml b/browser/components/aiwindow/ui/test/browser/browser.toml @@ -1,6 +1,8 @@ [DEFAULT] support-files = [ "head.js", + "test_chat_search_button.html", + "test_chat_search_button.mjs", ] ["browser_actor_user_prompt.js"] @@ -13,6 +15,8 @@ support-files = [ ["browser_aiwindow_integration.js"] +["browser_aiwindow_search_button.js"] + ["browser_aiwindow_transparency.js"] ["browser_aiwindowui.js"] diff --git a/browser/components/aiwindow/ui/test/browser/browser_aiwindow_search_button.js b/browser/components/aiwindow/ui/test/browser/browser_aiwindow_search_button.js @@ -0,0 +1,131 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + SearchUIUtils: "moz-src:///browser/components/search/SearchUIUtils.sys.mjs", + SearchUITestUtils: "resource://testing-common/SearchUITestUtils.sys.mjs", + SearchTestUtils: "resource://testing-common/SearchTestUtils.sys.mjs", + AIWindow: + "moz-src:///browser/components/aiwindow/ui/modules/AIWindow.sys.mjs", +}); + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +const TEST_PAGE = + "chrome://mochitests/content/browser/browser/components/aiwindow/ui/test/browser/test_chat_search_button.html"; + +/** + * Test the chat search hanfoff button params and if it was clicked + */ +add_task(async function test_chat_search_button() { + await BrowserTestUtils.withNewTab(TEST_PAGE, async browser => { + await SpecialPowers.spawn(browser, [], async () => { + await content.customElements.whenDefined("ai-chat-message"); + const aiChatMessage = content.document.querySelector("ai-chat-message"); + + let resolveEvent; + content.wrappedJSObject.__aiwindowChatSearchEvent = null; + content.wrappedJSObject.__aiwindowSearchPromise = new Promise( + r => (resolveEvent = r) + ); + + content.document.addEventListener( + "AIWindow:chat-search", + e => { + content.wrappedJSObject.__aiwindowChatSearchEvent = { + type: e?.type, + detail: e?.detail, + }; + resolveEvent(); + }, + { once: true } + ); + + Assert.ok(aiChatMessage, "ai-chat-message exists"); + + const chatSearchButtonHost = aiChatMessage.shadowRoot.querySelector( + "ai-chat-search-button" + ); + const chatSearchButton = chatSearchButtonHost.shadowRoot.querySelector( + "#ai-chat-search-button" + ); + Assert.ok(chatSearchButtonHost, "ai-chat-search-button exists"); + Assert.equal( + chatSearchButtonHost.getAttribute("label"), + "Ada Lovelace", + "Button has correct label" + ); + Assert.equal( + chatSearchButtonHost.getAttribute("query"), + "Ada Lovelace", + "Button has correct query" + ); + EventUtils.synthesizeMouseAtCenter(chatSearchButton, {}, content); + }); + + await SpecialPowers.spawn(browser, [], async () => { + await content.wrappedJSObject.__aiwindowSearchPromise; + }); + + let event = browser.contentWindow.wrappedJSObject.__aiwindowChatSearchEvent; + + Assert.equal( + event.type, + "AIWindow:chat-search", + "AIWindow:chat-search event was fired" + ); + + Assert.equal( + event.detail, + "Ada Lovelace", + "AIWindow:chat-search event includes the correct query" + ); + }); +}); + +/** + * Test the telemetry from the performSearch function called by the search handoff button + */ +add_task(async function test_telemetry_chat_search_button() { + lazy.SearchUITestUtils.init(this); + lazy.SearchTestUtils.init(this); + await lazy.SearchTestUtils.updateRemoteSettingsConfig([ + { identifier: "other" }, + ]); + + const loadSearchSpy = sinon.spy(lazy.SearchUIUtils, "loadSearch"); + const { topChromeWindow } = window.browsingContext; + + await lazy.AIWindow.performSearch("Ada Lovelace", topChromeWindow); + + Assert.ok( + loadSearchSpy.calledOnce, + "SearchUIUtils.loadSearch was called from AI Window Perform Search" + ); + + const args = loadSearchSpy.firstCall.args[0]; + Assert.equal( + args.searchText, + "Ada Lovelace", + "Correct query/searchText passed" + ); + Assert.equal( + args.sapSource, + "aiwindow_assistant", + "AI Window sapSource passed" + ); + + await lazy.SearchUITestUtils.assertSAPTelemetry({ + engineId: "other", + engineName: "other", + source: "aiwindow_assistant", + count: 1, + }); + + loadSearchSpy.restore(); +}); diff --git a/browser/components/aiwindow/ui/test/browser/test_chat_search_button.html b/browser/components/aiwindow/ui/test/browser/test_chat_search_button.html @@ -0,0 +1,12 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8" /> + <title>AI Window chat search button test</title> + <script type="module" src="test_chat_search_button.mjs"></script> + </script> + </head> + + <body> + </body> +</html> diff --git a/browser/components/aiwindow/ui/test/browser/test_chat_search_button.mjs b/browser/components/aiwindow/ui/test/browser/test_chat_search_button.mjs @@ -0,0 +1,10 @@ +// eslint-disable-next-line no-unused-vars +import * as _aiChatMessage from "chrome://browser/content/aiwindow/components/ai-chat-message.mjs"; + +(async () => { + await customElements.whenDefined("ai-chat-message"); + + const el = document.createElement("ai-chat-message"); + el.message = { role: "assistant", content: "testing..." }; + document.body.appendChild(el); +})(); diff --git a/browser/components/search/BrowserSearchTelemetry.sys.mjs b/browser/components/search/BrowserSearchTelemetry.sys.mjs @@ -48,6 +48,7 @@ class BrowserSearchTelemetryHandler { ["urlbar-persisted", "urlbar_persisted"], ["urlbar-searchmode", "urlbar_searchmode"], ["webextension", "webextension"], + ["aiwindow_assistant", "aiwindow_assistant"], ]); /** diff --git a/browser/components/search/metrics.yaml b/browser/components/search/metrics.yaml @@ -164,6 +164,7 @@ sap: `urlbar_persisted`, `urlbar_searchmode`, `webextension` + `aiwindow_assistant` Prior to Firefox 142, the possible source values were: `abouthome`, `contextmenu`, `newtab`, `searchbar`, `system`, `urlbar`, @@ -339,6 +340,7 @@ serp: `tabhistory`, `unknown`, `webextension` + `aiwindow_assistant` type: string search_mode: description: > @@ -901,6 +903,20 @@ browser.engagement.navigation: expires: never telemetry_mirror: BROWSER_ENGAGEMENT_NAVIGATION_WEBEXTENSION + aiwindow_assistant: + type: labeled_counter + description: > + The count URI loads triggered in a subsession from the AI window assistant + search handoff button. + bugs: + - https://bugzil.la/2001519 + data_reviews: + - https://bugzil.la/2001519 + notification_emails: + - fx-search-telemetry@mozilla.com + - rev-data@mozilla.com + expires: never + browser.search.content: urlbar: type: labeled_counter @@ -1145,6 +1161,22 @@ browser.search.content: expires: never telemetry_mirror: BROWSER_SEARCH_CONTENT_WEBEXTENSION + aiwindow_assistant: + type: labeled_counter + description: > + Records counts for in-content searches where the search was most + likely started from the AI window assistant. The key format is + <provider>:[tagged|tagged-follow-on|organic]:[code|other|none] + See https://firefox-source-docs.mozilla.org/browser/search/telemetry.html#browser-search-content + bugs: + - https://bugzil.la/2001519 + data_reviews: + - https://bugzil.la/2001519 + notification_emails: + - fx-search-telemetry@mozilla.com + - rev-data@mozilla.com + expires: never + system: type: labeled_counter description: > @@ -1426,6 +1458,23 @@ browser.search.withads: expires: never telemetry_mirror: BROWSER_SEARCH_WITHADS_WEBEXTENSION + aiwindow_assistant: + type: labeled_counter + description: > + Records counts of SERP pages with adverts displayed where the search + was started from the AI window assistant. The key format is + ‘<provider>:<tagged|organic>’ See https://firefox-source- + docs.mozilla.org/browser/search/telemetry.html#browser-search- + content. + bugs: + - https://bugzil.la/2001519 + data_reviews: + - https://bugzil.la/2001519 + notification_emails: + - fx-search-telemetry@mozilla.com + - rev-data@mozilla.com + expires: never + system: type: labeled_counter description: > @@ -1689,6 +1738,21 @@ browser.search.adclicks: expires: never telemetry_mirror: BROWSER_SEARCH_ADCLICKS_WEBEXTENSION + aiwindow_assistant: + type: labeled_counter + description: > + Records clicks of adverts on SERP pages where the search was started + from the AI window assistant. The key format is ‘<provider>:<tagged|organic>’ + See https://firefox-source-docs.mozilla.org/browser/search/telemetry.html#browser-search-content + bugs: + - https://bugzil.la/2001519 + data_reviews: + - https://bugzil.la/2001519 + notification_emails: + - fx-search-telemetry@mozilla.com + - rev-data@mozilla.com + expires: never + system: type: labeled_counter description: >