Source code
Revision control
Copy as Markdown
Other Tools
/**
* 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 {
AIWINDOW_URL,
AIWindow,
} from "moz-src:///browser/components/aiwindow/ui/modules/AIWindow.sys.mjs";
const gFadingWindows = new WeakSet();
export const AIWindowUI = {
BOX_ID: "ai-window-box",
SPLITTER_ID: "ai-window-splitter",
BROWSER_ID: "ai-window-browser",
STACK_CLASS: "ai-window-browser-stack",
AI_WINDOW_ELEMENT_TIMEOUT: 1500,
TAB_FADE_MS: 200,
TAB_FADE_TIMEOUT_MS: 200 * 2 + 50,
/**
* @param {Window} win
* @returns {{ chromeDoc: Document, box: Element, splitter: Element } | null}
*/
_getSidebarElements(win) {
if (!win) {
return null;
}
const chromeDoc = win.document;
const box = chromeDoc.getElementById(this.BOX_ID);
const splitter = chromeDoc.getElementById(this.SPLITTER_ID);
if (!box || !splitter) {
return null;
}
return { chromeDoc, box, splitter };
},
/**
* @param {Window} win
* @returns {{ chatId: string, messageSeq: number }}
*/
_getConversationFromSidebar(win) {
const conversation = AIWindow.getActiveConversation(win);
return {
chatId: conversation?.id ?? "",
messageSeq: conversation?.messageCount ?? 0,
};
},
/**
* Ensure the aiwindow <browser> exists under the sidebar box.
*
* @param {Document} chromeDoc
* @param {Element} box
* @returns {XULElement} browser
*/
ensureBrowserIsAppended(chromeDoc, box) {
const existingBrowser = chromeDoc.getElementById(this.BROWSER_ID);
if (existingBrowser) {
return existingBrowser;
}
const stack = box.querySelector(`.${this.STACK_CLASS}`);
if (!stack.isConnected) {
stack.className = this.STACK_CLASS;
stack.setAttribute("flex", "1");
box.appendChild(stack);
}
const browser = chromeDoc.createXULElement("browser");
browser.id = this.BROWSER_ID;
browser.setAttribute("transparent", "true");
browser.setAttribute("flex", "1");
browser.setAttribute("disablehistory", "true");
browser.setAttribute("disablefullscreen", "true");
browser.setAttribute("tooltip", "aHTMLTooltip");
browser.setAttribute("src", AIWINDOW_URL);
browser.setAttribute("type", "content");
stack.appendChild(browser);
return browser;
},
/**
* @param {Window} win
* @returns {boolean} whether the sidebar is open (visible)
*/
isSidebarOpen(win) {
const nodes = this._getSidebarElements(win);
if (!nodes) {
return false;
}
return !nodes.box.collapsed;
},
_showSidebarElements(box, splitter) {
box.collapsed = false;
splitter.collapsed = false;
box.parentElement.collapsed = false;
},
/**
* Open the AI Window in full window mode
*
* @param {Browser} browser
* @param {ChatConversation} conversation The conversation to open
*/
openInFullWindow(browser, conversation) {
this.closeSidebar(browser.ownerGlobal);
browser.setAttribute("data-conversation-id", conversation.id);
const { contentDocument } = browser;
contentDocument.dispatchEvent(
new browser.contentWindow.CustomEvent("OpenConversation", {
detail: conversation,
})
);
},
/**
* Open the AI Window sidebar
*
* @param {Window} win
* @param {ChatConversation} conversation The conversation to open in the sidebar
*/
async openSidebar(win, conversation) {
const nodes = this._getSidebarElements(win);
if (!nodes) {
return;
}
const { box, splitter } = nodes;
const aiBrowser = this.ensureBrowserIsAppended(win.document, box);
if (!this.isSidebarOpen(win)) {
this._showSidebarElements(box, splitter);
this._setAskButtonStyle(win, true);
}
Glean.smartWindow.sidebarOpen.record({
chat_id: conversation?.id ?? "",
message_seq: conversation?.messageCount ?? 0,
});
// Dispatch event to notify tab state manager that sidebar was toggled
win.dispatchEvent(
new win.CustomEvent("ai-window:sidebar-toggle", {
detail: {
tab: win.gBrowser.selectedTab,
isOpen: true,
source: "open",
},
})
);
if (conversation) {
aiBrowser.setAttribute("data-conversation-id", conversation.id);
} else {
aiBrowser.removeAttribute("data-conversation-id");
}
const aiWindowElement = await this.getAiWindowElement(win, aiBrowser);
if (!aiWindowElement) {
return;
}
if (conversation) {
aiWindowElement.openConversation(conversation);
return;
}
aiWindowElement.onCreateNewChatClick();
},
/**
* Gets the ai-window element from the sidebar browser. Polls until the
* custom element is defined or the timeout is reached.
*
* @param {Window} win
* @param {XULElement} aiBrowser
*
* @returns {Promise<AIWindow>} The sidebar AIWindow component
*/
async getAiWindowElement(win, aiBrowser) {
const deadline = Date.now() + AIWindowUI.AI_WINDOW_ELEMENT_TIMEOUT;
while (Date.now() < deadline) {
const el = aiBrowser.contentDocument?.querySelector("ai-window:defined");
if (el) {
return el;
}
await new Promise(resolve => win.setTimeout(resolve, 50));
}
return null;
},
/**
* Close the AI Window sidebar.
*
* @param {Window} win
*/
closeSidebar(win) {
if (!this.isSidebarOpen(win)) {
return;
}
const { box, splitter } = this._getSidebarElements(win);
box.collapsed = true;
splitter.collapsed = true;
this._setAskButtonStyle(win, false);
// Dispatch event to notify tab state manager that sidebar was toggled
win.dispatchEvent(
new win.CustomEvent("ai-window:sidebar-toggle", {
detail: {
tab: win.gBrowser?.selectedTab,
isOpen: false,
},
})
);
const { chatId, messageSeq } = this._getConversationFromSidebar(win);
Glean.smartWindow.sidebarClose.record({
chat_id: chatId,
message_seq: messageSeq,
});
},
/**
* Toggle the AI Window sidebar
*
* @param {Window} win
* @returns {boolean} true if now open, false if now closed
*/
toggleSidebar(win) {
const nodes = this._getSidebarElements(win);
if (!nodes) {
return false;
}
const { chromeDoc, box, splitter } = nodes;
if (!box.collapsed) {
box.collapsed = true;
splitter.collapsed = true;
this._setAskButtonStyle(win, false);
// Dispatch event to notify tab state manager that sidebar was toggled
win.dispatchEvent(
new win.CustomEvent("ai-window:sidebar-toggle", {
detail: {
tab: win.gBrowser?.selectedTab,
isOpen: false,
},
})
);
const { chatId, messageSeq } = this._getConversationFromSidebar(win);
Glean.smartWindow.sidebarClose.record({
chat_id: chatId,
message_seq: messageSeq,
});
return false;
}
this.ensureBrowserIsAppended(chromeDoc, box);
this._showSidebarElements(box, splitter);
this._setAskButtonStyle(win, true);
// Dispatch event to notify tab state manager that sidebar was toggled
win.dispatchEvent(
new win.CustomEvent("ai-window:sidebar-toggle", {
detail: {
tab: win.gBrowser?.selectedTab,
isOpen: true,
source: "toggle",
},
})
);
const { chatId, messageSeq } = this._getConversationFromSidebar(win);
Glean.smartWindow.sidebarOpen.record({
chat_id: chatId,
message_seq: messageSeq,
});
return true;
},
/**
* Restores the memories icon state on the sidebar or fullpage ai-window.
*
* @param {Window} win
* @param {MozTabbrowserTab} [tab] - If provided, targets the fullpage ai-window in that tab.
*/
restoreMemoriesState(win, tab = null) {
const aiWindowEl = tab
? tab.linkedBrowser?.contentDocument?.querySelector("ai-window:defined")
: this._getSidebarAiWindow(win);
if (aiWindowEl) {
aiWindowEl.syncSmartbarMemoriesStateFromConversation();
}
},
/**
* Update the Ask Button style based on the sidebar state.
*
* @param {Window} win
* @param {boolean} sidebarIsOpen
*/
_setAskButtonStyle(win, sidebarIsOpen) {
const askBtn = win.document.querySelector("#smartwindow-ask-button-inner");
if (!askBtn) {
return;
}
askBtn.classList.toggle("sidebar-is-open", sidebarIsOpen);
},
/**
* Moves a full-page AI Window conversation into the sidebar.
*
* @param {Window} win
* @param {object} tab - The tab containing the full-page AI Window
* @returns {XULElement|null} The sidebar browser element
*/
async moveFullPageToSidebar(win, tab) {
const fullPageBrowser = tab.linkedBrowser;
if (
!fullPageBrowser?.currentURI ||
!AIWindow.isAIWindowContentPage(fullPageBrowser.currentURI)
) {
return null;
}
let conversationId = null;
try {
const aiWindowEl =
fullPageBrowser.contentDocument?.querySelector("ai-window");
conversationId = aiWindowEl?.conversationId ?? null;
} catch {
// Content may not be accessible
}
let conversation = null;
if (conversationId) {
conversation =
await AIWindow.chatStore.findConversationById(conversationId);
}
this.openSidebar(win, conversation);
const nodes = this._getSidebarElements(win);
return nodes ? nodes.chromeDoc.getElementById(this.BROWSER_ID) : null;
},
/**
* Updates the sidebar input with the specified value.
*
* @param {Window} win
* @param {string} value The new input value
*/
updateSidebarInput(win, value) {
if (!this.isSidebarOpen(win)) {
return;
}
const aiWindowEl = this._getSidebarAiWindow(win);
if (!aiWindowEl?.updateInput) {
return;
}
aiWindowEl.updateInput(value);
},
/**
* Triggers updating the starter prompts in the sidebar window if it
* is already opened.
*
* @param {Window} win
*/
updateStarterPrompts(win) {
const sidebarAiWindow = this._getSidebarAiWindow(win);
if (!sidebarAiWindow) {
return;
}
sidebarAiWindow.loadStarterPrompts(true);
},
/**
* Gets the sidebar instance of the ai-window component
*
* @param {Window} win
*
* @private
*/
_getSidebarAiWindow(win) {
if (!this.isSidebarOpen(win)) {
return null;
}
const aiWindowBrowser = win.document.getElementById(this.BROWSER_ID);
return aiWindowBrowser?.contentDocument?.querySelector("ai-window:defined");
},
_getFadeTarget(win) {
const tabPanels = win?.document?.getElementById("tabbrowser-tabpanels");
return tabPanels?.selectedPanel ?? null;
},
_prefersReducedMotion(win) {
return !!win?.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches;
},
_fadeToOpacity(el, win, { to }) {
return new Promise(resolve => {
const onEnd = event => {
if (event.propertyName !== "opacity") {
return;
}
el.removeEventListener("transitionend", onEnd);
win.clearTimeout(timer);
resolve();
};
const timer = win.setTimeout(() => {
el.removeEventListener("transitionend", onEnd);
resolve();
}, this.TAB_FADE_TIMEOUT_MS);
el.addEventListener("transitionend", onEnd);
el.style.transition = `opacity ${this.TAB_FADE_MS}ms ease`;
el.style.opacity = String(to);
});
},
async _runTabPanelsFade(win) {
const target = this._getFadeTarget(win);
if (!win || !target) {
return;
}
if (this._prefersReducedMotion(win)) {
// TODO - find alternate approach here
return;
}
const prevTransition = target.style.transition;
const prevOpacity = target.style.opacity;
try {
await this._fadeToOpacity(target, win, { to: "0.25" });
target.getBoundingClientRect(); // layout flush for reliable fade-in
await this._fadeToOpacity(target, win, { to: "1" });
} finally {
target.style.transition = prevTransition;
target.style.opacity = prevOpacity;
}
},
/**
* Handle citation link click of a URL that the user is currently on
*
* @param {Window} win
*/
handleSameLinkClick(win) {
if (!win || gFadingWindows.has(win)) {
return;
}
gFadingWindows.add(win);
this._runTabPanelsFade(win).finally(() => {
gFadingWindows.delete(win);
});
},
};