tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit e4767804fdfe5d4798266bff8509727f6c3543b9
parent ed47c493b4f2bc6139e11de2d6698a5e57e170af
Author: Elissa Cha <echa@mozilla.com>
Date:   Fri,  9 Jan 2026 23:51:36 +0000

Bug 2001513 - Chat Assistant message footer, memories applied r=desktop-theme-reviewers,fluent-reviewers,bolsson,Mardak,jules,ai-frontend-reviewers

Differential Revision: https://phabricator.services.mozilla.com/D275329

Diffstat:
Mbrowser/components/aiwindow/ui/components/ai-chat-content/ai-chat-content.css | 20++++++++++++++++++++
Mbrowser/components/aiwindow/ui/components/ai-chat-content/ai-chat-content.mjs | 44+++++++++++++++++++++++++++++++++-----------
Mbrowser/components/aiwindow/ui/components/ai-chat-content/ai-chat-content.stories.mjs | 41+++++++++++++++++++++++++++--------------
Abrowser/components/aiwindow/ui/components/ai-chat-content/chat-assistant-footer/applied-memories-button.css | 157+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abrowser/components/aiwindow/ui/components/ai-chat-content/chat-assistant-footer/applied-memories-button.mjs | 248+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abrowser/components/aiwindow/ui/components/ai-chat-content/chat-assistant-footer/assistant-message-footer.css | 27+++++++++++++++++++++++++++
Abrowser/components/aiwindow/ui/components/ai-chat-content/chat-assistant-footer/assistant-message-footer.mjs | 154+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbrowser/components/aiwindow/ui/components/ai-chat-message/ai-chat-message.mjs | 2+-
Mbrowser/components/aiwindow/ui/components/ai-chat-message/ai-chat-message.stories.mjs | 2+-
Mbrowser/components/aiwindow/ui/jar.mn | 5+++++
Mbrowser/components/aiwindow/ui/test/browser/browser.toml | 6++++++
Abrowser/components/aiwindow/ui/test/browser/browser_aiwindow_applied_memories_button.js | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abrowser/components/aiwindow/ui/test/browser/browser_aiwindow_assistant_message_footer.js | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abrowser/components/aiwindow/ui/test/browser/test_applied_memories_page.html | 15+++++++++++++++
Abrowser/components/aiwindow/ui/test/browser/test_assistant_message_footer_page.html | 15+++++++++++++++
Mbrowser/locales-preview/aiWindow.ftl | 12++++++++++++
16 files changed, 857 insertions(+), 27 deletions(-)

