Files
sousa-gecko/browser/components/aiwindow/ui/modules/AIWindowUI.sys.mjs
T
Florian Zia dfc4d3a5b0 Bug 2016416 - Prevent Smart Window sidebar from being dragged over the content area r=desktop-theme-reviewers,tabbrowser-reviewers,devtools-reviewers,nchevobbe,yjamora,dao
The Smart Window sidebar splitter could be resized past the page content area: Clamp the splitter so it does not detach from the panel edge.

Differential Revision: https://phabricator.services.mozilla.com/D309792
2026-07-13 22:22:07 +00:00

742 lines
21 KiB
JavaScript

/**
* 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();
const gSidebarAnimations = new WeakMap();
const gSidebarWidthHandlers = new WeakMap();
/**
* @typedef {import("../components/ai-window/ai-window.mjs").SmartbarInputState} SmartbarInputState
*/
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,
SIDEBAR_ANIMATION_MS: 200,
/**
* @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 };
},
/**
* Sets a max width for the draggable sidebar.
*
* @param {Window} win
*/
updateSidebarMaxWidth(win) {
const nodes = this._getSidebarElements(win);
if (!nodes) {
return;
}
const maxWidthRatio = parseFloat(
win
.getComputedStyle(win.document.documentElement)
.getPropertyValue("--ai-window-sidebar-max-width-ratio")
);
nodes.box.style.setProperty(
"--ai-window-sidebar-max-width",
`${Math.round(win.innerWidth * maxWidthRatio)}px`
);
if (!gSidebarWidthHandlers.has(win)) {
const sidebarResizeHandler = () => this.updateSidebarMaxWidth(win);
gSidebarWidthHandlers.set(win, sidebarResizeHandler);
win.addEventListener("resize", sidebarResizeHandler);
}
},
/**
* Stop tracking the sidebar width for a window.
*
* @param {Window} win
*/
_removeSidebarWidthHandler(win) {
const handler = gSidebarWidthHandlers.get(win);
if (handler) {
win.removeEventListener("resize", handler);
gSidebarWidthHandlers.delete(win);
}
},
/**
* @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;
}
// While closing, the box stays uncollapsed until the slide finishes, so
// rely on the intended state recorded by _setSidebarCollapsed.
return nodes.box._aiWindowOpen ?? !nodes.box.collapsed;
},
/**
* Open or close the sidebar, animating the slide when possible. The box slides
* with a transform and the content area's apparent width with a clip-path, both
* compositor-driven, so the content area isn't reflowed on every frame; the real
* layout flips once, when the slide finishes. See the "Animating the content area"
* performance best practice.
*
* The slide is reserved for explicit user toggles (the Ask/Close button, which
* animate); every other path (tab switch, session restore, mode changes) passes
* `animate: false` so the sidebar doesn't slide on navigation.
*
* @param {Window} win
* @param {Element} box
* @param {Element} splitter
* @param {boolean} collapse
* @param {object} [options]
* @param {boolean} [options.animate=true] Whether to slide instead of committing instantly.
*/
_setSidebarCollapsed(win, box, splitter, collapse, { animate = true } = {}) {
box._aiWindowOpen = !collapse;
// Give the content area its minimum width while the sidebar is open.
win.document
.getElementById("tabbrowser-tabbox")
.toggleAttribute("ai-window-open", !collapse);
if (!collapse) {
this.updateSidebarMaxWidth(win);
} else {
this._removeSidebarWidthHandler(win);
}
const reduceMotion = win.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
if (!animate || reduceMotion) {
this._cancelSidebarAnimation(box);
this._commitSidebarCollapsed(box, splitter, collapse);
return;
}
this._animateSidebarToggle(win, box, splitter, collapse);
},
_commitSidebarCollapsed(box, splitter, collapse) {
this._clearSidebarAnimationStyles(box);
box.collapsed = collapse;
splitter.collapsed = collapse;
if (!collapse) {
box.parentElement.collapsed = false;
}
},
_clearSidebarAnimationStyles(box) {
box.style.position =
box.style.top =
box.style.bottom =
box.style.left =
box.style.right =
box.style.width =
"";
box.parentElement.style.overflow = "";
},
_cancelSidebarAnimation(box) {
const animations = gSidebarAnimations.get(box);
if (animations) {
gSidebarAnimations.delete(box);
animations.forEach(animation => animation.cancel());
}
},
_animateSidebarToggle(win, box, splitter, collapse) {
const browserEl = box.parentElement;
const tabbox = win.document.getElementById("tabbrowser-tabbox");
this._cancelSidebarAnimation(box);
this._clearSidebarAnimationStyles(box);
box.collapsed = false;
splitter.collapsed = false;
browserEl.collapsed = false;
const browserStyle = win.getComputedStyle(browserEl);
const boxRect = box.getBoundingClientRect();
const browserRect = browserEl.getBoundingClientRect();
const tabboxRect = tabbox.getBoundingClientRect();
const onRight = boxRect.left >= tabboxRect.right;
const paddingLeft = parseFloat(browserStyle.paddingLeft);
const paddingRight = parseFloat(browserStyle.paddingRight);
// Distance from the box's outer edge to #browser's matching edge.
const edgeGap = onRight
? browserRect.right - boxRect.right
: boxRect.left - browserRect.left;
const clipAmount = onRight
? browserRect.right - paddingRight - tabboxRect.right
: tabboxRect.left - (browserRect.left + paddingLeft);
if (boxRect.width <= 0 || clipAmount <= 0) {
this._commitSidebarCollapsed(box, splitter, collapse);
return;
}
// Take the box out of flow, pinned where it sits, so the content area can
// fill the full width regardless of Nova vs. non-Nova space calculations. The
// splitter stays hidden until the slide settles, and #browser is clipped so
// the box can slide past its edge.
box.style.position = "absolute";
box.style.top = `${boxRect.top - browserRect.top}px`;
box.style.bottom = `${browserRect.bottom - boxRect.bottom}px`;
box.style.width = `${boxRect.width}px`;
box.style[onRight ? "right" : "left"] = `${edgeGap}px`;
splitter.collapsed = true;
browserEl.style.overflow = "clip";
const slide = `${(onRight ? 1 : -1) * (boxRect.width + edgeGap)}px 0 0`;
const clipped = onRight
? `inset(0 ${clipAmount}px 0 0)`
: `inset(0 0 0 ${clipAmount}px)`;
const full = "inset(0 0 0 0)";
const options = {
duration: this.SIDEBAR_ANIMATION_MS,
easing: "ease-in-out",
};
const boxFrames = collapse
? [{ translate: "0" }, { translate: slide }]
: [{ translate: slide }, { translate: "0" }];
const contentFrames = collapse
? [{ clipPath: clipped }, { clipPath: full }]
: [{ clipPath: full }, { clipPath: clipped }];
const animations = [
box.animate(boxFrames, options),
tabbox.animate(contentFrames, options),
];
gSidebarAnimations.set(box, animations);
Promise.allSettled(animations.map(animation => animation.finished)).then(
() => {
if (gSidebarAnimations.get(box) !== animations) {
return;
}
gSidebarAnimations.delete(box);
this._commitSidebarCollapsed(box, splitter, collapse);
}
);
},
/**
* Open the AI Window in full window mode
*
* @param {Browser} browser
* @param {ChatConversation} conversation The conversation to open
*/
openInFullWindow(browser, conversation) {
this.closeSidebar(browser.documentGlobal);
browser.setAttribute("data-conversation-id", conversation.id);
const { contentDocument } = browser;
contentDocument.dispatchEvent(
new browser.contentWindow.CustomEvent("OpenConversation", {
detail: conversation,
})
);
},
/**
* Open the AI Window sidebar
*
* The slide is reserved for the Ask button, which opens via toggleSidebar; every
* other opener (tab switch, restore, menus, mode changes) routes through here and
* commits instantly.
*
* @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._setSidebarCollapsed(win, box, splitter, false, { animate: false });
this._updateAskButtonChecked(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;
}
// Return early if the sidebar was closed while we were waiting for the
// content element to load, prevents opening a conversation or creating
// a new one if the sidebar is closed anyway.
if (!this.isSidebarOpen(win)) {
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;
},
async focusSidebar(win, aiBrowser = null) {
aiBrowser ??= win.document.getElementById(this.BROWSER_ID);
if (!aiBrowser || !this.isSidebarOpen(win)) {
return false;
}
aiBrowser.focus();
const aiWindowElement = await this.getAiWindowElement(win, aiBrowser);
if (!aiWindowElement || !this.isSidebarOpen(win)) {
return false;
}
aiWindowElement.focusSmartbar?.();
return true;
},
/**
* Close the AI Window sidebar.
*
* Only the Close button animates the slide; it is the sole caller that passes
* `source === "toggle"`. Tab switches and mode changes close instantly.
*
* @param {Window} win
* @param {string} source
*/
closeSidebar(win, source = null) {
if (!this.isSidebarOpen(win)) {
return;
}
const { box, splitter } = this._getSidebarElements(win);
this._setSidebarCollapsed(win, box, splitter, true, {
animate: source === "toggle",
});
this._updateAskButtonChecked(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,
...(source && { source }),
},
})
);
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) {
if (this.isSidebarOpen(win)) {
this.closeSidebar(win, "toggle");
return false;
}
const nodes = this._getSidebarElements(win);
if (!nodes) {
return false;
}
const { chromeDoc, box, splitter } = nodes;
this.ensureBrowserIsAppended(chromeDoc, box);
this._setSidebarCollapsed(win, box, splitter, false);
this._updateAskButtonChecked(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",
},
})
);
this.focusSidebar(win);
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
*/
_updateAskButtonChecked(win, sidebarIsOpen) {
const askBtn = win.document.querySelector("#smartwindow-ask-button-inner");
if (!askBtn) {
return;
}
askBtn.setAttribute("aria-expanded", String(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);
}
await this.openSidebar(win, conversation);
const nodes = this._getSidebarElements(win);
if (!nodes) {
return null;
}
const aiBrowser = nodes.chromeDoc.getElementById(this.BROWSER_ID);
await this.focusSidebar(win, aiBrowser);
return aiBrowser;
},
/**
* Updates the sidebar input with the specified state.
*
* @param {Window} win
* @param {SmartbarInputState} state
* The structured input state to restore: plain text plus the list of
* inline mention chips with their text-character offsets.
*/
updateSidebarInput(win, state) {
if (!this.isSidebarOpen(win)) {
return;
}
const aiWindowEl = this._getSidebarAiWindow(win);
if (!aiWindowEl?.updateInput) {
return;
}
aiWindowEl.updateInput(state);
},
/**
* Restores the per-tab model selection on the sidebar ai-window. A null
* modelChoiceId falls back to the global default model.
*
* @param {Window} win
* @param {?string} modelChoiceId
*/
updateSidebarModel(win, modelChoiceId) {
if (!this.isSidebarOpen(win)) {
return;
}
const aiWindowEl = this._getSidebarAiWindow(win);
if (!aiWindowEl?.restoreModelChoiceOverride) {
return;
}
aiWindowEl.restoreModelChoiceOverride(modelChoiceId);
},
/**
* Triggers updating the starter prompts on the active ai-window
* for the given chrome window. The target is resolved by mode:
* sidebar uses the chrome window's sidebar ai-window, fullpage
* uses the ai-window inside the owning tab's browser.
*
* @param {Window} win
* @param {boolean} [clear=true] Clear current starter prompts first
* @param {"sidebar"|"fullpage"} [mode="sidebar"]
* @param {MozTabbrowserTab|null} [tab=null] Owner tab for fullpage mode
*/
updateStarterPrompts(win, clear = true, mode = "sidebar", tab = null) {
const aiWindow = this._getActiveAiWindow(win, mode, tab);
if (!aiWindow) {
return;
}
aiWindow.loadStarterPrompts(clear, win.gBrowser.selectedTab);
},
/**
* 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");
},
/**
* Resolves the ai-window instance relevant to a dispatched event,
* routed by mode. Sidebar and fullpage are mutually exclusive for
* a given tab — an AIWINDOW_URL tab always closes the sidebar.
*
* @param {Window} win
* @param {"sidebar"|"fullpage"} mode
* @param {MozTabbrowserTab|null} tab Owner tab for fullpage mode
*
* @private
*/
_getActiveAiWindow(win, mode, tab) {
if (mode === "sidebar") {
return this._getSidebarAiWindow(win);
}
if (mode === "fullpage" && tab) {
return tab.linkedBrowser.contentDocument.querySelector(
"ai-window:defined"
);
}
return null;
},
_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
// https://bugzilla.mozilla.org/show_bug.cgi?id=2024055
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);
});
},
};