Bug 2061046 - Add AITab Header component, wire up page actions - r=omarg,fluent-reviewers,bolsson

Differential Revision: https://phabricator.services.mozilla.com/D322792
This commit is contained in:
Maile Lucks
2026-09-11 19:45:19 +00:00
committed by mlucks@mozilla.com
parent 733b79bcbb
commit 384314f38c
30 changed files with 1896 additions and 92 deletions
@@ -202,11 +202,6 @@ var allowlist = [
// SpiderMonkey parser API, currently unused in browser/ and toolkit/
{ file: "moz-src:///toolkit/components/reflect/reflect.sys.mjs" },
// TODO Bug 2064553: Integrate ConversationStore into the Conversation model
{
file: "moz-src:///browser/components/aiwindow/ui/modules/ConversationStore.sys.mjs",
},
// extensions/pref/autoconfig/src/nsReadConfig.cpp
{ file: "resource://gre/defaults/autoconfig/prefcalls.js" },
@@ -284,7 +284,9 @@ let JSWINDOWACTORS = {
esModuleURI:
"moz-src:///browser/components/aiwindow/ui/actors/AITabChild.sys.mjs",
events: {
"AITab:RequestPage": { wantUntrusted: true },
"AITab:GetPage": { wantUntrusted: true },
"AITab:DeletePage": { wantUntrusted: true },
"AITab:OpenLink": { wantUntrusted: true },
},
},
matches: ["about:aitab", "about:aitab?*"],
@@ -2,20 +2,36 @@
* 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 REQUEST_PAGE_EVENT = "AITab:RequestPage";
/**
* Child actor for about:aitab. Forwards page lookups from the content document
* to the parent process and dispatches the answer back to the requester.
* Child actor for about:aitab. Forwards requests from the content document to
* the parent process under the same name content dispatched them with, so a
* message can be traced across the boundary without a translation table.
*/
export class AITabChild extends JSWindowActorChild {
handleEvent(event) {
if (event.type != REQUEST_PAGE_EVENT) {
console.warn(`AITabChild received unknown event: ${event.type}`);
return;
switch (event.type) {
case "AITab:GetPage":
case "AITab:DeletePage":
this.#query(event);
break;
// Nothing comes back from a link open, so this takes the fire and
// forget path rather than the query one.
case "AITab:OpenLink":
this.sendAsyncMessage(event.type, event.detail);
break;
default:
console.warn(`AITabChild received unknown event: ${event.type}`);
}
}
this.sendQuery("AITab:GetPage", event.detail)
/**
* Forwards a message that expects an answer, and dispatches the parent's
* reply back on the element that fired the event.
*
* @param {Event} event
*/
#query(event) {
this.sendQuery(event.type, event.detail)
.then(
response => this.#respond(event, "Response", response),
error => this.#respond(event, "Error", { error: error.message })
@@ -25,6 +41,14 @@ export class AITabChild extends JSWindowActorChild {
});
}
/**
* Dispatches `<event name>:Response` or `<event name>:Error` on the
* requesting element, which is what aitab-page's #request() waits for.
*
* @param {Event} event
* @param {string} suffix - "Response" or "Error".
* @param {object} detail
*/
#respond(event, suffix, detail) {
// The page can go away while the query is in flight.
if (!this.contentWindow) {
@@ -3,38 +3,218 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { AITabStore } from "moz-src:///browser/components/aiwindow/ui/modules/AITabStore.sys.mjs";
import { ConversationStore } from "moz-src:///browser/components/aiwindow/ui/modules/ConversationStore.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
AIWINDOW_URL:
"moz-src:///browser/components/aiwindow/ui/modules/AIWindow.sys.mjs",
URILoadingHelper: "resource:///modules/URILoadingHelper.sys.mjs",
});
ChromeUtils.defineLazyGetter(lazy, "fluentStrings", () => {
return new Localization(["preview/aiWindow.ftl"], true);
});
const PAGE_NAME_REGEX = /^[\w-]+(\.html)?$/;
/**
* Renders the eyebrow shown above a generated page's title.
*
* Formatting happens here rather than in the component because the strings
* are localized in the parent and handed to content ready to display, the
* same way the history grid passes its timestamps down.
*
* "Today" means the same calendar day in the local timezone, which is what a
* reader means by it. A rolling 24 hour window would call late yesterday
* today, and comparing timestamps directly would break either side of
* midnight. `now` is a parameter so tests can pin the boundary rather than
* racing the clock.
*
* @param {number} createdAt - aitab_pages.created_at, in microseconds.
* @param {number} [now] - Milliseconds to treat as the current time.
* @returns {string} Empty for a timestamp that is not a usable date.
*/
export function formatCreatedAt(createdAt, now = Date.now()) {
// The store writes this column as `Date.now() * 1000`.
const created = new Date(Math.round(createdAt / 1000));
if (!createdAt || Number.isNaN(created.valueOf())) {
return "";
}
if (created.toDateString() == new Date(now).toDateString()) {
return lazy.fluentStrings.formatValueSync("aitab-created-today");
}
return lazy.fluentStrings.formatValueSync("aitab-created-on", {
date: created.getTime(),
});
}
/**
* Parent actor for about:aitab. Resolves the page name from the page URL into
* the stored page config that content renders.
* the stored page config that content renders, and owns the destructive
* actions the page offers.
*/
export class AITabParent extends JSWindowActorParent {
async receiveMessage({ data, name }) {
if (name != "AITab:GetPage") {
console.warn(`AITabParent received unknown message: ${name}`);
switch (name) {
case "AITab:GetPage":
return this.#handleGetPage();
case "AITab:DeletePage":
return this.#handleDeletePage();
case "AITab:OpenLink":
this.#handleOpenLink(data);
return null;
default:
console.warn(`AITabParent received unknown message: ${name}`);
return null;
}
}
/**
* The page this tab is showing, read from the tab's own URL rather than
* taken from the child. Content is a lower-trust process, so a page name it
* supplied could name a page other than the one on screen; anything
* destructive would then act on the wrong one.
*
* @returns {?string} Null when the URL carries no usable page name.
*/
get #pageName() {
const spec = this.browsingContext?.currentURI?.spec;
if (!spec) {
return null;
}
return this.#handleGetPage(data);
const pageName = URL.parse(spec)?.searchParams.get("page");
return pageName && PAGE_NAME_REGEX.test(pageName) ? pageName : null;
}
async #handleGetPage({ pageName } = {}) {
async #handleGetPage() {
const pageName = this.#pageName;
if (!pageName) {
return { success: false, error: "Missing page name" };
}
if (!PAGE_NAME_REGEX.test(pageName)) {
return { success: false, error: "Invalid page name" };
}
try {
const page = await AITabStore.getBySlug(pageName);
return { success: true, page };
return {
success: true,
// Content renders the label as-is; it never sees the raw timestamp.
page: page && {
...page,
createdAtLabel: formatCreatedAt(page.createdAt),
},
};
} catch (error) {
console.error("Failed to retrieve AI Tab page:", error);
return { success: false, error: "Failed to retrieve page" };
}
}
/**
* Deletes a generated page and the conversation that produced it. Every
* page comes from its own conversation, so the two are removed together.
*
* They live in separate databases, so this cannot be one transaction. The
* pages go first: a conversation with no pages is unreachable, while pages
* that outlive their conversation would still load by slug.
*
* @returns {Promise<object>}
*/
async #handleDeletePage() {
const pageName = this.#pageName;
if (!pageName) {
return { success: false, error: "Invalid page name" };
}
try {
const page = await AITabStore.getBySlug(pageName);
// Deleting something already gone is not an error, but the tab still
// gets sent home: a second tab open on the same page would otherwise be
// stranded on the unavailable state with nowhere to go.
if (page) {
await AITabStore.deleteBySlug(page.slug);
// The page is gone from here on. If clearing the conversation fails,
// the delete the reader asked for still happened, so say so and log
// the leftover rather than reporting a failure that did not happen.
try {
await ConversationStore.deleteConversationById(page.convId);
} catch (error) {
console.error(
"Deleted an AI Tab page but could not delete its conversation",
error
);
}
}
// Deferred so the reply reaches content before the tab navigates away.
Services.tm.dispatchToMainThread(() => this.#returnToSmartWindowHome());
return { success: true };
} catch (error) {
console.error("Could not delete an AI Tab page", error);
return { success: false, error: "Could not delete the page" };
}
}
/**
* Opens a link from anywhere on a generated page. Kept separate from
* AIChatContentParent's handler so AI Tab clicks stay out of chat's
* recordUriLoad() metric.
*
* @param {object} data
* @param {string} data.url
* @param {boolean} [data.preferSwitchToTab]
*/
#handleOpenLink({ url, preferSwitchToTab } = {}) {
// Links come from model output, so only http and https are opened.
// file:, data: and javascript: URLs are dropped, as is a missing or
// unparseable url: URL.parse returns null for those.
const uri = URL.parse(url);
if (uri?.protocol != "http:" && uri?.protocol != "https:") {
return;
}
const window = this.browsingContext?.topChromeWindow;
if (!window) {
return;
}
if (
preferSwitchToTab &&
lazy.URILoadingHelper.switchToTabHavingURI(window, url, false, {})
) {
return;
}
const { userContextId } =
window.gBrowser.selectedBrowser.browsingContext.originAttributes;
lazy.URILoadingHelper.openWebLinkIn(window, url, "tab", {
triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal({
userContextId,
}),
userContextId,
forceForeground: false,
});
}
/**
* Sends the tab back to the Smart Window home page once its content has been
* deleted, so the user is not left on a page that no longer exists.
*
* This has to run in the parent: the home page is a chrome: URL and
* about:aitab is content, which is not allowed to navigate itself there.
*/
#returnToSmartWindowHome() {
// about:aitab is not MAKE_LINKABLE, so web content cannot load it at all,
// framed or otherwise: this is always the tab's own top-level context.
// The optional call covers the tab being closed mid-delete.
this.browsingContext?.loadURI(Services.io.newURI(lazy.AIWINDOW_URL), {
triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
});
}
}
@@ -2,39 +2,43 @@
* 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/. */
/* Declared on :host, not on .grouped-chips, so a consumer can retheme the
trigger from outside the shadow root. A custom property declared on an inner
element would shadow any inherited override; on :host the outer tree wins. */
:host {
/* sets a custom margin between the grouped-chips button and the panel list */
--smartwindow-panel-list-valign-bottom-margin-block-start: var(--space-xsmall);
--ai-grouped-chip-container-padding: var(--space-medium);
--ai-grouped-chip-container-border-radius: var(--border-radius-large);
--ai-grouped-chip-container-border-end-end-radius: var(--border-radius-small);
--ai-grouped-chip-container-bg-color: light-dark(var(--color-violet-20), var(--color-violet-70));
/* Bug 2033070: remove nova media queries */
@media -moz-pref("browser.nova.enabled") {
--ai-grouped-chip-container-border-end-end-radius: var(--border-radius-xsmall);
--ai-grouped-chip-container-bg-color: light-dark(var(--color-violet-desaturated-10), var(--color-violet-desaturated-60));
}
}
.grouped-chips {
border: var(--border-width) solid var(--border-color-transparent);
border-radius: var(--border-radius-large);
border-end-end-radius: var(--border-radius-small);
/* stylelint-disable-next-line stylelint-plugin-mozilla/use-design-tokens */
border-radius: var(--ai-grouped-chip-container-border-radius);
/* stylelint-disable-next-line stylelint-plugin-mozilla/use-design-tokens */
border-end-end-radius: var(--ai-grouped-chip-container-border-end-end-radius);
cursor: pointer;
display: flex;
gap: var(--space-xsmall);
padding: var(--space-medium);
/* stylelint-disable-next-line stylelint-plugin-mozilla/use-design-tokens */
padding: var(--ai-grouped-chip-container-padding);
@media not (prefers-reduced-motion) {
transition: gap 0.3s ease-in-out;
}
/* Bug 2033070: remove nova media queries */
@media -moz-pref("browser.nova.enabled") {
border-radius: var(--border-radius-large);
border-end-end-radius: var(--border-radius-xsmall);
}
@media not forced-colors {
--ai-grouped-chip-container-bg-color: light-dark(var(--color-violet-20), var(--color-violet-70));
/* stylelint-disable-next-line stylelint-plugin-mozilla/use-design-tokens */
background-color: var(--ai-grouped-chip-container-bg-color);
/* Bug 2033070: remove nova media queries */
@media -moz-pref("browser.nova.enabled") {
--ai-grouped-chip-container-bg-color: light-dark(var(--color-violet-desaturated-10), var(--color-violet-desaturated-60));
}
}
}
@@ -14,12 +14,15 @@ import "chrome://browser/content/aiwindow/components/smartwindow-panel-list.mjs"
export class AIGroupedChipContainer extends MozLitElement {
static properties = {
chips: { type: Array },
// Each host opens links itself, so it names the event it listens for.
openLinkEvent: { type: String },
isPanelOpen: { type: Boolean, state: true },
};
constructor() {
super();
this.chips = [];
this.openLinkEvent = "AIChatContent:OpenLink";
this.isPanelOpen = false;
}
@@ -44,7 +47,7 @@ export class AIGroupedChipContainer extends MozLitElement {
const url = event.detail?.id;
if (url) {
this.dispatchEvent(
new CustomEvent("AIChatContent:OpenLink", {
new CustomEvent(this.openLinkEvent, {
bubbles: true,
composed: true,
detail: { url, preferSwitchToTab: true },
@@ -0,0 +1,70 @@
/* 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/. */
/* Typography for the eyebrow, title and subhead comes from aitab-shared.css,
which is linked first so these rules win on equal specificity. */
:host {
--aitab-hero-fill: light-dark(var(--color-violet-10), var(--color-violet-desaturated-90, var(--color-violet-90)));
--aitab-hero-bloom: light-dark(var(--color-white), var(--color-violet-50));
--aitab-hero-radius: 128px;
}
.aitab-header {
display: grid;
justify-items: center;
gap: var(--space-xlarge);
/* stylelint-disable-next-line stylelint-plugin-mozilla/use-design-tokens */
padding: var(--space-xxlarge) var(--space-xxlarge) 60px;
/* stylelint-disable-next-line stylelint-plugin-mozilla/use-design-tokens */
border-end-start-radius: var(--aitab-hero-radius);
/* stylelint-disable-next-line stylelint-plugin-mozilla/use-design-tokens */
border-end-end-radius: var(--aitab-hero-radius);
text-align: center;
@media (forced-colors) {
/* Text across the feature is --button-text-color (see bug 2068782), which
forced colors resolves to ButtonText, so the surface uses its
guaranteed-contrasting partner ButtonFace. The text colour itself comes
from the shared base styles rather than being set here. */
border-inline: var(--border-width) solid var(--button-border-color);
border-block-end: var(--border-width) solid var(--button-border-color);
/* stylelint-disable-next-line stylelint-plugin-mozilla/no-background-without-text-color */
background-color: var(--button-background-color);
}
@media not (forced-colors) {
background-image: radial-gradient(ellipse 82% 169% at 50% 154%, var(--aitab-hero-bloom), var(--aitab-hero-fill) 100%);
}
}
aitab-page-actions {
justify-self: end;
}
.aitab-title,
.aitab-title-subtext {
line-height: 1;
margin: 0 auto;
/* stylelint-disable-next-line stylelint-plugin-mozilla/use-design-tokens */
max-width: 900px;
@media (max-width: 900px) {
/* stylelint-disable-next-line stylelint-plugin-mozilla/use-design-tokens */
max-width: 934px;
}
}
ai-grouped-chip-container {
--ai-grouped-chip-container-padding: var(--space-small);
--ai-grouped-chip-container-border-radius: var(--border-radius-medium);
--ai-grouped-chip-container-border-end-end-radius: var(--border-radius-medium);
--ai-grouped-chip-container-bg-color: light-dark(
var(--color-violet-desaturated-10, var(--color-violet-20)),
var(--color-violet-desaturated-60, var(--color-violet-70))
);
gap: var(--space-small);
}
@@ -0,0 +1,100 @@
/* 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, 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/ai-grouped-chip-container.mjs";
// eslint-disable-next-line import/no-unassigned-import
import "chrome://browser/content/aiwindow/components/aitab-page-actions.mjs";
/** @typedef {{ favicon?: string, title?: string, href: string }} SourceLink */
/**
* Hero for a generated AI Tab page, rendered from the page config's `Header`
* block. Title and subhead are model generated, so they are bound as text and
* never as markup.
*
* The `eyebrow` field is expected to come back blank, so `createdAt` is
* supplied by the renderer from the stored page's creation date rather than
* from the page config.
*
* @property {string} createdAt - Ready to display run date, already localized
* by AITabParent. The component does no date handling of its own.
* @property {string} heading - Page title. The block calls this `title`,
* which cannot be used as a property name without giving the whole hero a
* tooltip.
* @property {string} subhead - One sentence of context below the heading.
* @property {SourceLink[]} references - Pages this report was built from.
* @property {boolean} refreshing - Whether sources are being re-fetched.
*/
export class AITabHeader extends MozLitElement {
static properties = {
createdAt: { type: String },
heading: { type: String },
subhead: { type: String },
references: { type: Array },
refreshing: { type: Boolean },
};
constructor() {
super();
this.createdAt = "";
this.heading = "";
this.subhead = "";
this.references = [];
this.refreshing = false;
}
#renderCreatedAt() {
if (!this.createdAt) {
return nothing;
}
return html`<span class="aitab-eyebrow">${this.createdAt}</span>`;
}
#renderReferences() {
if (!this.references.length) {
return nothing;
}
const chips = this.references.map(source => ({
url: source.href,
label: source.title || source.href,
iconSrc: source.favicon ?? "",
}));
return html`<ai-grouped-chip-container
class="aitab-references"
.chips=${chips}
openLinkEvent="AITab:OpenLink"
></ai-grouped-chip-container>`;
}
render() {
return html`
<link
rel="stylesheet"
href="chrome://browser/content/aiwindow/components/aitab-shared.css"
/>
<link
rel="stylesheet"
href="chrome://browser/content/aiwindow/components/aitab-header.css"
/>
<header class="aitab-header">
<aitab-page-actions
class="aitab-header-actions"
?refreshing=${this.refreshing}
></aitab-page-actions>
${this.#renderCreatedAt()}
<h1 class="aitab-title">${this.heading}</h1>
${this.subhead
? html`<p class="aitab-title-subtext">${this.subhead}</p>`
: nothing}
${this.#renderReferences()}
</header>
`;
}
}
customElements.define("aitab-header", AITabHeader);
@@ -0,0 +1,112 @@
/* 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 { html } from "chrome://global/content/vendor/lit.all.mjs";
// eslint-disable-next-line import/no-unassigned-import
import "chrome://browser/content/aiwindow/components/aitab-header.mjs";
export default {
title: "Domain-specific UI Widgets/AI Window/AI Tab Header",
component: "aitab-header",
argTypes: {
createdAt: { control: { type: "text" } },
heading: { control: { type: "text" } },
subhead: { control: { type: "text" } },
refreshing: { control: { type: "boolean" } },
},
parameters: {
fluent: `
smart-window-context-chips-tag-count = { $tags ->
[one] { $tags } Tag
*[other] { $tags } Tags
}
aitab-page-refresh-sources =
.label = Refresh sources
aitab-page-refreshing-sources =
.label = Refreshing sources
aitab-page-delete =
.aria-label = Delete page
.title = Delete page
aitab-page-delete-dialog-title = Delete this [AI Tab]?
aitab-page-delete-dialog-message = This generated page will be removed. The sources it was built from aren't affected.
aitab-page-delete-dialog-cancel =
.label = Cancel
aitab-page-delete-dialog-confirm =
.label = Delete
`,
},
};
const REFERENCES = [
{
title: "energy.gov",
href: "https://energy.gov",
favicon: "chrome://branding/content/about-logo.svg",
},
{
title: "NEEP",
href: "https://neep.org",
favicon: "chrome://branding/content/icon16.png",
},
{
title: "r/heatpumps",
href: "https://reddit.com/r/heatpumps",
favicon: "chrome://global/skin/icons/defaultFavicon.svg",
},
{
title: "Yelp",
href: "https://yelp.com",
favicon: "chrome://branding/content/about-logo.svg",
},
];
const Template = ({
createdAt,
heading,
subhead,
references,
refreshing,
}) => html`
<aitab-header
.createdAt=${createdAt}
heading=${heading}
subhead=${subhead}
.references=${references}
?refreshing=${refreshing}
></aitab-header>
`;
export const Default = Template.bind({});
Default.args = {
createdAt: "Created today",
heading: "Three days in Kanazawa",
subhead:
"Travel research: short names, tidy numbers, real photography. The comfortable case for all six blocks.",
references: REFERENCES,
refreshing: false,
};
export const Refreshing = Template.bind({});
Refreshing.args = { ...Default.args, refreshing: true };
export const CreatedEarlier = Template.bind({});
CreatedEarlier.args = { ...Default.args, createdAt: "Created Jul 31" };
export const NoReferences = Template.bind({});
NoReferences.args = {
createdAt: "Created today",
heading: "Heat Pump for a 1940s House",
subhead: "What nine sources agree on — and where they don't",
references: [],
refreshing: false,
};
export const HeadingOnly = Template.bind({});
HeadingOnly.args = {
createdAt: "",
heading: "Places to Stay on Niijima",
subhead: "",
references: [],
refreshing: false,
};
@@ -0,0 +1,39 @@
/* 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/. */
.aitab-page-actions {
--button-icon-fill: currentColor;
display: flex;
gap: var(--space-medium);
}
.aitab-delete-dialog {
border: none;
border-radius: var(--border-radius-medium);
padding: var(--space-large);
background-color: var(--background-color-box);
color: var(--text-color);
&::backdrop {
background-color: var(--background-color-overlay);
}
}
.aitab-delete-dialog-content {
display: flex;
flex-direction: column;
gap: var(--space-medium);
& h2,
& p {
margin: 0;
}
}
.aitab-delete-dialog-buttons {
display: flex;
justify-content: end;
gap: var(--space-small);
}
@@ -0,0 +1,118 @@
/* 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";
const REFRESH_EVENT = "aitab-page-actions:refresh";
const DELETE_EVENT = "aitab-page-actions:delete";
/**
* Page-level controls for a generated AI Tab page: re-fetch the sources the
* page was built from, or delete the page.
*
* Owns the confirmation step for delete but performs neither action itself;
* it reports intent and the host decides what to do.
*
* Fires `aitab-page-actions:refresh` and, once confirmed,
* `aitab-page-actions:delete`. Both bubble and cross shadow boundaries.
*
* @property {boolean} refreshing - Whether a refresh is in flight. While set,
* the refresh button is disabled and reports its progress.
*/
export class AITabPageActions extends MozLitElement {
static properties = {
refreshing: { type: Boolean, reflect: true },
};
constructor() {
super();
this.refreshing = false;
}
get #dialog() {
return this.renderRoot.querySelector("dialog");
}
#emit(type) {
this.dispatchEvent(
new CustomEvent(type, { bubbles: true, composed: true })
);
}
#onRefresh() {
if (this.refreshing) {
return;
}
this.#emit(REFRESH_EVENT);
}
#onDeleteRequested() {
this.#dialog?.showModal();
}
#onConfirmDelete() {
this.#dialog?.close();
this.#emit(DELETE_EVENT);
}
#renderDeleteDialog() {
return html`
<dialog class="aitab-delete-dialog">
<div class="aitab-delete-dialog-content">
<h2 data-l10n-id="aitab-page-delete-dialog-title"></h2>
<p data-l10n-id="aitab-page-delete-dialog-message"></p>
<div class="aitab-delete-dialog-buttons">
<moz-button
class="aitab-delete-confirm"
type="destructive"
data-l10n-id="aitab-page-delete-dialog-confirm"
@click=${() => this.#onConfirmDelete()}
></moz-button>
<moz-button
class="aitab-delete-cancel"
autofocus
data-l10n-id="aitab-page-delete-dialog-cancel"
@click=${() => this.#dialog?.close()}
></moz-button>
</div>
</div>
</dialog>
`;
}
render() {
return html`
<link
rel="stylesheet"
href="chrome://browser/content/aiwindow/components/aitab-page-actions.css"
/>
<div class="aitab-page-actions">
<moz-button
class="aitab-action-refresh"
size="default"
iconSrc="chrome://global/skin/icons/reload.svg"
?disabled=${this.refreshing}
data-l10n-id=${this.refreshing
? "aitab-page-refreshing-sources"
: "aitab-page-refresh-sources"}
@click=${() => this.#onRefresh()}
></moz-button>
<moz-button
class="aitab-action-delete"
type="icon"
size="default"
iconSrc="chrome://global/skin/icons/delete.svg"
data-l10n-id="aitab-page-delete"
@click=${() => this.#onDeleteRequested()}
></moz-button>
</div>
${this.#renderDeleteDialog()}
`;
}
}
customElements.define("aitab-page-actions", AITabPageActions);
@@ -0,0 +1,42 @@
/* 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 { html } from "chrome://global/content/vendor/lit.all.mjs";
// eslint-disable-next-line import/no-unassigned-import
import "chrome://browser/content/aiwindow/components/aitab-page-actions.mjs";
export default {
title: "Domain-specific UI Widgets/AI Window/AI Tab Page Actions",
component: "aitab-page-actions",
argTypes: {
refreshing: { control: { type: "boolean" } },
},
parameters: {
fluent: `
aitab-page-refresh-sources =
.label = Refresh sources
aitab-page-refreshing-sources =
.label = Refreshing sources
aitab-page-delete =
.aria-label = Delete page
.title = Delete page
aitab-page-delete-dialog-title = Delete this [AI Tab]?
aitab-page-delete-dialog-message = This generated page will be removed. The sources it was built from aren't affected.
aitab-page-delete-dialog-cancel =
.label = Cancel
aitab-page-delete-dialog-confirm =
.label = Delete
`,
},
};
const Template = ({ refreshing }) => html`
<aitab-page-actions ?refreshing=${refreshing}></aitab-page-actions>
`;
export const Default = Template.bind({});
Default.args = { refreshing: false };
export const Refreshing = Template.bind({});
Refreshing.args = { refreshing: true };
@@ -17,22 +17,6 @@
padding: var(--space-xlarge);
}
.aitab-eyebrow {
margin: 0;
color: var(--text-color-deemphasized);
font-size: var(--font-size-small);
text-transform: uppercase;
}
.aitab-title {
margin: 0;
}
.aitab-subhead {
margin: 0;
color: var(--text-color-deemphasized);
}
.aitab-blocks {
display: flex;
flex-direction: column;
@@ -4,8 +4,13 @@
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/aitab-header.mjs";
const REQUEST_PAGE_EVENT = "AITab:RequestPage";
// The same names the child and parent actors use, so a message can be traced
// straight through without a translation table.
const GET_PAGE_EVENT = "AITab:GetPage";
const DELETE_PAGE_EVENT = "AITab:DeletePage";
/**
* Returns the href as an http(s) URL, or null for anything else. Footer button
@@ -63,13 +68,12 @@ export class AITabPage extends MozLitElement {
}
async #loadPage() {
const pageName = this.pageName;
if (!pageName) {
if (!this.pageName) {
this.status = "unavailable";
return;
}
const response = await this.#requestPage(pageName);
const response = await this.#request(GET_PAGE_EVENT);
if (!response?.success) {
throw new Error(response?.error ?? "No response from the parent process");
}
@@ -79,35 +83,44 @@ export class AITabPage extends MozLitElement {
}
/**
* Asks the AITab actor for a stored page config.
* Deletes this page, and with it the conversation that produced it. The
* page stays open showing the unavailable state rather than closing the
* tab, so the deletion is visible.
*/
async #deletePage() {
const response = await this.#request(DELETE_PAGE_EVENT);
if (!response?.success) {
throw new Error(response?.error ?? "No response from the parent process");
}
this.page = null;
this.status = "unavailable";
}
/**
* Sends a request to the AITab actor and waits for its answer.
*
* @param {string} pageName
* @param {string} eventType - Request event the child actor listens for.
* @param {object} [detail] - Payload forwarded to the parent actor.
* @returns {Promise<object>} Resolves with the parent actor's response.
*/
#requestPage(pageName) {
#request(eventType, detail = null) {
return new Promise((resolve, reject) => {
const onResponse = event => {
this.removeEventListener(`${REQUEST_PAGE_EVENT}:Error`, onError);
this.removeEventListener(`${eventType}:Error`, onError);
resolve(event.detail);
};
const onError = event => {
this.removeEventListener(`${REQUEST_PAGE_EVENT}:Response`, onResponse);
reject(new Error(event.detail?.error || "Failed to load the page"));
this.removeEventListener(`${eventType}:Response`, onResponse);
reject(new Error(event.detail?.error || "The request failed"));
};
this.addEventListener(`${REQUEST_PAGE_EVENT}:Response`, onResponse, {
once: true,
});
this.addEventListener(`${REQUEST_PAGE_EVENT}:Error`, onError, {
this.addEventListener(`${eventType}:Response`, onResponse, {
once: true,
});
this.addEventListener(`${eventType}:Error`, onError, { once: true });
this.dispatchEvent(
new CustomEvent(REQUEST_PAGE_EVENT, {
bubbles: true,
detail: { pageName },
})
);
this.dispatchEvent(new CustomEvent(eventType, { bubbles: true, detail }));
});
}
@@ -126,16 +139,21 @@ export class AITabPage extends MozLitElement {
if (!header) {
return nothing;
}
// The header block leaves `eyebrow` blank; the run date comes from the
// stored page, already formatted and localized by AITabParent.
return html`
<header class="aitab-header">
${header.eyebrow
? html`<p class="aitab-eyebrow">${header.eyebrow}</p>`
: nothing}
<h1 class="aitab-title">${header.title}</h1>
${header.subhead
? html`<p class="aitab-subhead">${header.subhead}</p>`
: nothing}
</header>
<aitab-header
.createdAt=${this.page?.createdAtLabel ?? ""}
.heading=${header.title ?? ""}
.subhead=${header.subhead ?? ""}
.references=${header.references?.items ?? []}
@aitab-page-actions:delete=${() => {
this.#deletePage().catch(error => {
console.error("Failed to delete AI Tab page:", error);
this.status = "error";
});
}}
></aitab-header>
`;
}
@@ -0,0 +1,17 @@
/* 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/. */
/* Typography shared by every AI Tab block component. A stylesheet linked by
* the page document cannot reach into a component's shadow root, so each
* block component links this sheet before its own. Gecko's shared stylesheet
* cache parses it once and reuses it across every shadow root that links it.
* Values that only need to be inherited belong on the page instead, as custom
* properties. */
.aitab-eyebrow {
margin: 0;
color: var(--text-color-deemphasized);
font-size: var(--font-size-small);
text-transform: uppercase;
}
@@ -18,6 +18,8 @@
<link rel="stylesheet" href="chrome://global/skin/in-content/common.css" />
<link rel="localization" href="toolkit/branding/brandings.ftl" />
<link rel="localization" href="preview/aiWindow.ftl" />
<!-- ai-grouped-chip-container labels its trigger from this file. -->
<link rel="localization" href="browser/aiWindowContent.ftl" />
<script src="chrome://browser/content/contentTheme.js"></script>
<script
type="module"
+7 -2
View File
@@ -116,6 +116,13 @@ browser.jar:
content/browser/aiwindow/components/ai-action-result.css (components/ai-action-result/ai-action-result.css)
content/browser/aiwindow/components/ai-action-confirmation.mjs (components/ai-action-confirmation/ai-action-confirmation.mjs)
content/browser/aiwindow/components/ai-action-confirmation.css (components/ai-action-confirmation/ai-action-confirmation.css)
content/browser/aiwindow/components/aitab-shared.css (components/aitab-page/aitab-shared.css)
content/browser/aiwindow/components/aitab-page.mjs (components/aitab-page/aitab-page.mjs)
content/browser/aiwindow/components/aitab-page.css (components/aitab-page/aitab-page.css)
content/browser/aiwindow/components/aitab-header.mjs (components/aitab-page/aitab-header/aitab-header.mjs)
content/browser/aiwindow/components/aitab-header.css (components/aitab-page/aitab-header/aitab-header.css)
content/browser/aiwindow/components/aitab-page-actions.mjs (components/aitab-page/aitab-page-actions/aitab-page-actions.mjs)
content/browser/aiwindow/components/aitab-page-actions.css (components/aitab-page/aitab-page-actions/aitab-page-actions.css)
content/browser/aiwindow/components/agent-monitor-item.mjs (components/agent-monitor-item/agent-monitor-item.mjs)
content/browser/aiwindow/components/agent-monitor-item.css (components/agent-monitor-item/agent-monitor-item.css)
content/browser/aiwindow/components/agent-monitor-panel.mjs (components/agent-monitor-panel/agent-monitor-panel.mjs)
@@ -126,8 +133,6 @@ browser.jar:
content/browser/aiwindow/components/smartwindow-topsites.css (components/smartwindow-topsites/smartwindow-topsites.css)
content/browser/aiwindow/components/smartwindow-group-tabs.mjs (components/smartwindow-group-tabs/smartwindow-group-tabs.mjs)
content/browser/aiwindow/assets/agent-watch.svg (assets/agent-watch.svg)
content/browser/aiwindow/components/aitab-page.mjs (components/aitab-page/aitab-page.mjs)
content/browser/aiwindow/components/aitab-page.css (components/aitab-page/aitab-page.css)
content/browser/aiwindow/assets/applied-policy.svg (assets/applied-policy.svg)
content/browser/aiwindow/assets/warning.svg (assets/warning.svg)
content/browser/aiwindow/assets/agent-watch-empty.svg (assets/agent-watch-empty.svg)
@@ -6,7 +6,7 @@
/**
* The current SQLite database schema version
*/
export const CURRENT_SCHEMA_VERSION = 1;
export const CURRENT_SCHEMA_VERSION = 2;
/**
* The name of the SQLite database file
@@ -3,4 +3,30 @@
* 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/. */
export const migrations = [];
import { AITAB_PAGES_SLUG_VERSION_INDEX } from "moz-src:///browser/components/aiwindow/ui/modules/AITabSql.sys.mjs";
// Each migration receives the schema version the database is currently on and
// returns without doing anything if it does not apply.
export const migrations = [
/**
* v2: idx_aitab_pages_slug_version became UNIQUE, so a slug can no longer be
* claimed by more than one conversation. Databases created under v1 carry
* the non-unique index and have to have it rebuilt.
*
* Nothing writes to this store in production yet, so no v1 database can hold
* rows that would violate the new constraint.
*
* @param {object} connection - The open database connection.
* @param {number} version - Schema version the database is migrating from.
*/
async (connection, version) => {
if (version >= 2) {
return;
}
await connection.execute(
"DROP INDEX IF EXISTS idx_aitab_pages_slug_version;"
);
await connection.execute(AITAB_PAGES_SLUG_VERSION_INDEX);
},
];
@@ -49,8 +49,13 @@ INSERT INTO aitab_pages (
// (walk the index backwards, no separate sort) and "a specific slug + version"
// (direct seek). By the leftmost-prefix rule it also covers plain slug-only
// lookups, so no separate single-column slug index is needed.
//
// UNIQUE on the pair, not on the slug column: a tab keeps every version under
// one slug, so the column alone cannot be unique. Constraining the pair still
// stops a second conversation from claiming a slug another one already uses,
// which is what makes a slug safe to treat as a page identity.
export const AITAB_PAGES_SLUG_VERSION_INDEX = `
CREATE INDEX idx_aitab_pages_slug_version ON aitab_pages (slug, version);
CREATE UNIQUE INDEX idx_aitab_pages_slug_version ON aitab_pages (slug, version);
`;
export const GET_NEXT_VERSION = `
@@ -97,3 +102,16 @@ FROM aitab_pages
WHERE conv_id = :conv_id
ORDER BY version ASC;
`;
// Keyed on slug so it can use idx_aitab_pages_slug_version; conv_id has no
// index and would scan the table. UNIQUE on (slug, version) is what makes this
// safe: a slug cannot be claimed by a second conversation, so every row it
// matches belongs to the one tab being deleted.
//
// The conversation lives in conversation-store.sqlite, a different database
// file, so no foreign key cascades into it: callers must delete it through
// ConversationStore as well.
export const DELETE_AITAB_PAGES_BY_SLUG = `
DELETE FROM aitab_pages
WHERE slug = :slug;
`;
@@ -19,6 +19,7 @@ import {
GET_AITAB_BY_SLUG_AND_VERSION,
GET_AITAB_VERSIONS_BY_SLUG,
GET_AITAB_PAGES_BY_CONV_ID,
DELETE_AITAB_PAGES_BY_SLUG,
} from "moz-src:///browser/components/aiwindow/ui/modules/AITabSql.sys.mjs";
import { SQLiteStoreBase } from "moz-src:///browser/components/aiwindow/ui/modules/SQLiteStoreBase.sys.mjs";
import {
@@ -162,6 +163,27 @@ class AITabStore extends SQLiteStoreBase {
return rows.map(row => this.#parseRow(row));
}
/**
* Deletes every version of the tab with the given slug.
*
* Keyed on slug rather than conv_id to use the (slug, version) index. The
* UNIQUE constraint on that index means a slug belongs to exactly one
* conversation, so this cannot reach another tab's rows.
*
* The conversation lives in a different database file, so nothing cascades
* from here: callers must also delete it through
* `ConversationStore.deleteConversationById`. Delete the pages first — a
* conversation left without pages is invisible, whereas pages left without
* a conversation still load by slug.
*
* @param {string} slug
*/
async deleteBySlug(slug) {
await this.#ensureConnection();
await this.connection.execute(DELETE_AITAB_PAGES_BY_SLUG, { slug });
}
/**
* Converts an aitab_pages result row into a plain page object.
*
@@ -84,6 +84,10 @@ skip-if = [
["browser_aichat_same_link_click.js"]
["browser_aitab_actions.js"]
["browser_aitab_blocks.js"]
["browser_aitab_page.js"]
["browser_aitab_store_integration.js"]
@@ -0,0 +1,425 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
// Drives delete the way a reader does: click the trash button, confirm in the
// dialog, and check that the page and its conversation are actually gone from
// both databases and that the tab is sent home. The store-level tests in
// test_AITabDelete.js cover the SQL; this covers the wiring between the
// component, the actor pair and the two stores.
//
// Everything runs in a real Smart Window opened with openAIWindow() from
// head.js. Setting browser.smartwindow.enabled on an ordinary window is not
// enough: the tab reconciliation swaps the tab out from under a running
// content task, and the home page load hands off to about:newtab.
const { AITabStore } = ChromeUtils.importESModule(
"moz-src:///browser/components/aiwindow/ui/modules/AITabStore.sys.mjs"
);
const { ConversationStore } = ChromeUtils.importESModule(
"moz-src:///browser/components/aiwindow/ui/modules/ConversationStore.sys.mjs"
);
const { Conversation } = ChromeUtils.importESModule(
"moz-src:///browser/components/aiwindow/models/Conversation.sys.mjs"
);
// AIWINDOW_URL and openAIWindow come from head.js.
const AITAB_PREF = "browser.smartwindow.aitab.enabled";
const CONV_ID = "conv-delete-flow";
const SLUG = "delete_flow";
const PAGE_URL = `about:aitab?page=${SLUG}`;
// The renderer still reads the legacy header/blocks/footer shape while the
// store holds the newer `components` block list. Until that migration lands
// the test
// hands the component a page config directly so there is a header to click;
// everything from the click onwards is the real path.
const PAGE_CONFIG = {
header: { type: "header", title: "Delete me", subhead: "A page to remove" },
blocks: [],
};
/**
* Recreates the stored page and its conversation from scratch. Leaving a
* previous task's rows behind makes create() throw, which would turn one
* failure into several.
*/
async function seedPage() {
await AITabStore.deleteBySlug(SLUG);
await ConversationStore.deleteConversationById(CONV_ID);
await ConversationStore.updateConversation(
new Conversation({ id: CONV_ID, feature: "aitab" })
);
await AITabStore.create({ convId: CONV_ID, slug: SLUG, title: "Delete me" });
}
/**
* Opens the seeded page in its own Smart Window.
*
* The window's existing tab is navigated rather than a new one opened: a new
* tab starts at about:blank, which Smart Window's reconciliation redirects to
* the AI Window home, tearing down the page mid-test.
*
* @returns {Promise<object>} The window and its browser, for the caller to
* assert against and close.
*/
async function openSeededPage() {
const win = await openAIWindow();
const browser = win.gBrowser.selectedBrowser;
BrowserTestUtils.startLoadingURIString(browser, PAGE_URL);
await BrowserTestUtils.browserLoaded(browser, false, PAGE_URL);
return { win, browser };
}
/**
* Renders the page config and clicks the trash button, leaving the
* confirmation dialog open.
*
* @param {object} browser
*/
function openDeleteDialog(browser) {
return SpecialPowers.spawn(browser, [PAGE_CONFIG], async config => {
await content.customElements.whenDefined("aitab-page");
const page = content.document.querySelector("aitab-page");
await ContentTaskUtils.waitForCondition(
() => page.wrappedJSObject.status != "loading",
"The page finishes its lookup"
);
page.wrappedJSObject.page = Cu.cloneInto(config, content);
page.wrappedJSObject.status = "ready";
await page.updateComplete;
const header = page.shadowRoot.querySelector("aitab-header");
Assert.ok(header, "The header renders");
await header.updateComplete;
const actions = header.shadowRoot.querySelector("aitab-page-actions");
Assert.ok(actions, "The actions render inside the header");
await actions.updateComplete;
const deleteButton = actions.shadowRoot.querySelector(
".aitab-action-delete"
);
Assert.ok(deleteButton, "The delete button is present");
Assert.ok(
!actions.shadowRoot.querySelector("dialog[open]"),
"No dialog is showing before the button is clicked"
);
// Each moz-button schedules its own first render, so awaiting the
// parent's updateComplete is not enough: without this the button is still
// 0x0 and a synthesized click lands on nothing.
await deleteButton.wrappedJSObject.updateComplete;
Assert.greater(
deleteButton.getBoundingClientRect().width,
0,
"The delete button has a hit area, so a real click can reach it"
);
EventUtils.synthesizeMouseAtCenter(deleteButton, {}, content);
await ContentTaskUtils.waitForCondition(
() => actions.shadowRoot.querySelector("dialog[open]"),
"The confirmation dialog opens"
);
});
}
add_setup(async function () {
await SpecialPowers.pushPrefEnv({ set: [[AITAB_PREF, true]] });
});
add_task(async function test_cancelling_the_dialog_keeps_the_page() {
await seedPage();
const { win, browser } = await openSeededPage();
await openDeleteDialog(browser);
await SpecialPowers.spawn(browser, [], async () => {
const actions = content.document
.querySelector("aitab-page")
.shadowRoot.querySelector("aitab-header")
.shadowRoot.querySelector("aitab-page-actions");
const cancelButton = actions.shadowRoot.querySelector(
".aitab-delete-cancel"
);
Assert.ok(cancelButton, "The dialog offers a way out");
EventUtils.synthesizeMouseAtCenter(cancelButton, {}, content);
await ContentTaskUtils.waitForCondition(
() => !actions.shadowRoot.querySelector("dialog[open]"),
"The dialog closes"
);
});
Assert.ok(
await AITabStore.getBySlug(SLUG),
"Cancelling leaves the page in place"
);
Assert.ok(
await ConversationStore.findConversationById(CONV_ID),
"Cancelling leaves the conversation in place"
);
Assert.equal(
browser.currentURI.spec,
PAGE_URL,
"Cancelling does not navigate away"
);
await BrowserTestUtils.closeWindow(win);
});
add_task(async function test_delete_from_the_dialog_removes_the_page() {
await seedPage();
Assert.ok(await AITabStore.getBySlug(SLUG), "The page exists to begin with");
Assert.ok(
await ConversationStore.findConversationById(CONV_ID),
"The conversation exists to begin with"
);
const { win, browser } = await openSeededPage();
const navigated = BrowserTestUtils.browserLoaded(
browser,
false,
AIWINDOW_URL
);
await openDeleteDialog(browser);
await SpecialPowers.spawn(browser, [], async () => {
const actions = content.document
.querySelector("aitab-page")
.shadowRoot.querySelector("aitab-header")
.shadowRoot.querySelector("aitab-page-actions");
const confirmButton = actions.shadowRoot.querySelector(
".aitab-delete-confirm"
);
Assert.ok(confirmButton, "The dialog offers a destructive confirm button");
EventUtils.synthesizeMouseAtCenter(confirmButton, {}, content);
});
await TestUtils.waitForCondition(
async () => !(await AITabStore.getBySlug(SLUG)),
"The page is deleted from the page store"
);
Assert.equal(
await ConversationStore.findConversationById(CONV_ID),
null,
"The conversation that produced the page is deleted too"
);
await navigated;
Assert.equal(
browser.currentURI.spec,
AIWINDOW_URL,
"The tab is sent back to the Smart Window home page"
);
await BrowserTestUtils.closeWindow(win);
});
add_task(async function test_delete_ignores_a_page_name_from_content() {
// The parent reads the page name from the tab's own URL, so a name supplied
// by the child is ignored. Without that, a compromised content process
// could aim a delete at a page the user is not looking at.
await seedPage();
await AITabStore.create({
convId: "conv-bystander",
slug: "bystander_page",
title: "Not the open page",
});
const { win, browser } = await openSeededPage();
const actor = browser.browsingContext.currentWindowGlobal.getActor("AITab");
const result = await actor.receiveMessage({
name: "AITab:DeletePage",
data: { pageName: "bystander_page" },
});
Assert.ok(result.success, "The delete reports success");
Assert.equal(
await AITabStore.getBySlug(SLUG),
null,
"The page the tab is actually showing is the one deleted"
);
Assert.ok(
await AITabStore.getBySlug("bystander_page"),
"The page named by content is untouched"
);
await AITabStore.deleteBySlug("bystander_page");
await BrowserTestUtils.closeWindow(win);
});
/**
* Finds the tab showing `url` in any window, or null.
*
* Every window is searched because the link is opened by openWebLinkIn()
* against topChromeWindow, and the chip asks for preferSwitchToTab, which lets
* switchToTabHavingURI() reuse a tab in another window rather than open one.
*
* @param {string} url
* @returns {?object} The tab showing url.
*/
function findTabShowing(url) {
for (const win of Services.wm.getEnumerator("navigator:browser")) {
const tab = win.gBrowser?.tabs.find(
t => t.linkedBrowser?.currentURI?.spec == url
);
if (tab) {
return tab;
}
}
return null;
}
/**
* Counts open tabs across every window, for the same reason findTabShowing
* searches them all: a link may open in a window other than the one under
* test.
*
* @returns {number}
*/
function countTabs() {
let total = 0;
for (const win of Services.wm.getEnumerator("navigator:browser")) {
total += win.gBrowser?.tabs.length ?? 0;
}
return total;
}
add_task(async function test_clicking_a_source_opens_it() {
const SOURCE_URL = "https://example.com/source";
await seedPage();
const { win, browser } = await openSeededPage();
// Guards the search below: a tab already at this URL would let the wait
// pass without the click having done anything.
Assert.ok(
!findTabShowing(SOURCE_URL),
"No tab is showing the source before the click"
);
await SpecialPowers.spawn(
browser,
[PAGE_CONFIG, SOURCE_URL],
async (config, url) => {
await content.customElements.whenDefined("aitab-page");
const page = content.document.querySelector("aitab-page");
await ContentTaskUtils.waitForCondition(
() => page.wrappedJSObject.status != "loading",
"The page finishes its lookup"
);
const withSource = Cu.cloneInto(
{
...config,
header: {
...config.header,
references: { items: [{ title: "A source", href: url }] },
},
},
content
);
page.wrappedJSObject.page = withSource;
page.wrappedJSObject.status = "ready";
await page.updateComplete;
const header = page.shadowRoot.querySelector("aitab-header");
await header.updateComplete;
const chips = header.shadowRoot.querySelector(
"ai-grouped-chip-container"
);
Assert.ok(chips, "The source chip renders");
await chips.wrappedJSObject.updateComplete;
const trigger = chips.shadowRoot.querySelector(".grouped-chips");
Assert.ok(trigger, "The chip has a trigger");
EventUtils.synthesizeMouseAtCenter(trigger, {}, content);
const panel = chips.shadowRoot.querySelector("smartwindow-panel-list");
await ContentTaskUtils.waitForCondition(
() => panel.shadowRoot.querySelector("panel-item"),
"The source list opens"
);
const item = panel.shadowRoot.querySelector("panel-item");
await item.wrappedJSObject?.updateComplete;
EventUtils.synthesizeMouseAtCenter(item, {}, content);
}
);
await TestUtils.waitForCondition(
() => findTabShowing(SOURCE_URL),
"A tab opens at the source URL"
);
const tab = findTabShowing(SOURCE_URL);
Assert.equal(
tab.linkedBrowser.currentURI.spec,
SOURCE_URL,
"Clicking a source in the chip opens it"
);
BrowserTestUtils.removeTab(tab);
await BrowserTestUtils.closeWindow(win);
});
add_task(async function test_a_non_web_source_link_is_refused() {
// A link's href comes from the model, so this check is what keeps model
// output from becoming a document: without it, data: renders model-supplied
// markup in a tab of its own. Nothing below AITabParent checks the scheme.
//
// A good link is sent last and waited on. Messages are handled in order, so
// once its tab exists the refused ones have been processed too, which
// avoids waiting an arbitrary amount of time for something not to happen.
const ALLOWED = "https://example.com/allowed";
await seedPage();
const { win, browser } = await openSeededPage();
const tabsBefore = countTabs();
await SpecialPowers.spawn(browser, [ALLOWED], async allowed => {
const page = content.document.querySelector("aitab-page");
for (const url of [
"about:robots",
"javascript:alert(1)",
"data:text/html,x",
"file:///etc/passwd",
allowed,
]) {
page.dispatchEvent(
new content.CustomEvent("AITab:OpenLink", {
bubbles: true,
detail: Cu.cloneInto({ url }, content),
})
);
}
});
await TestUtils.waitForCondition(
() => findTabShowing(ALLOWED),
"The allowed link opens a tab"
);
const tab = findTabShowing(ALLOWED);
Assert.equal(
countTabs(),
tabsBefore + 1,
"Only the http(s) source opened a tab"
);
BrowserTestUtils.removeTab(tab);
await BrowserTestUtils.closeWindow(win);
});
registerCleanupFunction(async () => {
await AITabStore.deleteBySlug(SLUG);
await ConversationStore.deleteConversationById(CONV_ID);
});
@@ -0,0 +1,139 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
// Rendering tests for the individual AI Tab block components. Each component
// gets its own add_task here rather than its own file: they are props in,
// markup out, and a file each would mean a fresh browser session each in CI.
// Behaviour that crosses a process or database boundary belongs in
// browser_aitab_actions.js instead.
//
// The components are mounted bare in the about:aitab document rather than
// through a stored page, so these stay independent of the page config shape.
// Note the mounting has to be repeated inside each content task: the task runs
// in the content process and cannot call helpers defined in this file.
const AITAB_TEST_PREF = "browser.smartwindow.aitab.enabled";
/**
* Opens about:aitab, where every AI Tab custom element is registered.
*
* @param {Function} task - Content task, receives the spawn args.
* @param {Array} args - Structured-cloneable arguments for the task.
*/
async function withAITabDocument(task, args = []) {
await SpecialPowers.pushPrefEnv({
set: [
["browser.smartwindow.enabled", true],
[AITAB_TEST_PREF, true],
],
});
await BrowserTestUtils.withNewTab("about:aitab", async browser => {
await SpecialPowers.spawn(browser, args, task);
});
await SpecialPowers.popPrefEnv();
}
// The date itself is formatted in AITabParent and covered by
// test_AITabDate.js. The header only renders the label it is handed.
add_task(async function test_header_renders_the_created_label() {
await withAITabDocument(
async label => {
await content.customElements.whenDefined("aitab-header");
const element = content.document.createElement("aitab-header");
content.document.body.append(element);
const header = element.wrappedJSObject;
header.createdAt = label;
await header.updateComplete;
Assert.equal(
header.shadowRoot.querySelector(".aitab-eyebrow").textContent,
label,
"The eyebrow shows the label the parent formatted"
);
header.createdAt = "";
await header.updateComplete;
Assert.ok(
!header.shadowRoot.querySelector(".aitab-eyebrow"),
"No eyebrow is rendered without a label"
);
},
["Created Sep 1"]
);
});
add_task(async function test_header_references() {
await withAITabDocument(
async sources => {
await content.customElements.whenDefined("aitab-header");
const element = content.document.createElement("aitab-header");
content.document.body.append(element);
const header = element.wrappedJSObject;
await header.updateComplete;
Assert.ok(
!header.shadowRoot.querySelector("ai-grouped-chip-container"),
"No chip container is rendered when there are no references"
);
header.references = Cu.cloneInto(sources, content);
await header.updateComplete;
// header is already the unwrapped object, so its shadow DOM query
// returns raw content elements; no second unwrap is needed.
const chips = header.shadowRoot.querySelector(
"ai-grouped-chip-container"
);
Assert.ok(chips, "The chip container renders once there are references");
Assert.deepEqual(
chips.chips.map(chip => [chip.url, chip.label]),
sources.map(source => [source.href, source.title]),
"Each source becomes a chip keyed by href with its title as the label"
);
},
[
[
{ title: "energy.gov", href: "https://energy.gov" },
{ title: "NEEP", href: "https://neep.org" },
],
]
);
});
add_task(async function test_header_reference_count_label() {
// Resolved text, not the l10n id: the string is in aiWindowContent.ftl,
// which aitab.html has to load.
await withAITabDocument(async () => {
await content.customElements.whenDefined("aitab-header");
const element = content.document.createElement("aitab-header");
content.document.body.append(element);
const header = element.wrappedJSObject;
for (const [count, expected] of [
[1, "1 Tag"],
[3, "3 Tags"],
]) {
header.references = Cu.cloneInto(
Array.from({ length: count }, (_, i) => ({
title: `example${i}.com`,
href: `https://example${i}.com`,
})),
content
);
await header.updateComplete;
const label = header.shadowRoot
.querySelector("ai-grouped-chip-container")
.shadowRoot.querySelector(".grouped-chips__label");
await ContentTaskUtils.waitForCondition(
() => label.textContent.trim() == expected,
`${count} reference(s) reads as "${expected}"`
);
Assert.equal(label.textContent.trim(), expected, `Reads "${expected}"`);
}
});
});
@@ -185,8 +185,10 @@ add_task(async function test_renders_page_config() {
await page.updateComplete;
const { shadowRoot } = element;
const header = shadowRoot.querySelector("aitab-header");
await header.updateComplete;
Assert.equal(
shadowRoot.querySelector(".aitab-title").textContent,
header.shadowRoot.querySelector(".aitab-title").textContent,
config.header.title,
"The header title is rendered"
);
@@ -217,3 +219,95 @@ add_task(async function test_renders_page_config() {
await SpecialPowers.popPrefEnv();
});
// Parent-actor branches that the states above do not reach: deleting
// something already gone, and a store that throws.
const { AITabStore } = ChromeUtils.importESModule(
"moz-src:///browser/components/aiwindow/ui/modules/AITabStore.sys.mjs"
);
const { ConversationStore } = ChromeUtils.importESModule(
"moz-src:///browser/components/aiwindow/ui/modules/ConversationStore.sys.mjs"
);
const { Conversation } = ChromeUtils.importESModule(
"moz-src:///browser/components/aiwindow/models/Conversation.sys.mjs"
);
/**
* Reads the page the content document ended up with.
*
* @param {object} browser
* @returns {Promise<object>} status and the resolved page title, if any.
*/
function getPageState(browser) {
return SpecialPowers.spawn(browser, [], async () => {
await content.customElements.whenDefined("aitab-page");
const page = content.document.querySelector("aitab-page").wrappedJSObject;
await ContentTaskUtils.waitForCondition(
() => page.status != "loading",
"The page finishes its lookup"
);
return { status: page.status, title: page.page?.title ?? null };
});
}
add_task(async function test_deleting_an_already_deleted_page_succeeds() {
await SpecialPowers.pushPrefEnv({ set: [[AITAB_PREF, true]] });
await ConversationStore.updateConversation(
new Conversation({ id: "conv-twice", feature: "aitab" })
);
await AITabStore.create({
convId: "conv-twice",
slug: "twice_page",
title: "Twice",
});
// The tab has to be on the page being deleted: the parent reads the name
// from the tab URL rather than from the message.
await BrowserTestUtils.withNewTab(
"about:aitab?page=twice_page",
async browser => {
const actor =
browser.browsingContext.currentWindowGlobal.getActor("AITab");
const first = await actor.receiveMessage({ name: "AITab:DeletePage" });
Assert.ok(first.success, "The first delete succeeds");
// Deleting again is a no-op, not an error: the reader may retry, or
// two tabs may be open on the same page.
const second = await actor.receiveMessage({ name: "AITab:DeletePage" });
Assert.ok(
second.success,
"Deleting a page that is already gone succeeds"
);
Assert.ok(!second.error, "and reports no error");
}
);
await SpecialPowers.popPrefEnv();
});
add_task(async function test_a_failing_store_surfaces_an_error() {
await SpecialPowers.pushPrefEnv({ set: [[AITAB_PREF, true]] });
const original = AITabStore.getBySlug;
AITabStore.getBySlug = () => {
throw new Error("simulated store failure");
};
try {
await BrowserTestUtils.withNewTab(PAGE_URL, async browser => {
const state = await getPageState(browser);
Assert.equal(
state.status,
"error",
"A store that throws renders the error state, not an empty page"
);
});
} finally {
AITabStore.getBySlug = original;
}
await SpecialPowers.popPrefEnv();
});
@@ -0,0 +1,95 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
// "Today" in the AI Tab header is a calendar day, not a rolling 24 hours.
// Both `now` and the timestamp are supplied here so the boundary cases are
// deterministic; asserting against the real clock would flake for a suite
// that happened to run across midnight.
do_get_profile();
const { formatCreatedAt } = ChromeUtils.importESModule(
"moz-src:///browser/components/aiwindow/ui/actors/AITabParent.sys.mjs"
);
const HOUR_MS = 60 * 60 * 1000;
const DAY_MS = 24 * HOUR_MS;
/**
* The store keeps created_at in microseconds, which is what the formatter
* takes.
*
* @param {string} isoString
* @returns {number}
*/
function storedTime(isoString) {
return new Date(isoString).getTime() * 1000;
}
add_task(async function test_today_uses_the_today_string() {
const now = new Date("2026-09-08T14:30:00").getTime();
const today = await formatCreatedAt(storedTime("2026-09-08T14:30:00"), now);
Assert.equal(today, "Created today", "The same instant reads as today");
Assert.equal(
await formatCreatedAt(storedTime("2026-09-08T00:00:00"), now),
today,
"The first moment of today reads as today"
);
Assert.equal(
await formatCreatedAt(storedTime("2026-09-08T23:59:59"), now),
today,
"The last moment of today reads as today"
);
});
add_task(async function test_other_days_use_a_date() {
const now = new Date("2026-09-08T14:30:00").getTime();
const yesterday = await formatCreatedAt(
storedTime("2026-09-07T23:59:59"),
now
);
Assert.notEqual(
yesterday,
"Created today",
"The last moment of yesterday is not today"
);
Assert.ok(yesterday.startsWith("Created "), `Reads as a date: ${yesterday}`);
Assert.notEqual(
await formatCreatedAt(storedTime("2026-09-09T00:00:00"), now),
"Created today",
"The first moment of tomorrow is not today either"
);
});
add_task(async function test_within_24_hours_is_not_enough() {
// Two hours apart but on different calendar days. A rolling 24 hour window
// would call the earlier one today, which is not what a reader means.
const now = new Date("2026-09-08T01:00:00").getTime();
const lateYesterday = new Date("2026-09-07T23:00:00").getTime();
Assert.less(now - lateYesterday, DAY_MS, "Less than a day apart");
Assert.notEqual(
await formatCreatedAt(lateYesterday * 1000, now),
"Created today",
"but the previous calendar day is still not today"
);
});
add_task(async function test_unusable_timestamps_render_nothing() {
const now = Date.now();
Assert.equal(await formatCreatedAt(0, now), "", "Zero produces no label");
Assert.equal(
await formatCreatedAt(Number.NaN, now),
"",
"NaN produces no label"
);
Assert.equal(
await formatCreatedAt(undefined, now),
"",
"A missing timestamp produces no label"
);
});
@@ -0,0 +1,135 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
// Deleting a generated page has to clear two databases: the page versions in
// ai-tab-pages-store.sqlite and the conversation that produced them in
// conversation-store.sqlite. They are separate files, so no foreign key
// cascades between them and the two deletes cannot share a transaction. These
// tests pin the contract AITabParent relies on.
do_get_profile();
const { AITabStore } = ChromeUtils.importESModule(
"moz-src:///browser/components/aiwindow/ui/modules/AITabStore.sys.mjs"
);
const { ConversationStore } = ChromeUtils.importESModule(
"moz-src:///browser/components/aiwindow/ui/modules/ConversationStore.sys.mjs"
);
const { Conversation } = ChromeUtils.importESModule(
"moz-src:///browser/components/aiwindow/models/Conversation.sys.mjs"
);
registerCleanupFunction(async () => {
await AITabStore.destroyDatabase();
await ConversationStore.destroyDatabase();
});
/**
* Creates a conversation and a two-version page belonging to it.
*
* @param {string} convId
* @param {string} slug
*/
async function createTabWithConversation(convId, slug) {
await ConversationStore.updateConversation(
new Conversation({ id: convId, feature: "aitab" })
);
await AITabStore.create({ convId, slug, title: "V1" });
await AITabStore.edit({ convId, slug, title: "V2" });
}
add_task(async function setup() {
await AITabStore.ensureDatabase();
await ConversationStore.ensureDatabase();
});
add_task(async function test_created_at_is_microseconds() {
await createTabWithConversation("conv-units", "units-slug");
const page = await AITabStore.getBySlug("units-slug");
// AITabStore writes `Date.now() * 1000`. The header divides this back down
// before building a Date, so if the column ever switches to milliseconds
// that conversion has to go with it.
Assert.greater(
page.createdAt,
Date.now() * 100,
"created_at is stored in microseconds, not milliseconds"
);
Assert.equal(
new Date(Math.round(page.createdAt / 1000)).getFullYear(),
new Date().getFullYear(),
"Dividing by 1000 yields a Date in the current year"
);
});
add_task(async function test_delete_clears_both_stores() {
await createTabWithConversation("conv-both", "both-slug");
// Sanity: both sides exist before the delete, so the assertions below are
// observing a real transition rather than an empty database.
Assert.ok(
await AITabStore.getBySlug("both-slug"),
"The page exists before deleting"
);
Assert.ok(
await ConversationStore.findConversationById("conv-both"),
"The conversation exists before deleting"
);
// The order AITabParent uses: pages first, conversation second. The page
// delete is keyed on slug, the conversation delete on conv_id.
await AITabStore.deleteBySlug("both-slug");
await ConversationStore.deleteConversationById("conv-both");
Assert.equal(
await AITabStore.getBySlug("both-slug"),
null,
"The page no longer resolves by slug"
);
Assert.equal(
await ConversationStore.findConversationById("conv-both"),
null,
"The conversation is gone too"
);
});
add_task(async function test_deleting_conversation_alone_orphans_the_page() {
await createTabWithConversation("conv-orphan", "orphan-slug");
await ConversationStore.deleteConversationById("conv-orphan");
// This is why AITabParent cannot rely on the conversation delete alone: the
// stores are different database files, so nothing cascades and the page
// would still load by slug.
Assert.equal(
await ConversationStore.findConversationById("conv-orphan"),
null,
"The conversation is gone"
);
Assert.ok(
await AITabStore.getBySlug("orphan-slug"),
"The page survives, so deleting the conversation alone is not enough"
);
});
add_task(async function test_delete_is_scoped_to_one_conversation() {
await createTabWithConversation("conv-a", "slug-a");
await createTabWithConversation("conv-b", "slug-b");
await AITabStore.deleteBySlug("slug-a");
await ConversationStore.deleteConversationById("conv-a");
Assert.equal(
await AITabStore.getBySlug("slug-a"),
null,
"The targeted page is gone"
);
Assert.ok(
await AITabStore.getBySlug("slug-b"),
"The other conversation's page is untouched"
);
Assert.ok(
await ConversationStore.findConversationById("conv-b"),
"The other conversation is untouched"
);
});
@@ -49,7 +49,11 @@ add_task(async function test_schema_version() {
AITabStore.CURRENT_SCHEMA_VERSION,
"Schema version matches the store's current version"
);
Assert.equal(version, 1, "Initial schema version is 1");
Assert.equal(
version,
2,
"Schema version is 2 after the unique-index migration"
);
});
add_task(async function test_aitab_pages_columns() {
@@ -71,6 +75,42 @@ add_task(async function test_aitab_pages_columns() {
Assert.ok(columns.title.notNull, "title is NOT NULL");
});
add_task(async function test_slug_version_index_is_unique() {
const indexes = await AITabStore.connection.execute(
"PRAGMA index_list(aitab_pages)"
);
const slugIndex = indexes.find(
row => row.getResultByName("name") == "idx_aitab_pages_slug_version"
);
Assert.ok(slugIndex, "The (slug, version) index exists");
Assert.equal(
slugIndex.getResultByName("unique"),
1,
"It is UNIQUE, so two conversations cannot claim the same slug"
);
});
add_task(async function test_a_slug_cannot_be_reused_by_another_tab() {
await AITabStore.create({
convId: "conv-slug-owner",
slug: "contested-slug",
title: "First claim",
});
// Both would be version 1, so the pair collides. Without the UNIQUE index
// this silently succeeded and left the slug pointing at two conversations.
await Assert.rejects(
AITabStore.create({
convId: "conv-slug-squatter",
slug: "contested-slug",
title: "Second claim",
}),
/UNIQUE|constraint/i,
"A second conversation cannot take a slug that is already in use"
);
});
add_task(async function test_create_inserts_first_version() {
const created = await AITabStore.create({
convId: "conv-create",
@@ -235,3 +275,55 @@ add_task(async function test_get_by_slug_returns_latest() {
const missing = await AITabStore.getBySlug("no-such-slug");
Assert.equal(missing, null, "An unknown slug returns null");
});
add_task(async function test_delete_by_slug_removes_every_version() {
await AITabStore.create({
convId: "conv-delete",
slug: "delete-slug",
title: "V1",
});
await AITabStore.edit({
convId: "conv-delete",
slug: "delete-slug",
title: "V2",
});
await AITabStore.deleteBySlug("delete-slug");
Assert.deepEqual(
await AITabStore.getAITabPagesByConvId("conv-delete"),
[],
"Every version of the tab is gone"
);
Assert.equal(
await AITabStore.getBySlug("delete-slug"),
null,
"The slug no longer resolves, so the page cannot be resurrected"
);
});
add_task(async function test_delete_by_slug_is_scoped_to_one_tab() {
await AITabStore.create({
convId: "conv-target",
slug: "target-slug",
title: "Delete me",
});
await AITabStore.create({
convId: "conv-bystander",
slug: "bystander-slug",
title: "Keep me",
});
await AITabStore.deleteBySlug("target-slug");
Assert.equal(
await AITabStore.getBySlug("target-slug"),
null,
"The targeted tab is gone"
);
Assert.equal(
(await AITabStore.getBySlug("bystander-slug"))?.title,
"Keep me",
"A tab belonging to another conversation is left alone"
);
});
@@ -5,6 +5,10 @@ run-if = [
"os != 'android'",
]
["test_AITabDate.js"]
["test_AITabDelete.js"]
["test_AITabStore.js"]
["test_AutoTabGrouping.js"]
+35
View File
@@ -350,3 +350,38 @@ ai-smart-form-fill-error-description = Something went wrong. To try again, selec
ai-smart-form-fill-try-again =
.label = Try again
## AI Tab generated pages
# Shown above the title of a generated page that was created today.
aitab-created-today = Created today
# Shown above the title of a generated page created on an earlier date.
# Variables:
# $date (number) - Timestamp of when the page was generated.
aitab-created-on = Created { DATETIME($date, month: "short", day: "numeric") }
# Button that re-fetches the sources a generated page was built from.
aitab-page-refresh-sources =
.label = Refresh sources
# Replaces the refresh label while the sources are being re-fetched.
aitab-page-refreshing-sources =
.label = Refreshing sources
# Icon-only button that deletes the generated page.
aitab-page-delete =
.aria-label = Delete page
.title = Delete page
# TODO: D321710 (bug 2061040) adds `-ai-tab-brand-name`; swap the literal
# placeholder for that term once it has landed on central.
# "[AI Tab]" is a placeholder for the final product name.
aitab-page-delete-dialog-title = Delete this [AI Tab]?
aitab-page-delete-dialog-message = This generated page will be removed. The sources it was built from arent affected.
aitab-page-delete-dialog-cancel =
.label = Cancel
aitab-page-delete-dialog-confirm =
.label = Delete