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:
committed by
mlucks@mozilla.com
parent
733b79bcbb
commit
384314f38c
@@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+19
-15
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -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);
|
||||
+112
@@ -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,
|
||||
};
|
||||
+39
@@ -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);
|
||||
}
|
||||
+118
@@ -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);
|
||||
+42
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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 aren’t affected.
|
||||
|
||||
aitab-page-delete-dialog-cancel =
|
||||
.label = Cancel
|
||||
|
||||
aitab-page-delete-dialog-confirm =
|
||||
.label = Delete
|
||||
|
||||
Reference in New Issue
Block a user