diff --git a/browser/components/aiwindow/ui/components/ai-chat-content/ai-chat-content.css b/browser/components/aiwindow/ui/components/ai-chat-content/ai-chat-content.css @@ -1,8 +1,28 @@ /* 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/. */ +/* stylelint-disable */ .chat-content-wrapper { display: flex; flex-direction: column; + gap: var(--space-small); +} + +/* Temporary chat bubble styling */ +.chat-bubble { + width: 400px; + display: flex; + flex-direction: column; + padding: var(--space-large); + box-sizing: border-box; + border: 1px dashed #ccc; +} + +.chat-bubble-user { + align-items: flex-end; +} + +.chat-bubble-assistant { + gap: var(--space-small); } diff --git a/browser/components/aiwindow/ui/components/ai-chat-content/ai-chat-content.mjs b/browser/components/aiwindow/ui/components/ai-chat-content/ai-chat-content.mjs @@ -2,8 +2,10 @@ * 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 { html, nothing } 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/assistant-message-footer.mjs"; /** * A custom element for managing AI Chat Content @@ -46,14 +48,15 @@ export class AIChatContent extends MozLitElement { /** * Handle user prompt events * - * @param {CustomeEvent} event - The custom event containing the user prompt + * @param {CustomEvent} event - The custom event containing the user prompt */ handleUserPromptEvent(event) { const { content } = event.detail; - - this.conversationState.push({ role: "user", content }); - + this.conversationState.push({ + role: "user", + body: content.body, + }); this.requestUpdate(); } @@ -64,9 +67,15 @@ export class AIChatContent extends MozLitElement { */ handleAIResponseEvent(event) { - const { ordinal } = event.detail; + // TODO (bug 2009434): update reference to insights + const { ordinal, id: messageId, content, insightsApplied } = event.detail; - this.conversationState[ordinal] = event.detail; + this.conversationState[ordinal] = { + role: "assistant", + messageId, + body: content.body, + appliedMemories: insightsApplied ?? [], + }; this.requestUpdate(); } @@ -79,10 +88,23 @@ export class AIChatContent extends MozLitElement { /> <div class="chat-content-wrapper"> ${this.conversationState.map(msg => { - return html`<ai-chat-message - .message=${msg.content.body} - .role=${msg.role} - ></ai-chat-message>`; + return html` + <div class=${`chat-bubble chat-bubble-${msg.role}`}> + <ai-chat-message + .message=${msg.body} + .role=${msg.role} + ></ai-chat-message> + + ${msg.role === "assistant" + ? html` + <assistant-message-footer + .messageId=${msg.messageId} + .appliedMemories=${msg.appliedMemories} + ></assistant-message-footer> + ` + : nothing} + </div> + `; })} </div> `; diff --git a/browser/components/aiwindow/ui/components/ai-chat-content/ai-chat-content.stories.mjs b/browser/components/aiwindow/ui/components/ai-chat-content/ai-chat-content.stories.mjs @@ -1,6 +1,6 @@ /* 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/. */ + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import { html } from "chrome://global/content/vendor/lit.all.mjs"; import "chrome://browser/content/aiwindow/components/ai-chat-content.mjs"; @@ -9,9 +9,19 @@ export default { title: "Domain-specific UI Widgets/AI Window/AI Chat Content", component: "ai-chat-content", argTypes: { - conversationState: { - control: { type: "object" }, - }, + conversationState: { control: { type: "object" } }, + }, + parameters: { + fluent: ` +aiwindow-memories-used = + .label = Memories used +aiwindow-retry-without-memories = + .label = Retry without memories +aiwindow-retry = + .tooltiptext = Retry +aiwindow-copy-message = + .tooltiptext = Copy + `, }, }; @@ -20,31 +30,34 @@ const Template = ({ conversationState }) => html` `; export const Empty = Template.bind({}); -Empty.args = { - conversationState: [], -}; +Empty.args = { conversationState: [] }; export const SingleUserMessage = Template.bind({}); SingleUserMessage.args = { conversationState: [ - { role: "user", content: "What is the weather like today?" }, + { role: "user", body: "What is the weather like today?" }, ], }; export const Conversation = Template.bind({}); Conversation.args = { conversationState: [ - { role: "user", content: "Test: What is the weather like today?" }, + { role: "user", body: "What is the weather like today?" }, { role: "assistant", - content: - "Test: I don't have access to real-time weather data, but I can help you with other tasks!", + messageId: "a1", + body: "I don't have access to real-time weather data, but I can help you with other tasks!", + appliedMemories: [], }, - { role: "user", content: "Test: Can you help me with coding?" }, + { role: "user", body: "Can you help me with coding?" }, { role: "assistant", - content: - "Test: Yes, I can help you with coding! What programming language or problem are you working on?", + messageId: "a2", + body: "Yes, I can help you with coding! What programming language or problem are you working on?", + appliedMemories: [ + "Looking for help with coding", + "Looking for real time weather data", + ], }, ], }; diff --git a/browser/components/aiwindow/ui/components/ai-chat-content/chat-assistant-footer/applied-memories-button.css b/browser/components/aiwindow/ui/components/ai-chat-content/chat-assistant-footer/applied-memories-button.css @@ -0,0 +1,157 @@ +/* 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 https://mozilla.org/MPL/2.0/. */ + +:host { + --aiwindow-text-accent: var(--color-violet-90); + --aiwindow-border-subtle: var(--color-gray-20); + --aiwindow-memory-item-bg: var(--color-gray-05); + --memories-accent-bg-hover: rgba(191, 143, 204, 0.2); + --memories-accent-bg-active: rgba(191, 143, 204, 0.26); + --memories-accent-border: rgba(0, 0, 0, 0); +} + +:host([data-open]) moz-button.memories-trigger { + /* stylelint-disable-next-line */ + color: var(--aiwindow-text-accent); + --button-background-color-ghost: var(--memories-accent-bg-hover); + --button-border-color-ghost: var(--memories-accent-border); + --button-background-color-ghost-selected: var(--memories-accent-bg-hover); + --button-border-color-ghost-selected: var(--memories-accent-border); + --button-text-color-ghost-selected: currentColor; + --button-background-color-ghost-hover: var(--memories-accent-bg-hover); + --button-border-color-ghost-hover: var(--memories-accent-border); +} + +moz-button.memories-trigger { + color: var(--text-color-deemphasized, rgba(21, 20, 26, 0.69)); + --button-padding: var(--space-small); + --button-background-color-ghost-hover: var(--memories-accent-bg-hover); + --button-border-color-ghost-hover: var(--memories-accent-border); + --button-background-color-ghost-active: var(--memories-accent-bg-active); + --button-border-color-ghost-active: var(--memories-accent-border); +} + +moz-button.memories-trigger::part(button) { + padding: var(--button-padding-block, var(--space-xsmall)) var(--space-xsmall); + gap: var(--space-xsmall); +} + +moz-button.memories-trigger[disabled] { + opacity: 0.6; +} + +moz-button.memories-trigger::part(moz-button-label) { + white-space: nowrap; + overflow: hidden; + opacity: 0; + max-width: 0; + transition: + opacity 120ms ease, + max-width 120ms ease; + font-size: var(--font-size-small, 13px); +} + +moz-button.memories-trigger:not([disabled]):hover::part(moz-button-label), +:host([data-open]) moz-button.memories-trigger::part(moz-button-label) { + opacity: 1; + max-width: 12em; +} + +.popover { + opacity: 0; + pointer-events: none; + position: absolute; + /* stylelint-disable-next-line */ + width: 318px; + /* stylelint-disable-next-line */ + bottom: 40px; + left: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--space-medium); + padding: var(--space-large); + border-radius: var(--border-radius-medium); + border-width: var(--border-width); + border-style: solid; + /* stylelint-disable-next-line */ + border-color: var(--aiwindow-border-subtle); + background: var(--color-white, white); + box-shadow: var(--box-shadow-level-3); + transition: + opacity 120ms ease, + transform 120ms ease; +} + +.popover.open { + opacity: 1; + pointer-events: auto; +} + +@media (prefers-reduced-motion: reduce) { + .popover { + transition: none; + } +} + +.memories-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: var(--space-xsmall); +} + +.memories-list-item { + display: flex; + /* stylelint-disable-next-line */ + width: 306px; + height: var(--size-item-large); + padding: var(--space-xsmall) var(--space-xsmall) var(--space-xsmall) var(--space-small); + justify-content: space-between; + align-items: center; + border-radius: var(--border-radius-medium); + /* stylelint-disable-next-line */ + background-color: var(--aiwindow-memory-item-bg); +} + +.memories-list-label { + font-size: var(--font-size-small, 13px); + font-weight: var(--font-weight-semibold); + overflow: hidden; + text-overflow: ellipsis; +} + +.memories-remove-button { + display: none; +} + +.memories-list-item:hover .memories-remove-button { + display: inline-flex; +} + +.retry-row { + padding: 0 var(--space-xsmall); + display: flex; + align-items: center; + gap: var(--space-xsmall); + font-size: var(--font-size-small, 13px); + cursor: pointer; +} + +.retry-row-button { + border: none; + height: var(--size-item-large); + padding: 0; + margin: 0; + background: transparent; + font: inherit; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: var(--space-xsmall); + --button-padding: var(--space-xsmall); + --button-font-weight: var(--button-font-weight); +} diff --git a/browser/components/aiwindow/ui/components/ai-chat-content/chat-assistant-footer/applied-memories-button.mjs b/browser/components/aiwindow/ui/components/ai-chat-content/chat-assistant-footer/applied-memories-button.mjs @@ -0,0 +1,248 @@ +/* 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 https://mozilla.org/MPL/2.0/. */ + +import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; +import { html, nothing } from "chrome://global/content/vendor/lit.all.mjs"; + +/** + * AppliedMemoriesButton + * + * TODO: Currently using placeholder "Highlights" icon which will be replaced + * with the memories icon once ready + * + * Custom element that renders the “Memories applied” pill and popover for + * a single assistant message. The popover shows a list of applied + * memories and allows the user to: + * - Remove an individual applied insight. + * - Retry the message without any applied memories. + * + * @property {string|null} messageId + * Identifier for the assistant message this control belongs to. + * + * @property {Array<object>} appliedMemories + * List of applied memories for the message. The component will render up + * to the first 5 items in the popover. + * + * @property {boolean} open + * Whether the popover is currently open. This is typically controlled + * internally when the button is clicked and also reflected via the + * "toggle-applied-memories" event. + * + * Events dispatched: + * - "toggle-applied-memories" + * detail: { messageId, open } + * - "remove-applied-memory" + * detail: { messageId, index, insight } + * - "retry-without-memories" + * detail: { messageId } + */ +export class AppliedMemoriesButton extends MozLitElement { + static properties = { + messageId: { type: String, attribute: "message-id" }, + appliedMemories: { attribute: false }, + open: { type: Boolean, reflect: false }, + }; + + constructor() { + super(); + this.messageId = null; + this.appliedMemories = []; + this.open = false; + + this._onDocumentClick = this._onDocumentClick.bind(this); + } + + connectedCallback() { + super.connectedCallback(); + document.addEventListener("click", this._onDocumentClick); + } + + disconnectedCallback() { + document.removeEventListener("click", this._onDocumentClick); + super.disconnectedCallback(); + } + + get _hasMemories() { + return Array.isArray(this.appliedMemories) && !!this.appliedMemories.length; + } + + get _visibleMemories() { + return this.appliedMemories.slice(0, 5); + } + + #onTriggerClick(event) { + event.stopPropagation(); + if (!this._hasMemories) { + return; + } + + this.open = !this.open; + this.toggleAttribute("data-open", this.open); + + this.dispatchEvent( + new CustomEvent("toggle-applied-memories", { + bubbles: true, + composed: true, + detail: { + messageId: this.messageId, + open: this.open, + }, + }) + ); + } + + _onPopoverClick(event) { + event.stopPropagation(); + } + + _onDocumentClick() { + if (!this.open) { + return; + } + this.open = false; + this.toggleAttribute("data-open", false); + this.requestUpdate(); + + this.dispatchEvent( + new CustomEvent("toggle-applied-memories", { + bubbles: true, + composed: true, + detail: { + messageId: this.messageId, + open: false, + }, + }) + ); + } + + _onRemoveInsight(event, index) { + event.stopPropagation(); + + if (!Array.isArray(this.appliedMemories)) { + return; + } + + const insight = this.appliedMemories[index]; + + // Remove insight visually, but update will be done by parent + this.appliedMemories = this.appliedMemories.filter((_, i) => { + return i !== index; + }); + + this.dispatchEvent( + new CustomEvent("remove-applied-memory", { + bubbles: true, + composed: true, + detail: { + messageId: this.messageId, + index, + insight, + }, + }) + ); + } + + _onRetryWithoutMemories(event) { + event.stopPropagation(); + + this.dispatchEvent( + new CustomEvent("retry-without-memories", { + bubbles: true, + composed: true, + detail: { + messageId: this.messageId, + }, + }) + ); + } + + // TODO: Update formatting function once shape of memories passed is confirmed + _formatInsightLabel(insight) { + if (typeof insight === "string") { + return insight; + } + return ""; + } + + renderPopover() { + if (!this._hasMemories) { + return nothing; + } + + const isOpen = this.open; + const visibleMemories = this._visibleMemories; + + return html` + <div + class="popover ${isOpen ? "open" : ""}" + role="region" + aria-hidden=${!isOpen} + @click=${event => this._onPopoverClick(event)} + > + <ul class="memories-list"> + ${visibleMemories.map((insight, index) => { + const label = this._formatInsightLabel(insight); + if (!label) { + return nothing; + } + return html` + <li class="memories-list-item"> + <span class="memories-list-label">${label}</span> + <moz-button + class="memories-remove-button" + type="ghost" + size="small" + iconsrc="chrome://global/skin/icons/close.svg" + aria-label="Remove this insight" + @click=${event => this._onRemoveInsight(event, index)} + ></moz-button> + </li> + `; + })} + </ul> + + <div class="retry-row"> + <moz-button + type="ghost" + size="default" + iconsrc="chrome://global/skin/icons/reload.svg" + iconposition="start" + class="retry-row-button" + data-l10n-id="aiwindow-retry-without-memories" + data-l10n-attrs="label" + ></moz-button> + </div> + </div> + `; + } + + render() { + if (!this._hasMemories) { + return null; + } + + return html` + <link + rel="stylesheet" + href="chrome://browser/content/aiwindow/components/applied-memories-button.css" + /> + <moz-button + class="memories-trigger" + type="ghost" + size="small" + iconposition="start" + iconsrc="chrome://global/skin/icons/highlights.svg" + aria-haspopup="dialog" + aria-expanded=${this.open && this._hasMemories} + data-l10n-id="aiwindow-memories-used" + data-l10n-attrs="label" + @click=${event => this.#onTriggerClick(event)} + ></moz-button> + + ${this.renderPopover()} + `; + } +} + +customElements.define("applied-memories-button", AppliedMemoriesButton); diff --git a/browser/components/aiwindow/ui/components/ai-chat-content/chat-assistant-footer/assistant-message-footer.css b/browser/components/aiwindow/ui/components/ai-chat-content/chat-assistant-footer/assistant-message-footer.css @@ -0,0 +1,27 @@ +/* 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 https://mozilla.org/MPL/2.0/. */ + +:host { + --chat-assistant-footer-button-padding: var(--space-xsmall); + --chat-assistant-footer-button-bg-hover: rgba(191, 143, 204, 0.2); + --chat-assistant-footer-button-bg-active: rgba(191, 143, 204, 0.26); + --chat-assistant-footer-button-border-hover: rgba(0, 0, 0, 0); + --chat-assistant-footer-button-border-active: rgba(0, 0, 0, 0); +} + +.footer { + position: relative; + display: inline-flex; + align-items: center; + gap: var(--space-xsmall); +} + +moz-button.footer-icon-button { + --button-icon-fill: var(--color-gray-70); + --button-padding: var(--chat-assistant-footer-button-padding); + --button-background-color-ghost-hover: var(--chat-assistant-footer-button-bg-hover); + --button-border-color-ghost-hover: var(--chat-assistant-footer-button-border-hover); + --button-background-color-ghost-active: var(--chat-assistant-footer-button-bg-active); + --button-border-color-ghost-active: var(--chat-assistant-footer-button-border-active); +} diff --git a/browser/components/aiwindow/ui/components/ai-chat-content/chat-assistant-footer/assistant-message-footer.mjs b/browser/components/aiwindow/ui/components/ai-chat-content/chat-assistant-footer/assistant-message-footer.mjs @@ -0,0 +1,154 @@ +/* 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 https://mozilla.org/MPL/2.0/. */ + +import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; +import { html } from "chrome://global/content/vendor/lit.all.mjs"; +// eslint-disable-next-line import/no-unassigned-import +import "chrome://browser/content/aiwindow/components/applied-memories-button.mjs"; + +/** + * AssistantMessageFooter + * + * TODO: Currently using placeholder "Edit Copy" icon which will be replaced + * with the copy icon once ready + * + * Custom element that renders the footer controls for an assistant message + * in the AI Window chat UI. The footer includes: + * - A copy button for copying the assistant response. + * - A retry button for regenerating the response. + * - An applied memories button for viewing and/or deleting applied memories. + * + * Data updates and network behavior are controlled by its parent. + * + * @property {string|null} messageId + * Identifier of the assistant message this footer is associated with. + * + * @property {Array<object>} appliedMemories + * List of applied memories for the message. Passed through to the + * <applied-memories-button> child. + * + * Events dispatched: + * - "copy-message" + * detail: { messageId } + * - "retry-message" + * detail: { messageId } + * - "retry-without-memories" + * detail: { messageId } + * - "remove-applied-memory" + * (re-dispatched from the applied memories button) + * detail: { messageId, index, memory } + * - "toggle-applied-memories" + * (re-dispatched from the applied memories button) + * detail: { messageId, open } + */ +export class AssistantMessageFooter extends MozLitElement { + static properties = { + messageId: { type: String, attribute: "message-id" }, + appliedMemories: { attribute: false }, + }; + + constructor() { + super(); + this.messageId = null; + this.appliedMemories = []; + } + + static eventBehaviors = { + bubbles: true, + composed: true, + }; + + static get events() { + return { + copy: "copy-message", + retry: "retry-message", + toggleMemories: "toggle-applied-memories", + removeMemory: "remove-applied-memory", + retryWithoutMemories: "retry-without-memories", + }; + } + + #emit(type, detail) { + this.dispatchEvent( + new CustomEvent(type, { + ...this.constructor.eventBehaviors, + ...(detail !== undefined ? { detail } : {}), + }) + ); + } + + #emitCopy() { + this.#emit(this.constructor.events.copy, { messageId: this.messageId }); + } + + #emitRetry() { + this.#emit(this.constructor.events.retry, { messageId: this.messageId }); + } + + #onAppliedMemoriesToggle(event) { + this.#emit(this.constructor.events.toggleMemories, event.detail); + } + + #onRemoveAppliedMemory(event) { + this.#emit(this.constructor.events.removeMemory, event.detail); + } + + #onRetryWithoutMemories(event) { + this.#emit( + this.constructor.events.retryWithoutMemories, + event.detail ?? { messageId: this.messageId } + ); + } + + render() { + return html` + <link + rel="stylesheet" + href="chrome://browser/content/aiwindow/components/assistant-message-footer.css" + /> + <div class="footer"> + <moz-button + data-l10n-id="aiwindow-copy-message" + data-l10n-attrs="tooltiptext,aria-label" + class="footer-icon-button copy-button" + type="ghost" + size="small" + iconsrc="chrome://global/skin/icons/edit-copy.svg" + @click=${() => { + this.#emitCopy(); + }} + > + </moz-button> + <moz-button + data-l10n-id="aiwindow-retry" + data-l10n-attrs="tooltiptext,aria-label" + type="ghost" + size="small" + iconsrc="chrome://global/skin/icons/reload.svg" + class="footer-icon-button retry-button" + @click=${() => { + this.#emitRetry(); + }} + > + </moz-button> + <applied-memories-button + .messageId=${this.messageId} + .appliedMemories=${this.appliedMemories ?? []} + @toggle-applied-memories=${event => { + this.#onAppliedMemoriesToggle(event); + }} + @remove-applied-memory=${event => { + this.#onRemoveAppliedMemory(event); + }} + @retry-without-memories=${event => { + this.#onRetryWithoutMemories(event); + }} + > + </applied-memories-button> + </div> + `; + } +} + +customElements.define("assistant-message-footer", AssistantMessageFooter); 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 @@ -55,7 +55,7 @@ export class AIChatMessage extends MozLitElement { /> <article> - <div class=${"message-" + this.role}> + <div class=${`message-${this.role}`}> <!-- TODO: Add markdown parsing here --> ${this.message} </div> diff --git a/browser/components/aiwindow/ui/components/ai-chat-message/ai-chat-message.stories.mjs b/browser/components/aiwindow/ui/components/ai-chat-message/ai-chat-message.stories.mjs @@ -20,7 +20,7 @@ export default { }; const Template = ({ role, content }) => html` - <ai-chat-message .message=${{ role, content }}></ai-chat-message> + <ai-chat-message .role=${role} .message=${content}></ai-chat-message> `; export const UserMessage = Template.bind({}); diff --git a/browser/components/aiwindow/ui/jar.mn b/browser/components/aiwindow/ui/jar.mn @@ -5,6 +5,7 @@ browser.jar: content/browser/aiwindow/aiChatContent.html (content/aiChatContent.html) content/browser/aiwindow/aiWindow.html (content/aiWindow.html) + content/browser/aiwindow/assets/input-cta-arrow-icon.svg (assets/input-cta-arrow-icon.svg) content/browser/aiwindow/components/ai-chat-content.mjs (components/ai-chat-content/ai-chat-content.mjs) content/browser/aiwindow/components/ai-chat-content.css (components/ai-chat-content/ai-chat-content.css) @@ -23,3 +24,7 @@ browser.jar: content/browser/aiwindow/firstrun.html (content/firstrun.html) content/browser/aiwindow/firstrun.css (content/firstrun.css) content/browser/aiwindow/firstrun.js (content/firstrun.js) + content/browser/aiwindow/components/applied-memories-button.mjs (components/ai-chat-content/chat-assistant-footer/applied-memories-button.mjs) + content/browser/aiwindow/components/applied-memories-button.css (components/ai-chat-content/chat-assistant-footer/applied-memories-button.css) + content/browser/aiwindow/components/assistant-message-footer.mjs (components/ai-chat-content/chat-assistant-footer/assistant-message-footer.mjs) + content/browser/aiwindow/components/assistant-message-footer.css (components/ai-chat-content/chat-assistant-footer/assistant-message-footer.css) diff --git a/browser/components/aiwindow/ui/test/browser/browser.toml b/browser/components/aiwindow/ui/test/browser/browser.toml @@ -4,6 +4,8 @@ support-files = [ "head.js", "test_chat_search_button.html", "test_chat_search_button.mjs", + "test_assistant_message_footer_page.html", + "test_applied_memories_page.html", ] ["browser_actor_user_prompt.js"] @@ -12,6 +14,10 @@ support-files = [ ["browser_aichat_message.js"] +["browser_aiwindow_applied_memories_button.js"] + +["browser_aiwindow_assistant_message_footer.js"] + ["browser_aiwindow_firstrun.js"] ["browser_aiwindow_integration.js"] diff --git a/browser/components/aiwindow/ui/test/browser/browser_aiwindow_applied_memories_button.js b/browser/components/aiwindow/ui/test/browser/browser_aiwindow_applied_memories_button.js @@ -0,0 +1,70 @@ +"use strict"; + +const TEST_PAGE = + "chrome://mochitests/content/browser/browser/components/aiwindow/ui/test/browser/test_applied_memories_page.html"; + +add_task(async function test_applied_memories_button_basic() { + await BrowserTestUtils.withNewTab(TEST_PAGE, async browser => { + await SpecialPowers.spawn(browser, [], async () => { + const doc = content.document; + const button = doc.getElementById("test-button"); + + button.messageId = "msg-1"; + button.appliedMemories = ["User is vegan", "User has a cat"]; + + await content.customElements.whenDefined("applied-memories-button"); + + let popover = button.shadowRoot.querySelector(".popover"); + ok(popover, "Popover element exists"); + ok(!popover.classList.contains("open"), "Popover is initially closed"); + + const trigger = button.shadowRoot.querySelector( + "moz-button.memories-trigger" + ); + ok(trigger, "Found memories trigger"); + + trigger.click(); + await content.Promise.resolve(); + + popover = button.shadowRoot.querySelector(".popover"); + ok( + popover.classList.contains("open"), + "Popover opens after trigger click" + ); + + const items = button.shadowRoot.querySelectorAll(".memories-list-item"); + is(items.length, 2, "Two memories rendered initially"); + + const removeButton = items[0].querySelector(".memories-remove-button"); + ok(removeButton, "Found remove button for first memory"); + + let removeEventDetail = null; + function onRemove(evt) { + button.removeEventListener("remove-applied-memory", onRemove); + removeEventDetail = evt.detail; + } + button.addEventListener("remove-applied-memory", onRemove); + + removeButton.click(); + await content.Promise.resolve(); + + const itemsAfter = button.shadowRoot.querySelectorAll( + ".memories-list-item" + ); + is(itemsAfter.length, 1, "One memory remains after removal"); + + ok(removeEventDetail, "remove-applied-memory event fired"); + is(removeEventDetail.messageId, "msg-1", "Event includes messageId"); + is(removeEventDetail.index, 0, "Event index is 0"); + + doc.body.click(); + await content.Promise.resolve(); + + popover = button.shadowRoot.querySelector(".popover"); + ok( + !popover.classList.contains("open"), + "Popover closes on outside click" + ); + }); + }); +}); diff --git a/browser/components/aiwindow/ui/test/browser/browser_aiwindow_assistant_message_footer.js b/browser/components/aiwindow/ui/test/browser/browser_aiwindow_assistant_message_footer.js @@ -0,0 +1,66 @@ +"use strict"; + +const TEST_PAGE = + "chrome://mochitests/content/browser/browser/components/aiwindow/ui/test/browser/test_assistant_message_footer_page.html"; + +add_task(async function test_message_footer_wires_buttons() { + await BrowserTestUtils.withNewTab(TEST_PAGE, async browser => { + await SpecialPowers.spawn(browser, [], async () => { + const doc = content.document; + const footer = doc.getElementById("footer"); + + footer.messageId = "msg-1"; + footer.appliedMemories = ["User is vegan"]; + + await content.customElements.whenDefined("assistant-message-footer"); + + const shadow = footer.shadowRoot; + ok(shadow, "Footer has a shadow root"); + + const copyButton = shadow.querySelector("moz-button.copy-button"); + const retryButton = shadow.querySelector("moz-button.retry-button"); + const appliedButton = shadow.querySelector("applied-memories-button"); + + ok(copyButton, "Found copy button"); + ok(retryButton, "Found retry button"); + ok(appliedButton, "Found applied memories button"); + + is( + appliedButton.messageId, + "msg-1", + "Footer passes messageId to applied memories button" + ); + is( + appliedButton.appliedMemories.length, + 1, + "Footer passes appliedMemories to applied memories button" + ); + + let copyDetail = null; + function onCopy(evt) { + footer.removeEventListener("copy-message", onCopy); + copyDetail = evt.detail; + } + footer.addEventListener("copy-message", onCopy); + + copyButton.click(); + await content.Promise.resolve(); + + ok(copyDetail, "copy-message event fired"); + is(copyDetail.messageId, "msg-1", "copy-message includes messageId"); + + let retryDetail = null; + function onRetry(evt) { + footer.removeEventListener("retry-message", onRetry); + retryDetail = evt.detail; + } + footer.addEventListener("retry-message", onRetry); + + retryButton.click(); + await content.Promise.resolve(); + + ok(retryDetail, "retry-message event fired"); + is(retryDetail.messageId, "msg-1", "retry-message includes messageId"); + }); + }); +}); diff --git a/browser/components/aiwindow/ui/test/browser/test_applied_memories_page.html b/browser/components/aiwindow/ui/test/browser/test_applied_memories_page.html @@ -0,0 +1,15 @@ +<!-- eslint-disable --> +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8" /> + <title>AI Window applied memories button test</title> + <script type="module"> + import "chrome://browser/content/aiwindow/components/applied-memories-button.mjs"; + </script> + <link rel="localization" href="preview/aiWindow.ftl"> + </head> + <body> + <applied-memories-button id="test-button"></applied-memories-button> + </body> +</html> diff --git a/browser/components/aiwindow/ui/test/browser/test_assistant_message_footer_page.html b/browser/components/aiwindow/ui/test/browser/test_assistant_message_footer_page.html @@ -0,0 +1,15 @@ +<!-- eslint-disable --> +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8" /> + <title>AI Window assistant message footer test</title> + <script type="module"> + import "chrome://browser/content/aiwindow/components/assistant-message-footer.mjs";; + </script> + <link rel="localization" href="preview/aiWindow.ftl"> + </head> + <body> + <assistant-message-footer id="footer"></assistant-message-footer> + </body> +</html> diff --git a/browser/locales-preview/aiWindow.ftl b/browser/locales-preview/aiWindow.ftl @@ -67,3 +67,15 @@ aiwindow-firstrun-model-allpurpose-body = Best for a variety of quick and comple aiwindow-firstrun-model-personal-label = Personalization aiwindow-firstrun-model-personal-body = Best for learning with you aiwindow-firstrun-button = Let’s go + +## Assistant Message footer + +aiwindow-memories-used = Memories used +aiwindow-retry-without-memories = + .label = Retry without memories +aiwindow-retry = + .tooltiptext = Retry + .aria-label = Retry +aiwindow-copy-message = + .tooltiptext = Copy + .aria-label = Copy message