diff --git a/browser/base/content/test/static/browser_all_files_referenced.js b/browser/base/content/test/static/browser_all_files_referenced.js index 39367e14c4ed..5f6ca1a9e476 100644 --- a/browser/base/content/test/static/browser_all_files_referenced.js +++ b/browser/base/content/test/static/browser_all_files_referenced.js @@ -202,6 +202,11 @@ 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" }, diff --git a/browser/components/DesktopActorRegistry.sys.mjs b/browser/components/DesktopActorRegistry.sys.mjs index 22d61a1d60b1..7b1f65b44f06 100644 --- a/browser/components/DesktopActorRegistry.sys.mjs +++ b/browser/components/DesktopActorRegistry.sys.mjs @@ -284,9 +284,7 @@ let JSWINDOWACTORS = { esModuleURI: "moz-src:///browser/components/aiwindow/ui/actors/AITabChild.sys.mjs", events: { - "AITab:GetPage": { wantUntrusted: true }, - "AITab:DeletePage": { wantUntrusted: true }, - "AITab:OpenLink": { wantUntrusted: true }, + "AITab:RequestPage": { wantUntrusted: true }, }, }, matches: ["about:aitab", "about:aitab?*"], diff --git a/browser/components/aiwindow/ui/actors/AITabChild.sys.mjs b/browser/components/aiwindow/ui/actors/AITabChild.sys.mjs index ed1ed23fd07e..164007d7b701 100644 --- a/browser/components/aiwindow/ui/actors/AITabChild.sys.mjs +++ b/browser/components/aiwindow/ui/actors/AITabChild.sys.mjs @@ -2,36 +2,20 @@ * 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 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. + * Child actor for about:aitab. Forwards page lookups from the content document + * to the parent process and dispatches the answer back to the requester. */ export class AITabChild extends JSWindowActorChild { handleEvent(event) { - 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}`); + if (event.type != REQUEST_PAGE_EVENT) { + console.warn(`AITabChild received unknown event: ${event.type}`); + return; } - } - /** - * 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) + this.sendQuery("AITab:GetPage", event.detail) .then( response => this.#respond(event, "Response", response), error => this.#respond(event, "Error", { error: error.message }) @@ -41,14 +25,6 @@ export class AITabChild extends JSWindowActorChild { }); } - /** - * Dispatches `:Response` or `: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) { diff --git a/browser/components/aiwindow/ui/actors/AITabParent.sys.mjs b/browser/components/aiwindow/ui/actors/AITabParent.sys.mjs index 8115f671874a..116fd8f1cf66 100644 --- a/browser/components/aiwindow/ui/actors/AITabParent.sys.mjs +++ b/browser/components/aiwindow/ui/actors/AITabParent.sys.mjs @@ -3,218 +3,38 @@ * 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, and owns the destructive - * actions the page offers. + * the stored page config that content renders. */ export class AITabParent extends JSWindowActorParent { async receiveMessage({ data, 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) { + if (name != "AITab:GetPage") { + console.warn(`AITabParent received unknown message: ${name}`); return null; } - const pageName = URL.parse(spec)?.searchParams.get("page"); - return pageName && PAGE_NAME_REGEX.test(pageName) ? pageName : null; + return this.#handleGetPage(data); } - async #handleGetPage() { - const pageName = this.#pageName; + async #handleGetPage({ 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, - // Content renders the label as-is; it never sees the raw timestamp. - page: page && { - ...page, - createdAtLabel: formatCreatedAt(page.createdAt), - }, - }; + return { success: true, page }; } 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} - */ - 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(), - }); - } } diff --git a/browser/components/aiwindow/ui/components/ai-grouped-chip-container/ai-grouped-chip-container.css b/browser/components/aiwindow/ui/components/ai-grouped-chip-container/ai-grouped-chip-container.css index e9359ad1c962..65d5f474087b 100644 --- a/browser/components/aiwindow/ui/components/ai-grouped-chip-container/ai-grouped-chip-container.css +++ b/browser/components/aiwindow/ui/components/ai-grouped-chip-container/ai-grouped-chip-container.css @@ -2,43 +2,39 @@ * 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); - /* 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); + border-radius: var(--border-radius-large); + border-end-end-radius: var(--border-radius-small); cursor: pointer; display: flex; gap: var(--space-xsmall); - /* stylelint-disable-next-line stylelint-plugin-mozilla/use-design-tokens */ - padding: var(--ai-grouped-chip-container-padding); + padding: var(--space-medium); @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)); + } } } diff --git a/browser/components/aiwindow/ui/components/ai-grouped-chip-container/ai-grouped-chip-container.mjs b/browser/components/aiwindow/ui/components/ai-grouped-chip-container/ai-grouped-chip-container.mjs index 4ae84e67c38f..c3ab8242e82e 100644 --- a/browser/components/aiwindow/ui/components/ai-grouped-chip-container/ai-grouped-chip-container.mjs +++ b/browser/components/aiwindow/ui/components/ai-grouped-chip-container/ai-grouped-chip-container.mjs @@ -14,15 +14,12 @@ 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; } @@ -47,7 +44,7 @@ export class AIGroupedChipContainer extends MozLitElement { const url = event.detail?.id; if (url) { this.dispatchEvent( - new CustomEvent(this.openLinkEvent, { + new CustomEvent("AIChatContent:OpenLink", { bubbles: true, composed: true, detail: { url, preferSwitchToTab: true }, diff --git a/browser/components/aiwindow/ui/components/aitab-page/aitab-header/aitab-header.css b/browser/components/aiwindow/ui/components/aitab-page/aitab-header/aitab-header.css deleted file mode 100644 index 5215f266abce..000000000000 --- a/browser/components/aiwindow/ui/components/aitab-page/aitab-header/aitab-header.css +++ /dev/null @@ -1,70 +0,0 @@ -/* 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); -} diff --git a/browser/components/aiwindow/ui/components/aitab-page/aitab-header/aitab-header.mjs b/browser/components/aiwindow/ui/components/aitab-page/aitab-header/aitab-header.mjs deleted file mode 100644 index 246691a7d6b1..000000000000 --- a/browser/components/aiwindow/ui/components/aitab-page/aitab-header/aitab-header.mjs +++ /dev/null @@ -1,100 +0,0 @@ -/* 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`${this.createdAt}`; - } - - #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``; - } - - render() { - return html` - - -
- - ${this.#renderCreatedAt()} -

${this.heading}

- ${this.subhead - ? html`

${this.subhead}

` - : nothing} - ${this.#renderReferences()} -
- `; - } -} - -customElements.define("aitab-header", AITabHeader); diff --git a/browser/components/aiwindow/ui/components/aitab-page/aitab-header/aitab-header.stories.mjs b/browser/components/aiwindow/ui/components/aitab-page/aitab-header/aitab-header.stories.mjs deleted file mode 100644 index 11f76f82a116..000000000000 --- a/browser/components/aiwindow/ui/components/aitab-page/aitab-header/aitab-header.stories.mjs +++ /dev/null @@ -1,112 +0,0 @@ -/* 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` - -`; - -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, -}; diff --git a/browser/components/aiwindow/ui/components/aitab-page/aitab-page-actions/aitab-page-actions.css b/browser/components/aiwindow/ui/components/aitab-page/aitab-page-actions/aitab-page-actions.css deleted file mode 100644 index 83dec9ba51d0..000000000000 --- a/browser/components/aiwindow/ui/components/aitab-page/aitab-page-actions/aitab-page-actions.css +++ /dev/null @@ -1,39 +0,0 @@ -/* 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); -} diff --git a/browser/components/aiwindow/ui/components/aitab-page/aitab-page-actions/aitab-page-actions.mjs b/browser/components/aiwindow/ui/components/aitab-page/aitab-page-actions/aitab-page-actions.mjs deleted file mode 100644 index 98a24a73d94e..000000000000 --- a/browser/components/aiwindow/ui/components/aitab-page/aitab-page-actions/aitab-page-actions.mjs +++ /dev/null @@ -1,118 +0,0 @@ -/* 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` - -
-

-

-
- this.#onConfirmDelete()} - > - this.#dialog?.close()} - > -
-
-
- `; - } - - render() { - return html` - -
- this.#onRefresh()} - > - this.#onDeleteRequested()} - > -
- ${this.#renderDeleteDialog()} - `; - } -} - -customElements.define("aitab-page-actions", AITabPageActions); diff --git a/browser/components/aiwindow/ui/components/aitab-page/aitab-page-actions/aitab-page-actions.stories.mjs b/browser/components/aiwindow/ui/components/aitab-page/aitab-page-actions/aitab-page-actions.stories.mjs deleted file mode 100644 index 2dc4861f1c6f..000000000000 --- a/browser/components/aiwindow/ui/components/aitab-page/aitab-page-actions/aitab-page-actions.stories.mjs +++ /dev/null @@ -1,42 +0,0 @@ -/* 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` - -`; - -export const Default = Template.bind({}); -Default.args = { refreshing: false }; - -export const Refreshing = Template.bind({}); -Refreshing.args = { refreshing: true }; diff --git a/browser/components/aiwindow/ui/components/aitab-page/aitab-page.css b/browser/components/aiwindow/ui/components/aitab-page/aitab-page.css index ed053569a13e..b2de95534f0d 100644 --- a/browser/components/aiwindow/ui/components/aitab-page/aitab-page.css +++ b/browser/components/aiwindow/ui/components/aitab-page/aitab-page.css @@ -17,6 +17,22 @@ 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; diff --git a/browser/components/aiwindow/ui/components/aitab-page/aitab-page.mjs b/browser/components/aiwindow/ui/components/aitab-page/aitab-page.mjs index d52d3973ae20..59b24553777b 100644 --- a/browser/components/aiwindow/ui/components/aitab-page/aitab-page.mjs +++ b/browser/components/aiwindow/ui/components/aitab-page/aitab-page.mjs @@ -4,13 +4,8 @@ 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"; -// 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"; +const REQUEST_PAGE_EVENT = "AITab:RequestPage"; /** * Returns the href as an http(s) URL, or null for anything else. Footer button @@ -68,12 +63,13 @@ export class AITabPage extends MozLitElement { } async #loadPage() { - if (!this.pageName) { + const pageName = this.pageName; + if (!pageName) { this.status = "unavailable"; return; } - const response = await this.#request(GET_PAGE_EVENT); + const response = await this.#requestPage(pageName); if (!response?.success) { throw new Error(response?.error ?? "No response from the parent process"); } @@ -83,44 +79,35 @@ export class AITabPage extends MozLitElement { } /** - * 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. + * Asks the AITab actor for a stored page config. * - * @param {string} eventType - Request event the child actor listens for. - * @param {object} [detail] - Payload forwarded to the parent actor. + * @param {string} pageName * @returns {Promise} Resolves with the parent actor's response. */ - #request(eventType, detail = null) { + #requestPage(pageName) { return new Promise((resolve, reject) => { const onResponse = event => { - this.removeEventListener(`${eventType}:Error`, onError); + this.removeEventListener(`${REQUEST_PAGE_EVENT}:Error`, onError); resolve(event.detail); }; const onError = event => { - this.removeEventListener(`${eventType}:Response`, onResponse); - reject(new Error(event.detail?.error || "The request failed")); + this.removeEventListener(`${REQUEST_PAGE_EVENT}:Response`, onResponse); + reject(new Error(event.detail?.error || "Failed to load the page")); }; - this.addEventListener(`${eventType}:Response`, onResponse, { + this.addEventListener(`${REQUEST_PAGE_EVENT}:Response`, onResponse, { + once: true, + }); + this.addEventListener(`${REQUEST_PAGE_EVENT}:Error`, onError, { once: true, }); - this.addEventListener(`${eventType}:Error`, onError, { once: true }); - this.dispatchEvent(new CustomEvent(eventType, { bubbles: true, detail })); + this.dispatchEvent( + new CustomEvent(REQUEST_PAGE_EVENT, { + bubbles: true, + detail: { pageName }, + }) + ); }); } @@ -139,21 +126,16 @@ 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` - { - this.#deletePage().catch(error => { - console.error("Failed to delete AI Tab page:", error); - this.status = "error"; - }); - }} - > +
+ ${header.eyebrow + ? html`

${header.eyebrow}

` + : nothing} +

${header.title}

+ ${header.subhead + ? html`

${header.subhead}

` + : nothing} +
`; } diff --git a/browser/components/aiwindow/ui/components/aitab-page/aitab-shared.css b/browser/components/aiwindow/ui/components/aitab-page/aitab-shared.css deleted file mode 100644 index a32ae015719f..000000000000 --- a/browser/components/aiwindow/ui/components/aitab-page/aitab-shared.css +++ /dev/null @@ -1,17 +0,0 @@ -/* 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; -} diff --git a/browser/components/aiwindow/ui/content/aitab.html b/browser/components/aiwindow/ui/content/aitab.html index 811fce86c034..885c834d7713 100644 --- a/browser/components/aiwindow/ui/content/aitab.html +++ b/browser/components/aiwindow/ui/content/aitab.html @@ -18,8 +18,6 @@ - -