[DEPRECATED] This script is no longer maintained. Please use "AI UX Customizer" instead.
// ==UserScript==
// @name Gemini-UX-Customizer
// @namespace https://github.com/p65536
// @version 2.5.0
// @license MIT
// @description [DEPRECATED] This script is no longer maintained. Please use "AI UX Customizer" instead.
// @icon https://www.google.com/s2/favicons?sz=64&domain=gemini.google.com
// @author p65536
// @match https://gemini.google.com/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addValueChangeListener
// @grant GM_removeValueChangeListener
// @grant GM_xmlhttpRequest
// @connect raw.githubusercontent.com
// @connect *
// @run-at document-start
// @noframes
// ==/UserScript==
(() => {
'use strict';
// =================================================================================
// SECTION: Platform-Specific Definitions (DO NOT COPY TO OTHER PLATFORM)
// =================================================================================
const OWNERID = 'p65536';
const APPID = 'gggux';
const APPNAME = 'Gemini UX Customizer';
const ASSISTANT_NAME = 'Gemini';
const LOG_PREFIX = `[${APPID.toUpperCase()}]`;
// =================================================================================
// SECTION: Style Definitions
// =================================================================================
// Style definitions for styled Logger.badge()
const LOG_STYLES = {
BASE: 'color: white; padding: 2px 6px; border-radius: 4px; font-weight: bold;',
BLUE: 'background: #007bff;',
GREEN: 'background: #28a745;',
YELLOW: 'background: #ffc107; color: black;',
RED: 'background: #dc3545;',
GRAY: 'background: #6c757d;',
};
// =================================================================================
// SECTION: Logging Utility
// Description: Centralized logging interface for consistent log output across modules.
// Handles log level control, message formatting, and console API wrapping.
// =================================================================================
class Logger {
/** @property {object} levels - Defines the numerical hierarchy of log levels. */
static levels = {
error: 0,
warn: 1,
info: 2,
log: 3,
debug: 4,
};
/** @property {string} level - The current active log level. */
static level = 'log'; // Default level
/**
* Sets the current log level.
* @param {string} level The new log level. Must be one of 'error', 'warn', 'info', 'log', 'debug'.
*/
static setLevel(level) {
if (Object.prototype.hasOwnProperty.call(this.levels, level)) {
this.level = level;
} else {
Logger.badge('INVALID LEVEL', LOG_STYLES.YELLOW, 'warn', `Invalid log level "${level}". Valid levels are: ${Object.keys(this.levels).join(', ')}. Level not changed.`);
}
}
/** @param {...any} args The messages or objects to log. */
static error(...args) {
if (this.levels[this.level] >= this.levels.error) {
console.error(LOG_PREFIX, ...args);
}
}
/** @param {...any} args The messages or objects to log. */
static warn(...args) {
if (this.levels[this.level] >= this.levels.warn) {
console.warn(LOG_PREFIX, ...args);
}
}
/** @param {...any} args The messages or objects to log. */
static info(...args) {
if (this.levels[this.level] >= this.levels.info) {
console.info(LOG_PREFIX, ...args);
}
}
/** @param {...any} args The messages or objects to log. */
static log(...args) {
if (this.levels[this.level] >= this.levels.log) {
console.log(LOG_PREFIX, ...args);
}
}
/**
* Logs messages for debugging. Only active in 'debug' level.
* @param {...any} args The messages or objects to log.
*/
static debug(...args) {
if (this.levels[this.level] >= this.levels.debug) {
// Use console.debug for better filtering in browser dev tools.
console.debug(LOG_PREFIX, ...args);
}
}
/**
* Starts a timer for performance measurement. Only active in 'debug' level.
* @param {string} label The label for the timer.
*/
static time(label) {
if (this.levels[this.level] >= this.levels.debug) {
console.time(`${LOG_PREFIX} ${label}`);
}
}
/**
* Ends a timer and logs the elapsed time. Only active in 'debug' level.
* @param {string} label The label for the timer, must match the one used in time().
*/
static timeEnd(label) {
if (this.levels[this.level] >= this.levels.debug) {
console.timeEnd(`${LOG_PREFIX} ${label}`);
}
}
/**
* @param {...any} args The title for the log group.
* @returns {void}
*/
static group = (...args) => console.group(LOG_PREFIX, ...args);
/**
* @param {...any} args The title for the collapsed log group.
* @returns {void}
*/
static groupCollapsed = (...args) => console.groupCollapsed(LOG_PREFIX, ...args);
/**
* Closes the current log group.
* @returns {void}
*/
static groupEnd = () => console.groupEnd();
/**
* Logs a message with a styled badge for better visibility.
* @param {string} badgeText - The text inside the badge.
* @param {string} badgeStyle - The background-color style (from LOG_STYLES).
* @param {'log'|'warn'|'error'|'info'|'debug'} level - The console log level.
* @param {...any} args - Additional messages to log after the badge.
*/
static badge(badgeText, badgeStyle, level, ...args) {
if (this.levels[this.level] < this.levels[level]) {
return; // Respect the current log level
}
const style = `${LOG_STYLES.BASE} ${badgeStyle}`;
const consoleMethod = console[level] || console.log;
consoleMethod(
`%c${LOG_PREFIX}%c %c${badgeText}%c`,
'font-weight: bold;', // Style for the prefix
'color: inherit;', // Reset for space
style, // Style for the badge
'color: inherit;', // Reset for the rest of the message
...args
);
}
}
/**
* @description A lightweight performance monitor to track event frequency.
* Only active when Logger.level is set to 'debug'.
*/
const PerfMonitor = {
_events: {},
/**
* Logs the frequency of an event, throttled by a specified delay.
* @param {string} key A unique key for the event to track.
* @param {number} [delay] The time window in milliseconds to aggregate calls.
*/
throttleLog(key, delay = 1000) {
if (Logger.levels[Logger.level] < Logger.levels.debug) {
return;
}
const now = Date.now();
if (!this._events[key]) {
this._events[key] = { count: 1, startTime: now };
return;
}
this._events[key].count++;
if (now - this._events[key].startTime >= delay) {
const callsPerSecond = (this._events[key].count / ((now - this._events[key].startTime) / 1000)).toFixed(2);
// Use Logger.debug to ensure the output is prefixed and controlled.
Logger.badge('PerfMonitor', LOG_STYLES.GRAY, 'debug', `${key}: ${this._events[key].count} calls in ${now - this._events[key].startTime}ms (${callsPerSecond} calls/sec)`);
delete this._events[key];
}
},
/**
* Resets all performance counters.
*/
reset() {
this._events = {};
},
};
// =================================================================================
// SECTION: Execution Guard
// Description: Prevents the script from being executed multiple times per page.
// =================================================================================
class ExecutionGuard {
// A shared key for all scripts from the same author to avoid polluting the window object.
static #GUARD_KEY = `__${OWNERID}_guard__`;
// A specific key for this particular script.
static #APP_KEY = `${APPID}_executed`;
/**
* Checks if the script has already been executed on the page.
* @returns {boolean} True if the script has run, otherwise false.
*/
static hasExecuted() {
return window[this.#GUARD_KEY]?.[this.#APP_KEY] || false;
}
/**
* Sets the flag indicating the script has now been executed.
*/
static setExecuted() {
window[this.#GUARD_KEY] = window[this.#GUARD_KEY] || {};
window[this.#GUARD_KEY][this.#APP_KEY] = true;
}
}
// =================================================================================
// SECTION: Configuration and Constants
// Description: Defines default settings, global constants, and CSS selectors.
// =================================================================================
// ---- Default Settings & Theme Configuration ----
const CONSTANTS = {
CONFIG_KEY: `${APPID}_config`,
CONFIG_SIZE_RECOMMENDED_LIMIT_BYTES: 5 * 1024 * 1024, // 5MB
CONFIG_SIZE_LIMIT_BYTES: 10 * 1024 * 1024, // 10MB
CACHE_SIZE_LIMIT_BYTES: 10 * 1024 * 1024, // 10MB
ICON_SIZE: 64,
ICON_SIZE_VALUES: [64, 96, 128, 160, 192],
ICON_MARGIN: 20,
SENTINEL: {
PANEL: 'PanelObserver',
},
OBSERVER_OPTIONS: {
childList: true,
subtree: true,
},
BUTTON_VISIBILITY_THRESHOLD_PX: 128,
BATCH_PROCESSING_SIZE: 50,
RETRY: {
SCROLL_OFFSET_FOR_NAV: 40,
},
TIMING: {
DEBOUNCE_DELAYS: {
// Delay for recalculating UI elements after visibility changes
VISIBILITY_CHECK: 250,
// Delay for updating the message cache after DOM mutations
CACHE_UPDATE: 250,
// Delay for recalculating layout-dependent elements (e.g., standing images) after resize
LAYOUT_RECALCULATION: 150,
// Delay for updating navigation buttons after a message is completed
NAVIGATION_UPDATE: 100,
// Delay for repositioning UI elements like the settings button
UI_REPOSITION: 100,
// Delay for updating the theme after sidebar mutations (Gemini-specific)
THEME_UPDATE: 150,
// Delay for saving settings after user input in the settings panel
SETTINGS_SAVE: 300,
// Delay for updating the theme editor's preview pane
THEME_PREVIEW: 50,
// Delay for batching avatar injection events on initial load
AVATAR_INJECTION: 25,
},
TIMEOUTS: {
// Delay to wait for the DOM to settle after a URL change before re-scanning
POST_NAVIGATION_DOM_SETTLE: 200,
// Delay before resetting the scroll-margin-top style used for smooth scrolling offset
SCROLL_OFFSET_CLEANUP: 1500,
// Delay before reopening a modal after a settings sync conflict is resolved
MODAL_REOPEN_DELAY: 100,
// Delay to wait for panel transition animations (e.g., Canvas, File Panel) to complete
PANEL_TRANSITION_DURATION: 350,
// Fallback delay for requestIdleCallback
IDLE_EXECUTION_FALLBACK: 50,
// Grace period to confirm a 0-message page before firing NAVIGATION_END
ZERO_MESSAGE_GRACE_PERIOD: 2000,
},
},
OBSERVED_ELEMENT_TYPES: {
BODY: 'body',
INPUT_AREA: 'inputArea',
SIDE_PANEL: 'sidePanel',
},
SLIDER_CONFIGS: {
CHAT_WIDTH: {
MIN: 29,
MAX: 80,
NULL_THRESHOLD: 30,
DEFAULT: null,
},
},
Z_INDICES: {
SETTINGS_BUTTON: 10000,
SETTINGS_PANEL: 11000,
THEME_MODAL: 12000,
JSON_MODAL: 15000,
JUMP_LIST_PREVIEW: 16000,
STANDING_IMAGE: 1,
BUBBLE_NAVIGATION: 'auto',
NAV_CONSOLE: 500,
},
MODAL: {
WIDTH: 440,
PADDING: 4,
RADIUS: 8,
BTN_RADIUS: 5,
BTN_FONT_SIZE: 13,
BTN_PADDING: '5px 16px',
TITLE_MARGIN_BOTTOM: 8,
BTN_GROUP_GAP: 8,
TEXTAREA_HEIGHT: 200,
},
UI_DEFAULTS: {
SETTINGS_BUTTON_PADDING_RIGHT: '44px',
},
INTERNAL_ROLES: {
USER: 'user',
ASSISTANT: 'assistant',
},
DATA_KEYS: {
AVATAR_INJECT_ATTEMPTS: 'avatarInjectAttempts',
AVATAR_INJECT_FAILED: 'avatarInjectFailed',
},
SELECTORS: {
// --- Main containers ---
MAIN_APP_CONTAINER: 'bard-sidenav-content',
CHAT_WINDOW_CONTENT: 'chat-window-content',
CHAT_WINDOW: 'chat-window',
CHAT_HISTORY_MAIN: 'div#chat-history',
INPUT_CONTAINER: 'input-container',
// --- Message containers ---
CONVERSATION_UNIT: 'user-query, model-response',
MESSAGE_ID_HOLDER: '[data-message-id]',
MESSAGE_CONTAINER_PARENT: 'div#chat-history',
MESSAGE_ROOT_NODE: 'user-query, model-response',
USER_QUERY_CONTAINER: 'user-query-content',
// --- Selectors for messages ---
USER_MESSAGE: 'user-query',
ASSISTANT_MESSAGE: 'model-response',
// --- Selectors for finding elements to tag ---
RAW_USER_BUBBLE: '.user-query-bubble-with-background',
RAW_ASSISTANT_BUBBLE: '.response-container-with-gpi',
// --- Text content ---
USER_TEXT_CONTENT: '.query-text',
ASSISTANT_TEXT_CONTENT: '.markdown',
ASSISTANT_ANSWER_CONTENT: 'message-content.model-response-text',
// --- Input area ---
INPUT_AREA_BG_TARGET: 'input-area-v2',
INPUT_TEXT_FIELD_TARGET: 'rich-textarea .ql-editor',
INPUT_RESIZE_TARGET: 'input-area-v2',
// --- Input area (Button Injection) ---
INSERTION_ANCHOR: 'input-area-v2 .trailing-actions-wrapper',
// --- Avatar area ---
AVATAR_USER: 'user-query',
AVATAR_ASSISTANT: 'model-response',
// --- Selectors for Avatar ---
SIDE_AVATAR_CONTAINER: '.side-avatar-container',
SIDE_AVATAR_ICON: '.side-avatar-icon',
SIDE_AVATAR_NAME: '.side-avatar-name',
// --- Other UI Selectors ---
SIDEBAR_WIDTH_TARGET: 'bard-sidenav',
// Used for CSS max-width application
CHAT_CONTENT_MAX_WIDTH: '.conversation-container',
// Used for standing image layout calculation
STANDING_IMAGE_ANCHOR: '.conversation-container, .bot-info-card-container',
SCROLL_CONTAINER: null,
// --- Site Specific Selectors ---
CONVERSATION_TITLE_WRAPPER: '[data-test-id="conversation"].selected',
CONVERSATION_TITLE_TEXT: '.conversation-title',
CHAT_HISTORY_SCROLL_CONTAINER: '[data-test-id="chat-history-container"]',
// --- BubbleFeature-specific Selectors ---
BUBBLE_FEATURE_MESSAGE_CONTAINERS: 'user-query, model-response',
BUBBLE_FEATURE_TURN_CONTAINERS: null, // Not applicable to Gemini
// --- FixedNav-specific Selectors ---
FIXED_NAV_INPUT_AREA_TARGET: 'input-area-v2',
FIXED_NAV_MESSAGE_CONTAINERS: 'user-query, model-response',
FIXED_NAV_TURN_CONTAINER: 'user-query, model-response',
FIXED_NAV_ROLE_USER: 'user-query',
FIXED_NAV_ROLE_ASSISTANT: 'model-response',
FIXED_NAV_HIGHLIGHT_TARGETS: `.${APPID}-highlight-message .user-query-bubble-with-background, .${APPID}-highlight-message .response-container-with-gpi`,
// --- Turn Completion Selector ---
TURN_COMPLETE_SELECTOR: 'model-response message-actions',
// --- Debug Selectors ---
DEBUG_CONTAINER_TURN: 'user-query, model-response',
DEBUG_CONTAINER_ASSISTANT: 'model-response',
DEBUG_CONTAINER_USER: 'user-query',
// --- Canvas ---
CANVAS_CONTAINER: 'immersive-panel',
CANVAS_CLOSE_BUTTON: 'button[data-test-id="close-button"]',
// --- File Panel ---
FILE_PANEL_CONTAINER: 'context-sidebar',
// --- Gem Selectors ---
GEM_SELECTED_ITEM: 'bot-list-item.bot-list-item--selected',
GEM_NAME: '.bot-name',
// --- List Item Selectors for Observation ---
CHAT_HISTORY_ITEM: '[data-test-id="conversation"]',
GEM_LIST_ITEM: 'bot-list-item',
// --- Gem Manager ---
GEM_MANAGER_CONTAINER: 'all-bots',
},
};
const EVENTS = {
// Theme & Style
/**
* @description Fired when the chat title changes, signaling a potential theme change.
* @event TITLE_CHANGED
* @property {null} detail - No payload.
*/
TITLE_CHANGED: `${APPID}:titleChanged`,
/**
* @description Requests a re-evaluation and application of the current theme.
* @event THEME_UPDATE
* @property {null} detail - No payload.
*/
THEME_UPDATE: `${APPID}:themeUpdate`,
/**
* @description Fired after all theme styles, including asynchronous images, have been fully applied.
* @event THEME_APPLIED
* @property {object} detail - Contains the theme and config objects.
* @property {ThemeSet} detail.theme - The theme set that was applied.
* @property {AppConfig} detail.config - The full application configuration.
*/
THEME_APPLIED: `${APPID}:themeApplied`,
/**
* @description Fired when a width-related slider in the settings panel is changed, to preview the new width.
* @event WIDTH_PREVIEW
* @property {string | null} detail - The new width value (e.g., '60vw') or null for default.
*/
WIDTH_PREVIEW: `${APPID}:widthPreview`,
// UI & Layout
/**
* @description Fired by ThemeManager after it has applied a new chat content width.
* @event CHAT_CONTENT_WIDTH_UPDATED
* @property {null} detail - No payload.
*/
CHAT_CONTENT_WIDTH_UPDATED: `${APPID}:chatContentWidthUpdated`,
/**
* @description Fired when the main window is resized.
* @event WINDOW_RESIZED
* @property {null} detail - No payload.
*/
WINDOW_RESIZED: `${APPID}:windowResized`,
/**
* @description Fired when the sidebar's layout (width or visibility) changes.
* @event SIDEBAR_LAYOUT_CHANGED
* @property {null} detail - No payload.
*/
SIDEBAR_LAYOUT_CHANGED: `${APPID}:sidebarLayoutChanged`,
/**
* @description Requests a re-check of visibility-dependent UI elements (e.g., standing images when a panel appears).
* @event VISIBILITY_RECHECK
* @property {null} detail - No payload.
*/
VISIBILITY_RECHECK: `${APPID}:visibilityRecheck`,
/**
* @description Requests a repositioning of floating UI elements like the settings button.
* @event UI_REPOSITION
* @property {null} detail - No payload.
*/
UI_REPOSITION: `${APPID}:uiReposition`,
/**
* @description Fired when the chat input area is resized.
* @event INPUT_AREA_RESIZED
* @property {null} detail - No payload.
*/
INPUT_AREA_RESIZED: `${APPID}:inputAreaResized`,
/**
* @description Requests to reopen a modal, typically after a settings sync conflict is resolved.
* @event REOPEN_MODAL
* @property {object} detail - Context for which modal to reopen (e.g., { type: 'json' }).
*/
REOPEN_MODAL: `${APPID}:reOpenModal`,
// Navigation & Cache
/**
* @description Fired when a page navigation is about to start.
* @event NAVIGATION_START
* @property {null} detail - No payload.
*/
NAVIGATION_START: `${APPID}:navigationStart`,
/**
* @description Fired after a page navigation has completed and the UI is stable.
* @event NAVIGATION_END
* @property {null} detail - No payload.
*/
NAVIGATION_END: `${APPID}:navigationEnd`,
/**
* @description Fired when a page navigation (URL change) is detected. Used to reset manager states.
* @event NAVIGATION
* @property {null} detail - No payload.
*/
NAVIGATION: `${APPID}:navigation`,
/**
* @description Fired to request an update of the message cache, typically after a DOM mutation.
* @event CACHE_UPDATE_REQUEST
* @property {null} detail - No payload.
*/
CACHE_UPDATE_REQUEST: `${APPID}:cacheUpdateRequest`,
/**
* @description Fired after the MessageCacheManager has finished rebuilding its cache.
* @event CACHE_UPDATED
* @property {null} detail - No payload.
*/
CACHE_UPDATED: `${APPID}:cacheUpdated`,
/**
* @description Requests that a specific message element be highlighted by the navigation system.
* @event NAV_HIGHLIGHT_MESSAGE
* @property {HTMLElement} detail - The message element to highlight.
*/
NAV_HIGHLIGHT_MESSAGE: `${APPID}:nav:highlightMessage`,
// Message Lifecycle
/**
* @description Fired by Sentinel when a new message bubble's core content is added to the DOM.
* @event RAW_MESSAGE_ADDED
* @property {HTMLElement} detail - The raw bubble element that was added.
*/
RAW_MESSAGE_ADDED: `${APPID}:rawMessageAdded`,
/**
* @description Fired to request the injection of an avatar into a specific message element.
* @event AVATAR_INJECT
* @property {HTMLElement} detail - The message element (e.g., `user-query`) to inject the avatar into.
*/
AVATAR_INJECT: `${APPID}:avatarInject`,
/**
* @description Fired when a message container has been identified and is ready for further processing, such as the injection of UI addons (e.g., navigation buttons).
* @event MESSAGE_COMPLETE
* @property {HTMLElement} detail - The completed message element.
*/
MESSAGE_COMPLETE: `${APPID}:messageComplete`,
/**
* @description Fired when an entire conversation turn (user query and assistant response) is complete, including streaming.
* @event TURN_COMPLETE
* @property {HTMLElement} detail - The completed turn container element.
*/
TURN_COMPLETE: `${APPID}:turnComplete`,
/**
* @description Fired when an assistant response starts streaming.
* @event STREAMING_START
*/
STREAMING_START: `${APPID}:streamingStart`,
/**
* @description Fired when an assistant response finishes streaming.
* @event STREAMING_END
*/
STREAMING_END: `${APPID}:streamingEnd`,
/**
* @description Fired after streaming ends to trigger deferred layout updates.
* @event DEFERRED_LAYOUT_UPDATE
*/
DEFERRED_LAYOUT_UPDATE: `${APPID}:deferredLayoutUpdate`,
/**
* @description (GPTUX-only) Fired when historical timestamps are loaded from the API.
* @event TIMESTAMPS_LOADED
* @property {null} detail - No payload.
*/
TIMESTAMPS_LOADED: `${APPID}:timestampsLoaded`,
/**
* @description Fired when a new timestamp for a realtime message is recorded.
* @event TIMESTAMP_ADDED
* @property {object} detail - Contains the message ID.
* @property {string} detail.messageId - The ID of the message.
* @property {Date} detail.timestamp - The timestamp (Date object) of when the message was processed.
*/
TIMESTAMP_ADDED: `${APPID}:timestampAdded`,
// System & Config
/**
* @description Fired when a remote configuration change is detected from another tab/window.
* @event REMOTE_CONFIG_CHANGED
* @property {object} detail - Contains the new configuration string.
* @property {string} detail.newValue - The raw string of the new configuration.
*/
REMOTE_CONFIG_CHANGED: `${APPID}:remoteConfigChanged`,
/**
* @description Requests the temporary suspension of all major DOM observers (MutationObserver, Sentinel).
* @event SUSPEND_OBSERVERS
* @property {null} detail - No payload.
*/
SUSPEND_OBSERVERS: `${APPID}:suspendObservers`,
/**
* @description Requests the resumption of suspended observers and a forced refresh of the UI.
* @event RESUME_OBSERVERS_AND_REFRESH
* @property {null} detail - No payload.
*/
RESUME_OBSERVERS_AND_REFRESH: `${APPID}:resumeObserversAndRefresh`,
/**
* @description Fired when the configuration size exceeds the storage limit.
* @event CONFIG_SIZE_EXCEEDED
* @property {object} detail - Contains the error message.
* @property {string} detail.message - The warning message to display.
*/
CONFIG_SIZE_EXCEEDED: `${APPID}:configSizeExceeded`,
/**
* @description Fired to update the display state of a configuration-related warning.
* @event CONFIG_WARNING_UPDATE
* @property {object} detail - The warning state.
* @property {boolean} detail.show - Whether to show the warning.
* @property {string} detail.message - The message to display.
*/
CONFIG_WARNING_UPDATE: `${APPID}:configWarningUpdate`,
/**
* @description Fired when the configuration is successfully saved.
* @event CONFIG_SAVE_SUCCESS
* @property {null} detail - No payload.
*/
CONFIG_SAVE_SUCCESS: `${APPID}:configSaveSuccess`,
/**
* @description Fired when the configuration has been updated, signaling UI components to refresh.
* @event CONFIG_UPDATED
* @property {AppConfig} detail - The new, complete configuration object.
*/
CONFIG_UPDATED: `${APPID}:configUpdated`,
/**
* @description Fired to request a full application shutdown and cleanup.
* @event APP_SHUTDOWN
* @property {null} detail - No payload.
*/
APP_SHUTDOWN: `${APPID}:appShutdown`,
/**
* @description (ChatGPT-only) Fired by the polling scanner when it detects new messages.
* @event POLLING_MESSAGES_FOUND
* @property {null} detail - No payload.
*/
POLLING_MESSAGES_FOUND: `${APPID}:pollingMessagesFound`,
/**
* @description (Gemini-only) Requests the start of the auto-scroll process to load full chat history.
* @event AUTO_SCROLL_REQUEST
* @property {null} detail - No payload.
*/
AUTO_SCROLL_REQUEST: `${APPID}:autoScrollRequest`,
/**
* @description (Gemini-only) Requests the cancellation of an in-progress auto-scroll.
* @event AUTO_SCROLL_CANCEL_REQUEST
* @property {null} detail - No payload.
*/
AUTO_SCROLL_CANCEL_REQUEST: `${APPID}:autoScrollCancelRequest`,
/**
* @description (Gemini-only) Fired when the auto-scroll process has actively started (i.e., progress bar detected).
* @event AUTO_SCROLL_START
* @property {null} detail - No payload.
*/
AUTO_SCROLL_START: `${APPID}:autoScrollStart`,
/**
* @description (Gemini-only) Fired when the auto-scroll process has completed or been cancelled.
* @event AUTO_SCROLL_COMPLETE
* @property {null} detail - No payload.
*/
AUTO_SCROLL_COMPLETE: `${APPID}:autoScrollComplete`,
};
// ---- Site-specific Style Variables ----
const UI_PALETTE = {
bg: 'var(--gem-sys-color--surface-container-highest)',
input_bg: 'var(--gem-sys-color--surface-container-low)',
text_primary: 'var(--gem-sys-color--on-surface)',
text_secondary: 'var(--gem-sys-color--on-surface-variant)',
border: 'var(--gem-sys-color--outline)',
btn_bg: 'var(--gem-sys-color--surface-container-high)',
btn_hover_bg: 'var(--gem-sys-color--surface-container-higher)',
btn_text: 'var(--gem-sys-color--on-surface-variant)',
btn_border: 'var(--gem-sys-color--outline)',
toggle_bg_off: 'var(--gem-sys-color--surface-container)',
toggle_bg_on: 'var(--gem-sys-color--primary)',
toggle_knob: 'var(--gem-sys-color--on-primary-container)',
danger_text: 'var(--gem-sys-color--error)',
accent_text: 'var(--gem-sys-color--primary)',
// Shared properties
slider_display_text: 'var(--gem-sys-color--on-surface)',
label_text: 'var(--gem-sys-color--on-surface-variant)',
error_text: 'var(--gem-sys-color--error)',
dnd_indicator_color: 'var(--gem-sys-color--primary)',
};
const SITE_STYLES = {
PALETTE: UI_PALETTE,
ICONS: {
// For ThemeModal
folder: {
tag: 'svg',
props: { xmlns: 'http://www.w3.org/2000/svg', height: '24px', viewBox: '0 -960 960 960', width: '24px', fill: 'currentColor' },
children: [{ tag: 'path', props: { d: 'M160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h240l80 80h320q33 0 56.5 23.5T880-640v400q0 33-23.5 56.5T800-160H160Zm0-80h640v-400H447l-80-80H160v480Zm0 0v-480 480Z' } }],
},
// For BubbleUI (prev, collapse), FixedNav (prev), ThemeModal (up)
arrowUp: {
tag: 'svg',
props: { xmlns: 'http://www.w3.org/2000/svg', height: '24px', viewBox: '0 -960 960 960', width: '24px', fill: 'currentColor' },
children: [{ tag: 'path', props: { d: 'M480-528 296-344l-56-56 240-240 240 240-56 56-184-184Z' } }],
},
// For BubbleUI (next), FixedNav (next), ThemeModal (down)
arrowDown: {
tag: 'svg',
props: { xmlns: 'http://www.w3.org/2000/svg', height: '24px', viewBox: '0 -960 960 960', width: '24px', fill: 'currentColor' },
children: [{ tag: 'path', props: { d: 'M480-344 240-584l56-56 184 184 184-184 56 56-240 240Z' } }],
},
// For BubbleUI (top)
scrollToTop: {
tag: 'svg',
props: { xmlns: 'http://www.w3.org/2000/svg', height: '24px', viewBox: '0 -960 960 960', width: '24px', fill: 'currentColor' },
children: [{ tag: 'path', props: { d: 'M440-160v-480L280-480l-56-56 256-256 256 256-56 56-160-160v480h-80Zm-200-640v-80h400v80H240Z' } }],
},
// For FixedNav
scrollToFirst: {
tag: 'svg',
props: { viewBox: '0 -960 960 960' },
children: [{ tag: 'path', props: { d: 'm280-280 200-200 200 200-56 56-144-144-144 144-56-56Zm-40-360v-80h480v80H240Z' } }],
},
scrollToLast: {
tag: 'svg',
props: { viewBox: '0 -960 960 960' },
children: [{ tag: 'path', props: { d: 'M240-200v-80h480v80H240Zm240-160L280-560l56-56 144 144 144-144 56 56-200 200Z' } }],
},
bulkCollapse: {
tag: 'svg',
props: { className: 'icon-collapse', viewBox: '0 -960 960 960' },
children: [{ tag: 'path', props: { d: 'M440-440v240h-80v-160H200v-80h240Zm160-320v160h160v80H520v-240h80Z' } }],
},
bulkExpand: {
tag: 'svg',
props: { className: 'icon-expand', viewBox: '0 -960 960 960' },
children: [{ tag: 'path', props: { d: 'M200-200v-240h80v160h160v80H200Zm480-320v-160H520v-80h240v240h-80Z' } }],
},
refresh: {
tag: 'svg',
props: { xmlns: 'http://www.w3.org/2000/svg', height: '24px', viewBox: '0 -960 960 960', width: '24px', fill: 'currentColor' },
children: [
{
tag: 'path',
props: {
d: 'M480-160q-134 0-227-93t-93-227q0-134 93-227t227-93q69 0 132 28.5T720-690v-110h80v280H520v-80h168q-32-54-87-87t-121-33q-100 0-170 70t-70 170q0 100 70 170t170 70q77 0 139-44t87-116h84q-28 106-114 173t-196 67Z',
},
},
],
},
},
SETTINGS_BUTTON: {
base: {
background: 'transparent',
border: 'none',
borderRadius: '50%',
borderColor: 'transparent',
position: 'static',
margin: '0 2px 0 0',
width: '40px',
height: '40px',
alignSelf: 'center',
// Match native tool button color
color: 'var(--mat-icon-button-icon-color, var(--mat-sys-on-surface-variant))',
},
hover: {
// Replicate Material Design 3 state layer: State Layer Color at 8% opacity
background: 'color-mix(in srgb, var(--mat-icon-button-state-layer-color) 8%, transparent)',
borderColor: 'transparent',
},
iconDef: {
tag: 'svg',
props: { xmlns: 'http://www.w3.org/2000/svg', height: '24px', viewBox: '0 0 24 24', width: '24px', fill: 'currentColor' },
children: [
{ tag: 'path', props: { d: 'M0 0h24v24H0V0z', fill: 'none' } },
{
tag: 'path',
props: {
d: 'M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.3-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.08-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.23.08.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z',
},
},
],
},
},
SETTINGS_PANEL: {
bg: UI_PALETTE.bg,
text_primary: UI_PALETTE.text_primary,
text_secondary: UI_PALETTE.text_secondary,
border_medium: UI_PALETTE.border,
border_default: UI_PALETTE.border,
border_light: UI_PALETTE.border,
toggle_bg_off: UI_PALETTE.toggle_bg_off,
toggle_bg_on: UI_PALETTE.toggle_bg_on,
toggle_knob: UI_PALETTE.toggle_knob,
},
JSON_MODAL: {
bg: UI_PALETTE.bg,
text: UI_PALETTE.text_primary,
border: UI_PALETTE.border,
btn_bg: UI_PALETTE.btn_bg,
btn_hover_bg: UI_PALETTE.btn_hover_bg,
btn_text: UI_PALETTE.btn_text,
btn_border: UI_PALETTE.btn_border,
textarea_bg: UI_PALETTE.input_bg,
textarea_text: UI_PALETTE.text_primary,
textarea_border: UI_PALETTE.border,
msg_error_text: UI_PALETTE.danger_text,
msg_success_text: UI_PALETTE.accent_text,
size_warning_text: '#FFD54F',
size_danger_text: UI_PALETTE.danger_text,
},
THEME_MODAL: {
bg: UI_PALETTE.bg,
text: UI_PALETTE.text_primary,
border: UI_PALETTE.border,
btn_bg: UI_PALETTE.btn_bg,
btn_hover_bg: UI_PALETTE.btn_hover_bg,
btn_text: UI_PALETTE.btn_text,
btn_border: UI_PALETTE.btn_border,
error_text: UI_PALETTE.danger_text,
delete_confirm_label_text: UI_PALETTE.danger_text,
delete_confirm_btn_text: 'var(--gem-sys-color--on-error-container)',
delete_confirm_btn_bg: 'var(--gem-sys-color--error-container)',
delete_confirm_btn_hover_text: 'var(--gem-sys-color--on-error-container)',
delete_confirm_btn_hover_bg: 'var(--gem-sys-color--error-container)',
fieldset_border: UI_PALETTE.border,
legend_text: UI_PALETTE.text_secondary,
label_text: UI_PALETTE.text_secondary,
input_bg: UI_PALETTE.input_bg,
input_text: UI_PALETTE.text_primary,
input_border: UI_PALETTE.border,
slider_display_text: UI_PALETTE.text_primary,
popup_bg: UI_PALETTE.bg,
popup_border: UI_PALETTE.border,
dnd_indicator_color: UI_PALETTE.dnd_indicator_color,
folderIconDef: {
tag: 'svg',
props: { xmlns: 'http://www.w3.org/2000/svg', height: '24px', viewBox: '0 -960 960 960', width: '24px', fill: 'currentColor' },
children: [{ tag: 'path', props: { d: 'M160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h240l80 80h320q33 0 56.5 23.5T880-640v400q0 33-23.5 56.5T800-160H160Zm0-80h640v-400H447l-80-80H160v480Zm0 0v-480 480Z' } }],
},
upIconDef: {
tag: 'svg',
props: { xmlns: 'http://www.w3.org/2000/svg', height: '24px', viewBox: '0 -960 960 960', width: '24px', fill: 'currentColor' },
children: [{ tag: 'path', props: { d: 'M480-528 296-344l-56-56 240-240 240 240-56 56-184-184Z' } }],
},
downIconDef: {
tag: 'svg',
props: { xmlns: 'http://www.w3.org/2000/svg', height: '24px', viewBox: '0 -960 960 960', width: '24px', fill: 'currentColor' },
children: [{ tag: 'path', props: { d: 'M480-344 240-584l56-56 184 184 184-184 56 56-240 240Z' } }],
},
},
FIXED_NAV: {
bg: 'var(--gem-sys-color--surface-container)',
border: 'var(--gem-sys-color--outline)',
separator_bg: 'var(--gem-sys-color--outline)',
label_text: UI_PALETTE.text_secondary,
counter_bg: 'var(--gem-sys-color--surface-container-high)',
counter_text: 'var(--gem-sys-color--on-surface-variant)',
counter_border: 'var(--gem-sys-color--primary)',
btn_bg: UI_PALETTE.btn_bg,
btn_hover_bg: UI_PALETTE.btn_hover_bg,
btn_text: UI_PALETTE.btn_text,
btn_border: UI_PALETTE.btn_border,
btn_accent_text: UI_PALETTE.accent_text,
btn_danger_text: UI_PALETTE.danger_text,
highlight_outline: UI_PALETTE.accent_text,
highlight_border_radius: '12px',
},
JUMP_LIST: {
list_bg: 'var(--gem-sys-color--surface-container)',
list_border: 'var(--gem-sys-color--outline)',
hover_outline: 'var(--gem-sys-color--outline)',
current_outline: UI_PALETTE.accent_text,
},
CSS_IMPORTANT_FLAG: ' !important',
COLLAPSIBLE_CSS: `
model-response.${APPID}-collapsible {
position: relative;
}
/* Create a transparent hover area above the button */
model-response.${APPID}-collapsible::before {
content: '';
position: absolute;
top: -24px;
left: 0;
width: 144px;
height: 24px;
}
/* Add a transparent border in the normal state to prevent width changes on collapse */
.${APPID}-collapsible-content {
border: 1px solid transparent;
box-sizing: border-box;
overflow: hidden;
max-height: 999999px;
}
.${APPID}-collapsible-toggle-btn {
position: absolute;
top: -24px;
width: 24px;
height: 24px;
padding: 4px;
border-radius: 5px;
box-sizing: border-box;
cursor: pointer;
visibility: hidden;
opacity: 0;
transition: visibility 0s linear 0.1s, opacity 0.1s ease-in-out;
background-color: var(--gem-sys-color--surface-container-high);
color: var(--gem-sys-color--on-surface-variant);
border: 1px solid var(--gem-sys-color--outline);
}
.${APPID}-collapsible-toggle-btn.${APPID}-hidden {
display: none;
}
model-response.${APPID}-collapsible:hover .${APPID}-collapsible-toggle-btn {
visibility: visible;
opacity: 1;
transition-delay: 0s;
}
.${APPID}-collapsible-toggle-btn:hover {
background-color: var(--gem-sys-color--surface-container-higher);
color: var(--gem-sys-color--on-surface);
}
.${APPID}-collapsible-toggle-btn svg {
width: 100%;
height: 100%;
transition: transform 0.2s ease-in-out;
}
.${APPID}-collapsible.${APPID}-bubble-collapsed .${APPID}-collapsible-content {
max-height: ${CONSTANTS.BUTTON_VISIBILITY_THRESHOLD_PX}px;
border: 1px dashed var(--gem-sys-color--outline);
box-sizing: border-box;
overflow-y: auto;
}
.${APPID}-collapsible.${APPID}-bubble-collapsed .${APPID}-collapsible-toggle-btn svg {
transform: rotate(-180deg);
}
`,
BUBBLE_NAV_CSS: `
.${APPID}-bubble-nav-container {
position: absolute;
top: 0;
bottom: 0;
width: 24px;
z-index: ${CONSTANTS.Z_INDICES.BUBBLE_NAVIGATION};
}
.${APPID}-nav-buttons {
position: relative;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
visibility: hidden;
opacity: 0;
transition: visibility 0s linear 0.1s, opacity 0.1s ease-in-out;
pointer-events: auto;
gap: 4px; /* Add gap between top and bottom groups when space is limited */
}
.${APPID}-bubble-parent-with-nav:hover .${APPID}-nav-buttons,
.${APPID}-bubble-nav-container:hover .${APPID}-nav-buttons {
visibility: visible;
opacity: 1;
transition-delay: 0s;
}
/* Default for assistant text turns */
${CONSTANTS.SELECTORS.ASSISTANT_MESSAGE} .${APPID}-bubble-nav-container {
left: -25px;
}
/* Override for assistant image turns where the anchor is the image container */
${CONSTANTS.SELECTORS.RAW_ASSISTANT_IMAGE_BUBBLE} > .${APPID}-bubble-nav-container {
left: 0;
transform: translateX(calc(-100% - 4px));
}
${CONSTANTS.SELECTORS.USER_MESSAGE} .${APPID}-bubble-nav-container {
right: -25px;
}
.${APPID}-nav-group-top, .${APPID}-nav-group-bottom {
position: relative; /* Changed from absolute */
display: flex;
flex-direction: column;
gap: 4px;
width: 100%; /* Ensure groups take full width of the flex container */
}
.${APPID}-nav-group-bottom {
margin-top: auto; /* Push to the bottom if space is available */
}
.${APPID}-nav-group-top.${APPID}-hidden, .${APPID}-nav-group-bottom.${APPID}-hidden {
display: none !important;
}
.${APPID}-bubble-nav-btn {
width: 20px;
height: 20px;
padding: 2px;
border-radius: 5px;
box-sizing: border-box;
cursor: pointer;
background: ${UI_PALETTE.btn_bg};
color: ${UI_PALETTE.text_secondary};
border: 1px solid ${UI_PALETTE.border};
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease-in-out;
margin: 0 auto; /* Center the buttons within the group */
}
.${APPID}-bubble-nav-btn:hover {
background-color: ${UI_PALETTE.btn_hover_bg};
color: ${UI_PALETTE.text_primary};
}
.${APPID}-bubble-nav-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.${APPID}-bubble-nav-btn svg {
width: 100%;
height: 100%;
}
`,
};
// ---- Validation Rules ----
const THEME_VALIDATION_RULES = {
bubbleBorderRadius: { unit: 'px', min: 0, max: 50, nullable: true },
bubbleMaxWidth: { unit: '%', min: 30, max: 100, nullable: true },
};
/** @type {AppConfig} */
const DEFAULT_THEME_CONFIG = {
options: {
icon_size: CONSTANTS.ICON_SIZE,
chat_content_max_width: CONSTANTS.SLIDER_CONFIGS.CHAT_WIDTH.DEFAULT,
respect_avatar_space: true,
},
features: {
collapsible_button: {
enabled: true,
},
auto_collapse_user_message: {
enabled: false,
},
sequential_nav_buttons: {
enabled: true,
},
scroll_to_top_button: {
enabled: true,
},
fixed_nav_console: {
enabled: true,
},
load_full_history_on_chat_load: {
enabled: true,
},
timestamp: {
enabled: true,
},
},
developer: {
logger_level: 'log', // 'error', 'warn', 'info', 'log', 'debug'
},
themeSets: [
{
metadata: {
id: `${APPID}-theme-example-1`,
name: 'Project Example',
matchPatterns: ['/project1/i'],
urlPatterns: [],
},
assistant: {
name: null,
icon: null,
textColor: null,
font: null,
bubbleBackgroundColor: null,
bubblePadding: null,
bubbleBorderRadius: null,
bubbleMaxWidth: null,
standingImageUrl: null,
},
user: {
name: null,
icon: null,
textColor: null,
font: null,
bubbleBackgroundColor: null,
bubblePadding: null,
bubbleBorderRadius: null,
bubbleMaxWidth: null,
standingImageUrl: null,
},
window: {
backgroundColor: null,
backgroundImageUrl: null,
backgroundSize: null,
backgroundPosition: null,
backgroundRepeat: null,
},
inputArea: {
backgroundColor: null,
textColor: null,
},
},
],
defaultSet: {
assistant: {
name: `${ASSISTANT_NAME}`,
icon: '<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24px" viewBox="0 0 24 24" width="24px" fill="#e3e3e3"><g><rect fill="none" height="24" width="24"/></g><g><g><path d="M19.94,9.06C19.5,5.73,16.57,3,13,3C9.47,3,6.57,5.61,6.08,9l-1.93,3.48C3.74,13.14,4.22,14,5,14h1l0,2c0,1.1,0.9,2,2,2h1 v3h7l0-4.68C18.62,15.07,20.35,12.24,19.94,9.06z M14.89,14.63L14,15.05V19h-3v-3H8v-4H6.7l1.33-2.33C8.21,7.06,10.35,5,13,5 c2.76,0,5,2.24,5,5C18,12.09,16.71,13.88,14.89,14.63z"/><path d="M12.5,12.54c-0.41,0-0.74,0.31-0.74,0.73c0,0.41,0.33,0.74,0.74,0.74c0.42,0,0.73-0.33,0.73-0.74 C13.23,12.85,12.92,12.54,12.5,12.54z"/><path d="M12.5,7c-1.03,0-1.74,0.67-2,1.45l0.96,0.4c0.13-0.39,0.43-0.86,1.05-0.86c0.95,0,1.13,0.89,0.8,1.36 c-0.32,0.45-0.86,0.75-1.14,1.26c-0.23,0.4-0.18,0.87-0.18,1.16h1.06c0-0.55,0.04-0.65,0.13-0.82c0.23-0.42,0.65-0.62,1.09-1.27 c0.4-0.59,0.25-1.38-0.01-1.8C13.95,7.39,13.36,7,12.5,7z"/></g></g></svg>',
textColor: null,
font: null,
bubbleBackgroundColor: null,
bubblePadding: '6px 10px',
bubbleBorderRadius: '10px',
bubbleMaxWidth: null,
standingImageUrl: null,
},
user: {
name: 'You',
icon: '<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#e3e3e3"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M12 6c1.1 0 2 .9 2 2s-.9 2-2 2-2-.9-2-2 .9-2 2-2m0 10c2.7 0 5.8 1.29 6 2H6c.23-.72 3.31-2 6-2m0-12C9.79 4 8 5.79 8 8s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4zm0 10c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>',
textColor: null,
font: null,
bubbleBackgroundColor: null,
bubblePadding: '6px 10px',
bubbleBorderRadius: '10px',
bubbleMaxWidth: null,
standingImageUrl: null,
},
window: {
backgroundColor: null,
backgroundImageUrl: null,
backgroundSize: 'cover',
backgroundPosition: 'center center',
backgroundRepeat: 'no-repeat',
},
inputArea: {
backgroundColor: null,
textColor: null,
},
},
};
// =================================================================================
// SECTION: Platform-Specific Adapter
// Description: Centralizes all platform-specific logic, such as selectors and
// DOM manipulation strategies. This isolates platform differences
// from the core application logic.
// =================================================================================
const PlatformAdapters = {
// =================================================================================
// SECTION: General Adapters
// =================================================================================
General: {
/**
* Checks if the Canvas feature is currently active on the page.
* @returns {boolean} True if Canvas mode is detected, otherwise false.
*/
isCanvasModeActive() {
return !!document.querySelector(CONSTANTS.SELECTORS.CANVAS_CONTAINER);
},
/**
* Checks if the current page URL is on the exclusion list for this platform.
* @returns {boolean} True if the page should be excluded, otherwise false.
*/
isExcludedPage() {
// No excluded pages for this platform.
return false;
},
/**
* Checks if the File Panel feature is currently active on the page.
* @returns {boolean} True if File Panel mode is detected, otherwise false.
*/
isFilePanelActive() {
return !!document.querySelector(CONSTANTS.SELECTORS.FILE_PANEL_CONTAINER);
},
/**
* Checks if the current page is the "New Chat" page.
* @returns {boolean} True if it is the new chat page, otherwise false.
*/
isNewChatPage() {
const p = window.location.pathname;
return p === '/app' || p === '/';
},
/**
* Gets the platform-specific role identifier from a message element.
* @param {Element} messageElement The message element.
* @returns {string | null} The platform's role identifier (e.g., 'user', 'user-query').
*/
getMessageRole(messageElement) {
if (!messageElement) return null;
return messageElement.tagName.toLowerCase();
},
/**
* Gets the current chat title in a platform-specific way.
* @returns {string | null}
*/
getChatTitle() {
// 1. Try to get title from selected chat history item
const chatTitle = document.querySelector(CONSTANTS.SELECTORS.CONVERSATION_TITLE_WRAPPER)?.querySelector(CONSTANTS.SELECTORS.CONVERSATION_TITLE_TEXT)?.textContent.trim();
if (chatTitle) {
return chatTitle;
}
// 2. If no chat selected, try to get title from selected Gem
const selectedGem = document.querySelector(CONSTANTS.SELECTORS.GEM_SELECTED_ITEM);
if (selectedGem) {
return selectedGem.querySelector(CONSTANTS.SELECTORS.GEM_NAME)?.textContent.trim() ?? null;
}
// Return null if no specific chat or Gem is active (e.g., initial load or "New Chat" page).
// This signals the ThemeManager to apply the default theme set.
return null;
},
/**
* Gets the platform-specific display text from a message element for the jump list.
* This method centralizes the logic for extracting the most relevant text,
* bypassing irrelevant content like system messages or UI elements within the message container.
* @param {HTMLElement} messageElement The message element.
* @returns {string} The text content to be displayed in the jump list.
*/
getJumpListDisplayText(messageElement) {
const role = this.getMessageRole(messageElement);
let contentEl;
if (role === CONSTANTS.SELECTORS.ASSISTANT_MESSAGE) {
// Gemini has a more specific structure for assistant messages we can target first
const answerContainer = messageElement.querySelector(CONSTANTS.SELECTORS.ASSISTANT_ANSWER_CONTENT);
contentEl = answerContainer?.querySelector(CONSTANTS.SELECTORS.ASSISTANT_TEXT_CONTENT);
// Fallback to the general assistant content selector if the specific one isn't found
if (!contentEl) {
contentEl = messageElement.querySelector(CONSTANTS.SELECTORS.ASSISTANT_TEXT_CONTENT);
}
} else if (role === CONSTANTS.SELECTORS.USER_MESSAGE) {
contentEl = messageElement.querySelector(CONSTANTS.SELECTORS.USER_TEXT_CONTENT);
}
return contentEl?.textContent || '';
},
/**
* @description Finds the root message container element for a given content element within it.
* @param {Element} contentElement The element inside a message bubble (e.g., the text content or an image).
* @returns {HTMLElement | null} The closest parent message container element (e.g., `user-query`, `div[data-message-author-role="user"]`), or `null` if not found.
*/
findMessageElement(contentElement) {
return contentElement.closest(CONSTANTS.SELECTORS.BUBBLE_FEATURE_MESSAGE_CONTAINERS);
},
/**
* Filters out ghost/empty message containers before they are added to the cache.
* @param {Element} messageElement The message element to check.
* @returns {boolean} Returns `false` to exclude the message, `true` to keep it.
*/
filterMessage(messageElement) {
// This issue does not occur on Gemini, so we always keep the message.
return true;
},
/**
* Placeholder for ensuring a message container exists for an image.
* On Gemini, images are already within message containers, so this is a no-op.
* @param {HTMLElement} imageContentElement The image container element.
* @returns {null} Always returns null as no action is needed.
*/
ensureMessageContainerForImage(imageContentElement) {
// Not needed for Gemini, images are structured within model-response.
return null;
},
/**
* @description Sets up platform-specific Sentinel listeners to detect when new message content elements are added to the DOM.
* @param {(element: HTMLElement) => void} callback The function to be called when a new message content element is detected by Sentinel.
*/
initializeSentinel(callback) {
const userBubbleSelector = `${CONSTANTS.SELECTORS.USER_MESSAGE} ${CONSTANTS.SELECTORS.RAW_USER_BUBBLE}`;
const assistantBubbleSelector = `${CONSTANTS.SELECTORS.ASSISTANT_MESSAGE} ${CONSTANTS.SELECTORS.RAW_ASSISTANT_BUBBLE}`;
sentinel.on(userBubbleSelector, callback);
sentinel.on(assistantBubbleSelector, callback);
},
/**
* @description (Gemini) No-op. This platform does not require an initial scan for messages.
* The method exists for architectural consistency.
* @param {MessageLifecycleManager} lifecycleManager
* @returns {number}
*/
performInitialScan(lifecycleManager) {
// No-op for this platform.
return 0;
},
/**
* @description (Gemini) No-op. This platform does not require special handling on navigation end.
* The method exists for architectural consistency.
* @param {MessageLifecycleManager} lifecycleManager
*/
onNavigationEnd(lifecycleManager) {
// No-op for this platform.
},
},
// =================================================================================
// SECTION: Adapters for class StyleManager
// =================================================================================
StyleManager: {
/**
* Returns the platform-specific static CSS that does not change with themes.
* @returns {string} The static CSS string.
*/
getStaticCss() {
return `
${CONSTANTS.SELECTORS.MAIN_APP_CONTAINER} {
transition: background-image 0.3s ease-in-out;
}
/* This rule is now conditional on a body class, which is toggled by applyChatContentMaxWidth. */
body.${APPID}-max-width-active ${CONSTANTS.SELECTORS.CHAT_CONTENT_MAX_WIDTH}{
max-width: var(--${APPID}-chat-content-max-width) !important;
margin-inline: auto !important;
}
/* Ensure the user message container inside the turn expands and aligns the bubble to the right. */
${CONSTANTS.SELECTORS.CHAT_HISTORY_MAIN} ${CONSTANTS.SELECTORS.MESSAGE_CONTAINER_PARENT} ${CONSTANTS.SELECTORS.USER_MESSAGE} {
width: 100% !important;
max-width: none !important;
display: flex !important;
justify-content: flex-end !important;
}
/* Make content areas transparent to show the main background */
${CONSTANTS.SELECTORS.CHAT_WINDOW},
${CONSTANTS.SELECTORS.INPUT_CONTAINER},
${CONSTANTS.SELECTORS.INPUT_AREA_BG_TARGET},
${CONSTANTS.SELECTORS.GEM_MANAGER_CONTAINER},
${CONSTANTS.SELECTORS.GEM_MANAGER_CONTAINER} > .container {
background: none !important;
}
/* Forcefully hide the gradient pseudo-element on the input container */
${CONSTANTS.SELECTORS.INPUT_CONTAINER}::before {
display: none !important;
}
`;
},
/**
* Returns the complete configuration object for the settings button.
* @param {object} themeStyles - The base styles from SITE_STYLES.SETTINGS_BUTTON.
* @returns {object} The complete configuration object.
*/
getSettingsButtonConfig(themeStyles) {
return {
zIndex: CONSTANTS.Z_INDICES.SETTINGS_BUTTON,
iconDef: themeStyles.iconDef,
styles: themeStyles.base,
hoverStyles: themeStyles.hover,
};
},
},
// =================================================================================
// SECTION: Adapters for class ThemeManager
// =================================================================================
ThemeManager: {
/**
* Determines if the initial theme application should be deferred on this platform.
* @param {ThemeManager} themeManager - The main controller instance.
* @returns {boolean} True if theme application should be deferred.
*/
shouldDeferInitialTheme(themeManager) {
// This issue is specific to ChatGPT's title behavior, so Gemini never defers.
return false;
},
/**
* Selects the appropriate theme set based on platform-specific logic during an update check.
* @param {ThemeManager} themeManager - The instance of the theme manager.
* @param {AppConfig} config - The full application configuration.
* @param {boolean} urlChanged - Whether the URL has changed since the last check.
* @param {boolean} titleChanged - Whether the title has changed since the last check.
* @returns {ThemeSet} The theme set that should be applied.
*/
selectThemeForUpdate(themeManager, config, urlChanged, titleChanged) {
// If the URL has changed, we must invalidate the cache to allow 'urlPatterns' (and 'matchPatterns') to be re-evaluated against the new context.
if (urlChanged) {
themeManager.cachedThemeSet = null;
}
// Always return the evaluated theme set.
return themeManager.getThemeSet();
},
/**
* Returns platform-specific CSS overrides for the style definition generator.
* @returns {object} An object containing CSS rule strings.
*/
getStyleOverrides() {
// The default block alignment is sufficient for Gemini.
return {};
},
},
// =================================================================================
// SECTION: Adapters for class BubbleUIManager
// =================================================================================
BubbleUI: {
/**
* @description Gets the platform-specific parent element for attaching navigation buttons.
* On Gemini, the positioning context differs between user and assistant messages due to the DOM structure.
* For user messages, a specific inner container must be used as the anchor.
* For assistant messages, the main message element itself is the correct anchor.
* @param {HTMLElement} messageElement The message element.
* @returns {HTMLElement | null} The parent element for the nav container.
*/
getNavPositioningParent(messageElement) {
const role = PlatformAdapters.General.getMessageRole(messageElement);
if (role === CONSTANTS.SELECTORS.USER_MESSAGE) {
// For user messages, use the specific content container as the positioning context.
return messageElement.querySelector(CONSTANTS.SELECTORS.USER_QUERY_CONTAINER);
} else {
// For model-response, the element itself remains the correct context.
return messageElement;
}
},
/**
* @description Retrieves the necessary DOM elements for applying the collapsible button feature to a message.
* @description The returned object contains the elements needed to manage the collapsed state and position the toggle button correctly. The specific elements returned are platform-dependent.
* @param {HTMLElement} messageElement The root element of the message to be processed.
* @returns {{msgWrapper: HTMLElement, bubbleElement: HTMLElement, positioningParent: HTMLElement} | null} An object containing key elements for the feature, or `null` if the message is not eligible for the collapse feature on the current platform.
*/
getCollapsibleInfo(messageElement) {
if (messageElement.tagName.toLowerCase() !== CONSTANTS.SELECTORS.ASSISTANT_MESSAGE) {
return null;
}
const bubbleElement = messageElement.querySelector(CONSTANTS.SELECTORS.RAW_ASSISTANT_BUBBLE);
if (!(bubbleElement instanceof HTMLElement)) return null;
// For Gemini, the messageElement serves as both msgWrapper and positioningParent
return {
msgWrapper: messageElement,
bubbleElement,
positioningParent: messageElement,
};
},
/**
* @description Determines if a message element is eligible for sequential navigation buttons (previous/next).
* @description This method is designed for extensibility. Currently, it allows buttons on all messages.
* @param {HTMLElement} messageElement The message element to check.
* @returns {object | null} An empty object `{}` if the buttons should be rendered, or `null` to prevent rendering.
*/
getSequentialNavInfo(messageElement) {
return {};
},
/**
* @description Determines if a message element is eligible for the "Scroll to Top" button.
* @description This method is designed for extensibility. Currently, it allows buttons on all messages.
* @param {HTMLElement} messageElement The message element to check.
* @returns {object | null} An empty object `{}` if the buttons should be rendered, or `null` to prevent rendering.
*/
getScrollToTopInfo(messageElement) {
return {};
},
},
// =================================================================================
// SECTION: Toast Manager
// =================================================================================
Toast: {
getAutoScrollMessage() {
return 'Auto-scrolling to load history...';
},
},
// =================================================================================
// SECTION: Adapters for class ThemeAutomator
// =================================================================================
ThemeAutomator: {
/**
* Initializes platform-specific managers and registers them with the main application controller.
* @param {ThemeAutomator} automatorInstance - The main controller instance.
*/
initializePlatformManagers(automatorInstance) {
// =================================================================================
// SECTION: Auto Scroll Manager
// Description: Manages the auto-scrolling feature to load the entire chat history.
// =================================================================================
class AutoScrollManager {
static CONFIG = {
// The minimum number of messages required to trigger the auto-scroll feature.
MESSAGE_THRESHOLD: 20,
// The maximum time (in ms) to wait for the progress bar to appear after scrolling up.
APPEAR_TIMEOUT_MS: 2000,
// The maximum time (in ms) to wait for the progress bar to disappear after it has appeared.
DISAPPEAR_TIMEOUT_MS: 5000,
// The grace period (in ms) after navigation to allow messages to load before deciding not to scroll.
GRACE_PERIOD_MS: 2000,
// The maximum time (in ms) to wait for Canvas to close before aborting scroll.
CANVAS_CLOSE_TIMEOUT_MS: 1000,
};
/**
* @param {ConfigManager} configManager
* @param {MessageCacheManager} messageCacheManager
* @param {ToastManager} toastManager
*/
constructor(configManager, messageCacheManager, toastManager) {
this.configManager = configManager;
this.messageCacheManager = messageCacheManager;
this.toastManager = toastManager;
this.scrollContainer = null;
this.observerContainer = null;
this.isEnabled = false;
this.isScrolling = false;
this.toastShown = false;
this.isInitialScrollCheckDone = false;
this.boundStop = null;
this.subscriptions = [];
this.PROGRESS_BAR_SELECTOR = 'mat-progress-bar[role="progressbar"]';
this.progressObserver = null;
this.appearTimeout = null;
this.disappearTimeout = null;
this.navigationStartTime = 0;
}
/**
* Helper to subscribe to EventBus and track the subscription for cleanup.
* Appends the listener name and a unique suffix to the key to avoid conflicts.
* @param {string} event
* @param {Function} listener
*/
_subscribe(event, listener) {
const baseKey = createEventKey(this, event);
// Use function name for debugging aid, fallback to 'anonymous'
const listenerName = listener.name || 'anonymous';
// Generate a short random suffix to guarantee uniqueness even for anonymous functions
const uniqueSuffix = Math.random().toString(36).substring(2, 7);
const key = `${baseKey}_${listenerName}_${uniqueSuffix}`;
EventBus.subscribe(event, listener, key);
this.subscriptions.push({ event, key });
}
/**
* Helper to subscribe to EventBus once and track the subscription for cleanup.
* Appends the listener name and a unique suffix to the key to avoid conflicts.
* @param {string} event
* @param {Function} listener
*/
_subscribeOnce(event, listener) {
const baseKey = createEventKey(this, event);
// Use function name for debugging aid, fallback to 'anonymous'
const listenerName = listener.name || 'anonymous';
// Generate a short random suffix to guarantee uniqueness even for anonymous functions
const uniqueSuffix = Math.random().toString(36).substring(2, 7);
const key = `${baseKey}_${listenerName}_${uniqueSuffix}`;
const wrappedListener = (...args) => {
this.subscriptions = this.subscriptions.filter((sub) => sub.key !== key);
listener(...args);
};
EventBus.once(event, wrappedListener, key);
this.subscriptions.push({ event, key });
}
init() {
this.isEnabled = this.configManager.get().features.load_full_history_on_chat_load.enabled;
this._subscribe(EVENTS.AUTO_SCROLL_REQUEST, () => this.start());
this._subscribe(EVENTS.AUTO_SCROLL_CANCEL_REQUEST, () => this.stop());
this._subscribe(EVENTS.CACHE_UPDATED, () => this._onCacheUpdated());
this._subscribe(EVENTS.NAVIGATION, () => this._onNavigation());
this._subscribe(EVENTS.APP_SHUTDOWN, () => this.destroy());
this._subscribe(EVENTS.STREAMING_START, () => this._onStreamingStart());
}
destroy() {
this.subscriptions.forEach(({ event, key }) => EventBus.unsubscribe(event, key));
this.subscriptions = [];
this.stop();
}
enable() {
this.isEnabled = true;
}
disable() {
this.isEnabled = false;
this.stop();
}
async start() {
if (this.isScrolling) return;
// Canvas (Immersive Panel) Handling
// If Canvas is open, it changes the DOM structure and causes freezing during scroll.
// We must close it before starting the scroll process.
const canvas = document.querySelector(CONSTANTS.SELECTORS.CANVAS_CONTAINER);
if (canvas) {
Logger.badge('AUTOSCROLL', LOG_STYLES.BLUE, 'debug', 'Canvas detected. Attempting to close...');
// Scope the search strictly within the canvas container to avoid false positives
const closeBtn = canvas.querySelector(CONSTANTS.SELECTORS.CANVAS_CLOSE_BUTTON);
if (closeBtn instanceof HTMLElement) {
closeBtn.click();
// Notify user about the action
if (this.toastManager) {
this.toastManager.show('Canvas closed for auto-scroll', false);
}
// Wait for Canvas to disappear from DOM
const startWait = Date.now();
while (document.querySelector(CONSTANTS.SELECTORS.CANVAS_CONTAINER)) {
if (Date.now() - startWait > AutoScrollManager.CONFIG.CANVAS_CLOSE_TIMEOUT_MS) {
Logger.badge('AUTOSCROLL WARN', LOG_STYLES.YELLOW, 'warn', 'Timed out waiting for Canvas to close. Aborting scroll.');
return;
}
await new Promise((r) => requestAnimationFrame(r));
}
} else {
Logger.badge('AUTOSCROLL WARN', LOG_STYLES.YELLOW, 'warn', 'Canvas active but close button not found. Aborting scroll to prevent freeze.');
return;
}
}
// Set the flag immediately to prevent re-entrancy from other events.
this.isScrolling = true;
this.observerContainer = await waitForElement(CONSTANTS.SELECTORS.CHAT_WINDOW_CONTENT);
// Guard against cancellation during await
if (!this.isScrolling) return;
this.scrollContainer = this.observerContainer?.querySelector(CONSTANTS.SELECTORS.CHAT_HISTORY_SCROLL_CONTAINER);
if (!this.observerContainer || !this.scrollContainer) {
Logger.badge('AUTOSCROLL WARN', LOG_STYLES.YELLOW, 'warn', 'Could not find required containers.');
// Reset flags to allow re-triggering
this.isInitialScrollCheckDone = false;
this.isScrolling = false;
return;
}
Logger.log('AutoScrollManager: Starting auto-scroll with MutationObserver.');
this.toastShown = false;
EventBus.publish(EVENTS.SUSPEND_OBSERVERS);
this.boundStop = () => this.stop();
this.scrollContainer.addEventListener('wheel', this.boundStop, { passive: true, once: true });
this.scrollContainer.addEventListener('touchmove', this.boundStop, { passive: true, once: true });
this._startObserver();
this._triggerScroll();
}
stop(isNavigation = false) {
if (!this.isScrolling && !this.progressObserver) return; // Prevent multiple stops
Logger.log('AutoScrollManager: Stopping auto-scroll.');
this.isScrolling = false;
this.toastShown = false;
// Cleanup listeners and observers
if (this.boundStop) {
this.scrollContainer?.removeEventListener('wheel', this.boundStop);
this.scrollContainer?.removeEventListener('touchmove', this.boundStop);
this.boundStop = null;
}
this.progressObserver?.disconnect();
this.progressObserver = null;
clearTimeout(this.appearTimeout);
clearTimeout(this.disappearTimeout);
this.appearTimeout = null;
this.disappearTimeout = null;
this.scrollContainer = null;
this.observerContainer = null;
EventBus.publish(EVENTS.AUTO_SCROLL_COMPLETE);
// On navigation, ObserverManager handles observer resumption.
if (!isNavigation) {
EventBus.publish(EVENTS.RESUME_OBSERVERS_AND_REFRESH);
// Ensure the theme is re-evaluated and applied after scrolling is complete and observers are resumed.
EventBus.publish(EVENTS.THEME_UPDATE);
}
}
/**
* Starts the MutationObserver to watch for the progress bar.
*/
_startObserver() {
if (this.progressObserver) this.progressObserver.disconnect();
const observerCallback = (mutations) => {
for (const mutation of mutations) {
this._handleProgressChange(mutation.addedNodes, mutation.removedNodes);
}
};
this.progressObserver = new MutationObserver(observerCallback);
this.progressObserver.observe(this.observerContainer, {
childList: true,
subtree: true,
});
}
/**
* Handles the appearance and disappearance of the progress bar.
* @param {NodeList} addedNodes
* @param {NodeList} removedNodes
*/
_handleProgressChange(addedNodes, removedNodes) {
const progressBarAppeared = Array.from(addedNodes).some((node) => {
if (node instanceof Element) {
return node.matches(this.PROGRESS_BAR_SELECTOR) || node.querySelector(this.PROGRESS_BAR_SELECTOR);
}
return false;
});
const progressBarDisappeared = Array.from(removedNodes).some((node) => {
if (node instanceof Element) {
return node.matches(this.PROGRESS_BAR_SELECTOR) || node.querySelector(this.PROGRESS_BAR_SELECTOR);
}
return false;
});
if (progressBarAppeared) {
Logger.badge('AUTOSCROLL', LOG_STYLES.GRAY, 'debug', 'Progress bar appeared.');
clearTimeout(this.appearTimeout); // Cancel the "end of history" timer
if (!this.toastShown) {
EventBus.publish(EVENTS.AUTO_SCROLL_START);
this.toastShown = true;
}
// Set a safety timeout in case loading gets stuck
this.disappearTimeout = setTimeout(() => {
Logger.warn('AutoScrollManager: Timed out waiting for progress bar to disappear. Stopping.');
this.stop();
}, AutoScrollManager.CONFIG.DISAPPEAR_TIMEOUT_MS);
}
if (progressBarDisappeared) {
Logger.badge('AUTOSCROLL', LOG_STYLES.GRAY, 'debug', 'Progress bar disappeared.');
clearTimeout(this.disappearTimeout); // Cancel the "stuck" timer
this._triggerScroll(); // Trigger the next scroll
}
}
/**
* Scrolls the container to the top and sets a timeout to check if loading has started.
*/
_triggerScroll() {
if (!this.isScrolling || !this.scrollContainer) return;
this.scrollContainer.scrollTop = 0;
// Set a timeout to detect the end of the history. If the progress bar
// doesn't appear within this time, we assume there's no more content to load.
this.appearTimeout = setTimeout(() => {
Logger.log('AutoScrollManager: Progress bar did not appear. Assuming scroll is complete.');
this.stop();
}, AutoScrollManager.CONFIG.APPEAR_TIMEOUT_MS);
}
/**
* @private
* @description Handles the CACHE_UPDATED event to perform the initial scroll check.
*/
_onCacheUpdated() {
if (!this.isEnabled || this.isInitialScrollCheckDone) {
return;
}
const messageCount = this.messageCacheManager.getTotalMessages().length;
if (messageCount >= AutoScrollManager.CONFIG.MESSAGE_THRESHOLD) {
Logger.log(`AutoScrollManager: ${messageCount} messages found. Triggering auto-scroll.`);
this.isInitialScrollCheckDone = true;
EventBus.publish(EVENTS.AUTO_SCROLL_REQUEST);
} else {
// If message count is low, check if the grace period has expired.
const timeSinceNavigation = Date.now() - this.navigationStartTime;
if (timeSinceNavigation > AutoScrollManager.CONFIG.GRACE_PERIOD_MS) {
Logger.log(`AutoScrollManager: ${messageCount} messages found after grace period. No scroll needed.`);
this.isInitialScrollCheckDone = true;
} else {
// Within grace period: do nothing and wait for subsequent cache updates.
// This handles cases where messages load progressively.
}
}
}
/**
* @private
* @description Handles the STREAMING_START event to prevent auto-scroll from misfiring.
* Once the user starts interacting (which causes streaming), we consider the "initial" phase over.
*/
_onStreamingStart() {
// If streaming starts (e.g., user sends a new message), permanently disable the
// initial auto-scroll check for this page load.
if (!this.isInitialScrollCheckDone) {
Logger.log('AutoScrollManager: Streaming detected. Disabling initial auto-scroll check.');
this.isInitialScrollCheckDone = true;
}
}
/**
* @private
* @description Handles the NAVIGATION event to reset the manager's state.
*/
_onNavigation() {
if (this.isScrolling) {
// Stop scroll without triggering a UI refresh, as a new page is loading.
this.stop(true);
}
this.isInitialScrollCheckDone = false;
this.navigationStartTime = Date.now();
}
}
automatorInstance.autoScrollManager = new AutoScrollManager(automatorInstance.configManager, automatorInstance.messageCacheManager, automatorInstance.toastManager);
automatorInstance.autoScrollManager.init();
},
/**
* Applies UI updates specific to the platform after a configuration change.
* @param {ThemeAutomator} automatorInstance - The main controller instance.
* @param {object} newConfig - The newly applied configuration object.
*/
applyPlatformSpecificUiUpdates(automatorInstance, newConfig) {
// Enable or disable the auto-scroll manager based on the new config.
if (newConfig.features.load_full_history_on_chat_load.enabled) {
automatorInstance.autoScrollManager?.enable();
} else {
automatorInstance.autoScrollManager?.disable();
}
},
},
// =================================================================================
// SECTION: Adapters for class SettingsPanelComponent
// =================================================================================
SettingsPanel: {
/**
* Returns an array of UI definitions for platform-specific feature toggles in the settings panel.
* @returns {object[]} An array of definition objects.
*/
getPlatformSpecificFeatureToggles() {
return [
{
id: 'load-history-enabled',
configKey: 'features.load_full_history_on_chat_load.enabled',
label: 'Load full history on chat load',
title: 'When enabled, automatically scrolls back through the history when a chat is opened to load all messages.',
},
];
},
},
// =================================================================================
// SECTION: Adapters for class AvatarManager
// =================================================================================
Avatar: {
/**
* Returns the platform-specific CSS for styling avatars.
* @param {string} iconSizeCssVar - The CSS variable name for icon size.
* @param {string} iconMarginCssVar - The CSS variable name for icon margin.
* @returns {string} The CSS string.
*/
getCss(iconSizeCssVar, iconMarginCssVar) {
return `
/* Set message containers as positioning contexts */
${CONSTANTS.SELECTORS.AVATAR_USER},
${CONSTANTS.SELECTORS.AVATAR_ASSISTANT} {
position: relative !important;
overflow: visible !important;
}
/* Performance: Ensure the wrapper is tall enough for the avatar + name without JS calculation. */
${CONSTANTS.SELECTORS.AVATAR_USER},
${CONSTANTS.SELECTORS.AVATAR_ASSISTANT} {
min-height: calc(var(${iconSizeCssVar}) + 3em);
}
${CONSTANTS.SELECTORS.SIDE_AVATAR_CONTAINER} {
position: absolute;
top: 0;
display: flex;
flex-direction: column;
align-items: center;
width: var(${iconSizeCssVar});
pointer-events: none;
white-space: normal;
word-break: break-word;
}
/* Position Assistant avatar (inside model-response) to the LEFT */
${CONSTANTS.SELECTORS.AVATAR_ASSISTANT} ${CONSTANTS.SELECTORS.SIDE_AVATAR_CONTAINER} {
right: 100%;
margin-right: var(${iconMarginCssVar});
}
/* Position User avatar (inside user-query) to the RIGHT */
${CONSTANTS.SELECTORS.AVATAR_USER} ${CONSTANTS.SELECTORS.SIDE_AVATAR_CONTAINER} {
left: 100%;
margin-left: var(${iconMarginCssVar});
}
${CONSTANTS.SELECTORS.SIDE_AVATAR_ICON} {
width: var(${iconSizeCssVar});
height: var(${iconSizeCssVar});
border-radius: 50%;
display: block;
box-shadow: 0 0 6px rgb(0 0 0 / 0.2);
background-size: cover;
background-position: center;
background-repeat: no-repeat;
transition: background-image 0.3s ease-in-out;
}
${CONSTANTS.SELECTORS.SIDE_AVATAR_NAME} {
font-size: 0.75rem;
text-align: center;
margin-top: 4px;
width: 100%;
background-color: rgb(0 0 0 / 0.2);
padding: 2px 6px;
border-radius: 4px;
box-sizing: border-box;
}
${CONSTANTS.SELECTORS.AVATAR_USER} ${CONSTANTS.SELECTORS.SIDE_AVATAR_ICON} {
background-image: var(--${APPID}-user-icon);
}
${CONSTANTS.SELECTORS.AVATAR_USER} ${CONSTANTS.SELECTORS.SIDE_AVATAR_NAME} {
color: var(--${APPID}-user-textColor);
}
${CONSTANTS.SELECTORS.AVATAR_USER} ${CONSTANTS.SELECTORS.SIDE_AVATAR_NAME}::after {
content: var(--${APPID}-user-name);
}
${CONSTANTS.SELECTORS.AVATAR_ASSISTANT} ${CONSTANTS.SELECTORS.SIDE_AVATAR_ICON} {
background-image: var(--${APPID}-assistant-icon);
}
${CONSTANTS.SELECTORS.AVATAR_ASSISTANT} ${CONSTANTS.SELECTORS.SIDE_AVATAR_NAME} {
color: var(--${APPID}-assistant-textColor);
}
${CONSTANTS.SELECTORS.AVATAR_ASSISTANT} ${CONSTANTS.SELECTORS.SIDE_AVATAR_NAME}::after {
content: var(--${APPID}-assistant-name);
}
/* Gemini Only: force user message and avatar to be top-aligned */
${CONSTANTS.SELECTORS.AVATAR_USER} {
align-items: flex-start !important;
}
${CONSTANTS.SELECTORS.SIDE_AVATAR_CONTAINER} {
align-self: flex-start !important;
}
`;
},
/**
* Injects the avatar UI into the appropriate location within a message element.
* @param {HTMLElement} msgElem - The root message element.
* @param {HTMLElement} avatarContainer - The avatar container element to inject.
*/
addAvatarToMessage(msgElem, avatarContainer) {
// The guard should only check for the existence of the avatar container itself.
if (msgElem.querySelector(CONSTANTS.SELECTORS.SIDE_AVATAR_CONTAINER)) return;
const processedClass = `${APPID}-avatar-processed`;
// Add the container to the message element and mark as processed.
msgElem.prepend(avatarContainer);
// Add the processed class only if it's not already there.
if (!msgElem.classList.contains(processedClass)) {
msgElem.classList.add(processedClass);
}
},
},
// =================================================================================
// SECTION: Adapters for class StandingImageManager
// =================================================================================
StandingImage: {
/**
* Recalculates and applies the layout for standing images.
* @param {StandingImageManager} instance - The instance of the StandingImageManager.
*/
async recalculateLayout(instance) {
// Handle early exits that don't require measurement.
if (PlatformAdapters.General.isCanvasModeActive() || PlatformAdapters.General.isFilePanelActive()) {
const rootStyle = document.documentElement.style;
rootStyle.setProperty(`--${APPID}-standing-image-assistant-width`, '0px');
rootStyle.setProperty(`--${APPID}-standing-image-user-width`, '0px');
return;
}
await withLayoutCycle({
measure: () => {
// --- Read Phase ---
const chatArea = document.querySelector(CONSTANTS.SELECTORS.MAIN_APP_CONTAINER);
// Find the message area using priority selectors defined in STANDING_IMAGE_ANCHOR
const selectors = CONSTANTS.SELECTORS.STANDING_IMAGE_ANCHOR.split(',').map((s) => s.trim());
let messageArea = null;
for (const selector of selectors) {
messageArea = document.querySelector(selector);
if (messageArea) break;
}
if (!chatArea || !messageArea) return null; // Signal to mutate to reset styles.
const assistantImg = document.getElementById(`${APPID}-standing-image-assistant`);
const userImg = document.getElementById(`${APPID}-standing-image-user`);
return {
chatRect: chatArea.getBoundingClientRect(),
messageRect: messageArea.getBoundingClientRect(),
windowHeight: window.innerHeight,
assistantImgHeight: assistantImg ? assistantImg.offsetHeight : 0,
userImgHeight: userImg ? userImg.offsetHeight : 0,
};
},
mutate: (measured) => {
// --- Write Phase ---
const rootStyle = document.documentElement.style;
if (!measured) {
rootStyle.setProperty(`--${APPID}-standing-image-assistant-width`, '0px');
rootStyle.setProperty(`--${APPID}-standing-image-user-width`, '0px');
return;
}
const { chatRect, messageRect, windowHeight, assistantImgHeight, userImgHeight } = measured;
// Config values can be read here as they don't cause reflow.
const config = instance.configManager.get();
const iconSize = instance.configManager.getIconSize();
const respectAvatarSpace = config.options.respect_avatar_space;
const avatarGap = respectAvatarSpace ? iconSize + CONSTANTS.ICON_MARGIN * 2 : 0;
const assistantWidth = Math.max(0, messageRect.left - chatRect.left - avatarGap);
const userWidth = Math.max(0, chatRect.right - messageRect.right - avatarGap);
rootStyle.setProperty(`--${APPID}-standing-image-assistant-left`, `${chatRect.left}px`);
rootStyle.setProperty(`--${APPID}-standing-image-assistant-width`, `${assistantWidth}px`);
rootStyle.setProperty(`--${APPID}-standing-image-user-width`, `${userWidth}px`);
// Masking
const maskValue = `linear-gradient(to bottom, transparent 0px, rgb(0 0 0 / 1) 60px, rgb(0 0 0 / 1) 100%)`;
if (assistantImgHeight >= windowHeight - 32) {
rootStyle.setProperty(`--${APPID}-standing-image-assistant-mask`, maskValue);
} else {
rootStyle.setProperty(`--${APPID}-standing-image-assistant-mask`, 'none');
}
if (userImgHeight >= windowHeight - 32) {
rootStyle.setProperty(`--${APPID}-standing-image-user-mask`, maskValue);
} else {
rootStyle.setProperty(`--${APPID}-standing-image-user-mask`, 'none');
}
},
});
},
/**
* Updates the visibility of standing images based on the current context.
* @param {StandingImageManager} instance - The instance of the StandingImageManager.
*/
updateVisibility(instance) {
const isCanvasActive = PlatformAdapters.General.isCanvasModeActive();
const isFilePanelActive = PlatformAdapters.General.isFilePanelActive();
['user', 'assistant'].forEach((actor) => {
const imgElement = document.getElementById(`${APPID}-standing-image-${actor}`);
if (!imgElement) return;
const hasImage = !!document.documentElement.style.getPropertyValue(`--${APPID}-${actor}-standing-image`);
imgElement.style.opacity = hasImage && !isCanvasActive && !isFilePanelActive ? '1' : '0';
});
},
/**
* Sets up platform-specific event listeners for the StandingImageManager.
* @param {StandingImageManager} instance - The instance of the StandingImageManager.
*/
setupEventListeners(instance) {
// Gemini-specific: Subscribe to cacheUpdated because this platform's updateVisibility() logic depends on the message count.
// Use scheduleUpdate to ensure layout is also recalculated after navigation or DOM updates.
instance._subscribe(EVENTS.CACHE_UPDATED, instance.scheduleUpdate);
},
},
// =================================================================================
// SECTION: Adapters for class DebugManager
// =================================================================================
Debug: {
/**
* Returns the platform-specific CSS for debugging layout borders.
* @returns {string} The CSS string.
*/
getBordersCss() {
const userFrameSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 100 100" preserveAspectRatio="none"><rect x="1" y="1" width="98" height="98" fill="rgb(231 76 60 / 0.1)" stroke="rgb(231 76 60 / 0.9)" stroke-width="2" /></svg>`;
const asstFrameSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 100 100" preserveAspectRatio="none"><rect x="1" y="1" width="98" height="98" fill="rgb(52 152 219 / 0.1)" stroke="rgb(52 152 219 / 0.9)" stroke-width="2" /></svg>`;
const userFrameDataUri = svgToDataUrl(userFrameSvg);
const asstFrameDataUri = svgToDataUrl(asstFrameSvg);
return `
/* --- DEBUG BORDERS --- */
:root {
--dbg-layout-color: rgb(26 188 156 / 0.8); /* Greenish */
--dbg-user-color: rgb(231 76 60 / 0.8); /* Reddish */
--dbg-asst-color: rgb(52 152 219 / 0.8); /* Blueish */
--dbg-comp-color: rgb(22 160 133 / 0.8); /* Cyan */
--dbg-zone-color: rgb(142 68 173 / 0.9); /* Purplish */
--dbg-neutral-color: rgb(128 128 128 / 0.7); /* Gray */
}
/* Layout Containers */
${CONSTANTS.SELECTORS.SIDEBAR_WIDTH_TARGET} { outline: 2px solid var(--dbg-layout-color) !important; }
${CONSTANTS.SELECTORS.CHAT_CONTENT_MAX_WIDTH} { outline: 2px dashed var(--dbg-layout-color) !important; }
${CONSTANTS.SELECTORS.INPUT_AREA_BG_TARGET} { outline: 1px solid var(--dbg-layout-color) !important; }
#${APPID}-nav-console { outline: 1px dotted var(--dbg-layout-color) !important; }
/* Message Containers */
${CONSTANTS.SELECTORS.DEBUG_CONTAINER_TURN} { outline: 1px solid var(--dbg-neutral-color) !important; outline-offset: -1px; }
${CONSTANTS.SELECTORS.DEBUG_CONTAINER_USER} { outline: 2px solid var(--dbg-user-color) !important; outline-offset: -2px; }
${CONSTANTS.SELECTORS.RAW_USER_BUBBLE} { outline: 1px dashed var(--dbg-user-color) !important; outline-offset: -4px; }
${CONSTANTS.SELECTORS.DEBUG_CONTAINER_ASSISTANT} { outline: 2px solid var(--dbg-asst-color) !important; outline-offset: -2px; }
${CONSTANTS.SELECTORS.RAW_ASSISTANT_BUBBLE} { outline: 1px dashed var(--dbg-asst-color) !important; outline-offset: -4px; }
/* Components */
${CONSTANTS.SELECTORS.SIDE_AVATAR_CONTAINER} { outline: 1px solid var(--dbg-comp-color) !important; }
${CONSTANTS.SELECTORS.SIDE_AVATAR_ICON} { outline: 1px dotted var(--dbg-comp-color) !important; }
${CONSTANTS.SELECTORS.SIDE_AVATAR_NAME} { outline: 1px dotted var(--dbg-comp-color) !important; }
/* Standing Image Debug Overrides */
#${APPID}-standing-image-user {
background-image: url("${userFrameDataUri}") !important;
z-index: 15000 !important;
opacity: 0.7 !important;
min-width: 30px !important;
}
#${APPID}-standing-image-assistant {
background-image: url("${asstFrameDataUri}") !important;
z-index: 15000 !important;
opacity: 0.7 !important;
min-width: 30px !important;
}
/* Interactive Zones */
model-response.${APPID}-collapsible::before {
outline: 1px solid var(--dbg-zone-color) !important;
content: 'HOVER AREA' !important;
color: var(--dbg-zone-color);
font-size: 10px;
display: flex;
align-items: center;
justify-content: center;
}
.${APPID}-bubble-nav-container { outline: 1px dashed var(--dbg-zone-color) !important; }
`;
},
},
// =================================================================================
// SECTION: Adapters for class ObserverManager
// =================================================================================
Observer: {
/**
* Returns an array of functions that start platform-specific observers.
* Each function, when called, should return a cleanup function to stop its observer.
* @returns {Array<Function>} An array of observer starter functions.
*/
// prettier-ignore
getPlatformObserverStarters() {
return [
this.startSidebarObserver,
this.startPanelObserver,
this.startInputAreaObserver,
];
},
/**
* @private
* @description A generic observer for side panels that handles appearance, disappearance, resizing, and immediate state callbacks.
* @param {object} dependencies - The ObserverManager dependencies ({ observeElement, unobserveElement }).
* @param {string} triggerSelector - The selector for the element that triggers the panel's existence check.
* @param {string} observerType - The type identifier for ObserverManager (e.g., CONSTANTS.OBSERVED_ELEMENT_TYPES.SIDE_PANEL).
* @param {function(HTMLElement): HTMLElement|null} targetResolver - A function to resolve the actual panel element from the trigger element.
* @param {number} transitionDelay - Unused in the new loop implementation (kept for signature compatibility).
* @param {function(): void} [immediateCallback] - An optional callback executed immediately and repeatedly during the animation loop.
* @returns {() => void} A cleanup function.
*/
_startGenericPanelObserver(dependencies, triggerSelector, observerType, targetResolver, transitionDelay, immediateCallback) {
const { observeElement, unobserveElement } = dependencies;
let isPanelVisible = false;
let isStateUpdating = false; // Lock to prevent race conditions
let disappearanceObserver = null;
let observedPanel = null;
let animationLoopId = null;
const ANIMATION_DURATION = 500; // ms
// Function to run the layout update loop
const startUpdateLoop = () => {
if (animationLoopId) cancelAnimationFrame(animationLoopId);
const startTime = Date.now();
const loop = () => {
// Run the callback (e.g., VISIBILITY_RECHECK or SIDEBAR_LAYOUT_CHANGED)
if (immediateCallback) immediateCallback();
// Also trigger UI repositioning for smooth movement
EventBus.publish(EVENTS.UI_REPOSITION);
if (Date.now() - startTime < ANIMATION_DURATION) {
animationLoopId = requestAnimationFrame(loop);
} else {
animationLoopId = null;
}
};
loop();
};
// This is the single source of truth for updating the UI based on panel visibility.
const updatePanelState = () => {
if (isStateUpdating) return; // Prevent concurrent executions
isStateUpdating = true;
try {
const trigger = document.querySelector(triggerSelector);
let isNowVisible = false;
let panel = null;
if (trigger instanceof HTMLElement) {
panel = targetResolver(trigger);
// Check if the panel exists and is visible in the DOM (offsetParent is non-null).
if (panel instanceof HTMLElement && panel.offsetParent !== null) {
isNowVisible = true;
}
}
// Do nothing if the state hasn't changed.
if (isNowVisible === isPanelVisible) {
// If visible, ensure we are still observing the same element (defensive)
if (isNowVisible && panel && panel !== observedPanel) {
// If the element reference changed but logic says it's still visible, switch observation
if (observedPanel) unobserveElement(observedPanel);
observedPanel = panel;
observeElement(observedPanel, observerType);
}
return;
}
isPanelVisible = isNowVisible;
if (isNowVisible && panel) {
// --- Panel just appeared ---
Logger.badge('PANEL STATE', LOG_STYLES.GRAY, 'debug', 'Panel appeared:', triggerSelector);
startUpdateLoop();
observedPanel = panel;
observeElement(observedPanel, observerType);
// Setup a lightweight observer to detect when the panel is removed from DOM.
// We observe the parent because the panel itself might be removed.
if (panel.parentElement) {
disappearanceObserver?.disconnect();
disappearanceObserver = new MutationObserver(() => {
// Re-check state if the parent container's children change.
updatePanelState();
});
disappearanceObserver.observe(panel.parentElement, { childList: true, subtree: false });
}
} else {
// --- Panel just disappeared ---
Logger.badge('PANEL STATE', LOG_STYLES.GRAY, 'debug', 'Panel disappeared:', triggerSelector);
startUpdateLoop();
disappearanceObserver?.disconnect();
disappearanceObserver = null;
if (observedPanel) {
unobserveElement(observedPanel);
observedPanel = null;
}
}
} finally {
isStateUpdating = false; // Release the lock
}
};
// Use Sentinel to efficiently detect when the trigger might have been added.
sentinel.on(triggerSelector, updatePanelState);
// Perform an initial check in case the panel is already present on load.
updatePanelState();
// Return the cleanup function.
return () => {
sentinel.off(triggerSelector, updatePanelState);
disappearanceObserver?.disconnect();
if (observedPanel) {
unobserveElement(observedPanel);
}
if (animationLoopId) cancelAnimationFrame(animationLoopId);
};
},
/**
* @private
* @description Starts a stateful observer to detect the appearance and disappearance of panels (Immersive/File) using a high-performance hybrid approach.
* @param {object} dependencies The required methods from ObserverManager.
* @returns {() => void} A cleanup function.
*/
startPanelObserver(dependencies) {
// Use explicit reference to PlatformAdapters.Observer instead of 'this' to avoid context issues
return PlatformAdapters.Observer._startGenericPanelObserver(
dependencies,
`${CONSTANTS.SELECTORS.CANVAS_CONTAINER}, ${CONSTANTS.SELECTORS.FILE_PANEL_CONTAINER}`, // Trigger (Panel itself)
CONSTANTS.OBSERVED_ELEMENT_TYPES.SIDE_PANEL, // Observer Type
(el) => el, // Target Resolver (The trigger is the panel)
0, // Transition Delay (Unused in loop implementation)
() => EventBus.publish(EVENTS.VISIBILITY_RECHECK) // Immediate callback for loop
);
},
/**
* @param {object} dependencies The dependencies passed from ObserverManager (unused in this method).
* @private
* @description Sets up a targeted observer on the sidebar for title and selection changes.
* @returns {() => void} A cleanup function.
*/
startSidebarObserver(dependencies) {
let animationLoopId = null;
const ANIMATION_DURATION = 500; // ms
let sidebarObserver = null;
let transitionEndHandler = null;
const setupObserver = (sidebar) => {
sidebarObserver?.disconnect();
if (transitionEndHandler) {
sidebar.removeEventListener('transitionend', transitionEndHandler);
}
// Function to run the layout update loop
const startUpdateLoop = () => {
if (animationLoopId) cancelAnimationFrame(animationLoopId);
const startTime = Date.now();
const loop = () => {
EventBus.publish(EVENTS.SIDEBAR_LAYOUT_CHANGED);
if (Date.now() - startTime < ANIMATION_DURATION) {
animationLoopId = requestAnimationFrame(loop);
} else {
animationLoopId = null;
}
};
loop();
};
// Keep title updates debounced as they don't require animation loops
const debouncedTitleUpdate = debounce(() => EventBus.publish(EVENTS.TITLE_CHANGED), CONSTANTS.TIMING.DEBOUNCE_DELAYS.THEME_UPDATE, true);
// Handle transition end as a safety net to ensure final position is captured
transitionEndHandler = () => EventBus.publish(EVENTS.SIDEBAR_LAYOUT_CHANGED);
sidebar.addEventListener('transitionend', transitionEndHandler);
sidebarObserver = new MutationObserver((mutations) => {
let layoutChanged = false;
let titleChanged = false;
for (const mutation of mutations) {
const target = mutation.target;
// Check for layout changes (start of animation)
if (mutation.type === 'attributes' && (mutation.attributeName === 'class' || mutation.attributeName === 'style' || mutation.attributeName === 'width')) {
layoutChanged = true;
// Enhanced Check: Detect selection changes in Chat History or Gem List
// If the class of a list item changes, it likely means selection/deselection, which implies a title change.
if (mutation.attributeName === 'class' && target instanceof Element) {
// Check if the target is a chat history item or a gem list item
if (target.matches(CONSTANTS.SELECTORS.CHAT_HISTORY_ITEM) || target.matches(CONSTANTS.SELECTORS.GEM_LIST_ITEM)) {
titleChanged = true;
}
}
}
// Check for title text changes (renaming)
if (mutation.type === 'characterData' && target.parentElement?.matches(CONSTANTS.SELECTORS.CONVERSATION_TITLE_TEXT)) {
titleChanged = true;
}
}
if (layoutChanged) {
startUpdateLoop();
}
if (titleChanged) {
debouncedTitleUpdate();
}
});
sidebarObserver.observe(sidebar, {
attributes: true, // Enable attribute observation for layout changes and selection state
attributeFilter: ['class', 'style', 'width'], // specific attributes
characterData: true, // For title changes
subtree: true, // Needed for title text nodes deeper in the tree
childList: false,
});
// Initial triggers for the first load.
debouncedTitleUpdate();
EventBus.publish(EVENTS.SIDEBAR_LAYOUT_CHANGED);
};
const selector = CONSTANTS.SELECTORS.SIDEBAR_WIDTH_TARGET;
sentinel.on(selector, setupObserver);
const existingSidebar = document.querySelector(selector);
if (existingSidebar) {
setupObserver(existingSidebar);
}
// Return the cleanup function for all resources created by this observer.
return () => {
sentinel.off(selector, setupObserver);
if (sidebarObserver) {
sidebarObserver.disconnect();
}
if (animationLoopId) cancelAnimationFrame(animationLoopId);
// Note: We cannot easily remove the event listener from the correct element here
// because we don't have a reference to the specific element instance that was set up.
// However, when the element is removed from DOM, listeners are cleaned up by browser.
};
},
/**
* @private
* @description Starts a stateful observer for the input area to detect resizing and DOM reconstruction (button removal).
* @param {object} dependencies The ObserverManager dependencies.
* @returns {() => void} A cleanup function.
*/
startInputAreaObserver(dependencies) {
const { observeElement, unobserveElement } = dependencies;
let observedInputArea = null;
const setupObserver = (inputArea) => {
if (inputArea === observedInputArea) return;
// Cleanup previous observers
if (observedInputArea) {
unobserveElement(observedInputArea);
}
observedInputArea = inputArea;
// Resize Observer (via ObserverManager)
const resizeTargetSelector = CONSTANTS.SELECTORS.INPUT_RESIZE_TARGET;
const resizeTarget = inputArea.matches(resizeTargetSelector) ? inputArea : inputArea.querySelector(resizeTargetSelector);
if (resizeTarget instanceof HTMLElement) {
observeElement(resizeTarget, CONSTANTS.OBSERVED_ELEMENT_TYPES.INPUT_AREA);
}
// Trigger initial placement
EventBus.publish(EVENTS.UI_REPOSITION);
};
const selector = CONSTANTS.SELECTORS.INSERTION_ANCHOR;
sentinel.on(selector, setupObserver);
// Initial check
const initialInputArea = document.querySelector(selector);
if (initialInputArea instanceof HTMLElement) {
setupObserver(initialInputArea);
}
return () => {
sentinel.off(selector, setupObserver);
if (observedInputArea) unobserveElement(observedInputArea);
};
},
/**
* Checks if a conversation turn is complete based on Gemini's DOM structure.
* @param {HTMLElement} turnNode The turn container element.
* @returns {boolean} True if the turn is complete.
*/
isTurnComplete(turnNode) {
// In Gemini, a single turn container can include the user message.
// Therefore, a turn is considered complete *only* when the assistant's
// action buttons are present, regardless of whether a user message exists.
const assistantActions = turnNode.querySelector(CONSTANTS.SELECTORS.TURN_COMPLETE_SELECTOR);
return !!assistantActions;
},
},
// =================================================================================
// SECTION: Adapters for class TimestampManager
// =================================================================================
Timestamp: {
init() {
// No-op for this platform.
},
cleanup() {
// No-op for this platform.
},
hasTimestampLogic() {
return false;
},
},
// =================================================================================
// SECTION: Adapters for class UIManager
// =================================================================================
UIManager: {
repositionSettingsButton(settingsButton) {
if (!settingsButton?.element) return;
withLayoutCycle({
measure: () => {
// Read phase
const anchor = document.querySelector(CONSTANTS.SELECTORS.INSERTION_ANCHOR);
if (!(anchor instanceof HTMLElement)) return { anchor: null };
// Ghost Detection Logic
const existingBtn = document.getElementById(settingsButton.element.id);
const isGhost = existingBtn && existingBtn !== settingsButton.element;
// Check if button is already inside (only if it's the correct instance)
const isInside = !isGhost && anchor.contains(settingsButton.element);
return {
anchor,
isGhost,
existingBtn,
shouldInject: !isInside,
};
},
mutate: (measured) => {
// Write phase
// Guard: Check for excluded page immediately to prevent zombie UI.
if (PlatformAdapters.General.isExcludedPage()) {
if (settingsButton.element.isConnected) {
settingsButton.element.remove();
Logger.badge('UI GUARD', LOG_STYLES.GRAY, 'debug', 'Excluded page detected during UI update. Button removed.');
}
return;
}
if (!measured || !measured.anchor) {
// Hide if anchor is gone
settingsButton.element.style.display = 'none';
return;
}
const { anchor, isGhost, existingBtn, shouldInject } = measured;
// Safety Check: Ensure the anchor is still part of the document
if (!anchor.isConnected) {
return;
}
// 1. Ghost Buster
if (isGhost && existingBtn) {
Logger.badge('GHOST BUSTER', LOG_STYLES.YELLOW, 'warn', 'Detected non-functional ghost button. Removing...');
existingBtn.remove();
}
// 2. Injection
if (shouldInject || isGhost) {
anchor.prepend(settingsButton.element);
Logger.badge('UI INJECTION', LOG_STYLES.BLUE, 'debug', 'Settings button injected into anchor.');
}
settingsButton.element.style.display = '';
},
});
},
},
// =================================================================================
// SECTION: Adapters for class FixedNavigationManager
// =================================================================================
FixedNav: {
/**
* @description (Gemini) A lifecycle hook for `FixedNavigationManager` to handle UI state changes after new messages are loaded via infinite scrolling.
* @description When the user scrolls to the top and older messages are loaded into the DOM, this function ensures that the navigation indices (`currentIndices`) are recalculated relative to the newly expanded message list, preventing the highlighted message from "losing its place".
* @param {FixedNavigationManager} fixedNavManagerInstance The instance of the `FixedNavigationManager`.
* @param {HTMLElement | null} highlightedMessage The currently highlighted message element.
* @param {number} previousTotalMessages The total number of messages before the cache update.
* @returns {void}
*/
handleInfiniteScroll(fixedNavManagerInstance, highlightedMessage, previousTotalMessages) {
const currentTotalMessages = fixedNavManagerInstance.messageCacheManager.getTotalMessages().length;
// If new messages have been loaded (scrolled up), and a message is currently highlighted.
if (currentTotalMessages > previousTotalMessages && highlightedMessage) {
// Re-calculate the indices based on the updated (larger) message cache.
fixedNavManagerInstance.setHighlightAndIndices(highlightedMessage);
}
},
/**
* Applies additional, platform-specific highlight classes if needed.
* @param {HTMLElement} messageElement The currently highlighted message element.
*/
applyAdditionalHighlight(messageElement) {
// No additional logic is needed for Gemini.
},
/**
* @description Returns an array of platform-specific UI elements, such as buttons and separators,
* to be added to the left side of the navigation console.
* @param {FixedNavigationManager} fixedNavManagerInstance The instance of the FixedNavigationManager.
* @returns {Element[]} An array of `Element` objects. Returns an empty array
* if no platform-specific buttons are needed for the current platform.
*/
getPlatformSpecificButtons(fixedNavManagerInstance) {
const autoscrollBtn = h(
`button#${APPID}-autoscroll-btn.${APPID}-nav-btn`,
{
title: 'Load full chat history',
dataset: { originalTitle: 'Load full chat history' },
onclick: () => EventBus.publish(EVENTS.AUTO_SCROLL_REQUEST),
},
[createIconFromDef(SITE_STYLES.ICONS.scrollToTop)]
);
return [autoscrollBtn, h(`div.${APPID}-nav-separator`)];
},
/**
* @description Updates the state (disabled, title) of platform-specific buttons in the navigation console.
* @param {HTMLButtonElement} autoscrollBtn The platform-specific button element.
* @param {boolean} isAutoScrolling The shared `isAutoScrolling` state from FixedNavigationManager.
* @param {object | null} autoScrollManager The platform-specific AutoScrollManager instance.
*/
updatePlatformSpecificButtonState(autoscrollBtn, isAutoScrolling, autoScrollManager) {
autoscrollBtn.disabled = isAutoScrolling;
if (isAutoScrolling) {
autoscrollBtn.title = 'Loading history...';
} else {
autoscrollBtn.title = autoscrollBtn.dataset.originalTitle;
}
},
},
};
// =================================================================================
// SECTION: Declarative Style Mapper
// Description: Single source of truth for all theme-driven style generation.
// This array declaratively maps configuration properties to CSS variables and rules.
// The StyleGenerator engine processes this array to build the final CSS.
// =================================================================================
/**
* @param {string} actor - 'user' or 'assistant'
* @param {object} [overrides={}] - Platform-specific overrides.
* @returns {object[]} An array of style definition objects for the given actor.
*/
function createActorStyleDefinitions(actor, overrides = {}) {
const actorUpper = actor.toUpperCase();
const important = SITE_STYLES.CSS_IMPORTANT_FLAG;
return [
{
configKey: `${actor}.name`,
fallbackKey: `defaultSet.${actor}.name`,
cssVar: `--${APPID}-${actor}-name`,
transformer: (value) => (value ? `'${value.replace(/'/g, "\\'")}'` : null),
},
{
configKey: `${actor}.icon`,
fallbackKey: `defaultSet.${actor}.icon`,
cssVar: `--${APPID}-${actor}-icon`,
},
{
configKey: `${actor}.standingImageUrl`,
fallbackKey: `defaultSet.${actor}.standingImageUrl`,
cssVar: `--${APPID}-${actor}-standing-image`,
},
{
configKey: `${actor}.textColor`,
fallbackKey: `defaultSet.${actor}.textColor`,
cssVar: `--${APPID}-${actor}-textColor`,
selector: `${CONSTANTS.SELECTORS[`${actorUpper}_MESSAGE`]} ${CONSTANTS.SELECTORS[`${actorUpper}_TEXT_CONTENT`]}`,
property: 'color',
generator: (value) => {
if (actor !== 'assistant' || !value) return '';
// This generator is specific to the assistant and is common across platforms.
const childSelectors = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul li', 'ol li', 'ul li::marker', 'ol li::marker', 'strong', 'em', 'blockquote', 'table', 'th', 'td'];
const fullSelectors = childSelectors.map((s) => `${CONSTANTS.SELECTORS.ASSISTANT_MESSAGE} ${CONSTANTS.SELECTORS.ASSISTANT_TEXT_CONTENT} ${s}`);
return `${fullSelectors.join(', ')} { color: var(--${APPID}-assistant-textColor); }`;
},
},
{
configKey: `${actor}.font`,
fallbackKey: `defaultSet.${actor}.font`,
cssVar: `--${APPID}-${actor}-font`,
selector: `${CONSTANTS.SELECTORS[`${actorUpper}_MESSAGE`]} ${CONSTANTS.SELECTORS[`${actorUpper}_TEXT_CONTENT`]}`,
property: 'font-family',
},
{
configKey: `${actor}.bubbleBackgroundColor`,
fallbackKey: `defaultSet.${actor}.bubbleBackgroundColor`,
cssVar: `--${APPID}-${actor}-bubble-bg`,
selector: `${CONSTANTS.SELECTORS[`${actorUpper}_MESSAGE`]} ${CONSTANTS.SELECTORS[`RAW_${actorUpper}_BUBBLE`]}`,
property: 'background-color',
},
{
configKey: `${actor}.bubblePadding`,
fallbackKey: `defaultSet.${actor}.bubblePadding`,
cssVar: `--${APPID}-${actor}-bubble-padding`,
selector: `${CONSTANTS.SELECTORS[`${actorUpper}_MESSAGE`]} ${CONSTANTS.SELECTORS[`RAW_${actorUpper}_BUBBLE`]}`,
property: 'padding',
},
{
configKey: `${actor}.bubbleBorderRadius`,
fallbackKey: `defaultSet.${actor}.bubbleBorderRadius`,
cssVar: `--${APPID}-${actor}-bubble-radius`,
selector: `${CONSTANTS.SELECTORS[`${actorUpper}_MESSAGE`]} ${CONSTANTS.SELECTORS[`RAW_${actorUpper}_BUBBLE`]}`,
property: 'border-radius',
},
{
configKey: `${actor}.bubbleMaxWidth`,
fallbackKey: `defaultSet.${actor}.bubbleMaxWidth`,
cssVar: `--${APPID}-${actor}-bubble-maxwidth`,
generator: (value) => {
if (!value) return '';
const selector = `${CONSTANTS.SELECTORS[`${actorUpper}_MESSAGE`]} ${CONSTANTS.SELECTORS[`RAW_${actorUpper}_BUBBLE`]}`;
const cssVar = `--${APPID}-${actor}-bubble-maxwidth`;
const extraRule = overrides[actor] || '';
return `${selector} { max-width: var(${cssVar})${important};${extraRule} }`;
},
},
];
}
const STYLE_DEFINITIONS = {
user: createActorStyleDefinitions('user', PlatformAdapters.ThemeManager.getStyleOverrides()),
assistant: createActorStyleDefinitions('assistant', PlatformAdapters.ThemeManager.getStyleOverrides()),
window: [
{
configKey: 'window.backgroundColor',
fallbackKey: 'defaultSet.window.backgroundColor',
cssVar: `--${APPID}-window-bg-color`,
selector: CONSTANTS.SELECTORS.MAIN_APP_CONTAINER,
property: 'background-color',
},
{
configKey: 'window.backgroundImageUrl',
fallbackKey: 'defaultSet.window.backgroundImageUrl',
cssVar: `--${APPID}-window-bg-image`,
generator: (value) => (value ? `${CONSTANTS.SELECTORS.MAIN_APP_CONTAINER} { background-image: var(--${APPID}-window-bg-image)${SITE_STYLES.CSS_IMPORTANT_FLAG}; background-attachment: fixed${SITE_STYLES.CSS_IMPORTANT_FLAG}; }` : ''),
},
{
configKey: 'window.backgroundSize',
fallbackKey: 'defaultSet.window.backgroundSize',
cssVar: `--${APPID}-window-bg-size`,
generator: (value) => (value ? `${CONSTANTS.SELECTORS.MAIN_APP_CONTAINER} { background-size: var(--${APPID}-window-bg-size)${SITE_STYLES.CSS_IMPORTANT_FLAG}; }` : ''),
},
{
configKey: 'window.backgroundPosition',
fallbackKey: 'defaultSet.window.backgroundPosition',
cssVar: `--${APPID}-window-bg-pos`,
generator: (value) => (value ? `${CONSTANTS.SELECTORS.MAIN_APP_CONTAINER} { background-position: var(--${APPID}-window-bg-pos)${SITE_STYLES.CSS_IMPORTANT_FLAG}; }` : ''),
},
{
configKey: 'window.backgroundRepeat',
fallbackKey: 'defaultSet.window.backgroundRepeat',
cssVar: `--${APPID}-window-bg-repeat`,
generator: (value) => (value ? `${CONSTANTS.SELECTORS.MAIN_APP_CONTAINER} { background-repeat: var(--${APPID}-window-bg-repeat)${SITE_STYLES.CSS_IMPORTANT_FLAG}; }` : ''),
},
],
inputArea: [
{
configKey: 'inputArea.backgroundColor',
fallbackKey: 'defaultSet.inputArea.backgroundColor',
cssVar: `--${APPID}-input-bg`,
selector: CONSTANTS.SELECTORS.INPUT_AREA_BG_TARGET,
property: 'background-color',
generator: (value) => (value ? `${CONSTANTS.SELECTORS.INPUT_TEXT_FIELD_TARGET} { background-color: transparent; }` : ''),
},
{
configKey: 'inputArea.textColor',
fallbackKey: 'defaultSet.inputArea.textColor',
cssVar: `--${APPID}-input-color`,
selector: CONSTANTS.SELECTORS.INPUT_TEXT_FIELD_TARGET,
property: 'color',
},
],
};
// Flatten the structured definitions into a single array for easier iteration.
const ALL_STYLE_DEFINITIONS = Object.values(STYLE_DEFINITIONS).flat();
// =================================================================================
// SECTION: Event-Driven Architecture (Pub/Sub)
// Description: A event bus for decoupled communication between classes.
// =================================================================================
const EventBus = {
events: {},
uiWorkQueue: [],
isUiWorkScheduled: false,
_logAggregation: {},
// prettier-ignore
_aggregatedEvents: new Set([
EVENTS.RAW_MESSAGE_ADDED,
EVENTS.AVATAR_INJECT,
EVENTS.MESSAGE_COMPLETE,
EVENTS.TURN_COMPLETE,
EVENTS.SIDEBAR_LAYOUT_CHANGED,
EVENTS.VISIBILITY_RECHECK,
EVENTS.UI_REPOSITION,
EVENTS.INPUT_AREA_RESIZED,
EVENTS.TIMESTAMP_ADDED,
]),
_aggregationDelay: 500, // ms
/**
* Subscribes a listener to an event using a unique key.
* If a subscription with the same event and key already exists, it will be overwritten.
* @param {string} event The event name.
* @param {Function} listener The callback function.
* @param {string} key A unique key for this subscription (e.g., 'ClassName.methodName').
*/
subscribe(event, listener, key) {
if (!key) {
Logger.error('EventBus.subscribe requires a unique key.');
return;
}
if (!this.events[event]) {
this.events[event] = new Map();
}
this.events[event].set(key, listener);
},
/**
* Subscribes a listener that will be automatically unsubscribed after one execution.
* @param {string} event The event name.
* @param {Function} listener The callback function.
* @param {string} key A unique key for this subscription.
*/
once(event, listener, key) {
if (!key) {
Logger.error('EventBus.once requires a unique key.');
return;
}
const onceListener = (...args) => {
this.unsubscribe(event, key);
listener(...args);
};
this.subscribe(event, onceListener, key);
},
/**
* Unsubscribes a listener from an event using its unique key.
* @param {string} event The event name.
* @param {string} key The unique key used during subscription.
*/
unsubscribe(event, key) {
if (!this.events[event] || !key) {
return;
}
this.events[event].delete(key);
if (this.events[event].size === 0) {
delete this.events[event];
}
},
/**
* Publishes an event, calling all subscribed listeners with the provided data.
* @param {string} event The event name.
* @param {...any} args The data to pass to the listeners.
*/
publish(event, ...args) {
if (!this.events[event]) {
return;
}
if (Logger.levels[Logger.level] >= Logger.levels.debug) {
// --- Aggregation logic START ---
if (this._aggregatedEvents.has(event)) {
if (!this._logAggregation[event]) {
this._logAggregation[event] = { timer: null, count: 0 };
}
const aggregation = this._logAggregation[event];
aggregation.count++;
clearTimeout(aggregation.timer);
aggregation.timer = setTimeout(() => {
const finalCount = this._logAggregation[event]?.count || 0;
if (finalCount > 0) {
console.log(LOG_PREFIX, `Event Published: ${event} (x${finalCount})`);
}
delete this._logAggregation[event];
}, this._aggregationDelay);
// Execute subscribers for the aggregated event, but without the verbose individual logs.
[...this.events[event].values()].forEach((listener) => {
try {
listener(...args);
} catch (e) {
Logger.error(`EventBus error in listener for event "${event}":`, e);
}
});
return; // End execution here for aggregated events in debug mode.
}
// --- Aggregation logic END ---
// In debug mode, provide detailed logging for NON-aggregated events.
const subscriberKeys = [...this.events[event].keys()];
// Use groupCollapsed for a cleaner default view
console.groupCollapsed(LOG_PREFIX, `Event Published: ${event}`);
if (args.length > 0) {
console.log(' - Payload:', ...args);
} else {
console.log(' - Payload: (No data)');
}
// Displaying subscribers helps in understanding the event's impact.
if (subscriberKeys.length > 0) {
console.log(' - Subscribers:\n' + subscriberKeys.map((key) => ` > ${key}`).join('\n'));
} else {
console.log(' - Subscribers: (None)');
}
// Iterate with keys for better logging
this.events[event].forEach((listener, key) => {
try {
// Log which specific subscriber is being executed
Logger.debug(`-> Executing: ${key}`);
listener(...args);
} catch (e) {
// Enhance error logging with the specific subscriber key
Logger.badge('LISTENER ERROR', LOG_STYLES.RED, 'error', `Listener "${key}" failed for event "${event}":`, e);
}
});
console.groupEnd();
} else {
// Iterate over a copy of the values in case a listener unsubscribes itself.
[...this.events[event].values()].forEach((listener) => {
try {
listener(...args);
} catch (e) {
Logger.badge('LISTENER ERROR', LOG_STYLES.RED, 'error', `Listener failed for event "${event}":`, e);
}
});
}
},
/**
* Queues a function to be executed on the next animation frame.
* Batches multiple UI updates into a single repaint cycle.
* @param {Function} workFunction The function to execute.
*/
queueUIWork(workFunction) {
this.uiWorkQueue.push(workFunction);
if (!this.isUiWorkScheduled) {
this.isUiWorkScheduled = true;
requestAnimationFrame(this._processUIWorkQueue.bind(this));
}
},
/**
* @private
* Processes all functions in the UI work queue.
*/
_processUIWorkQueue() {
// Prevent modifications to the queue while processing.
const queueToProcess = [...this.uiWorkQueue];
this.uiWorkQueue.length = 0;
for (const work of queueToProcess) {
try {
work();
} catch (e) {
Logger.badge('UI QUEUE ERROR', LOG_STYLES.RED, 'error', 'Error in queued UI work:', e);
}
}
this.isUiWorkScheduled = false;
},
};
/**
* Creates a unique, consistent event subscription key for EventBus.
* @param {object} context The `this` context of the subscribing class instance.
* @param {string} eventName The full event name from the EVENTS constant.
* @returns {string} A key in the format 'ClassName.purpose'.
*/
function createEventKey(context, eventName) {
// Extract a meaningful 'purpose' from the event name
const parts = eventName.split(':');
const purpose = parts.length > 1 ? parts.slice(1).join('_') : parts[0];
let contextName = 'UnknownContext';
if (context && context.constructor && context.constructor.name) {
contextName = context.constructor.name;
}
return `${contextName}.${purpose}`;
}
// =================================================================================
// SECTION: Data Conversion Utilities
// Description: Handles image optimization.
// =================================================================================
class DataConverter {
/**
* Converts an image file to an optimized Data URL.
* @param {File} file The image file object.
* @param {{ maxWidth?: number, maxHeight?: number, quality?: number }} options
* @returns {Promise<string>} A promise that resolves with the optimized Data URL.
*/
imageToOptimizedDataUrl(file, { maxWidth, maxHeight, quality = 0.85 }) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => {
const img = new Image();
img.onload = () => {
// Check if we can skip re-compression
const isWebP = file.type === 'image/webp';
const needsResize = (maxWidth && img.width > maxWidth) || (maxHeight && img.height > maxHeight);
if (isWebP && !needsResize) {
// It's an appropriately sized WebP, so just use the original Data URL.
if (event.target && typeof event.target.result === 'string') {
resolve(event.target.result);
} else {
reject(new Error('Failed to read file as a data URL.'));
}
return;
}
// Otherwise, proceed with canvas-based resizing and re-compression.
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Failed to get 2D context from canvas.'));
return;
}
let { width, height } = img;
if (needsResize) {
const ratio = width / height;
if (maxWidth && width > maxWidth) {
width = maxWidth;
height = width / ratio;
}
if (maxHeight && height > maxHeight) {
height = maxHeight;
width = height * ratio;
}
}
canvas.width = Math.round(width);
canvas.height = Math.round(height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
resolve(canvas.toDataURL('image/webp', quality));
};
img.onerror = (err) => reject(new Error('Failed to load image.'));
if (event.target && typeof event.target.result === 'string') {
img.src = event.target.result;
} else {
reject(new Error('Failed to read file as a data URL.'));
}
};
reader.onerror = (err) => reject(new Error('Failed to read file.'));
reader.readAsDataURL(file);
});
}
}
// =================================================================================
// SECTION: Utility Functions
// Description: General helper functions used across the script.
// =================================================================================
/**
* Schedules a function to run when the browser is idle.
* @param {(deadline: IdleDeadline) => void} callback The function to execute.
* @param {number} [timeout] The maximum delay in milliseconds.
* @returns {void}
*/
function runWhenIdle(callback, timeout = 2000) {
if ('requestIdleCallback' in window) {
window.requestIdleCallback(callback, { timeout });
} else {
setTimeout(callback, CONSTANTS.TIMING.TIMEOUTS.IDLE_EXECUTION_FALLBACK);
}
}
/**
* @param {Function} func
* @param {number} delay
* @param {boolean} useIdle
* @returns {((...args: any[]) => void) & { cancel: () => void }}
*/
function debounce(func, delay, useIdle) {
let timeout;
const debounced = function (...args) {
clearTimeout(timeout);
timeout = setTimeout(() => {
if (useIdle) {
// After the debounce delay, schedule the actual execution for when the browser is idle.
runWhenIdle(() => func.apply(this, args));
} else {
// Execute immediately after the delay without waiting for idle time.
func.apply(this, args);
}
}, delay);
};
debounced.cancel = () => {
clearTimeout(timeout);
};
return debounced;
}
/**
* Helper function to check if an item is a non-array object.
* @param {unknown} item The item to check.
* @returns {item is Record<string, any>}
*/
function isObject(item) {
return !!(item && typeof item === 'object' && !Array.isArray(item));
}
/**
* Creates a deep copy of a JSON-serializable object.
* @template T
* @param {T} obj The object to clone.
* @returns {T} The deep copy of the object.
*/
function deepClone(obj) {
return structuredClone(obj);
}
/**
* Recursively resolves the configuration by overlaying source properties onto the target object.
* The target object is mutated. This handles recursive updates for nested objects but overwrites arrays/primitives.
*
* [MERGE BEHAVIOR]
* Keys present in 'source' but missing in 'target' are ignored.
* The 'target' object acts as a schema; it must contain all valid keys.
*
* @param {object} target The target object (e.g., a deep copy of default config).
* @param {object} source The source object (e.g., user config).
* @returns {object} The mutated target object.
*/
function resolveConfig(target, source) {
for (const key in source) {
// Security: Prevent prototype pollution
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
continue;
}
if (Object.prototype.hasOwnProperty.call(source, key)) {
// Strict check: Ignore keys that do not exist in the target (default config).
if (!Object.prototype.hasOwnProperty.call(target, key)) {
continue;
}
const sourceVal = source[key];
const targetVal = target[key];
if (isObject(sourceVal) && isObject(targetVal)) {
// If both are objects, recurse
resolveConfig(targetVal, sourceVal);
} else if (typeof sourceVal !== 'undefined') {
// Otherwise, overwrite or set the value from the source
target[key] = sourceVal;
}
}
}
return target;
}
/**
* Checks if the current page is the "New Chat" page.
* This is determined by checking if the URL path matches the platform-specific pattern.
* @returns {boolean} True if it is the new chat page, otherwise false.
*/
function isNewChatPage() {
return PlatformAdapters.General.isNewChatPage();
}
/**
* Checks if the current browser is Firefox.
* @returns {boolean} True if the browser is Firefox, otherwise false.
*/
function isFirefox() {
return navigator.userAgent.includes('Firefox');
}
/**
* @typedef {Node|string|number|boolean|null|undefined} HChild
*/
/**
* Creates a DOM element using a hyperscript-style syntax.
* @param {string} tag - Tag name with optional ID/class (e.g., "div#app.container", "my-element").
* @param {object | HChild | HChild[]} [propsOrChildren] - Attributes object or children.
* @param {HChild | HChild[]} [children] - Children (if props are specified).
* @returns {HTMLElement|SVGElement} The created DOM element.
*/
function h(tag, propsOrChildren, children) {
const SVG_NS = 'http://www.w3.org/2000/svg';
const match = tag.match(/^([a-z0-9-]+)(#[\w-]+)?((\.[\w-]+)*)$/i);
if (!match) throw new Error(`Invalid tag syntax: ${tag}`);
const [, tagName, id, classList] = match;
const isSVG = ['svg', 'circle', 'rect', 'path', 'g', 'line', 'text', 'use', 'defs', 'clipPath'].includes(tagName);
const el = isSVG ? document.createElementNS(SVG_NS, tagName) : document.createElement(tagName);
if (id) el.id = id.slice(1);
if (classList) {
const classes = classList.replace(/\./g, ' ').trim();
if (classes) {
el.classList.add(...classes.split(/\s+/));
}
}
let props = {};
let childrenArray;
if (propsOrChildren && Object.prototype.toString.call(propsOrChildren) === '[object Object]') {
props = propsOrChildren;
childrenArray = children;
} else {
childrenArray = propsOrChildren;
}
// --- Start of Attribute/Property Handling ---
const directProperties = new Set(['value', 'checked', 'selected', 'readOnly', 'disabled', 'multiple', 'textContent']);
const urlAttributes = new Set(['href', 'src', 'action', 'formaction']);
const safeProtocols = new Set(['https:', 'http:', 'mailto:', 'tel:', 'blob:', 'data:']);
for (const [key, value] of Object.entries(props)) {
// 0. Handle `ref` callback (highest priority after props parsing).
if (key === 'ref' && typeof value === 'function') {
value(el);
}
// 1. Security check for URL attributes.
else if (urlAttributes.has(key)) {
const url = String(value);
try {
const parsedUrl = new URL(url); // Throws if not an absolute URL.
if (safeProtocols.has(parsedUrl.protocol)) {
el.setAttribute(key, url);
} else {
el.setAttribute(key, '#');
Logger.badge('UNSAFE URL', LOG_STYLES.YELLOW, 'warn', `Blocked potentially unsafe protocol "${parsedUrl.protocol}" in attribute "${key}":`, url);
}
} catch {
el.setAttribute(key, '#');
Logger.badge('INVALID URL', LOG_STYLES.YELLOW, 'warn', `Blocked invalid or relative URL in attribute "${key}":`, url);
}
}
// 2. Direct property assignments.
else if (directProperties.has(key)) {
el[key] = value;
}
// 3. Other specialized handlers.
else if (key === 'style' && typeof value === 'object') {
Object.assign(el.style, value);
} else if (key === 'dataset' && typeof value === 'object') {
for (const [dataKey, dataVal] of Object.entries(value)) {
el.dataset[dataKey] = dataVal;
}
} else if (key.startsWith('on')) {
if (typeof value === 'function') {
el.addEventListener(key.slice(2).toLowerCase(), value);
}
} else if (key === 'className') {
const classes = String(value).trim();
if (classes) {
el.classList.add(...classes.split(/\s+/));
}
} else if (key.startsWith('aria-')) {
el.setAttribute(key, String(value));
}
// 4. Default attribute handling.
else if (value !== false && value !== null) {
el.setAttribute(key, value === true ? '' : String(value));
}
}
// --- End of Attribute/Property Handling ---
const fragment = document.createDocumentFragment();
/**
* Appends a child node or text to the document fragment.
* @param {HChild} child - The child to append.
*/
function append(child) {
if (child === null || child === false || typeof child === 'undefined') return;
if (typeof child === 'string' || typeof child === 'number') {
fragment.appendChild(document.createTextNode(String(child)));
} else if (Array.isArray(child)) {
child.forEach(append);
} else if (child instanceof Node) {
fragment.appendChild(child);
} else {
throw new Error('Unsupported child type');
}
}
append(childrenArray);
el.appendChild(fragment);
return el;
}
/**
* @description A dispatch table object that maps UI schema types to their respective rendering functions.
*/
const UI_SCHEMA_RENDERERS = {
_renderContainer(def) {
let className = def.className;
if (!className) {
const classMap = {
'compound-slider': `${APPID}-compound-slider-container`,
'compound-container': `${APPID}-compound-form-field-container`,
'slider-container': `${APPID}-slider-container`,
'container-row': `${APPID}-submenu-row`,
'container-stacked-row': `${APPID}-submenu-row ${APPID}-submenu-row-stacked`,
};
className = classMap[def.type] || '';
}
const element = h(`div`, { className });
if (def.children) {
element.appendChild(buildUIFromSchema(def.children));
}
return element;
},
fieldset(def) {
const element = h(`fieldset.${APPID}-submenu-fieldset`, [h('legend', def.legend)]);
if (def.children) {
element.appendChild(buildUIFromSchema(def.children));
}
return element;
},
separator(def) {
let element = h(`hr.${APPID}-theme-separator`, { tabIndex: -1 });
if (def.legend) {
element = h('fieldset', [h('legend', def.legend), element]);
}
return element;
},
'submenu-separator': (def) => h(`div.${APPID}-submenu-separator`),
textarea(def, formId) {
return h(`div.${APPID}-form-field`, [h('label', { htmlFor: formId, title: def.tooltip }, def.label), h('textarea', { id: formId, rows: def.rows }), h(`div.${APPID}-form-error-msg`, { 'data-error-for': def.id.replace(/\./g, '-') })]);
},
textfield(def, formId) {
const isImageField = ['image', 'icon'].includes(def.fieldType);
const inputWrapperChildren = [h('input', { type: 'text', id: formId })];
if (isImageField) {
inputWrapperChildren.push(h(`button.${APPID}-local-file-btn`, { type: 'button', 'data-target-id': def.id.replace(/\./g, '-'), title: 'Select local file' }, [createIconFromDef(SITE_STYLES.ICONS.folder)]));
}
return h(`div.${APPID}-form-field`, [
h('label', { htmlFor: formId, title: def.tooltip }, def.label),
h(`div.${APPID}-input-wrapper`, inputWrapperChildren),
h(`div.${APPID}-form-error-msg`, { 'data-error-for': def.id.replace(/\./g, '-') }),
]);
},
colorfield(def, formId) {
const hint = 'Click the swatch to open the color picker.\nAccepts any valid CSS color string.';
const fullTooltip = def.tooltip ? `${def.tooltip}\n---\n${hint}` : hint;
return h(`div.${APPID}-form-field`, [
h('label', { htmlFor: formId, title: fullTooltip }, def.label),
h(`div.${APPID}-color-field-wrapper`, [
h('input', { type: 'text', id: formId, autocomplete: 'off' }),
h(`button.${APPID}-color-swatch`, { type: 'button', 'data-controls-color': def.id.replace(/\./g, '-'), title: 'Open color picker' }, [h(`span.${APPID}-color-swatch-checkerboard`), h(`span.${APPID}-color-swatch-value`)]),
]),
]);
},
select(def, formId) {
return h(`div.${APPID}-form-field`, [h('label', { htmlFor: formId, title: def.tooltip }, def.label), h('select', { id: formId }, [h('option', { value: '' }, '(not set)'), ...def.options.map((o) => h('option', { value: o }, o))])]);
},
slider(def, formId) {
const wrapperTag = def.containerClass ? `div.${def.containerClass}` : 'div';
const inputId = `${formId}-slider`;
return h(wrapperTag, [
h('label', { htmlFor: inputId, title: def.tooltip }, def.label),
h(`div.${APPID}-slider-subgroup-control`, [h('input', { type: 'range', id: inputId, min: def.min, max: def.max, step: def.step, dataset: def.dataset }), h('span', { 'data-slider-display-for': def.id })]),
]);
},
paddingslider(def, formId) {
const createSubgroup = (name, suffix, min, max, step) => {
const sliderId = `${APPID}-form-${def.actor}-bubblePadding-${suffix}`;
return h(`div.${APPID}-slider-subgroup`, [
h('label', { htmlFor: sliderId }, name),
h(`div.${APPID}-slider-subgroup-control`, [h('input', { type: 'range', id: sliderId, min, max, step, dataset: { nullThreshold: 0, sliderFor: sliderId, unit: 'px' } }), h('span', { 'data-slider-display-for': sliderId })]),
]);
};
return h(`div.${APPID}-form-field`, { id: formId }, [h(`div.${APPID}-compound-slider-container`, [createSubgroup('Padding Top/Bottom:', `tb`, -1, 30, 1), createSubgroup('Padding Left/Right:', `lr`, -1, 30, 1)])]);
},
preview(def) {
const wrapperClass = `${APPID}-preview-bubble-wrapper ${def.actor === 'user' ? 'user-preview' : ''}`;
return h(`div.${APPID}-preview-container`, [h('label', 'Preview:'), h('div', { className: wrapperClass }, [h(`div.${APPID}-preview-bubble`, { 'data-preview-for': def.actor }, [h('span', 'Sample Text')])])]);
},
'preview-input': (def) =>
h(`div.${APPID}-preview-container`, [h('label', 'Preview:'), h(`div.${APPID}-preview-bubble-wrapper`, [h(`div.${APPID}-preview-input-area`, { 'data-preview-for': 'inputArea' }, [h('span', 'Sample input text')])])]),
'preview-background': (def) =>
h(`div.${APPID}-form-field`, [h('label', 'BG Preview:'), h(`div.${APPID}-preview-bubble-wrapper`, { style: { padding: '0', minHeight: '0' } }, [h(`div.${APPID}-preview-background`, { 'data-preview-for': 'window' })])]),
button: (def) => h(`button#${def.id}.${APPID}-modal-button`, { title: def.title, style: { width: def.fullWidth ? '100%' : 'auto' } }, def.text),
label: (def) => h('label', { htmlFor: def.for, title: def.title }, def.text),
toggle: (def, formId) => h(`label.${APPID}-toggle-switch`, [h('input', { type: 'checkbox', id: formId }), h(`span.${APPID}-toggle-slider`)]),
};
// Assign aliases for container types
['container', 'grid', 'compound-slider', 'compound-container', 'slider-container', 'container-row', 'container-stacked-row'].forEach((type) => {
UI_SCHEMA_RENDERERS[type] = UI_SCHEMA_RENDERERS._renderContainer;
});
/**
* @description Recursively builds a DOM fragment from a declarative schema object.
* This function is the core of the declarative UI system, translating object definitions into DOM elements.
* @param {Array<object>} definitions - An array of objects, each defining a UI element.
* @returns {DocumentFragment} A document fragment containing the constructed DOM elements.
*/
function buildUIFromSchema(definitions) {
const fragment = document.createDocumentFragment();
if (!definitions) return fragment;
for (const def of definitions) {
const formId = def.id ? `${APPID}-form-${def.id.replace(/\./g, '-')}` : '';
const renderer = UI_SCHEMA_RENDERERS[def.type];
let element = null;
if (renderer) {
element = renderer(def, formId);
}
if (element) {
if (def.isDefaultHidden) {
element.dataset.isDefaultHidden = 'true';
}
fragment.appendChild(element);
}
}
return fragment;
}
/**
* Recursively builds a DOM element from a definition object using the h() function.
* @param {object} def The definition object for the element.
* @returns {HTMLElement | SVGElement | null} The created DOM element.
*/
function createIconFromDef(def) {
if (!def) return null;
const children = def.children ? def.children.map((child) => createIconFromDef(child)) : [];
return h(def.tag, def.props, children);
}
/**
* Waits for a specific HTMLElement to appear in the DOM using a high-performance, Sentinel-based approach.
* It specifically checks for `instanceof HTMLElement` and will not resolve for other element types (e.g., SVGElement), even if they match the selector.
* @param {string} selector The CSS selector for the element.
* @param {object} [options]
* @param {number} [options.timeout] The maximum time to wait in milliseconds.
* @param {Document | HTMLElement} [options.context] The element to search within.
* @param {Sentinel} [sentinelInstance] The Sentinel instance to use (defaults to global `sentinel`).
* @returns {Promise<HTMLElement | null>} A promise that resolves with the HTMLElement or null if timed out.
*/
function waitForElement(selector, { timeout = 10000, context = document } = {}, sentinelInstance = sentinel) {
// First, check if the element already exists.
const existingEl = context.querySelector(selector);
if (existingEl instanceof HTMLElement) {
return Promise.resolve(existingEl);
}
// If not, use Sentinel wrapped in a Promise.
return new Promise((resolve) => {
let timer = null;
let sentinelCallback = null;
const cleanup = () => {
if (timer) clearTimeout(timer);
if (sentinelCallback) sentinelInstance.off(selector, sentinelCallback);
};
timer = setTimeout(() => {
cleanup();
Logger.badge('WAIT TIMEOUT', LOG_STYLES.YELLOW, 'warn', `Timed out after ${timeout}ms waiting for element "${selector}"`);
resolve(null);
}, timeout);
sentinelCallback = (element) => {
// Ensure the found element is an HTMLElement and is within the specified context.
if (element instanceof HTMLElement && context.contains(element)) {
cleanup();
resolve(element);
}
};
sentinelInstance.on(selector, sentinelCallback);
});
}
/**
* Generates a unique ID string with a given prefix.
* Uses crypto.randomUUID() if available, otherwise falls back to timestamp + random.
* @param {string} [prefix='theme'] - The prefix for the ID.
* @returns {string}
*/
function generateUniqueId(prefix = 'theme') {
let uuid;
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
uuid = crypto.randomUUID();
} else {
uuid = Date.now() + '-' + Math.random().toString(36).substring(2, 9);
}
return `${APPID}-${prefix}-${uuid}`;
}
/**
* Proposes a unique name by appending a suffix if the base name already exists in a given set.
* It checks for "Copy", "Copy 2", "Copy 3", etc., in a case-insensitive manner.
* @param {string} baseName The initial name to check.
* @param {Set<string> | Array<string>} existingNames A Set or Array containing existing names.
* @returns {string} A unique name.
*/
function proposeUniqueName(baseName, existingNames) {
const existingNamesLower = new Set(Array.from(existingNames).map((name) => name.toLowerCase()));
if (!existingNamesLower.has(baseName.trim().toLowerCase())) {
return baseName;
}
let proposedName = `${baseName} Copy`;
if (!existingNamesLower.has(proposedName.trim().toLowerCase())) {
return proposedName;
}
let counter = 2;
while (true) {
proposedName = `${baseName} Copy ${counter}`;
if (!existingNamesLower.has(proposedName.trim().toLowerCase())) {
return proposedName;
}
counter++;
}
}
/**
* Converts an SVG string to a data URL, sanitizing it by removing script tags.
* @param {string | null} svg The SVG string.
* @returns {string | null} The data URL or null if input is invalid.
*/
function svgToDataUrl(svg) {
if (!svg || typeof svg !== 'string') return null;
// Basic sanitization: remove <script> tags.
const sanitizedSvg = svg.replace(/<script.+?<\/script>/gs, '');
// Gemini's CSP blocks single quotes in data URLs, so they must be encoded.
const encodedSvg = encodeURIComponent(sanitizedSvg).replace(/'/g, '%27').replace(/"/g, '%22');
return `data:image/svg+xml,${encodedSvg}`;
}
/**
* Validates an image-related string based on its type (URL, Data URI, or SVG).
* @param {string | null} value The string to validate.
* @param {'icon' | 'image'} fieldType The type of field ('icon' allows SVGs, 'image' does not).
* @returns {{isValid: boolean, message: string}} An object with validation result and an error message.
*/
function validateImageString(value, fieldType) {
// This check safely handles null, undefined, empty, and whitespace-only strings.
if (!value || String(value).trim() === '') {
return { isValid: true, message: '' };
}
const val = String(value).trim();
// Rule: Should not be enclosed in quotes
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
return { isValid: false, message: 'Input should not be enclosed in quotes.' };
}
// Case 1: Known CSS functions (url(), linear-gradient(), etc.)
if (/^(url|linear-gradient|radial-gradient|conic-gradient)\(/i.test(val)) {
return { isValid: true, message: '' };
}
// Case 2: SVG String (for 'icon' type only)
if (fieldType === 'icon' && val.startsWith('<svg')) {
if (!/<\/svg>$/i.test(val)) {
return { isValid: false, message: 'Must end with </svg>.' };
}
if ((val.match(/</g) || []).length !== (val.match(/>/g) || []).length) {
return { isValid: false, message: 'Has mismatched brackets; check for unclosed tags.' };
}
return { isValid: true, message: '' };
}
// Case 3: Data URI
if (val.startsWith('data:image')) {
// A basic prefix check is sufficient.
return { isValid: true, message: '' };
}
// Case 4: Standard URL
if (val.startsWith('http')) {
try {
// The URL constructor is a reliable way to check for basic structural validity.
new URL(val);
return { isValid: true, message: '' };
} catch {
return { isValid: false, message: 'The URL format is invalid.' };
}
}
// If none of the recognized patterns match
const allowed = fieldType === 'icon' ? 'a URL (http...), Data URI (data:image...), an SVG string, or a CSS function (url(), linear-gradient())' : 'a URL, a Data URI, or a CSS function';
return { isValid: false, message: `Invalid format. Must be ${allowed}.` };
}
/**
* Escapes special characters in a string for use in a regular expression.
* @param {string} string The string to escape.
* @returns {string} The escaped string.
*/
function escapeRegExp(string) {
// $& means the whole matched string
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Parses a regex string in the format "/pattern/flags".
* @param {string} input The string to parse.
* @returns {RegExp} The constructed RegExp object.
* @throws {Error} If the format is invalid or the regex is invalid.
*/
function parseRegexPattern(input) {
if (typeof input !== 'string' || !/^\/.*\/[gimsuy]*$/.test(input)) {
throw new Error(`Invalid format. Must be /pattern/flags string: ${input}`);
}
const lastSlash = input.lastIndexOf('/');
const pattern = input.slice(1, lastSlash);
const flags = input.slice(lastSlash + 1);
try {
return new RegExp(pattern, flags);
} catch (e) {
throw new Error(`Invalid RegExp: "${input}". ${e.message}`);
}
}
/**
* Gets the current width of the sidebar.
* @returns {number}
*/
function getSidebarWidth() {
const sidebar = document.querySelector(CONSTANTS.SELECTORS.SIDEBAR_WIDTH_TARGET);
if (sidebar instanceof HTMLElement && sidebar.offsetParent !== null) {
const styleWidth = sidebar.style.width;
if (styleWidth && styleWidth.endsWith('px')) {
return parseInt(styleWidth, 10);
}
if (sidebar.offsetWidth) {
return sidebar.offsetWidth;
}
}
return 0;
}
/**
* @description Scrolls to a target element, with an optional pixel offset.
* It's platform-aware. For platforms with a dedicated scroll container (like ChatGPT), it uses the reliable `scrollTo` method.
* For others (like Gemini), it falls back to `scrollIntoView` with `scroll-margin-top` for offset handling.
* @param {HTMLElement} element The target element to scroll to.
* @param {object} [options] - Scrolling options.
* @param {number} [options.offset] - A pixel offset to apply above the target element.
* @param {boolean} [options.smooth] - Whether to use smooth scrolling.
*/
function scrollToElement(element, options = {}) {
if (!element) return;
const { offset = 0, smooth = false } = options;
const behavior = smooth ? 'smooth' : 'auto';
const scrollContainerSelector = CONSTANTS.SELECTORS.SCROLL_CONTAINER;
const scrollContainer = scrollContainerSelector ? document.querySelector(scrollContainerSelector) : null;
if (scrollContainer) {
// Case 1: Container is known (ChatGPT). Use strict math, no DOM styling changes.
Logger.badge('SCROLL', LOG_STYLES.GRAY, 'debug', 'Using scroll container method.');
// Find the actual bubble element to be used as the scroll target
const bubbleSelector = `${CONSTANTS.SELECTORS.RAW_USER_BUBBLE}, ${CONSTANTS.SELECTORS.RAW_ASSISTANT_BUBBLE}`;
const scrollTargetElement = element.querySelector(bubbleSelector) || element;
const targetScrollTop = scrollContainer.scrollTop + scrollTargetElement.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top - offset;
scrollContainer.scrollTo({
top: targetScrollTop,
behavior,
});
} else {
// Case 2: Container is unknown (Gemini). Use scrollIntoView + scroll-margin-top.
Logger.badge('SCROLL', LOG_STYLES.GRAY, 'debug', '(Scroll container not found): Using scrollIntoView() with scroll-margin-top.');
// Use scroll-margin-top to handle the offset without modifying the DOM structure.
const originalScrollMargin = element.style.scrollMarginTop;
element.style.scrollMarginTop = `${offset}px`;
element.scrollIntoView({ behavior, block: 'start' });
// Clean up after a delay to restore the original state.
// The delay ensures the smooth scroll has finished before removing the margin.
setTimeout(() => {
element.style.scrollMarginTop = originalScrollMargin;
}, CONSTANTS.TIMING.TIMEOUTS.SCROLL_OFFSET_CLEANUP);
}
}
/**
* Sets a nested property on an object using a dot-notation path.
* @param {object} obj The object to modify.
* @param {string} path The dot-separated path to the property.
* @param {any} value The value to set.
*/
function setPropertyByPath(obj, path, value) {
const keys = path.split('.');
let current = obj;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (!isObject(current[key])) {
current[key] = {};
}
current = current[key];
}
current[keys[keys.length - 1]] = value;
}
/**
* Populates a form with data from a configuration object based on a UI schema.
* @param {Array<object>} definitions The UI schema definitions.
* @param {HTMLElement} rootElement The root element of the form to populate.
* @param {object} config The configuration object containing the data.
* @param {object} componentInstance The component instance, passed to the handler for context.
*/
function populateFormFromSchema(definitions, rootElement, config, componentInstance) {
for (const def of definitions) {
const configKey = def.configKey || def.id;
const handler = FORM_FIELD_HANDLERS[def.type];
if (handler && configKey) {
const formId = `${APPID}-form-${configKey.replace(/\./g, '-')}`;
const element = rootElement.querySelector(`#${formId}, #${formId}-slider`);
if (element) {
const value = getPropertyByPath(config, configKey);
handler.setValue(element, value, { componentInstance });
}
}
if (def.children) {
populateFormFromSchema(def.children, rootElement, config, componentInstance);
}
}
}
/**
* Collects data from a form into a configuration object based on a UI schema.
* @param {Array<object>} definitions The UI schema definitions.
* @param {HTMLElement} rootElement The root element of the form.
* @param {object} configObject The configuration object to populate with data.
*/
function collectDataFromSchema(definitions, rootElement, configObject) {
for (const def of definitions) {
const configKey = def.configKey || def.id;
const handler = FORM_FIELD_HANDLERS[def.type];
if (handler && configKey) {
const formId = `${APPID}-form-${configKey.replace(/\./g, '-')}`;
const element = rootElement.querySelector(`#${formId}, #${formId}-slider`);
if (element) {
const value = handler.getValue(element);
setPropertyByPath(configObject, configKey, value);
}
}
if (def.children) {
collectDataFromSchema(def.children, rootElement, configObject);
}
}
}
/**
* @description A utility function to update the text display of a slider component.
* It handles values from a predefined map, nullable thresholds, and units.
* @param {HTMLInputElement} slider The slider input element.
* @param {HTMLElement} display The element where the slider's value is displayed.
*/
function updateSliderDisplay(slider, display) {
if (!slider || !display) return;
const sliderValue = parseInt(slider.value, 10);
const { valueMapKey, unit, nullThreshold } = slider.dataset;
const sliderContainer = slider.closest(`.${APPID}-slider-subgroup-control`);
if (valueMapKey) {
const values = getPropertyByPath(CONSTANTS, valueMapKey);
if (values) {
display.textContent = `${values[sliderValue]}px`;
}
return;
}
const threshold = parseInt(nullThreshold, 10);
if (!isNaN(threshold) && sliderValue < threshold) {
display.textContent = 'Auto';
display.title = 'Auto means the default value is used.';
if (sliderContainer) sliderContainer.classList.add('is-default');
} else {
display.textContent = `${sliderValue}${unit}`;
display.title = '';
if (sliderContainer) sliderContainer.classList.remove('is-default');
}
}
/**
* @description A dispatch table defining how to get/set values for different form field types.
*/
const FORM_FIELD_HANDLERS = {
textfield: {
getValue: (el) => el.value.trim() || null,
setValue: (el, value) => {
el.value = value ?? '';
},
},
textarea: {
getValue: (el) =>
el.value
.split('\n')
.map((p) => p.trim())
.filter(Boolean) || [],
setValue: (el, value) => {
el.value = Array.isArray(value) ? value.join('\n') : (value ?? '');
},
},
select: {
getValue: (el) => el.value || null,
setValue: (el, value) => {
el.value = value ?? '';
},
},
colorfield: {
getValue: (el) => el.value.trim() || null,
setValue: (el, value, { modalElement }) => {
el.value = value ?? '';
// Manually update the swatch color
const swatch = el.closest(`.${APPID}-color-field-wrapper`)?.querySelector(`.${APPID}-color-swatch-value`);
if (swatch) {
swatch.style.backgroundColor = value || 'transparent';
}
},
},
slider: {
getValue: (slider) => {
const value = parseInt(slider.value, 10);
const { valueMapKey, unit, nullThreshold } = slider.dataset;
if (valueMapKey) {
const values = getPropertyByPath(CONSTANTS, valueMapKey);
return values?.[value] ?? values?.[0];
}
const threshold = parseInt(nullThreshold, 10);
if (!isNaN(threshold) && value < threshold) {
return null;
}
return unit ? `${value}${unit}` : `${value}px`; // Default to 'px' if no unit is specified
},
setValue: (slider, value, { componentInstance }) => {
const { valueMapKey, nullThreshold } = slider.dataset;
if (valueMapKey) {
const values = getPropertyByPath(CONSTANTS, valueMapKey);
if (values) {
const index = values.indexOf(value);
slider.value = index !== -1 ? String(index) : '0';
}
} else {
const threshold = parseInt(nullThreshold, 10);
const numVal = parseInt(String(value), 10);
if (value === null || isNaN(numVal)) {
slider.value = String(!isNaN(threshold) ? threshold - 1 : slider.min);
} else {
slider.value = String(numVal);
}
}
componentInstance._updateSliderDisplay(slider);
},
},
paddingslider: {
getValue: (el) => {
const tb = el.querySelector(`[id$="-bubblePadding-tb"]`);
const lr = el.querySelector(`[id$="-bubblePadding-lr"]`);
if (!tb || !lr || tb.value < 0 || lr.value < 0) {
return null;
}
return `${tb.value}px ${lr.value}px`;
},
setValue: (el, value, { componentInstance }) => {
const tbSlider = el.querySelector(`[id$="-bubblePadding-tb"]`);
const lrSlider = el.querySelector(`[id$="-bubblePadding-lr"]`);
if (!tbSlider || !lrSlider) return;
if (value === null) {
tbSlider.value = -1;
lrSlider.value = -1;
} else {
const parts = String(value)
.replace(/px/g, '')
.trim()
.split(/\s+/)
.map((p) => parseInt(p, 10));
if (parts.length === 1 && !isNaN(parts[0])) {
tbSlider.value = lrSlider.value = parts[0];
} else if (parts.length >= 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
tbSlider.value = parts[0];
lrSlider.value = parts[1];
} else {
tbSlider.value = -1;
lrSlider.value = -1;
}
}
componentInstance._updateSliderDisplay(tbSlider);
componentInstance._updateSliderDisplay(lrSlider);
},
},
toggle: {
getValue: (el) => el.checked,
setValue: (el, value) => {
el.checked = !!value;
},
},
};
/**
* @description Synchronizes a cache Map against the current list of messages from MessageCacheManager.
* It removes entries from the map if their key (a message element) is no longer in the live message list.
* @param {Map<HTMLElement, any>} cacheMap The cache Map to synchronize. The keys are expected to be message HTMLElements.
* @param {MessageCacheManager} messageCacheManager The instance of the message cache manager.
*/
function syncCacheWithMessages(cacheMap, messageCacheManager) {
const currentMessages = new Set(messageCacheManager.getTotalMessages());
for (const messageElement of cacheMap.keys()) {
if (!currentMessages.has(messageElement)) {
cacheMap.delete(messageElement);
}
}
}
/**
* @description A utility to prevent layout thrashing by separating DOM reads (measure)
* from DOM writes (mutate). The mutate function is executed in the next animation frame.
* @param {{
* measure: () => T,
* mutate: (data: T) => void
* }} param0 - An object containing the measure and mutate functions.
* @returns {Promise<void>} A promise that resolves after the mutate function has completed.
* @template T
*/
function withLayoutCycle({ measure, mutate }) {
return new Promise((resolve, reject) => {
let measuredData;
// Phase 1: Synchronously read all required layout properties from the DOM.
try {
measuredData = measure();
} catch (e) {
Logger.badge('LAYOUT ERROR', LOG_STYLES.RED, 'error', 'Error during measure phase:', e);
reject(e);
return;
}
// Phase 2: Schedule the DOM mutations to run in the next animation frame.
requestAnimationFrame(() => {
try {
mutate(measuredData);
resolve();
} catch (e) {
Logger.badge('LAYOUT ERROR', LOG_STYLES.RED, 'error', 'Error during mutate phase:', e);
reject(e);
}
});
});
}
/**
* @description Processes an array of items in asynchronous batches to avoid blocking the main thread.
* @param {Array<T>} items The array of items to process.
* @param {(item: T, index: number) => void} processItem The function to execute for each item.
* @param {number} batchSize The number of items to process in each batch.
* @param {() => void} [onComplete] An optional callback to execute when all batches are complete.
* @template T
*/
function processInBatches(items, processItem, batchSize, onComplete) {
let index = 0;
const totalItems = items.length;
if (totalItems === 0) {
onComplete?.();
return;
}
function runNextBatch() {
const endIndex = Math.min(index + batchSize, totalItems);
for (; index < endIndex; index++) {
processItem(items[index], index);
}
if (index < totalItems) {
requestAnimationFrame(runNextBatch);
} else {
onComplete?.();
}
}
requestAnimationFrame(runNextBatch);
}
// =================================================================================
// SECTION: Configuration Management (GM Storage)
// =================================================================================
/**
* @description A centralized utility for validating and sanitizing configuration objects.
* @typedef {{
* id: string;
* type: 'icon' | 'image';
* label: string;
* }} ImageFieldDefinition
*/
const ConfigProcessor = {
/**
* Validates a single theme object and returns user-facing errors. Does not mutate the object.
* @param {object} themeData The theme data to validate.
* @param {boolean} isDefaultSet Whether the theme being validated is the defaultSet.
* @returns {{isValid: boolean, errors: Array<{field: string, message: string}>}} Validation result.
*/
validate(themeData, isDefaultSet = false) {
/** @type {Array<{field: string, message: string}>} */
const errors = [];
// --- Image and Icon Validation ---
/** @type {ImageFieldDefinition[]} */
const imageFields = [
{ id: 'user.icon', type: 'icon', label: 'Icon:' },
{ id: 'user.standingImageUrl', type: 'image', label: 'Standing image:' },
{ id: 'assistant.icon', type: 'icon', label: 'Icon:' },
{ id: 'assistant.standingImageUrl', type: 'image', label: 'Standing image:' },
{ id: 'window.backgroundImageUrl', type: 'image', label: 'Background image:' },
];
for (const { id, type, label } of imageFields) {
const value = getPropertyByPath(themeData, id);
const result = validateImageString(value, type);
if (!result.isValid) {
errors.push({ field: id.replace(/\./g, '-'), message: `${label} ${result.message}` });
}
}
// --- RegExp Pattern Validation (only for non-default themes) ---
if (!isDefaultSet) {
const patternFields = ['matchPatterns', 'urlPatterns'];
for (const field of patternFields) {
if (themeData.metadata?.[field]) {
for (const p of themeData.metadata[field]) {
try {
parseRegexPattern(p);
} catch (e) {
errors.push({ field: `metadata-${field}`, message: e.message });
break; // Stop after first invalid regex in the list
}
}
}
}
}
return { isValid: errors.length === 0, errors };
},
/**
* Processes and sanitizes an entire configuration object, applying defaults for invalid values.
* Mutates the passed config object.
* @param {AppConfig} config The full configuration object to process.
* @returns {AppConfig} The sanitized configuration object.
*/
process(config) {
// 1. Sanitize Global Options
if (config.options) {
// Sanitize icon_size
if (!CONSTANTS.ICON_SIZE_VALUES.includes(config.options.icon_size)) {
config.options.icon_size = CONSTANTS.ICON_SIZE;
}
// Sanitize chat_content_max_width
const width = config.options.chat_content_max_width;
const widthConfig = CONSTANTS.SLIDER_CONFIGS.CHAT_WIDTH;
const defaultValue = widthConfig.DEFAULT;
let sanitized = false;
if (width === null) {
sanitized = true;
} else if (typeof width === 'string' && width.endsWith('vw')) {
const numVal = parseInt(width, 10);
if (!isNaN(numVal) && numVal >= widthConfig.NULL_THRESHOLD && numVal <= widthConfig.MAX) {
sanitized = true;
}
}
if (!sanitized) {
config.options.chat_content_max_width = defaultValue;
}
}
// 2. Sanitize all theme sets and the default set
const allThemes = [...(config.themeSets || []), config.defaultSet];
for (const theme of allThemes) {
if (!theme) continue;
// Sanitize image strings (throws error on invalid format)
this._validateThemeImageStrings(theme);
// Sanitize numeric/unit properties (falls back to default)
['user', 'assistant'].forEach((actor) => {
if (!theme[actor]) theme[actor] = {};
const actorConf = theme[actor];
const defaultActorConf = DEFAULT_THEME_CONFIG.defaultSet[actor];
for (const key in THEME_VALIDATION_RULES) {
if (Object.prototype.hasOwnProperty.call(actorConf, key)) {
const rule = THEME_VALIDATION_RULES[key];
actorConf[key] = this._sanitizeProperty(actorConf[key], rule, defaultActorConf[key]);
}
}
});
}
// 3. Validate RegExp patterns (throws error on invalid format)
this._validatePatternsInConfig(config, 'matchPatterns');
this._validatePatternsInConfig(config, 'urlPatterns');
return config;
},
/**
* @private
* Throws an error if any image string in a theme is invalid.
* @param {Partial<ThemeSet>} theme
*/
_validateThemeImageStrings(theme) {
const themeName = theme.metadata?.name || 'Default Set';
const check = (value, type) => {
const result = validateImageString(value, type);
if (!result.isValid) throw new Error(`Theme "${themeName}": ${result.message}`);
};
if (theme.user) {
check(theme.user.icon, 'icon');
check(theme.user.standingImageUrl, 'image');
}
if (theme.assistant) {
check(theme.assistant.icon, 'icon');
check(theme.assistant.standingImageUrl, 'image');
}
if (theme.window) {
check(theme.window.backgroundImageUrl, 'image');
}
},
/**
* @private
* Validates the specified pattern list within the themeSets of a given config object.
* Throws an error if validation fails.
* @param {AppConfig} config - The configuration object to validate.
* @param {string} propertyName - The property name to validate ('matchPatterns' or 'urlPatterns').
*/
_validatePatternsInConfig(config, propertyName) {
if (!config || !config.themeSets || !Array.isArray(config.themeSets)) {
return;
}
for (const set of config.themeSets) {
if (!set.metadata || !Array.isArray(set.metadata[propertyName])) continue;
for (const p of set.metadata[propertyName]) {
// Use the shared parser to validate. It throws on error.
parseRegexPattern(p);
}
}
},
/**
* @private
* @param {string | null} value The value to sanitize.
* @param {object} rule The validation rule from THEME_VALIDATION_RULES.
* @param {string | null} defaultValue The fallback value.
* @returns {string | null} The sanitized value.
*/
_sanitizeProperty(value, rule, defaultValue) {
if (rule.nullable && value === null) {
return value;
}
if (typeof value !== 'string' || !value.endsWith(rule.unit)) {
return defaultValue;
}
const numVal = parseInt(value, 10);
if (isNaN(numVal) || numVal < rule.min || numVal > rule.max) {
return defaultValue;
}
return value; // The original value is valid
},
};
/**
* @abstract
* @description Base class for managing script configurations via GM_setValue/GM_getValue.
* Handles generic logic for loading, saving, backups, and validation.
* This class is platform-agnostic and designed to be extended.
*/
class ConfigManagerBase {
/**
* @param {object} params
* @param {string} params.configKey The key for GM_setValue/GM_getValue.
* @param {object} params.defaultConfig The default configuration object for the script.
*/
constructor({ configKey, defaultConfig }) {
if (!configKey || !defaultConfig) {
throw new Error('configKey and defaultConfig must be provided.');
}
this.CONFIG_KEY = configKey;
this.DEFAULT_CONFIG = defaultConfig;
/** @type {AppConfig|null} */
this.config = null;
}
/**
* Loads the configuration from storage.
* Assumes the configuration is stored as a JSON string.
* @returns {Promise<any>}
*/
async load() {
const raw = await GM_getValue(this.CONFIG_KEY);
let userConfig = null;
if (raw) {
try {
userConfig = JSON.parse(raw);
} catch (e) {
Logger.badge('LOAD FAILED', LOG_STYLES.RED, 'error', 'Failed to parse configuration. Resetting to default settings.', e);
userConfig = null;
}
}
const completeConfig = deepClone(this.DEFAULT_CONFIG);
this.config = resolveConfig(completeConfig, userConfig || {});
this._validateAndSanitizeOptions();
}
/**
* Saves the configuration object to storage as a JSON string.
* @param {object} obj The configuration object to save.
* @returns {Promise<void>}
*/
async save(obj) {
this.config = obj;
await GM_setValue(this.CONFIG_KEY, JSON.stringify(obj));
}
/**
* @returns {AppConfig|null} The current configuration object.
*/
get() {
return this.config;
}
/**
* @abstract
* @protected
* This method should be overridden by subclasses to perform script-specific
* validation and sanitization of the `this.config.options` object.
*/
_validateAndSanitizeOptions() {
// Default implementation does nothing.
// Subclasses should provide their own logic.
}
}
class ConfigManager extends ConfigManagerBase {
constructor(dataConverter) {
super({
configKey: CONSTANTS.CONFIG_KEY,
defaultConfig: DEFAULT_THEME_CONFIG,
});
this.dataConverter = dataConverter;
}
/**
* @override
* Loads the configuration from storage.
* @returns {Promise<AppConfig>}
*/
async load() {
const raw = await GM_getValue(this.CONFIG_KEY);
let userConfig = null;
if (raw) {
// Try parsing as plain JSON.
try {
const parsed = JSON.parse(raw);
if (isObject(parsed)) {
userConfig = parsed;
}
} catch (e) {
Logger.error('Failed to parse config. Resetting to default.', e);
userConfig = null;
}
}
const completeConfig = deepClone(this.DEFAULT_CONFIG);
this.config = resolveConfig(completeConfig, userConfig || {});
this._validateAndSanitizeOptions();
return this.config;
}
/**
* @override
* Saves the configuration object to storage, but only if it's under the size limit.
* Throws a specific error if the limit is exceeded.
* @param {object} obj The configuration object to save.
* @returns {Promise<void>}
*/
async save(obj) {
const jsonString = JSON.stringify(obj);
const configSize = new Blob([jsonString]).size; // Use Blob to get accurate byte size
if (configSize > CONSTANTS.CONFIG_SIZE_LIMIT_BYTES) {
this.config = obj; // Keep oversized config in memory
const sizeInMB = (configSize / 1024 / 1024).toFixed(2);
const limitInMB = (CONSTANTS.CONFIG_SIZE_LIMIT_BYTES / 1024 / 1024).toFixed(1);
const errorMsg = `Configuration size (${sizeInMB} MB) exceeds the ${limitInMB} MB limit. Changes are not saved.`;
EventBus.publish(EVENTS.CONFIG_SIZE_EXCEEDED, { message: errorMsg });
throw new Error(errorMsg); // Throw error for immediate UI feedback
}
this.config = obj;
await GM_setValue(this.CONFIG_KEY, jsonString);
EventBus.publish(EVENTS.CONFIG_SAVE_SUCCESS); // Notify UI to clear warnings
}
/**
* @override
* @protected
* Validates and sanitizes App-specific option values after loading.
*/
_validateAndSanitizeOptions() {
if (!this.config || !this.config.options) return;
const options = this.config.options;
const width = options.chat_content_max_width;
const widthConfig = CONSTANTS.SLIDER_CONFIGS.CHAT_WIDTH;
const defaultValue = widthConfig.DEFAULT;
let sanitized = false;
if (width === null) {
sanitized = true;
} else if (typeof width === 'string' && width.endsWith('vw')) {
const numVal = parseInt(width, 10);
if (!isNaN(numVal) && numVal >= widthConfig.NULL_THRESHOLD && numVal <= widthConfig.MAX) {
sanitized = true;
}
}
// If validation fails at any point, reset to default (null).
if (!sanitized) {
this.config.options.chat_content_max_width = defaultValue;
}
}
/**
* Getter for the icon size, required by other managers.
* @returns {number}
*/
getIconSize() {
return this.config?.options?.icon_size || CONSTANTS.ICON_SIZE;
}
}
// =================================================================================
// SECTION: Sync Manager
// =================================================================================
class SyncManager {
constructor() {
this.listenerId = null;
this.subscriptions = [];
}
/**
* Helper to subscribe to EventBus and track the subscription for cleanup.
* Appends the listener name and a unique suffix to the key to avoid conflicts.
* @param {string} event
* @param {Function} listener
*/
_subscribe(event, listener) {
const baseKey = createEventKey(this, event);
// Use function name for debugging aid, fallback to 'anonymous'
const listenerName = listener.name || 'anonymous';
// Generate a short random suffix to guarantee uniqueness even for anonymous functions
const uniqueSuffix = Math.random().toString(36).substring(2, 7);
const key = `${baseKey}_${listenerName}_${uniqueSuffix}`;
EventBus.subscribe(event, listener, key);
this.subscriptions.push({ event, key });
}
init() {
this.listenerId = GM_addValueChangeListener(CONSTANTS.CONFIG_KEY, (name, oldValue, newValue, remote) => {
if (remote) {
this._handleRemoteChange(newValue);
}
});
this._subscribe(EVENTS.APP_SHUTDOWN, () => this.destroy());
}
destroy() {
if (this.listenerId) {
GM_removeValueChangeListener(this.listenerId);
this.listenerId = null;
}
this.subscriptions.forEach(({ event, key }) => EventBus.unsubscribe(event, key));
this.subscriptions = [];
}
/**
* Handles the config change event from another tab/window by publishing an event.
* @private
* @param {string} newValue The new configuration value from the storage event.
*/
_handleRemoteChange(newValue) {
Logger.log('SyncManager: Remote config change detected. Publishing event.');
EventBus.publish(EVENTS.REMOTE_CONFIG_CHANGED, { newValue });
}
}
// =================================================================================
// SECTION: Navigation Monitor
// Description: Centralizes URL change detection via history API hooks and popstate events.
// =================================================================================
class NavigationMonitor {
constructor() {
this.originalHistoryMethods = { pushState: null, replaceState: null };
this._historyWrappers = {};
this.isInitialized = false;
this.lastPath = null;
this._handlePopState = this._handlePopState.bind(this);
// Debounce the navigation event to allow the DOM to settle and prevent duplicate events
this.debouncedNavigation = debounce(
() => {
EventBus.publish(EVENTS.NAVIGATION);
},
CONSTANTS.TIMING.TIMEOUTS.POST_NAVIGATION_DOM_SETTLE,
true
);
}
init() {
if (this.isInitialized) return;
this.isInitialized = true;
// Capture initial path
this.lastPath = location.pathname + location.search;
this._hookHistory();
window.addEventListener('popstate', this._handlePopState);
}
destroy() {
if (!this.isInitialized) return;
this._restoreHistory();
window.removeEventListener('popstate', this._handlePopState);
this.debouncedNavigation.cancel();
this.isInitialized = false;
}
_hookHistory() {
// Capture the instance for use in the wrapper
const instance = this;
for (const m of ['pushState', 'replaceState']) {
const orig = history[m];
this.originalHistoryMethods[m] = orig;
const wrapper = function (...args) {
const result = orig.apply(this, args);
instance._onUrlChange();
return result;
};
this._historyWrappers[m] = wrapper;
history[m] = wrapper;
}
}
_restoreHistory() {
for (const m of ['pushState', 'replaceState']) {
if (this.originalHistoryMethods[m]) {
if (history[m] === this._historyWrappers[m]) {
history[m] = this.originalHistoryMethods[m];
} else {
Logger.badge('HISTORY HOOK', LOG_STYLES.YELLOW, 'warn', `history.${m} has been wrapped by another script. Skipping restoration to prevent breaking the chain.`);
}
this.originalHistoryMethods[m] = null;
}
}
this._historyWrappers = {};
}
_handlePopState() {
this._onUrlChange();
}
_onUrlChange() {
const currentPath = location.pathname + location.search;
// Prevent re-triggers if the path hasn't actually changed
if (currentPath === this.lastPath) {
return;
}
this.lastPath = currentPath;
// Immediate check for excluded pages
if (PlatformAdapters.General.isExcludedPage()) {
Logger.badge('EXCLUDED URL', LOG_STYLES.YELLOW, 'log', 'Excluded URL detected. Suspending script functions.');
EventBus.publish(EVENTS.APP_SHUTDOWN);
return;
}
EventBus.publish(EVENTS.NAVIGATION_START);
this.debouncedNavigation();
}
}
// =================================================================================
// SECTION: Image Data Management
// Description: Handles fetching external images and converting them to data URLs to bypass CSP.
// =================================================================================
class ImageDataManager {
constructor(dataConverter) {
this.dataConverter = dataConverter;
/** @type {Map<string, {data: string, size: number}>} */
this.cache = new Map();
/** @type {Set<string>} */
this.failedUrls = new Set();
this.currentCacheSize = 0;
}
/**
* Ensures there is enough space in the cache for a new item.
* If not, it evicts the least recently used items until there is space.
* @private
* @param {number} newItemSize - The size of the new item to be added.
*/
_makeSpaceForNewItem(newItemSize) {
if (newItemSize > CONSTANTS.CACHE_SIZE_LIMIT_BYTES) {
Logger.badge('CACHE LIMIT', LOG_STYLES.YELLOW, 'warn', `Item size (${newItemSize}) exceeds cache limit (${CONSTANTS.CACHE_SIZE_LIMIT_BYTES}). Cannot be cached.`);
return;
}
while (this.currentCacheSize + newItemSize > CONSTANTS.CACHE_SIZE_LIMIT_BYTES && this.cache.size > 0) {
// Evict the least recently used item (first item in map iterator)
const oldestKey = this.cache.keys().next().value;
const oldestItem = this.cache.get(oldestKey);
if (oldestItem) {
this.currentCacheSize -= oldestItem.size;
this.cache.delete(oldestKey);
Logger.log(`Evicted ${oldestKey} from cache to free up space.`);
}
}
}
/**
* Gets an image as a data URL. Returns a cached version immediately if available.
* Can fetch and resize the image based on the provided options.
* @param {string} url The URL of the image to fetch.
* @param {object} [resizeOptions] Optional resizing parameters.
* @param {number} [resizeOptions.width] The target max width for resizing.
* @param {number} [resizeOptions.height] The target max height for resizing.
* @returns {Promise<string|null>} A promise that resolves with the data URL or null on failure.
*/
async getImageAsDataUrl(url, resizeOptions = {}) {
if (!url || typeof url !== 'string' || !url.startsWith('http')) {
return url; // Return data URIs or other values directly
}
const cacheKey = resizeOptions.width ? `${url}|w=${resizeOptions.width},h=${resizeOptions.height}` : url;
if (this.failedUrls.has(cacheKey)) {
return null;
}
if (this.cache.has(cacheKey)) {
const cached = this.cache.get(cacheKey);
// Move to the end of the map to mark as recently used
this.cache.delete(cacheKey);
this.cache.set(cacheKey, cached);
return cached.data;
}
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: 'GET',
url: url,
responseType: 'blob',
onload: async (response) => {
if (response.status >= 200 && response.status < 300) {
try {
const dataUrl = await this.dataConverter.imageToOptimizedDataUrl(response.response, {
maxWidth: resizeOptions.width,
maxHeight: resizeOptions.height,
quality: 0.85,
});
const size = new Blob([dataUrl]).size;
this._makeSpaceForNewItem(size);
this.cache.set(cacheKey, { data: dataUrl, size });
this.currentCacheSize += size;
resolve(dataUrl);
} catch (e) {
Logger.badge('CONVERSION FAILED', LOG_STYLES.RED, 'error', `Data conversion error for URL: ${url}`, e);
this.failedUrls.add(cacheKey);
resolve(null);
}
} else {
Logger.badge('FETCH FAILED', LOG_STYLES.RED, 'error', `HTTP Error: ${response.status}, URL: ${url}`);
this.failedUrls.add(cacheKey);
resolve(null);
}
},
onerror: (error) => {
Logger.badge('FETCH FAILED', LOG_STYLES.RED, 'error', `GM_xmlhttpRequest error for URL: ${url}`, error);
this.failedUrls.add(cacheKey);
resolve(null);
},
ontimeout: () => {
Logger.badge('FETCH FAILED', LOG_STYLES.RED, 'error', `GM_xmlhttpRequest timeout for URL: ${url}`);
this.failedUrls.add(cacheKey);
resolve(null);
},
});
});
}
}
// =================================================================================
// SECTION: Message Cache Management
// Description: Centralized manager for caching and sorting message elements from the DOM.
// =================================================================================
class MessageCacheManager {
constructor() {
this.userMessages = [];
this.assistantMessages = [];
this.totalMessages = [];
this.elementMap = new Map();
this.subscriptions = [];
this.isStreaming = false;
this.debouncedRebuildCache = debounce(this._rebuildCache.bind(this), CONSTANTS.TIMING.DEBOUNCE_DELAYS.CACHE_UPDATE, true);
}
/**
* Helper to subscribe to EventBus and track the subscription for cleanup.
* Appends the listener name and a unique suffix to the key to avoid conflicts.
* @param {string} event
* @param {Function} listener
*/
_subscribe(event, listener) {
const baseKey = createEventKey(this, event);
// Use function name for debugging aid, fallback to 'anonymous'
const listenerName = listener.name || 'anonymous';
// Generate a short random suffix to guarantee uniqueness even for anonymous functions
const uniqueSuffix = Math.random().toString(36).substring(2, 7);
const key = `${baseKey}_${listenerName}_${uniqueSuffix}`;
EventBus.subscribe(event, listener, key);
this.subscriptions.push({ event, key });
}
init() {
this._subscribe(EVENTS.CACHE_UPDATE_REQUEST, () => this.debouncedRebuildCache());
this._subscribe(EVENTS.NAVIGATION, () => this.clear());
this._subscribe(EVENTS.APP_SHUTDOWN, () => this.destroy());
this._subscribe(EVENTS.STREAMING_START, () => (this.isStreaming = true));
this._subscribe(EVENTS.STREAMING_END, () => (this.isStreaming = false));
this._rebuildCache();
}
destroy() {
this.debouncedRebuildCache.cancel();
this.subscriptions.forEach(({ event, key }) => EventBus.unsubscribe(event, key));
this.subscriptions = [];
}
_rebuildCache() {
if (this.isStreaming) return;
Logger.badge('CACHE', LOG_STYLES.BLUE, 'info', 'Rebuilding cache...');
// Guard clause: If no conversation turns are on the page (e.g., on the homepage),
// clear the cache if it's not already empty and exit early to prevent unnecessary queries.
if (!document.querySelector(CONSTANTS.SELECTORS.CONVERSATION_UNIT)) {
if (this.totalMessages.length > 0) {
this.clear();
}
return;
}
this.userMessages = Array.from(document.querySelectorAll(CONSTANTS.SELECTORS.USER_MESSAGE));
const rawAssistantMessages = Array.from(document.querySelectorAll(CONSTANTS.SELECTORS.ASSISTANT_MESSAGE));
// Filter out empty, non-functional message containers that might appear in image-only turns.
this.assistantMessages = rawAssistantMessages.filter((msg) => PlatformAdapters.General.filterMessage(msg));
// Construct totalMessages using a linear merge sort approach (O(N)).
// Since querySelectorAll returns elements in document order, userMessages and assistantMessages are already sorted.
const total = [];
let u = 0,
a = 0;
const uLen = this.userMessages.length;
const aLen = this.assistantMessages.length;
while (u < uLen && a < aLen) {
const uMsg = this.userMessages[u];
const aMsg = this.assistantMessages[a];
// Check position: Node.DOCUMENT_POSITION_FOLLOWING (4) means aMsg follows uMsg (uMsg comes first).
if (uMsg.compareDocumentPosition(aMsg) & Node.DOCUMENT_POSITION_FOLLOWING) {
total.push(uMsg);
u++;
} else {
total.push(aMsg);
a++;
}
}
// Append any remaining elements
while (u < uLen) total.push(this.userMessages[u++]);
while (a < aLen) total.push(this.assistantMessages[a++]);
this.totalMessages = total;
// Rebuild the lookup map for O(1) access.
// This must be done after the arrays are fully filtered and sorted to ensure consistency.
this.elementMap.clear();
// Use for loops for better performance in hot paths
for (let i = 0; i < this.userMessages.length; i++) {
this.elementMap.set(this.userMessages[i], { role: CONSTANTS.INTERNAL_ROLES.USER, index: i, totalIndex: -1 });
}
for (let i = 0; i < this.assistantMessages.length; i++) {
this.elementMap.set(this.assistantMessages[i], { role: CONSTANTS.INTERNAL_ROLES.ASSISTANT, index: i, totalIndex: -1 });
}
// Populate totalIndex using the sorted totalMessages array
for (let i = 0; i < this.totalMessages.length; i++) {
const entry = this.elementMap.get(this.totalMessages[i]);
if (entry) {
entry.totalIndex = i;
}
}
this.notify();
}
/**
* Publishes the :cacheUpdated event with the current cache state.
* Useful for notifying newly initialized components.
*/
notify() {
EventBus.publish(EVENTS.CACHE_UPDATED);
}
/**
* Finds the role and index of a given message element within the cached arrays.
* @param {HTMLElement} messageElement The element to find.
* @returns {{role: 'user'|'assistant', index: number, totalIndex: number} | null} An object with the role and index, or null if not found.
*/
findMessageIndex(messageElement) {
return this.elementMap.get(messageElement) || null;
}
/**
* Retrieves a message element at a specific index for a given role.
* @param {'user'|'assistant'} role The role of the message to retrieve.
* @param {number} index The index of the message in its role-specific array.
* @returns {HTMLElement | null} The element at the specified index, or null if out of bounds.
*/
getMessageAtIndex(role, index) {
const targetArray = role === 'user' ? this.userMessages : this.assistantMessages;
if (index >= 0 && index < targetArray.length) {
return targetArray[index];
}
return null;
}
clear() {
this.userMessages = [];
this.assistantMessages = [];
this.totalMessages = [];
this.elementMap.clear();
this.notify();
}
/**
* Gets the cached user message elements.
* @returns {HTMLElement[]} An array of user message elements.
*/
getUserMessages() {
return this.userMessages;
}
/**
* Gets the cached assistant message elements.
* @returns {HTMLElement[]} An array of assistant message elements.
*/
getAssistantMessages() {
return this.assistantMessages;
}
/**
* Gets all cached message elements (user and assistant combined).
* @returns {HTMLElement[]} An array of all message elements.
*/
getTotalMessages() {
return this.totalMessages;
}
}
// =================================================================================
// SECTION: Theme and Style Management
// =================================================================================
/**
* A helper function to safely retrieve a nested property from an object using a dot-notation string.
* @param {object} obj The object to query.
* @param {string} path The dot-separated path to the property.
* @returns {any} The value of the property, or undefined if not found.
*/
function getPropertyByPath(obj, path) {
if (!obj || typeof path !== 'string') {
return undefined;
}
return path.split('.').reduce((o, k) => (o && o[k] !== 'undefined' ? o[k] : undefined), obj);
}
class StyleGenerator {
/**
* Generates all dynamic CSS rules based on the active theme and STYLE_DEFINITIONS.
* @param {ThemeSet} currentThemeSet The active theme configuration.
* @param {AppConfig} fullConfig The entire configuration object, including defaultSet.
* @returns {string[]} An array of CSS rule strings.
*/
generateDynamicCss(currentThemeSet, fullConfig) {
const dynamicRules = [];
const important = SITE_STYLES.CSS_IMPORTANT_FLAG || '';
for (const definition of ALL_STYLE_DEFINITIONS) {
const value = getPropertyByPath(currentThemeSet, definition.configKey) ?? getPropertyByPath(fullConfig, definition.fallbackKey);
if (value === null || value === undefined) continue;
// Generate rules for direct selector-property mappings
if (definition.selector && definition.property) {
const selectors = Array.isArray(definition.selector) ? definition.selector.join(', ') : definition.selector;
dynamicRules.push(`${selectors} { ${definition.property}: var(${definition.cssVar})${important}; }`);
}
// Generate additional complex CSS blocks if a generator function is defined
if (typeof definition.generator === 'function') {
const block = definition.generator(value);
if (block) {
dynamicRules.push(block);
}
}
}
return dynamicRules;
}
}
class ThemeManager {
/**
* @param {ConfigManager} configManager
* @param {ImageDataManager} imageDataManager
*/
constructor(configManager, imageDataManager) {
this.configManager = configManager;
this.imageDataManager = imageDataManager;
this.styleGenerator = new StyleGenerator();
this.themeStyleElem = null;
this.dynamicRulesStyleElem = null;
this.lastURL = null;
this.lastTitle = null;
this.lastAppliedThemeSet = null;
this.cachedTitle = null;
/** @type {ThemeSet | null} */
this.cachedThemeSet = null;
this.subscriptions = [];
this.debouncedUpdateTheme = debounce(this.updateTheme.bind(this), CONSTANTS.TIMING.DEBOUNCE_DELAYS.THEME_UPDATE, true);
this.isDestroyed = false;
this.currentRequestId = 0;
/** @type {Map<string, string|null>} */
this.lastAppliedImageValues = new Map();
this.lastAppliedIconSize = 0;
}
/**
* Helper to subscribe to EventBus and track the subscription for cleanup.
* Appends the listener name and a unique suffix to the key to avoid conflicts.
* @param {string} event
* @param {Function} listener
*/
_subscribe(event, listener) {
const baseKey = createEventKey(this, event);
// Use function name for debugging aid, fallback to 'anonymous'
const listenerName = listener.name || 'anonymous';
// Generate a short random suffix to guarantee uniqueness even for anonymous functions
const uniqueSuffix = Math.random().toString(36).substring(2, 7);
const key = `${baseKey}_${listenerName}_${uniqueSuffix}`;
EventBus.subscribe(event, listener, key);
this.subscriptions.push({ event, key });
}
init() {
this._subscribe(EVENTS.NAVIGATION, () => this._onNavigation());
this._subscribe(EVENTS.TITLE_CHANGED, this.debouncedUpdateTheme);
this._subscribe(EVENTS.THEME_UPDATE, this.debouncedUpdateTheme);
this._subscribe(EVENTS.DEFERRED_LAYOUT_UPDATE, () => this._handleLayoutEvent());
}
destroy() {
this.isDestroyed = true;
this.debouncedUpdateTheme.cancel();
this.subscriptions.forEach(({ event, key }) => EventBus.unsubscribe(event, key));
this.subscriptions = [];
this._cleanupCssVariables();
this.themeStyleElem?.remove();
this.dynamicRulesStyleElem?.remove();
this.themeStyleElem = null;
this.dynamicRulesStyleElem = null;
}
/**
* @private
* Removes all CSS variables defined in ALL_STYLE_DEFINITIONS from the root element.
* This ensures a clean state when the manager is destroyed.
*/
_cleanupCssVariables() {
const rootStyle = document.documentElement.style;
for (const definition of ALL_STYLE_DEFINITIONS) {
if (definition.cssVar) {
rootStyle.removeProperty(definition.cssVar);
}
}
}
_onNavigation() {
if (!PlatformAdapters.ThemeManager.shouldDeferInitialTheme(this)) {
this.updateTheme();
}
}
_handleLayoutEvent(forcedWidth = undefined) {
this.applyChatContentMaxWidth(forcedWidth);
}
/**
* Gets the title of the currently active chat from the page.
* @returns {string | null}
*/
getChatTitleAndCache() {
const currentTitle = PlatformAdapters.General.getChatTitle();
if (currentTitle !== this.cachedTitle) {
this.cachedTitle = currentTitle;
this.cachedThemeSet = null;
}
return this.cachedTitle;
}
/** @returns {ThemeSet} */
getThemeSet() {
if (this.cachedThemeSet) {
return this.cachedThemeSet;
}
const config = this.configManager.get();
const regexArr = [];
// Compile patterns into a linear array.
// The order of push operations here strictly determines the matching priority.
// Items pushed earlier will be checked first by regexArr.find().
const compilePatterns = (patterns, type, set) => {
for (const patternStr of patterns ?? []) {
try {
const regex = parseRegexPattern(patternStr);
regexArr.push({ pattern: regex, set, type });
} catch (e) {
Logger.error(`Invalid ${type} pattern in theme "${set.metadata?.name}": ${e.message}`);
}
}
};
for (const set of config.themeSets ?? []) {
// 1. Check URL patterns first.
// If a URL match is found, we can skip title checks and avoid waiting for the title to load.
compilePatterns(set.metadata?.urlPatterns, 'url', set);
// 2. Check Title patterns next.
compilePatterns(set.metadata?.matchPatterns, 'title', set);
}
const titleName = this.cachedTitle;
const urlPath = window.location.pathname;
// Find the first matching pattern in the array.
// Note: The order of checks inside this callback does NOT affect priority.
// Priority is determined solely by the order of elements in regexArr.
const regexHit = regexArr.find((r) => {
if (r.type === 'title' && titleName) {
return r.pattern.test(titleName);
}
if (r.type === 'url') {
return r.pattern.test(urlPath);
}
return false;
});
if (regexHit) {
this.cachedThemeSet = regexHit.set;
return regexHit.set;
}
// Fallback to default if no title or no match
const defaultMetadata = { id: 'default', name: 'Default Settings', matchPatterns: [], urlPatterns: [] };
this.cachedThemeSet = { ...config.defaultSet, metadata: defaultMetadata };
return this.cachedThemeSet;
}
/**
* Main theme update handler.
* @param {boolean} [force] - If true, forces the theme to be reapplied even if no changes are detected.
*/
updateTheme(force = false) {
Logger.badge('THEME CHECK', LOG_STYLES.GRAY, 'debug', 'Update triggered.');
const currentLiveURL = location.href;
const currentTitle = this.getChatTitleAndCache();
const urlChanged = currentLiveURL !== this.lastURL;
if (urlChanged) this.lastURL = currentLiveURL;
const titleChanged = currentTitle !== this.lastTitle;
if (titleChanged) this.lastTitle = currentTitle;
const config = this.configManager.get();
const currentThemeSet = PlatformAdapters.ThemeManager.selectThemeForUpdate(this, config, urlChanged, titleChanged);
// If the adapter returns null, it signals that the theme update should be deferred.
// This is used to wait for a final page title after navigating from an excluded page.
if (currentThemeSet === null) {
Logger.badge('THEME CHECK', LOG_STYLES.GRAY, 'debug', 'Theme update deferred by platform adapter.');
return;
}
// Deep comparison to detect changes from the settings panel
const contentChanged = JSON.stringify(currentThemeSet) !== JSON.stringify(this.lastAppliedThemeSet);
const themeShouldUpdate = force || urlChanged || titleChanged || contentChanged;
if (themeShouldUpdate) {
this.applyThemeStyles(currentThemeSet, config);
this.applyChatContentMaxWidth();
}
}
/**
* @private
* Ensures that the <style> elements for static and dynamic CSS exist in the document head.
* Creates them if they don't exist and stores references in `this`.
*/
_ensureStylesheets() {
if (!this.themeStyleElem) {
this.themeStyleElem = h('style', {
id: `${APPID}-theme-style`,
textContent: PlatformAdapters.StyleManager.getStaticCss(),
});
document.head.appendChild(this.themeStyleElem);
}
if (!this.dynamicRulesStyleElem) {
const dynamicRulesStyleId = `${APPID}-dynamic-rules-style`;
this.dynamicRulesStyleElem = document.getElementById(dynamicRulesStyleId);
if (!this.dynamicRulesStyleElem) {
this.dynamicRulesStyleElem = h('style', { id: dynamicRulesStyleId });
document.head.appendChild(this.dynamicRulesStyleElem);
}
}
}
/**
* Applies all theme-related styles to the document by orchestrating helper methods.
* @param {ThemeSet} currentThemeSet The active theme configuration.
* @param {AppConfig} fullConfig The entire configuration object, including defaultSet.
*/
async applyThemeStyles(currentThemeSet, fullConfig) {
if (this.isDestroyed) return;
const myRequestId = ++this.currentRequestId;
Logger.time('ThemeManager.applyThemeStyles');
this.lastAppliedThemeSet = currentThemeSet;
this._ensureStylesheets();
const dynamicRules = this.styleGenerator.generateDynamicCss(currentThemeSet, fullConfig);
this.dynamicRulesStyleElem.textContent = dynamicRules.join('\n');
const rootStyle = document.documentElement.style;
const imageProcessingPromises = [];
// Capture current icon size to detect changes that affect icon rendering
const currentIconSize = this.configManager.getIconSize();
const iconSizeChanged = this.lastAppliedIconSize !== currentIconSize;
this.lastAppliedIconSize = currentIconSize;
for (const definition of ALL_STYLE_DEFINITIONS) {
if (!definition.cssVar) continue;
const value = getPropertyByPath(currentThemeSet, definition.configKey) ?? getPropertyByPath(fullConfig, definition.fallbackKey);
const isImage = definition.configKey.endsWith('icon') || definition.configKey.includes('ImageUrl');
if (isImage) {
const val = value ? String(value).trim() : null;
const lastVal = this.lastAppliedImageValues.get(definition.cssVar);
const isIcon = definition.configKey.endsWith('icon');
// Optimization: Skip if the value hasn't changed.
// Exception: If it's an icon and the global icon size setting has changed, we must re-process it.
if (val === lastVal && (!isIcon || !iconSizeChanged)) {
continue;
}
// Invalidation: Immediately remove the current value from cache.
// This ensures that if we switch back to the original theme while this request is pending,
// the value check will correctly identify it as a change (undefined !== val) and re-apply it.
this.lastAppliedImageValues.delete(definition.cssVar);
// Stage 1 (Sync): Immediately set to 'none' to prevent flicker of default images.
rootStyle.setProperty(definition.cssVar, 'none');
if (value) {
// Stage 2 (Async): Start processing the image in the background.
const processImage = async () => {
let finalCssValue = val;
if (val.startsWith('<svg')) {
finalCssValue = `url("${svgToDataUrl(val)}")`;
} else if (val.startsWith('http')) {
let resizeOptions = {};
if (isIcon) {
resizeOptions = { width: currentIconSize, height: currentIconSize };
}
const dataUrl = await this.imageDataManager.getImageAsDataUrl(val, resizeOptions);
finalCssValue = dataUrl ? `url("${dataUrl}")` : 'none';
} else if (val.startsWith('data:image')) {
finalCssValue = `url("${val}")`;
}
// Guard: If a new theme request has started or app is destroyed, discard this result.
if (this.isDestroyed || this.currentRequestId !== myRequestId) return;
// When ready, update the CSS variable to show the themed image.
if (finalCssValue !== 'none') {
rootStyle.setProperty(definition.cssVar, finalCssValue);
// Update the cache only after successful application
this.lastAppliedImageValues.set(definition.cssVar, val);
}
};
imageProcessingPromises.push(processImage());
} else {
// If value is null, we already set 'none' above. Just update the cache to reflect the 'none' state.
this.lastAppliedImageValues.set(definition.cssVar, val);
}
} else {
// This is a non-image style, apply it synchronously.
if (value !== null && value !== undefined) {
// Apply the transformer function if it exists (e.g., for actor names).
const finalValue = typeof definition.transformer === 'function' ? definition.transformer(value, fullConfig) : value;
rootStyle.setProperty(definition.cssVar, finalValue);
} else {
rootStyle.removeProperty(definition.cssVar);
}
}
}
// After all image processing promises have resolved, publish the final event.
Promise.all(imageProcessingPromises).then(() => {
// Guard event publication as well
if (!this.isDestroyed && this.currentRequestId === myRequestId) {
EventBus.publish(EVENTS.THEME_APPLIED, { theme: currentThemeSet, config: fullConfig });
}
});
Logger.timeEnd('ThemeManager.applyThemeStyles');
}
/**
* Calculates and applies the dynamic max-width for the chat content area.
* @param {string | null} [forcedWidth] - A specific width value to apply for previews.
*/
applyChatContentMaxWidth(forcedWidth = undefined) {
const rootStyle = document.documentElement.style;
const config = this.configManager.get();
if (!config) return;
// Use forcedWidth for preview if provided; otherwise, get from config.
const userMaxWidth = forcedWidth !== undefined ? forcedWidth : config.options.chat_content_max_width;
withLayoutCycle({
measure: () => {
// --- Read Phase ---
// Read layout properties needed for the 'else' block.
return {
sidebarWidth: getSidebarWidth(),
windowWidth: window.innerWidth,
};
},
mutate: (measured) => {
// --- Write Phase ---
if (!userMaxWidth) {
document.body.classList.remove(`${APPID}-max-width-active`);
rootStyle.removeProperty(`--${APPID}-chat-content-max-width`);
} else {
document.body.classList.add(`${APPID}-max-width-active`);
const themeSet = this.getThemeSet();
const iconSize = config.options.icon_size;
// Check if standing images are active in the current theme or default.
const hasStandingImage =
getPropertyByPath(themeSet, 'user.standingImageUrl') ||
getPropertyByPath(themeSet, 'assistant.standingImageUrl') ||
getPropertyByPath(config.defaultSet, 'user.standingImageUrl') ||
getPropertyByPath(config.defaultSet, 'assistant.standingImageUrl');
let requiredMarginPerSide = iconSize + CONSTANTS.ICON_MARGIN * 2;
if (hasStandingImage) {
const minStandingImageWidth = iconSize * 2;
requiredMarginPerSide = Math.max(requiredMarginPerSide, minStandingImageWidth);
}
const { sidebarWidth, windowWidth } = measured;
const totalRequiredMargin = sidebarWidth + requiredMarginPerSide * 2;
const maxAllowedWidth = windowWidth - totalRequiredMargin;
// Use CSS min() to ensure the user's value does not exceed the calculated available space.
const finalMaxWidth = `min(${userMaxWidth}, ${maxAllowedWidth}px)`;
rootStyle.setProperty(`--${APPID}-chat-content-max-width`, finalMaxWidth);
}
},
});
// Notify other managers that the chat content width may have changed.
EventBus.publish(EVENTS.CHAT_CONTENT_WIDTH_UPDATED);
}
}
// =================================================================================
// SECTION: DOM Observers and Event Listeners
// =================================================================================
class ObserverManager {
constructor(messageCacheManager) {
// Initialize the MutationObserver with the bound callback immediately.
this.mainObserver = new MutationObserver((mutations) => this._handleMainMutations(mutations));
this.mainObserverContainer = null;
this.layoutResizeObserver = new ResizeObserver(this._handleResize.bind(this));
this.observedElements = new Map();
this.processedTurnNodes = new Set();
this.sentinelTurnListeners = new Map();
this.subscriptions = [];
this.debouncedCacheUpdate = debounce(this._publishCacheUpdate.bind(this), CONSTANTS.TIMING.DEBOUNCE_DELAYS.CACHE_UPDATE, true);
this.activeObservers = [];
this.activePageObservers = [];
// The debounced visibility check
this.debouncedVisibilityCheck = debounce(() => EventBus.queueUIWork(this.publishVisibilityRecheck.bind(this)), CONSTANTS.TIMING.DEBOUNCE_DELAYS.VISIBILITY_CHECK, true);
// Add reference to MessageCacheManager
this.messageCacheManager = messageCacheManager;
// Timer for 0-message page grace period
this.zeroMessageTimer = null;
// Bound listener for navigation-related cache updates
this.boundHandleCacheUpdateForNavigation = this._handleCacheUpdateForNavigation.bind(this);
// Bound listener for Sentinel main observer setup
this.boundSetupMainObserver = this._setupMainObserver.bind(this);
}
/**
* Helper to subscribe to EventBus and track the subscription for cleanup.
* Appends the listener name and a unique suffix to the key to avoid conflicts.
* @param {string} event
* @param {Function} listener
*/
_subscribe(event, listener) {
const baseKey = createEventKey(this, event);
// Use function name for debugging aid, fallback to 'anonymous'
const listenerName = listener.name || 'anonymous';
// Generate a short random suffix to guarantee uniqueness even for anonymous functions
const uniqueSuffix = Math.random().toString(36).substring(2, 7);
const key = `${baseKey}_${listenerName}_${uniqueSuffix}`;
EventBus.subscribe(event, listener, key);
this.subscriptions.push({ event, key });
}
/**
* Helper to subscribe to EventBus once and track the subscription for cleanup.
* Appends the listener name and a unique suffix to the key to avoid conflicts.
* @param {string} event
* @param {Function} listener
*/
_subscribeOnce(event, listener) {
const baseKey = createEventKey(this, event);
// Use function name for debugging aid, fallback to 'anonymous'
const listenerName = listener.name || 'anonymous';
// Generate a short random suffix to guarantee uniqueness even for anonymous functions
const uniqueSuffix = Math.random().toString(36).substring(2, 7);
const key = `${baseKey}_${listenerName}_${uniqueSuffix}`;
const wrappedListener = (...args) => {
this.subscriptions = this.subscriptions.filter((sub) => sub.key !== key);
listener(...args);
};
EventBus.once(event, wrappedListener, key);
this.subscriptions.push({ event, key });
}
/**
* Initializes the manager by subscribing to system-wide events.
* This method's functionality was previously part of start().
*/
init() {
this._subscribe(EVENTS.SUSPEND_OBSERVERS, () => this.stopMainObserver());
this._subscribe(EVENTS.RESUME_OBSERVERS_AND_REFRESH, () => {
if (this.mainObserverContainer) {
this.startMainObserver(this.mainObserverContainer);
// Manually trigger a full refresh.
this.debouncedCacheUpdate.bind(this)();
}
});
this._subscribe(EVENTS.APP_SHUTDOWN, () => this.destroy());
}
addObserver(observer) {
this.activeObservers.push(observer);
}
/**
* Starts all platform-specific observers using Sentinel for synchronous registration.
*/
start() {
// Use Sentinel to detect the main container.
sentinel.on(CONSTANTS.SELECTORS.MESSAGE_CONTAINER_PARENT, this.boundSetupMainObserver);
// Immediate check in case the element already exists.
const existingContainer = document.querySelector(CONSTANTS.SELECTORS.MESSAGE_CONTAINER_PARENT);
if (existingContainer instanceof HTMLElement) {
this.boundSetupMainObserver(existingContainer);
}
// Centralized ResizeObserver for layout changes
this.observeElement(document.body, CONSTANTS.OBSERVED_ELEMENT_TYPES.BODY);
// Subscribe to navigation events to handle page changes
this._subscribe(EVENTS.NAVIGATION, () => this._onNavigation());
// Subscribe to RAW_MESSAGE_ADDED to trigger cache updates when new messages appear.
this._subscribe(EVENTS.RAW_MESSAGE_ADDED, () => this.debouncedCacheUpdate());
this.startInputAreaObserver();
// Perform initial setup.
// This ensures all managers recognize the initial load as a navigation event.
EventBus.publish(EVENTS.NAVIGATION);
}
destroy() {
this.debouncedCacheUpdate.cancel();
this.stopMainObserver();
// Clean up Sentinel listener for main observer
sentinel.off(CONSTANTS.SELECTORS.MESSAGE_CONTAINER_PARENT, this.boundSetupMainObserver);
// Clean up any lingering turn completion listeners.
for (const [selector, callback] of this.sentinelTurnListeners.values()) {
sentinel.off(selector, callback);
}
this.sentinelTurnListeners.clear();
this.layoutResizeObserver?.disconnect();
this.activeObservers.forEach((observer) => observer.disconnect());
this.activeObservers = [];
// Add cleanup for page-specific observers
this.activePageObservers.forEach((cleanup) => cleanup());
this.activePageObservers = [];
// Clear any pending grace period timers
clearTimeout(this.zeroMessageTimer);
this.subscriptions.forEach(({ event, key }) => EventBus.unsubscribe(event, key));
this.subscriptions = [];
}
/**
* @private
* @description Callback for Sentinel to set up the main observer when the container is detected.
* @param {HTMLElement} container The detected container element.
*/
_setupMainObserver(container) {
// Prevent duplicate setup for the same container reference
if (!container || this.mainObserverContainer === container) return;
Logger.debug('Main app container detected. Starting observers.');
this.mainObserverContainer = container;
this.startMainObserver(container);
}
/**
* @private
* @description Handles the logic required when a navigation occurs or the app initializes.
* Resets observers and sets up page-specific listeners synchronously to avoid race conditions.
*/
_onNavigation() {
try {
// Clean up all resources from the previous page.
clearTimeout(this.zeroMessageTimer); // Stop any pending 0-message timers
// Safely cleanup all active page observers
this.activePageObservers.forEach((cleanup) => {
try {
cleanup();
} catch (e) {
Logger.warn('Error during observer cleanup:', e);
}
});
this.activePageObservers = [];
this.stopMainObserver();
// Only restart the main observer if the container is still connected to the DOM.
// This prevents holding onto "zombie" containers during SPA transitions (especially on ChatGPT).
if (this.mainObserverContainer && this.mainObserverContainer.isConnected) {
this.startMainObserver(this.mainObserverContainer);
} else {
// If disconnected, clear the reference. Sentinel will re-detect the new container when it appears.
this.mainObserverContainer = null;
}
// Clean up any lingering turn completion listeners from the previous page.
for (const [selector, callback] of this.sentinelTurnListeners.values()) {
sentinel.off(selector, callback);
}
this.sentinelTurnListeners.clear();
// Subscribe to CACHE_UPDATED to manage the NAVIGATION_END lifecycle.
const cacheEventKey = createEventKey(this, EVENTS.CACHE_UPDATED);
EventBus.subscribe(EVENTS.CACHE_UPDATED, this.boundHandleCacheUpdateForNavigation, cacheEventKey);
this.activePageObservers.push(() => EventBus.unsubscribe(EVENTS.CACHE_UPDATED, cacheEventKey));
// Trigger an initial cache update immediately. This will start the navigation end detection.
this.debouncedCacheUpdate();
// --- Start all page-specific observers from here ---
const observerStarters = PlatformAdapters.Observer.getPlatformObserverStarters();
for (const startObserver of observerStarters) {
// Synchronously start observer and get cleanup function
const cleanup = startObserver({
observeElement: this.observeElement.bind(this),
unobserveElement: this.unobserveElement.bind(this),
});
if (typeof cleanup === 'function') {
this.activePageObservers.push(cleanup);
}
}
} catch (e) {
Logger.badge('NAV_HANDLER_ERROR', LOG_STYLES.RED, 'error', 'Error during navigation handling:', e);
}
}
/**
* @private
* @description A stateful handler for CACHE_UPDATED events, specifically to manage the NAVIGATION_END lifecycle.
* It distinguishes between "history loading" (0 messages) and "history loaded" (N messages) or "new chat page" (0 messages confirmed after a grace period).
*/
_handleCacheUpdateForNavigation() {
// Stop any pending 0-message confirmation timer.
clearTimeout(this.zeroMessageTimer);
if (this.messageCacheManager && this.messageCacheManager.getTotalMessages().length > 0) {
// --- Case A: Messages Found ---
// This is the "history loaded" state. Navigation is complete.
Logger.debug('Cache update has messages. Firing NAVIGATION_END.');
EventBus.publish(EVENTS.NAVIGATION_END);
// Unsubscribe self, as navigation is complete.
EventBus.unsubscribe(EVENTS.CACHE_UPDATED, createEventKey(this, EVENTS.CACHE_UPDATED));
} else {
// --- Case B: 0 Messages Found ---
// This could be a "true 0-message page" OR "history is still loading".
// Start a timer to give messages time to load.
Logger.debug(`Cache update has 0 messages. Starting ${CONSTANTS.TIMING.TIMEOUTS.ZERO_MESSAGE_GRACE_PERIOD}ms grace period...`);
this.zeroMessageTimer = setTimeout(() => {
// If the timer finishes *without* being canceled by another cache update,
// we are definitively on a 0-message page. Navigation is complete.
Logger.debug('Grace period ended. Assuming 0-message page. Firing NAVIGATION_END.');
EventBus.publish(EVENTS.NAVIGATION_END);
// Unsubscribe self, as navigation is complete.
EventBus.unsubscribe(EVENTS.CACHE_UPDATED, createEventKey(this, EVENTS.CACHE_UPDATED));
}, CONSTANTS.TIMING.TIMEOUTS.ZERO_MESSAGE_GRACE_PERIOD);
}
}
/**
* @private
* @description Starts a stateful observer for the input area, attaching a ResizeObserver to trigger UI repositioning events.
*/
startInputAreaObserver() {
let observedInputArea = null;
// Capture the ObserverManager instance for use in the callback
const instance = this;
const setupObserver = (inputArea) => {
if (inputArea === observedInputArea) return; // Already observing this element
// Unobserve the old element if it exists and is different
if (observedInputArea) {
instance.unobserveElement(observedInputArea);
}
// Observe the new element
instance.observeElement(inputArea, CONSTANTS.OBSERVED_ELEMENT_TYPES.INPUT_AREA);
observedInputArea = inputArea;
};
const selector = CONSTANTS.SELECTORS.INPUT_RESIZE_TARGET;
sentinel.on(selector, setupObserver);
// Initial check in case the element is already present on load
const initialInputArea = document.querySelector(selector);
if (initialInputArea instanceof HTMLElement) {
setupObserver(initialInputArea);
}
}
_handleResize(entries) {
for (const entry of entries) {
const type = this.observedElements.get(entry.target);
switch (type) {
case CONSTANTS.OBSERVED_ELEMENT_TYPES.BODY:
EventBus.publish(EVENTS.WINDOW_RESIZED);
break;
case CONSTANTS.OBSERVED_ELEMENT_TYPES.INPUT_AREA:
EventBus.publish(EVENTS.INPUT_AREA_RESIZED);
break;
case CONSTANTS.OBSERVED_ELEMENT_TYPES.SIDE_PANEL:
EventBus.publish(EVENTS.UI_REPOSITION);
break;
}
}
}
observeElement(element, type) {
if (!element || this.observedElements.has(element)) return;
this.observedElements.set(element, type);
this.layoutResizeObserver.observe(element);
}
unobserveElement(element) {
if (!element || !this.observedElements.has(element)) return;
this.layoutResizeObserver.unobserve(element);
this.observedElements.delete(element);
}
/**
* Starts the main MutationObserver to watch for DOM changes.
* @param {HTMLElement} container The main container element to observe.
*/
startMainObserver(container) {
// Guard: Ensure the container is valid and actually connected to the DOM.
// This prevents starting observers on stale elements or null references.
if (!container || !container.isConnected) {
Logger.debug('startMainObserver skipped: Container is invalid or disconnected.');
return;
}
if (this.mainObserver) {
this.mainObserver.observe(container, CONSTANTS.OBSERVER_OPTIONS);
}
}
/**
* Stops the main MutationObserver.
*/
stopMainObserver() {
if (this.mainObserver) {
this.mainObserver.disconnect();
}
}
/**
* @private
* @description Callback for the main MutationObserver, now specialized to handle only message deletions.
* Message additions are handled exclusively by the Sentinel class for performance.
* If a deletion of a message node is detected, it triggers a debounced update of the message cache
* to keep the application state consistent.
* @param {MutationRecord[]} mutations An array of MutationRecord objects provided by the observer.
*/
_handleMainMutations(mutations) {
// Check only for removed nodes that are message containers.
// Additions are handled exclusively by Sentinel for better performance.
const hasDeletion = mutations.some((mutation) => Array.from(mutation.removedNodes).some((node) => node instanceof Element && node.matches(CONSTANTS.SELECTORS.MESSAGE_ROOT_NODE)));
if (hasDeletion) {
Logger.badge('MUTATION', LOG_STYLES.GRAY, 'debug', 'Message deletion detected.');
// A deletion occurred, so a full cache rebuild is necessary.
this.debouncedCacheUpdate();
}
}
/**
* @description Processes a turn node, handling both completed and streaming turns.
* If the turn is already complete, it triggers final updates (e.g., for navigation).
* If the turn is streaming, it attaches a dedicated MutationObserver to watch for its completion.
* @param {HTMLElement} turnNode The turn container element to process or observe.
*/
observeTurnForCompletion(turnNode) {
// If this turn contains a user message, it signifies the start of a new interaction.
if (turnNode.querySelector(CONSTANTS.SELECTORS.USER_MESSAGE)) {
PerfMonitor.reset();
}
PerfMonitor.throttleLog('observeTurnForCompletion');
// Do not re-process turns that have already been handled or are currently being observed.
if (this.processedTurnNodes.has(turnNode) || this.sentinelTurnListeners.has(turnNode)) return;
if (turnNode.nodeType !== Node.ELEMENT_NODE) return;
if (this._isTurnComplete(turnNode)) {
EventBus.publish(EVENTS.TURN_COMPLETE, turnNode);
this.debouncedCacheUpdate(); // Update cache for completed turns to immediately reflect the message count in the navigation console.
// Mark this turn as processed to prevent redundant executions.
this.processedTurnNodes.add(turnNode);
} else {
// This branch handles streaming turns using the efficient Sentinel observer.
const sentinelCallback = (completionElement) => {
// Ensure the completion element belongs to the turn we are observing.
const completedTurnNode = completionElement.closest(CONSTANTS.SELECTORS.CONVERSATION_UNIT);
if (completedTurnNode !== turnNode) return;
// Self-remove the listener to prevent memory leaks and redundant calls.
sentinel.off(CONSTANTS.SELECTORS.TURN_COMPLETE_SELECTOR, sentinelCallback);
this.sentinelTurnListeners.delete(turnNode);
EventBus.publish(EVENTS.TURN_COMPLETE, turnNode);
// Manually trigger a cache update now that streaming is complete.
this.debouncedCacheUpdate();
this.processedTurnNodes.add(turnNode);
};
// Store the listener so it can be cleaned up on navigation.
this.sentinelTurnListeners.set(turnNode, [CONSTANTS.SELECTORS.TURN_COMPLETE_SELECTOR, sentinelCallback]);
sentinel.on(CONSTANTS.SELECTORS.TURN_COMPLETE_SELECTOR, sentinelCallback);
}
}
publishVisibilityRecheck() {
EventBus.publish(EVENTS.VISIBILITY_RECHECK);
}
/**
* Checks if a conversation turn is complete by delegating to the platform-specific adapter.
* @param {HTMLElement} turnNode The turn container element.
* @returns {boolean} True if the turn is considered complete.
* @private
*/
_isTurnComplete(turnNode) {
return PlatformAdapters.Observer.isTurnComplete(turnNode);
}
/** @private */
_publishCacheUpdate() {
EventBus.publish(EVENTS.CACHE_UPDATE_REQUEST);
}
}
class AvatarManager {
/**
* @param {ConfigManager} configManager
* @param messageCacheManager
*/
constructor(configManager, messageCacheManager) {
this.configManager = configManager;
this.messageCacheManager = messageCacheManager;
this.subscriptions = [];
// A queue to hold incoming avatar injection requests.
this._injectionQueue = [];
// A debounced function to process the queue in a single batch.
this._debouncedProcessQueue = debounce(this._processInjectionQueue.bind(this), CONSTANTS.TIMING.DEBOUNCE_DELAYS.AVATAR_INJECTION, true);
this._handleAvatarDisappearance = (element) => {
if (element instanceof HTMLElement) {
this.queueForInjection(element);
}
};
// Create an avatar template once to be cloned later for performance.
this.avatarTemplate = h(`div${CONSTANTS.SELECTORS.SIDE_AVATAR_CONTAINER}`, [h(`span${CONSTANTS.SELECTORS.SIDE_AVATAR_ICON}`), h(`div${CONSTANTS.SELECTORS.SIDE_AVATAR_NAME}`)]);
}
/**
* Helper to subscribe to EventBus and track the subscription for cleanup.
* Appends the listener name and a unique suffix to the key to avoid conflicts.
* @param {string} event
* @param {Function} listener
*/
_subscribe(event, listener) {
const baseKey = createEventKey(this, event);
// Use function name for debugging aid, fallback to 'anonymous'
const listenerName = listener.name || 'anonymous';
// Generate a short random suffix to guarantee uniqueness even for anonymous functions
const uniqueSuffix = Math.random().toString(36).substring(2, 7);
const key = `${baseKey}_${listenerName}_${uniqueSuffix}`;
EventBus.subscribe(event, listener, key);
this.subscriptions.push({ event, key });
}
/**
* Initializes the manager by injecting styles and subscribing to events.
*/
init() {
this.injectAvatarStyle();
// Instead of processing immediately, queue the element for batch processing.
this._subscribe(EVENTS.AVATAR_INJECT, (elem) => this.queueForInjection(elem));
this._subscribe(EVENTS.APP_SHUTDOWN, () => this.destroy());
// Use the Sentinel class to detect when an avatar has been removed from a processed element.
// This is a highly performant self-healing mechanism.
const disappearanceSelector = `.${APPID}-avatar-processed:not(:has(${CONSTANTS.SELECTORS.SIDE_AVATAR_CONTAINER}))`;
sentinel.on(disappearanceSelector, this._handleAvatarDisappearance);
}
destroy() {
this.subscriptions.forEach(({ event, key }) => EventBus.unsubscribe(event, key));
this.subscriptions = [];
document.getElementById(`${APPID}-avatar-style`)?.remove();
// Clean up the listener from the Sentinel instance.
const disappearanceSelector = `.${APPID}-avatar-processed:not(:has(${CONSTANTS.SELECTORS.SIDE_AVATAR_CONTAINER}))`;
sentinel.off(disappearanceSelector, this._handleAvatarDisappearance);
}
/**
* Adds a message element to the injection queue and triggers the debounced processor.
* @param {HTMLElement} msgElem The message element to process.
*/
queueForInjection(msgElem) {
const MAX_ATTEMPTS = 5;
const attempts = parseInt(msgElem.dataset[CONSTANTS.DATA_KEYS.AVATAR_INJECT_ATTEMPTS] || '0', 10);
if (attempts >= MAX_ATTEMPTS) {
// Log the failure only once to avoid spamming the console.
if (!msgElem.dataset[CONSTANTS.DATA_KEYS.AVATAR_INJECT_FAILED]) {
Logger.badge('AVATAR RETRY FAILED', LOG_STYLES.YELLOW, 'warn', `Avatar injection failed after ${MAX_ATTEMPTS} attempts. Halting retries for this element:`, msgElem);
msgElem.dataset[CONSTANTS.DATA_KEYS.AVATAR_INJECT_FAILED] = 'true';
}
return; // Stop trying
}
msgElem.dataset[CONSTANTS.DATA_KEYS.AVATAR_INJECT_ATTEMPTS] = String(attempts + 1);
if (!this._injectionQueue.includes(msgElem)) {
this._injectionQueue.push(msgElem);
}
this._debouncedProcessQueue();
}
/**
* Processes all queued avatar injection requests in a batch to optimize performance.
* This method separates DOM writes from reads to prevent layout thrashing.
* @private
*/
_processInjectionQueue() {
if (this._injectionQueue.length === 0) {
return;
}
Logger.badge('AVATAR QUEUE', LOG_STYLES.GRAY, 'debug', `Processing ${this._injectionQueue.length} items.`);
const messagesToProcess = [...this._injectionQueue];
this._injectionQueue = [];
processInBatches(
messagesToProcess,
(msgElem) => {
const role = PlatformAdapters.General.getMessageRole(msgElem);
if (!role) return;
const container = this.avatarTemplate.cloneNode(true);
if (container instanceof HTMLElement) {
// Call the platform-specific injection logic
PlatformAdapters.Avatar.addAvatarToMessage(msgElem, container);
// On successful injection attempt, remove the counter.
// If the injection somehow fails and the avatar is still missing,
// Sentinel will re-queue it, and the counter will be incremented again.
// Also clear the permanent failure flag.
delete msgElem.dataset[CONSTANTS.DATA_KEYS.AVATAR_INJECT_ATTEMPTS];
delete msgElem.dataset[CONSTANTS.DATA_KEYS.AVATAR_INJECT_FAILED];
}
},
CONSTANTS.BATCH_PROCESSING_SIZE
);
}
/**
* Injects the CSS for avatar styling.
*/
injectAvatarStyle() {
const styleId = `${APPID}-avatar-style`;
const existingStyle = document.getElementById(styleId);
if (existingStyle) {
existingStyle.remove();
}
this.updateIconSizeCss();
const iconSizeCssVar = `--${APPID}-icon-size`;
const iconMarginCssVar = `--${APPID}-icon-margin`;
const avatarStyle = h('style', {
id: styleId,
textContent: PlatformAdapters.Avatar.getCss(iconSizeCssVar, iconMarginCssVar),
});
document.head.appendChild(avatarStyle);
}
/**
* Reads the icon size from config and applies it as a CSS variable.
*/
updateIconSizeCss() {
const iconSize = this.configManager.getIconSize();
document.documentElement.style.setProperty(`--${APPID}-icon-size`, `${iconSize}px`);
document.documentElement.style.setProperty(`--${APPID}-icon-margin`, `${CONSTANTS.ICON_MARGIN}px`);
}
}
class StandingImageManager {
/**
* @param {ConfigManager} configManager
* @param messageCacheManager
*/
constructor(configManager, messageCacheManager) {
this.configManager = configManager;
this.messageCacheManager = messageCacheManager;
this.subscriptions = [];
this.isUpdateScheduled = false;
this.scheduleUpdate = this.scheduleUpdate.bind(this);
this.anchorSelectors = [];
}
/**
* Helper to subscribe to EventBus and track the subscription for cleanup.
* Appends the listener name and a unique suffix to the key to avoid conflicts.
* @param {string} event
* @param {Function} listener
*/
_subscribe(event, listener) {
const baseKey = createEventKey(this, event);
// Use function name for debugging aid, fallback to 'anonymous'
const listenerName = listener.name || 'anonymous';
// Generate a short random suffix to guarantee uniqueness even for anonymous functions
const uniqueSuffix = Math.random().toString(36).substring(2, 7);
const key = `${baseKey}_${listenerName}_${uniqueSuffix}`;
EventBus.subscribe(event, listener, key);
this.subscriptions.push({ event, key });
}
/**
* Initializes the manager by injecting styles and subscribing to events.
*/
init() {
this.createContainers();
this.injectStyles();
this._subscribe(EVENTS.WINDOW_RESIZED, this.scheduleUpdate);
this._subscribe(EVENTS.SIDEBAR_LAYOUT_CHANGED, this.scheduleUpdate);
this._subscribe(EVENTS.THEME_APPLIED, this.scheduleUpdate);
this._subscribe(EVENTS.VISIBILITY_RECHECK, this.scheduleUpdate);
this._subscribe(EVENTS.UI_REPOSITION, this.scheduleUpdate);
this._subscribe(EVENTS.CHAT_CONTENT_WIDTH_UPDATED, this.scheduleUpdate);
this._subscribe(EVENTS.APP_SHUTDOWN, () => this.destroy());
this._subscribe(EVENTS.DEFERRED_LAYOUT_UPDATE, this.scheduleUpdate);
// Anchor Detection using Sentinel
// Automatically detect when the layout anchor element appears or is re-inserted into the DOM.
// This is critical for SPA transitions (like switching Gems in Gemini) where the container is replaced.
this.anchorSelectors = CONSTANTS.SELECTORS.STANDING_IMAGE_ANCHOR.split(',').map((s) => s.trim());
this.anchorSelectors.forEach((selector) => {
sentinel.on(selector, this.scheduleUpdate);
});
PlatformAdapters.StandingImage.setupEventListeners(this);
}
destroy() {
this.subscriptions.forEach(({ event, key }) => EventBus.unsubscribe(event, key));
this.subscriptions = [];
// Cleanup Sentinel listeners
if (this.anchorSelectors) {
this.anchorSelectors.forEach((selector) => {
sentinel.off(selector, this.scheduleUpdate);
});
}
document.getElementById(`${APPID}-standing-image-user`)?.remove();
document.getElementById(`${APPID}-standing-image-assistant`)?.remove();
document.getElementById(`${APPID}-standing-image-style`)?.remove();
}
scheduleUpdate() {
if (this.isUpdateScheduled) return;
this.isUpdateScheduled = true;
EventBus.queueUIWork(() => {
this.updateVisibility();
this.recalculateStandingImagesLayout();
this.isUpdateScheduled = false;
});
}
injectStyles() {
const styleId = `${APPID}-standing-image-style`;
if (document.getElementById(styleId)) return;
document.head.appendChild(
h('style', {
id: styleId,
textContent: `
#${APPID}-standing-image-user,
#${APPID}-standing-image-assistant {
position: fixed;
bottom: 0;
height: 100vh;
max-height: 100vh;
pointer-events: none;
z-index: ${CONSTANTS.Z_INDICES.STANDING_IMAGE};
margin: 0;
padding: 0;
opacity: 0;
transition: opacity 0.3s, background-image 0.3s ease-in-out;
background-repeat: no-repeat;
background-position: bottom center;
background-size: contain;
}
#${APPID}-standing-image-assistant {
background-image: var(--${APPID}-assistant-standing-image, none);
left: var(--${APPID}-standing-image-assistant-left, 0px);
width: var(--${APPID}-standing-image-assistant-width, 0px);
max-width: var(--${APPID}-standing-image-assistant-width, 0px);
mask-image: var(--${APPID}-standing-image-assistant-mask, none);
-webkit-mask-image: var(--${APPID}-standing-image-assistant-mask, none);
}
#${APPID}-standing-image-user {
background-image: var(--${APPID}-user-standing-image, none);
right: 0;
width: var(--${APPID}-standing-image-user-width, 0px);
max-width: var(--${APPID}-standing-image-user-width, 0px);
mask-image: var(--${APPID}-standing-image-user-mask, none);
-webkit-mask-image: var(--${APPID}-standing-image-user-mask, none);
}
`,
})
);
}
createContainers() {
if (document.getElementById(`${APPID}-standing-image-assistant`)) return;
['user', 'assistant'].forEach((actor) => {
document.body.appendChild(h(`div`, { id: `${APPID}-standing-image-${actor}` }));
});
}
updateVisibility() {
PlatformAdapters.StandingImage.updateVisibility(this);
}
/**
* Recalculates the layout for the standing images.
* @returns {Promise<void>}
*/
async recalculateStandingImagesLayout() {
PlatformAdapters.StandingImage.recalculateLayout(this);
}
}
// =================================================================================
// SECTION: Bubble Feature Management
// =================================================================================
/**
* Manages the lifecycle of UI elements injected into chat bubbles, such as collapsible and navigation buttons.
* It uses a feature-driven architecture, where each UI addition is a self-contained "feature" object.
* This class acts as an engine that processes these features for each message element.
*/
class BubbleUIManager {
/**
* @param {ConfigManager} configManager
* @param {MessageCacheManager} messageCacheManager
*/
constructor(configManager, messageCacheManager) {
this.configManager = configManager;
this.messageCacheManager = messageCacheManager;
this.navContainers = new Map();
this.featureElementsCache = new Map();
this.subscriptions = [];
this.autoCollapseProcessedIds = new Set();
// Create templates for UI features to be cloned for performance.
this.featureTemplates = {
collapsibleButton: h(`button.${APPID}-collapsible-toggle-btn`, { type: 'button', title: 'Toggle message' }, [this._createIcon('collapse')]),
sequentialNavPrevButton: h(`button.${APPID}-bubble-nav-btn.${APPID}-nav-prev`, { type: 'button', title: 'Scroll to previous message', dataset: { originalTitle: 'Scroll to previous message' } }, [this._createIcon('prev')]),
sequentialNavNextButton: h(`button.${APPID}-bubble-nav-btn.${APPID}-nav-next`, { type: 'button', title: 'Scroll to next message', dataset: { originalTitle: 'Scroll to next message' } }, [this._createIcon('next')]),
scrollToTopButton: h(`button.${APPID}-bubble-nav-btn.${APPID}-nav-top`, { type: 'button', title: 'Scroll to top of this message' }, [this._createIcon('top')]),
};
/**
* @private
* @type {Array<object>}
*/
this._features = [
// Collapsible Button Feature Definition
{
name: 'collapsible',
isEnabled: (config) => config.features.collapsible_button.enabled,
getInfo: (msgElem) => PlatformAdapters.BubbleUI.getCollapsibleInfo(msgElem),
render: (info, msgElem, manager) => {
const button = manager.featureTemplates.collapsibleButton.cloneNode(true);
if (!(button instanceof HTMLElement)) return null;
button.onclick = (e) => {
e.stopPropagation();
info.msgWrapper.classList.toggle(`${APPID}-bubble-collapsed`);
};
// Only create and return the element. Insertion is handled by processElement.
return button;
},
update: (element, info, isEnabled, messageElement) => {
if (isEnabled && info) {
element.classList.remove(`${APPID}-hidden`);
info.msgWrapper.classList.add(`${APPID}-collapsible`);
info.bubbleElement.classList.add(`${APPID}-collapsible-content`);
info.positioningParent.classList.add(`${APPID}-collapsible-parent`);
// --- Auto Collapse Logic ---
const uniqueId = messageElement.dataset[`${APPID}UniqueId`];
if (uniqueId && !this.autoCollapseProcessedIds.has(uniqueId)) {
const config = this.configManager.get();
// Check if auto-collapse is enabled AND collapsible button is enabled (isEnabled is already true here)
if (config.features.auto_collapse_user_message.enabled) {
const role = PlatformAdapters.General.getMessageRole(messageElement);
if (role === CONSTANTS.SELECTORS.FIXED_NAV_ROLE_USER) {
// Check height threshold using the raw bubble element
const height = info.bubbleElement.offsetHeight;
if (height > CONSTANTS.BUTTON_VISIBILITY_THRESHOLD_PX) {
info.msgWrapper.classList.add(`${APPID}-bubble-collapsed`);
}
}
}
// Mark as processed to ensure it only runs once per message lifetime
this.autoCollapseProcessedIds.add(uniqueId);
}
} else {
element.classList.add(`${APPID}-hidden`);
if (info) {
info.msgWrapper.classList.remove(`${APPID}-collapsible`, `${APPID}-bubble-collapsed`);
info.bubbleElement.classList.remove(`${APPID}-collapsible-content`);
info.positioningParent.classList.remove(`${APPID}-collapsible-parent`);
}
}
},
},
// Sequential Navigation Buttons Feature Definition
{
name: 'sequentialNav',
group: 'sideNav',
position: 'top',
isEnabled: (config) => config.features.sequential_nav_buttons.enabled,
getInfo: (msgElem) => PlatformAdapters.BubbleUI.getSequentialNavInfo(msgElem),
render: (info, msgElem, manager) => {
const createClickHandler = (direction) => (e) => {
e.stopPropagation();
const roleInfo = manager.messageCacheManager.findMessageIndex(msgElem);
if (!roleInfo) return;
const newIndex = roleInfo.index + direction;
const targetMsg = manager.messageCacheManager.getMessageAtIndex(roleInfo.role, newIndex);
if (targetMsg) {
scrollToElement(targetMsg, { offset: CONSTANTS.RETRY.SCROLL_OFFSET_FOR_NAV });
EventBus.publish(EVENTS.NAV_HIGHLIGHT_MESSAGE, targetMsg);
}
};
const prevBtn = manager.featureTemplates.sequentialNavPrevButton.cloneNode(true);
const nextBtn = manager.featureTemplates.sequentialNavNextButton.cloneNode(true);
if (!(prevBtn instanceof HTMLElement) || !(nextBtn instanceof HTMLElement)) return null;
prevBtn.onclick = createClickHandler(-1);
nextBtn.onclick = createClickHandler(1);
return h(`div.${APPID}-nav-group-top`, [prevBtn, nextBtn]);
},
update: (element, info, isEnabled) => {
element.classList.toggle(`${APPID}-hidden`, !isEnabled);
},
},
// Scroll to Top Button Feature Definition
{
name: 'scrollToTop',
group: 'sideNav',
position: 'bottom',
isEnabled: (config) => config.features.scroll_to_top_button.enabled,
getInfo: (msgElem) => PlatformAdapters.BubbleUI.getScrollToTopInfo(msgElem),
render: (info, msgElem, manager) => {
const topBtn = manager.featureTemplates.scrollToTopButton.cloneNode(true);
if (!(topBtn instanceof HTMLElement)) return null;
topBtn.onclick = (e) => {
e.stopPropagation();
scrollToElement(msgElem, { offset: CONSTANTS.RETRY.SCROLL_OFFSET_FOR_NAV });
};
return h(`div.${APPID}-nav-group-bottom`, [topBtn]);
},
update: (element, info, isEnabled) => {
element.classList.toggle(`${APPID}-hidden`, !isEnabled);
},
},
];
}
/**
* Helper to subscribe to EventBus and track the subscription for cleanup.
* Appends the listener name and a unique suffix to the key to avoid conflicts.
* @param {string} event
* @param {Function} listener
*/
_subscribe(event, listener) {
const baseKey = createEventKey(this, event);
// Use function name for debugging aid, fallback to 'anonymous'
const listenerName = listener.name || 'anonymous';
// Generate a short random suffix to guarantee uniqueness even for anonymous functions
const uniqueSuffix = Math.random().toString(36).substring(2, 7);
const key = `${baseKey}_${listenerName}_${uniqueSuffix}`;
EventBus.subscribe(event, listener, key);
this.subscriptions.push({ event, key });
}
/**
* Initializes the manager by injecting styles and subscribing to events.
*/
init() {
this.injectStyle();
this._subscribe(EVENTS.TURN_COMPLETE, (turnNode) => this.processTurn(turnNode));
this._subscribe(EVENTS.NAVIGATION, () => this._onNavigation());
this._subscribe(EVENTS.CACHE_UPDATED, () => this.updateAll());
this._subscribe(EVENTS.APP_SHUTDOWN, () => this.destroy());
}
/**
* Cleans up all event listeners and clears caches.
*/
destroy() {
this.subscriptions.forEach(({ event, key }) => EventBus.unsubscribe(event, key));
this.subscriptions = [];
document.getElementById(this.getStyleId())?.remove();
this.navContainers.clear();
this.featureElementsCache.clear();
}
/**
* Forces a re-processing of all visible messages, typically after a config change.
*/
updateAll() {
this._syncCaches();
const allMessages = this.messageCacheManager.getTotalMessages();
processInBatches(
allMessages,
(messageElement) => {
this.processElement(messageElement);
},
CONSTANTS.BATCH_PROCESSING_SIZE,
() => {
// Update nav button states after all elements have been processed.
this._updateNavButtonStates();
}
);
}
/**
* Gets the unique ID for the style element.
* @returns {string}
*/
getStyleId() {
return `${APPID}-bubble-ui-style`;
}
/**
* Generates the consolidated CSS string for all bubble features.
* @returns {string}
*/
generateCss() {
return SITE_STYLES.COLLAPSIBLE_CSS + '\n' + SITE_STYLES.BUBBLE_NAV_CSS;
}
/**
* Injects the feature's specific CSS into the document head if not already present.
*/
injectStyle() {
const styleId = this.getStyleId();
if (document.getElementById(styleId)) return;
const style = h('style', {
id: styleId,
textContent: this.generateCss(),
});
document.head.appendChild(style);
}
/**
* Processes a single message element, applying all relevant features.
* @param {HTMLElement} messageElement The message element to process.
*/
processElement(messageElement) {
const config = this.configManager.get();
if (!config) return;
// Self-correction: If this element was previously marked as an image-only anchor but is now receiving content, remove the anchor class to restore normal layout.
if (messageElement.classList.contains(`${APPID}-image-only-anchor`)) {
messageElement.classList.remove(`${APPID}-image-only-anchor`);
}
const uniqueId = messageElement.dataset[`${APPID}UniqueId`] || (messageElement.dataset[`${APPID}UniqueId`] = generateUniqueId('msg'));
// Phase 1: Read/Gather information without modifying the DOM.
const featureTasks = this._features.map((feature) => ({
feature,
cacheKey: `${feature.name}-${uniqueId}`,
isEnabled: feature.isEnabled(config),
info: feature.getInfo(messageElement),
}));
// Phase 2: Write/Mutate the DOM based on the gathered information.
let sideNavContainer = null;
for (const task of featureTasks) {
const { feature, cacheKey, isEnabled, info } = task;
if (isEnabled && info) {
let featureElement = this.featureElementsCache.get(cacheKey);
if (!featureElement) {
featureElement = feature.render(info, messageElement, this);
if (featureElement) {
this.featureElementsCache.set(cacheKey, featureElement);
// --- Unified Placement and Cleanup Logic ---
let targetContainer = null;
let cleanupSelector = null;
if (feature.group === 'sideNav') {
if (!sideNavContainer) {
sideNavContainer = this._getOrCreateNavContainer(messageElement);
}
if (sideNavContainer) {
targetContainer = sideNavContainer.querySelector(`.${APPID}-nav-buttons`);
// Identify duplication by the main class of the container returned by render
if (featureElement.classList.contains(`${APPID}-nav-group-top`)) {
cleanupSelector = `.${APPID}-nav-group-top`;
} else {
cleanupSelector = `.${APPID}-nav-group-bottom`;
}
}
} else {
// For non-grouped features like collapsible, attach directly to positioningParent
targetContainer = info.positioningParent;
// Identify duplication by the main class of the element itself
// We assume the first class added in render is the identifier, e.g., APPID-collapsible-toggle-btn
if (featureElement.classList.length > 0) {
cleanupSelector = `.${featureElement.classList[0]}`;
}
}
if (targetContainer && cleanupSelector) {
// 1. Cleanup: Remove any existing element of the same type
const existing = targetContainer.querySelector(cleanupSelector);
if (existing) existing.remove();
// 2. Append: Insert the new element
if (feature.position === 'top') {
targetContainer.prepend(featureElement);
} else {
targetContainer.appendChild(featureElement);
}
}
}
}
if (featureElement) {
feature.update(featureElement, info, true, messageElement);
}
} else {
const featureElement = this.featureElementsCache.get(cacheKey);
if (featureElement) {
feature.update(featureElement, info, false, messageElement);
}
}
}
}
/**
* Processes a conversation turn after it has completed rendering.
* @param {HTMLElement} turnNode The turn container element.
*/
processTurn(turnNode) {
/** @type {NodeListOf<HTMLElement>} */
const allMessageElements = turnNode.querySelectorAll(CONSTANTS.SELECTORS.BUBBLE_FEATURE_MESSAGE_CONTAINERS);
allMessageElements.forEach((messageElement) => {
this.processElement(messageElement);
});
}
/**
* Resets caches on page navigation.
* @private
*/
_onNavigation() {
this.navContainers.clear();
this.featureElementsCache.clear();
this.autoCollapseProcessedIds.clear();
}
/**
* Removes stale entries from caches.
* @private
*/
_syncCaches() {
// Remove entries for messages that are no longer in the DOM.
syncCacheWithMessages(this.navContainers, this.messageCacheManager);
// Remove entries for feature elements that are no longer connected to the DOM.
for (const [key, element] of this.featureElementsCache.entries()) {
if (!element.isConnected) {
this.featureElementsCache.delete(key);
}
}
}
/**
* Creates an SVG icon element from a predefined map.
* @param {string} type The type of icon to create.
* @returns {SVGElement | null}
* @private
*/
_createIcon(type) {
const iconMap = {
collapse: 'arrowUp',
prev: 'arrowUp',
next: 'arrowDown',
top: 'scrollToTop',
};
const iconKey = iconMap[type];
if (!iconKey) {
return null;
}
const element = createIconFromDef(SITE_STYLES.ICONS[iconKey]);
if (element instanceof SVGElement) {
return element;
}
return null;
}
/**
* Retrieves or creates the shared navigation button container for a message element.
* @private
* @param {HTMLElement} messageElement The message to attach the container to.
* @returns {HTMLElement | null} The navigation container element.
*/
_getOrCreateNavContainer(messageElement) {
if (this.navContainers.has(messageElement)) {
return this.navContainers.get(messageElement);
}
const positioningParent = PlatformAdapters.BubbleUI.getNavPositioningParent(messageElement);
if (!positioningParent) {
Logger.badge('NAV SKIP', LOG_STYLES.GRAY, 'debug', 'Navigation attachment skipped (no positioning parent). This is expected for image-only or transient elements:', messageElement);
return null;
}
let container = messageElement.querySelector(`.${APPID}-bubble-nav-container`);
if (container instanceof HTMLElement) {
this.navContainers.set(messageElement, container);
return container;
}
positioningParent.style.position = 'relative';
positioningParent.classList.add(`${APPID}-bubble-parent-with-nav`);
container = h(`div.${APPID}-bubble-nav-container`, [h(`div.${APPID}-nav-buttons`)]);
if (!(container instanceof HTMLElement)) return null;
positioningParent.appendChild(container);
this.navContainers.set(messageElement, container);
return container;
}
/**
* Updates the enabled/disabled state of sequential navigation buttons.
* @private
*/
_updateNavButtonStates() {
this._syncCaches(); // Clean up caches before processing
const disabledHint = '(No message to scroll to)';
const updateActorButtons = (messages) => {
messages.forEach((message, index) => {
const container = this.navContainers.get(message);
if (!container) return;
const prevBtn = container.querySelector(`.${APPID}-nav-prev`);
if (prevBtn) {
const isDisabled = index === 0;
prevBtn.disabled = isDisabled;
prevBtn.title = isDisabled ? `${prevBtn.dataset.originalTitle} ${disabledHint}` : prevBtn.dataset.originalTitle;
}
const nextBtn = container.querySelector(`.${APPID}-nav-next`);
if (nextBtn) {
const isDisabled = index === messages.length - 1;
nextBtn.disabled = isDisabled;
nextBtn.title = isDisabled ? `${nextBtn.dataset.originalTitle} ${disabledHint}` : nextBtn.dataset.originalTitle;
}
});
};
updateActorButtons(this.messageCacheManager.getUserMessages());
updateActorButtons(this.messageCacheManager.getAssistantMessages());
}
}
// =================================================================================
// SECTION: Fixed Navigation Console
// Description: Manages the fixed navigation UI docked to the input area.
// =================================================================================
class FixedNavigationManager {
/**
* @param {object} dependencies
* @param {MessageCacheManager} dependencies.messageCacheManager
* @param {ConfigManager} dependencies.configManager
* @param {any} dependencies.autoScrollManager
* @param {MessageLifecycleManager} dependencies.messageLifecycleManager
* @param {object} [options]
*/
constructor({ messageCacheManager, configManager, autoScrollManager, messageLifecycleManager }, options = {}) {
this.messageCacheManager = messageCacheManager;
this.configManager = configManager;
this.autoScrollManager = autoScrollManager; // May be null
this.messageLifecycleManager = messageLifecycleManager;
// Centralized state management
this.state = {
currentIndices: { user: -1, asst: -1, total: -1 },
highlightedMessage: null,
isInitialSelectionDone: !!options.isReEnabling,
jumpListComponent: null,
lastFilterValue: '',
previousTotalMessages: 0,
isAutoScrolling: false,
isStreaming: false,
};
// Cache for UI elements to avoid repeated querySelector calls
this.uiCache = null;
this.subscriptions = [];
this.isRepositionScheduled = false;
this.scheduleReposition = this.scheduleReposition.bind(this);
this.handleBodyClick = this.handleBodyClick.bind(this);
this._handleKeyDown = this._handleKeyDown.bind(this);
}
/**
* Helper to subscribe to EventBus and track the subscription for cleanup.
* Appends the listener name and a unique suffix to the key to avoid conflicts.
* @param {string} event
* @param {Function} listener
*/
_subscribe(event, listener) {
const baseKey = createEventKey(this, event);
// Use function name for debugging aid, fallback to 'anonymous'
const listenerName = listener.name || 'anonymous';
// Generate a short random suffix to guarantee uniqueness even for anonymous functions
const uniqueSuffix = Math.random().toString(36).substring(2, 7);
const key = `${baseKey}_${listenerName}_${uniqueSuffix}`;
EventBus.subscribe(event, listener, key);
this.subscriptions.push({ event, key });
}
/**
* Helper to subscribe to EventBus once and track the subscription for cleanup.
* Appends the listener name and a unique suffix to the key to avoid conflicts.
* @param {string} event
* @param {Function} listener
*/
_subscribeOnce(event, listener) {
const baseKey = createEventKey(this, event);
// Use function name for debugging aid, fallback to 'anonymous'
const listenerName = listener.name || 'anonymous';
// Generate a short random suffix to guarantee uniqueness even for anonymous functions
const uniqueSuffix = Math.random().toString(36).substring(2, 7);
const key = `${baseKey}_${listenerName}_${uniqueSuffix}`;
const wrappedListener = (...args) => {
this.subscriptions = this.subscriptions.filter((sub) => sub.key !== key);
listener(...args);
};
EventBus.once(event, wrappedListener, key);
this.subscriptions.push({ event, key });
}
/**
* Initializes the fixed navigation console.
* @returns {Promise<void>}
*/
async init() {
this.injectStyle();
this.createContainers();
this._subscribe(EVENTS.CACHE_UPDATED, this._handleCacheUpdate.bind(this));
this._subscribe(EVENTS.NAVIGATION, this.resetState.bind(this));
this._subscribe(EVENTS.POLLING_MESSAGES_FOUND, this._handlePollingMessagesFound.bind(this));
this._subscribe(EVENTS.NAV_HIGHLIGHT_MESSAGE, this.setHighlightAndIndices.bind(this));
this._subscribe(EVENTS.WINDOW_RESIZED, this.scheduleReposition);
this._subscribe(EVENTS.SIDEBAR_LAYOUT_CHANGED, this.scheduleReposition);
this._subscribe(EVENTS.INPUT_AREA_RESIZED, this.scheduleReposition);
this._subscribe(EVENTS.UI_REPOSITION, this.scheduleReposition);
this._subscribe(EVENTS.APP_SHUTDOWN, () => this.destroy());
this._subscribe(EVENTS.MESSAGE_COMPLETE, this._detectStreamingStart.bind(this));
this._subscribe(EVENTS.TURN_COMPLETE, this._handleTurnComplete.bind(this));
this._subscribe(EVENTS.DEFERRED_LAYOUT_UPDATE, this.scheduleReposition);
// Subscribe to auto-scroll events if the manager exists.
if (this.autoScrollManager) {
this._subscribe(EVENTS.AUTO_SCROLL_START, () => {
this.state.isAutoScrolling = true;
this.updateUI(); // Re-render the UI to reflect the state change.
this.hideJumpList();
});
this._subscribe(EVENTS.AUTO_SCROLL_COMPLETE, () => {
this.state.isAutoScrolling = false;
this.updateUI(); // Re-render the UI to reflect the state change.
this.selectLastMessage();
});
}
// After the main UI is ready, trigger an initial UI update.
this._handleCacheUpdate();
}
scheduleReposition() {
if (this.isRepositionScheduled) return;
this.isRepositionScheduled = true;
EventBus.queueUIWork(() => {
this.repositionContainers();
this.isRepositionScheduled = false;
});
}
resetState() {
if (this.state.highlightedMessage) {
this.state.highlightedMessage.classList.remove(`${APPID}-highlight-message`);
}
this.state = {
currentIndices: { user: -1, asst: -1, total: -1 },
highlightedMessage: null,
isInitialSelectionDone: false,
jumpListComponent: null,
lastFilterValue: '',
previousTotalMessages: 0,
isAutoScrolling: false,
isStreaming: false,
};
// Reset filter text
this.lastFilterValue = '';
// Reset the bulk collapse button to its default state
const collapseBtn = this.navConsole?.querySelector(`#${APPID}-bulk-collapse-btn`);
if (collapseBtn instanceof HTMLElement) {
collapseBtn.dataset.state = 'expanded';
}
this._renderUI();
}
/**
* Destroys the component and cleans up all related DOM elements and listeners.
* @returns {void}
*/
destroy() {
if (this.state.highlightedMessage) {
this.state.highlightedMessage.classList.remove(`${APPID}-highlight-message`);
}
this.subscriptions.forEach(({ event, key }) => EventBus.unsubscribe(event, key));
this.subscriptions = [];
this.state.jumpListComponent?.destroy();
this.navConsole?.remove();
this.navConsole = null;
document.getElementById(`${APPID}-fixed-nav-style`)?.remove();
document.body.removeEventListener('click', this.handleBodyClick, true);
document.removeEventListener('keydown', this._handleKeyDown, true);
}
_detectStreamingStart(messageElement) {
// Guard against re-entry if streaming is already detected.
if (this.state.isStreaming) {
return;
}
// Guard: Do not check for streaming during the initial page load or auto-scrolling phase.
if (!this.state.isInitialSelectionDone || this.state.isAutoScrolling) {
return;
}
const role = PlatformAdapters.General.getMessageRole(messageElement);
// If an assistant message is detected and it's not yet complete, flag that streaming has started.
if (role === CONSTANTS.SELECTORS.FIXED_NAV_ROLE_ASSISTANT) {
const turnNode = messageElement.closest(CONSTANTS.SELECTORS.FIXED_NAV_TURN_CONTAINER);
if (turnNode && !PlatformAdapters.Observer.isTurnComplete(turnNode)) {
this.state.isStreaming = true;
EventBus.publish(EVENTS.STREAMING_START);
}
}
}
_handleTurnComplete(turnNode) {
// If streaming was in progress, reset the flag and trigger a cache update,
// which will then update the UI with the final message counts.
if (this.state.isStreaming) {
this.state.isStreaming = false;
EventBus.publish(EVENTS.STREAMING_END);
this.messageCacheManager.debouncedRebuildCache();
EventBus.publish(EVENTS.DEFERRED_LAYOUT_UPDATE);
}
// Also trigger a cache update for non-streaming turns (e.g., user messages)
// to keep the nav console in sync.
else {
// Failsafe: If a non-streaming turn (like a user message) completes while
// the flag is stuck on true (e.g., from a previously failed stream), reset it.
if (this.state.isStreaming) {
this.state.isStreaming = false;
EventBus.publish(EVENTS.STREAMING_END);
EventBus.publish(EVENTS.DEFERRED_LAYOUT_UPDATE);
}
this.messageCacheManager.debouncedRebuildCache();
}
}
_handlePollingMessagesFound() {
this.selectLastMessage();
}
updateUI() {
this._renderUI();
}
_handleCacheUpdate() {
// Do not update the UI while a message is streaming to prevent flickering and performance issues.
// The UI will be updated once the turn is complete.
if (this.state.isStreaming) return;
// If the jump list is open, a cache update means its data is stale.
// Close it to prevent inconsistent state and user confusion.
if (this.state.jumpListComponent) {
this._hideJumpList();
return; // Exit early to prevent further UI changes while the user was interacting.
}
const totalMessages = this.messageCacheManager.getTotalMessages();
const newTotal = totalMessages.length;
const oldTotal = this.state.previousTotalMessages;
// Check if new messages were added (e.g., from layout scan) and if we were at the end.
if (newTotal > oldTotal && this.state.currentIndices.total === oldTotal - 1 && !this.state.isAutoScrolling) {
// We were at the old last message, and new messages appeared.
// Re-select the new last message. This will update indices and call _renderUI().
this.selectLastMessage();
// Update previousTotalMessages here to prevent logic blocks below from running incorrectly
this.state.previousTotalMessages = newTotal;
// Exit, as selectLastMessage() already handled the UI update.
return;
}
// Validate the currently highlighted message.
if (this.state.highlightedMessage && !totalMessages.includes(this.state.highlightedMessage)) {
Logger.log('Highlighted message was removed from the DOM. Reselecting...');
// The highlighted message was deleted. Find the best candidate to re-highlight.
const lastKnownIndex = this.state.currentIndices.total;
// Try to select the message at the same index, or the new last message if the index is now out of bounds.
const newIndex = Math.min(lastKnownIndex, totalMessages.length - 1);
if (newIndex >= 0) {
this.setHighlightAndIndices(totalMessages[newIndex]);
} else {
// Cache is empty, reset state.
this.resetState();
}
}
// Select the last message on initial load, but only if auto-scroll is not in progress.
if (!this.state.isAutoScrolling && !this.state.isInitialSelectionDone && totalMessages.length > 0) {
this.selectLastMessage();
this.state.isInitialSelectionDone = true;
} else if (!this.state.highlightedMessage && totalMessages.length > 0) {
this.setHighlightAndIndices(totalMessages[0]);
}
PlatformAdapters.FixedNav.handleInfiniteScroll(this, this.state.highlightedMessage, this.state.previousTotalMessages);
this.state.previousTotalMessages = totalMessages.length;
this._renderUI();
}
_renderUI() {
if (!this.navConsole || !this.uiCache) return;
const { currentIndices, highlightedMessage } = this.state;
const userMessages = this.messageCacheManager.getUserMessages();
const asstMessages = this.messageCacheManager.getAssistantMessages();
const totalMessages = this.messageCacheManager.getTotalMessages();
// Determine visibility
// Hide if it's explicitly a new chat page.
// If not a new chat page, only hide if there are NO messages in cache AND no message elements in the DOM.
// This prevents the console from disappearing during cache rebuilds or temporary state inconsistencies.
const isNewChat = isNewChatPage();
const hasCachedMessages = totalMessages.length > 0;
const hasDomMessages = !!document.querySelector(CONSTANTS.SELECTORS.MESSAGE_ROOT_NODE);
if (isNewChat || (!hasCachedMessages && !hasDomMessages)) {
this.navConsole.classList.add(`${APPID}-nav-hidden`);
} else {
this.navConsole.classList.remove(`${APPID}-nav-hidden`);
// The first time it becomes visible, also remove the initial positioning-guard class.
if (this.navConsole.classList.contains(`${APPID}-nav-unpositioned`)) {
this.navConsole.classList.remove(`${APPID}-nav-unpositioned`);
}
}
// Access elements from cache
const { counters } = this.uiCache;
counters.user.total.textContent = String(userMessages.length ? userMessages.length : '--');
counters.assistant.total.textContent = String(asstMessages.length ? asstMessages.length : '--');
counters.total.total.textContent = String(totalMessages.length ? totalMessages.length : '--');
counters.user.current.textContent = String(currentIndices.user > -1 ? currentIndices.user + 1 : '--');
counters.assistant.current.textContent = String(currentIndices.asst > -1 ? currentIndices.asst + 1 : '--');
counters.total.current.textContent = String(currentIndices.total > -1 ? currentIndices.total + 1 : '--');
document.querySelectorAll(`.${APPID}-highlight-message, .${APPID}-highlight-turn`).forEach((el) => {
el.classList.remove(`${APPID}-highlight-message`);
el.classList.remove(`${APPID}-highlight-turn`);
});
if (highlightedMessage) {
highlightedMessage.classList.add(`${APPID}-highlight-message`);
PlatformAdapters.FixedNav.applyAdditionalHighlight(highlightedMessage);
}
if (this.state.jumpListComponent) {
this.state.jumpListComponent.updateHighlightedMessage(highlightedMessage);
}
// Update UI state for the auto-scroll feature.
if (this.autoScrollManager && this.uiCache.autoscrollBtn) {
const autoscrollBtn = this.uiCache.autoscrollBtn;
if (autoscrollBtn instanceof HTMLButtonElement) {
PlatformAdapters.FixedNav.updatePlatformSpecificButtonState(autoscrollBtn, this.state.isAutoScrolling, this.autoScrollManager);
}
}
// Update bulk collapse button visibility
const config = this.configManager.get();
const collapseBtn = this.uiCache.bulkCollapseBtn;
if (collapseBtn instanceof HTMLElement) {
const collapsibleEnabled = config?.features?.collapsible_button?.enabled ?? false;
const shouldShow = collapsibleEnabled && totalMessages.length > 0;
collapseBtn.style.display = shouldShow ? 'flex' : 'none';
const separator = this.uiCache.bulkCollapseSeparator;
if (separator instanceof HTMLElement && separator.classList.contains(`${APPID}-nav-separator`)) {
separator.style.display = shouldShow ? 'flex' : 'none';
}
this._updateBulkCollapseButtonTooltip(collapseBtn);
}
this.repositionContainers();
}
_updateBulkCollapseButtonTooltip(button) {
if (!button) return;
const currentState = button.dataset.state;
// Set the tooltip to describe the action that WILL be taken on click.
const tooltipText = currentState === 'expanded' ? 'Collapse all messages' : 'Expand all messages';
button.title = tooltipText;
}
_toggleAllMessages() {
const button = this.navConsole.querySelector(`#${APPID}-bulk-collapse-btn`);
if (!(button instanceof HTMLElement)) return;
const currentState = button.dataset.state;
const nextState = currentState === 'expanded' ? 'collapsed' : 'expanded';
button.dataset.state = nextState;
this._updateBulkCollapseButtonTooltip(button);
const messages = document.querySelectorAll(`.${APPID}-collapsible`);
const shouldCollapse = nextState === 'collapsed';
const highlightedMessage = this.state.highlightedMessage;
messages.forEach((msg) => {
msg.classList.toggle(`${APPID}-bubble-collapsed`, shouldCollapse);
});
if (highlightedMessage) {
requestAnimationFrame(() => {
document.body.offsetHeight; // Forcing reflow
requestAnimationFrame(() => {
this._scrollToMessage(highlightedMessage);
});
});
}
}
/**
* Highlights a target message and updates the navigation counters to reflect its position.
* @param {HTMLElement} targetMsg The message element to highlight and use as the reference for indices.
* @returns {void}
*/
setHighlightAndIndices(targetMsg) {
if (!targetMsg) return;
// Retrieve cached info for O(1) access
const cachedInfo = this.messageCacheManager.findMessageIndex(targetMsg);
if (!cachedInfo) return;
const totalMessages = this.messageCacheManager.getTotalMessages();
const userMessages = this.messageCacheManager.getUserMessages();
const asstMessages = this.messageCacheManager.getAssistantMessages();
let currentTotalIndex = cachedInfo.totalIndex;
let currentRoleIndex = cachedInfo.index;
// Verify if the cached index actually points to the target message.
if (totalMessages[currentTotalIndex] !== targetMsg) {
// If mismatch (e.g. due to cache lag), fall back to O(N) indexOf for ALL indices to ensure consistency.
// We cannot trust ANY part of the cachedInfo if the totalIndex verification fails.
currentTotalIndex = totalMessages.indexOf(targetMsg);
if (cachedInfo.role === CONSTANTS.INTERNAL_ROLES.USER) {
currentRoleIndex = userMessages.indexOf(targetMsg);
} else {
currentRoleIndex = asstMessages.indexOf(targetMsg);
}
}
const newIndices = {
total: currentTotalIndex,
user: -1,
asst: -1,
};
// Determine indices based on the message role
if (cachedInfo.role === CONSTANTS.INTERNAL_ROLES.USER) {
newIndices.user = currentRoleIndex;
newIndices.asst = this.findNearestIndex(targetMsg, CONSTANTS.INTERNAL_ROLES.ASSISTANT);
} else {
newIndices.asst = currentRoleIndex;
newIndices.user = this.findNearestIndex(targetMsg, CONSTANTS.INTERNAL_ROLES.USER);
}
this.state.highlightedMessage = targetMsg;
this.state.currentIndices = newIndices;
this._renderUI();
}
/**
* @private
* @description Finds the index of the nearest preceding message of a specific role using cached data.
* This avoids O(N) Set creation and array searches.
* @param {HTMLElement} currentMsg The reference message element.
* @param {string} targetRole The role to search for ('user' or 'assistant').
* @returns {number} The index of the nearest message in the target role's array, or -1 if not found.
*/
findNearestIndex(currentMsg, targetRole) {
const currentInfo = this.messageCacheManager.findMessageIndex(currentMsg);
if (!currentInfo) return -1;
const totalMessages = this.messageCacheManager.getTotalMessages();
let startIndex = currentInfo.totalIndex;
// Verify if the cached index is valid. If mismatch, fallback to O(N) search for safety.
if (totalMessages[startIndex] !== currentMsg) {
startIndex = totalMessages.indexOf(currentMsg);
}
if (startIndex === -1) return -1;
// Iterate backwards from the current message's position in the master list.
for (let i = startIndex; i >= 0; i--) {
const candidateMsg = totalMessages[i];
const candidateInfo = this.messageCacheManager.findMessageIndex(candidateMsg);
// Check if the candidate matches the target role.
if (candidateInfo && candidateInfo.role === targetRole) {
// Found the nearest message. Return its cached role-specific index directly.
// Note: If totalIndex verification passed (or was corrected), we assume candidateInfo is consistent enough for this lookup.
return candidateInfo.index;
}
}
return -1; // Fallback if no preceding message is found
}
/**
* Handles clicks on the main navigation buttons (prev, next, etc.).
* @param {HTMLElement} buttonElement The navigation button element that was clicked.
* @returns {void}
*/
handleButtonClick(buttonElement) {
const navCommand = buttonElement.dataset.nav;
if (!navCommand) return;
const [role, direction] = navCommand.split('-');
if (role && direction) {
this._navigateTo(role, direction);
}
}
/**
* Navigates to a message based on role and direction.
* @private
* @param {string} role The role to navigate within ('user', 'asst', 'total').
* @param {string} direction The direction to navigate ('prev', 'next', 'first', 'last').
*/
_navigateTo(role, direction) {
const { user: currentUserIndex, asst: currentAsstIndex, total: currentTotalIndex } = this.state.currentIndices;
const roleMap = {
user: { messages: this.messageCacheManager.getUserMessages(), currentIndex: currentUserIndex },
asst: { messages: this.messageCacheManager.getAssistantMessages(), currentIndex: currentAsstIndex },
total: { messages: this.messageCacheManager.getTotalMessages(), currentIndex: currentTotalIndex },
};
const { messages, currentIndex } = roleMap[role];
if (!messages || messages.length === 0) return;
let nextIndex = -1;
switch (direction) {
case 'first':
nextIndex = 0;
break;
case 'last':
nextIndex = messages.length - 1;
break;
case 'prev': {
const prevIndex = currentIndex > -1 ? currentIndex : 0;
nextIndex = Math.max(0, prevIndex - 1);
break;
}
case 'next': {
const nextIndexBase = currentIndex === -1 ? 0 : currentIndex + 1;
nextIndex = Math.min(messages.length - 1, nextIndexBase);
break;
}
}
if (nextIndex !== -1 && messages[nextIndex]) {
this.navigateToMessage(messages[nextIndex]);
}
}
/**
* Handles clicks on the navigation counters, allowing the user to jump to a specific message number.
* @param {MouseEvent} e The click event object.
* @param {HTMLElement} counterSpan The counter span element that was clicked.
* @returns {void}
*/
handleCounterClick(e, counterSpan) {
const role = counterSpan.dataset.role;
const input = h(`input.${APPID}-nav-jump-input`, { type: 'text' });
if (!(input instanceof HTMLInputElement)) return;
counterSpan.classList.add('is-hidden');
counterSpan.parentNode.insertBefore(input, counterSpan.nextSibling);
input.focus();
let isEditing = true;
const endEdit = (shouldJump) => {
if (!isEditing) return;
isEditing = false;
if (shouldJump) {
const num = parseInt(input.value, 10);
if (!isNaN(num)) {
const roleMap = {
user: this.messageCacheManager.getUserMessages(),
asst: this.messageCacheManager.getAssistantMessages(),
total: this.messageCacheManager.getTotalMessages(),
};
const targetArray = roleMap[role];
const index = num - 1;
if (targetArray && index >= 0 && index < targetArray.length) {
this.navigateToMessage(targetArray[index]);
}
}
}
input.remove();
counterSpan.classList.remove('is-hidden');
};
input.addEventListener('blur', () => endEdit(false));
input.addEventListener('keydown', (ev) => {
if (ev instanceof KeyboardEvent) {
if (ev.key === 'Enter') {
ev.preventDefault();
endEdit(true);
} else if (ev.key === 'Escape') {
endEdit(false);
}
}
});
}
navigateToMessage(element) {
if (!element) return;
this.setHighlightAndIndices(element);
this._scrollToMessage(element);
}
_scrollToMessage(element) {
if (!element) return;
const targetToScroll = element;
scrollToElement(targetToScroll, { offset: CONSTANTS.RETRY.SCROLL_OFFSET_FOR_NAV });
}
/**
* Repositions the navigation console to align with the main input form.
* @returns {void}
*/
repositionContainers() {
const inputForm = document.querySelector(CONSTANTS.SELECTORS.FIXED_NAV_INPUT_AREA_TARGET);
if (!inputForm || !this.navConsole) return;
// Use withLayoutCycle to prevent layout thrashing
withLayoutCycle({
measure: () => {
// --- Read Phase ---
return {
formRect: inputForm.getBoundingClientRect(),
consoleWidth: this.navConsole.offsetWidth,
windowHeight: window.innerHeight,
};
},
mutate: (measured) => {
// --- Write Phase ---
if (!measured) return;
const { formRect, consoleWidth, windowHeight } = measured;
const bottomPosition = `${windowHeight - formRect.top + 8}px`;
const formCenter = formRect.left + formRect.width / 2;
this.navConsole.style.left = `${formCenter - consoleWidth / 2}px`;
this.navConsole.style.bottom = bottomPosition;
},
});
}
/**
* Selects the last message in the chat and updates the navigation console.
*/
selectLastMessage() {
const totalMessages = this.messageCacheManager.getTotalMessages();
if (totalMessages.length > 0) {
const lastMessage = totalMessages[totalMessages.length - 1];
this.navigateToMessage(lastMessage);
}
}
hideJumpList() {
this._hideJumpList();
}
_toggleJumpList(labelElement) {
const role = labelElement.dataset.role;
if (this.state.jumpListComponent?.role === role) {
this._hideJumpList();
return;
}
this._hideJumpList();
const roleMap = {
user: this.messageCacheManager.getUserMessages(),
asst: this.messageCacheManager.getAssistantMessages(),
total: this.messageCacheManager.getTotalMessages(),
};
const messages = roleMap[role];
if (!messages || messages.length === 0) return;
this.state.jumpListComponent = new JumpListComponent(
role,
messages,
this.state.highlightedMessage,
{
onSelect: (message) => this._handleJumpListSelect(message),
},
SITE_STYLES.FIXED_NAV,
this.state.lastFilterValue
);
this.state.jumpListComponent.show(labelElement);
}
_hideJumpList() {
if (!this.state.jumpListComponent) return;
this.state.lastFilterValue = this.state.jumpListComponent.getFilterValue();
this.state.jumpListComponent.destroy();
this.state.jumpListComponent = null;
}
_handleJumpListSelect(messageElement) {
this.navigateToMessage(messageElement);
this._hideJumpList();
}
_handleKeyDown(e) {
if (e.key === 'Escape') {
// Handle auto-scroll cancellation first.
if (this.autoScrollManager?.isScrolling) {
e.preventDefault();
e.stopPropagation();
EventBus.publish(EVENTS.AUTO_SCROLL_CANCEL_REQUEST);
}
// Then handle jump list closure if auto-scroll is not active.
else if (this.state.jumpListComponent) {
e.preventDefault();
e.stopPropagation();
this._hideJumpList();
}
}
}
/**
* Handles clicks on the document body to delegate actions for the nav console.
* @param {MouseEvent} e The click event object.
* @returns {void}
*/
handleBodyClick(e) {
const target = e.target;
if (!(target instanceof Element)) {
return;
}
// If the click is inside the jump list (including preview), let the component handle it.
if (this.state.jumpListComponent?.contains(target)) {
return;
}
// Close the jump list if the click is outside both the console and the list itself.
if (this.state.jumpListComponent && !this.navConsole?.contains(target)) {
this._hideJumpList();
}
const navButton = target.closest(`.${APPID}-nav-btn`);
if (navButton instanceof HTMLElement && this.navConsole?.contains(navButton)) {
this.handleButtonClick(navButton);
return;
}
const counter = target.closest(`.${APPID}-nav-counter[data-role]`);
if (counter instanceof HTMLElement) {
this.handleCounterClick(e, counter);
return;
}
const label = target.closest(`.${APPID}-nav-label[data-role]`);
if (label instanceof HTMLElement) {
this._toggleJumpList(label);
return;
}
const messageElement = target.closest(CONSTANTS.SELECTORS.FIXED_NAV_MESSAGE_CONTAINERS);
if (messageElement instanceof HTMLElement && !target.closest(`a, button, input, #${APPID}-nav-console`)) {
this.setHighlightAndIndices(messageElement);
}
}
createContainers() {
if (document.getElementById(`${APPID}-nav-console`)) return;
const navConsole = h(`div#${APPID}-nav-console.${APPID}-nav-unpositioned`);
if (!(navConsole instanceof HTMLElement)) return;
this.navConsole = navConsole;
document.body.appendChild(this.navConsole);
this.renderInitialUI();
this.attachEventListeners();
}
renderInitialUI() {
if (!this.navConsole) return;
const bulkCollapseBtn = h(
`button#${APPID}-bulk-collapse-btn.${APPID}-nav-btn`,
{
style: { display: 'none' },
dataset: { state: 'expanded' },
onclick: (e) => {
e.stopPropagation();
this._toggleAllMessages();
},
},
[createIconFromDef(SITE_STYLES.ICONS.bulkCollapse), createIconFromDef(SITE_STYLES.ICONS.bulkExpand)]
);
const platformButtons = PlatformAdapters.FixedNav.getPlatformSpecificButtons(this);
const navUI = [
...platformButtons,
h(`div#${APPID}-nav-group-assistant.${APPID}-nav-group`, [
h(`button.${APPID}-nav-btn`, { 'data-nav': 'asst-prev', title: 'Previous assistant message' }, [createIconFromDef(SITE_STYLES.ICONS.arrowUp)]),
h(`button.${APPID}-nav-btn`, { 'data-nav': 'asst-next', title: 'Next assistant message' }, [createIconFromDef(SITE_STYLES.ICONS.arrowDown)]),
h(`span.${APPID}-nav-label`, { 'data-role': 'asst', title: 'Show message list' }, 'Assistant:'),
h(`span.${APPID}-nav-counter`, { 'data-role': 'asst', title: 'Click to jump to a message' }, [h(`span.${APPID}-counter-current`, '--'), ' / ', h(`span.${APPID}-counter-total`, '--')]),
]),
h(`div.${APPID}-nav-separator`),
h(`div#${APPID}-nav-group-total.${APPID}-nav-group`, [
h(`button.${APPID}-nav-btn`, { 'data-nav': 'total-first', title: 'First message' }, [createIconFromDef(SITE_STYLES.ICONS.scrollToFirst)]),
h(`button.${APPID}-nav-btn`, { 'data-nav': 'total-prev', title: 'Previous message' }, [createIconFromDef(SITE_STYLES.ICONS.arrowUp)]),
h(`span.${APPID}-nav-label`, { 'data-role': 'total', title: 'Show message list' }, 'Total:'),
h(`span.${APPID}-nav-counter`, { 'data-role': 'total', title: 'Click to jump to a message' }, [h(`span.${APPID}-counter-current`, '--'), ' / ', h(`span.${APPID}-counter-total`, '--')]),
h(`button.${APPID}-nav-btn`, { 'data-nav': 'total-next', title: 'Next message' }, [createIconFromDef(SITE_STYLES.ICONS.arrowDown)]),
h(`button.${APPID}-nav-btn`, { 'data-nav': 'total-last', title: 'Last message' }, [createIconFromDef(SITE_STYLES.ICONS.scrollToLast)]),
]),
h(`div.${APPID}-nav-separator`),
h(`div#${APPID}-nav-group-user.${APPID}-nav-group`, [
h(`span.${APPID}-nav-label`, { 'data-role': 'user', title: 'Show message list' }, 'User:'),
h(`span.${APPID}-nav-counter`, { 'data-role': 'user', title: 'Click to jump to a message' }, [h(`span.${APPID}-counter-current`, '--'), ' / ', h(`span.${APPID}-counter-total`, '--')]),
h(`button.${APPID}-nav-btn`, { 'data-nav': 'user-prev', title: 'Previous user message' }, [createIconFromDef(SITE_STYLES.ICONS.arrowUp)]),
h(`button.${APPID}-nav-btn`, { 'data-nav': 'user-next', title: 'Next user message' }, [createIconFromDef(SITE_STYLES.ICONS.arrowDown)]),
]),
h(`div.${APPID}-nav-separator`),
bulkCollapseBtn,
];
this.navConsole.textContent = '';
navUI.forEach((el) => this.navConsole.appendChild(el));
// Build the cache for O(1) access during updates
this.uiCache = {
autoscrollBtn: this.navConsole.querySelector(`#${APPID}-autoscroll-btn`),
bulkCollapseBtn: bulkCollapseBtn,
bulkCollapseSeparator: bulkCollapseBtn.previousElementSibling,
counters: {
user: {
total: this.navConsole.querySelector(`#${APPID}-nav-group-user .${APPID}-counter-total`),
current: this.navConsole.querySelector(`#${APPID}-nav-group-user .${APPID}-counter-current`),
},
assistant: {
total: this.navConsole.querySelector(`#${APPID}-nav-group-assistant .${APPID}-counter-total`),
current: this.navConsole.querySelector(`#${APPID}-nav-group-assistant .${APPID}-counter-current`),
},
total: {
total: this.navConsole.querySelector(`#${APPID}-nav-group-total .${APPID}-counter-total`),
current: this.navConsole.querySelector(`#${APPID}-nav-group-total .${APPID}-counter-current`),
},
},
};
}
attachEventListeners() {
document.body.addEventListener('click', this.handleBodyClick, true);
document.addEventListener('keydown', this._handleKeyDown, true);
}
injectStyle() {
const styleId = `${APPID}-fixed-nav-style`;
if (document.getElementById(styleId)) return;
const navStyles = SITE_STYLES.FIXED_NAV;
const jumpListStyles = SITE_STYLES.JUMP_LIST;
// Add Firefox-specific style for the scrollbar gutter
const firefoxScrollbarFix = /firefox/i.test(navigator.userAgent)
? `
.${APPID}-jump-list-scrollbox {
padding-right: 12px; /* Add physical space for the overlay scrollbar */
}`
: '';
const style = h('style', {
id: styleId,
textContent: `
#${APPID}-nav-console .is-hidden {
display: none !important;
}
#${APPID}-nav-console.${APPID}-nav-unpositioned {
visibility: hidden;
opacity: 0;
}
#${APPID}-nav-console {
position: fixed;
z-index: ${CONSTANTS.Z_INDICES.NAV_CONSOLE};
display: flex;
align-items: center;
gap: 8px;
background-color: ${navStyles.bg};
padding: 4px 8px;
border-radius: 8px;
border: 1px solid ${navStyles.border};
box-shadow: 0 2px 10px rgb(0 0 0 / 0.05);
font-size: 0.8rem;
opacity: 1;
transform-origin: bottom;
}
#${APPID}-nav-console.${APPID}-nav-hidden {
display: none !important;
}
#${APPID}-nav-console .${APPID}-nav-group {
display: flex;
align-items: center;
gap: 6px;
}
#${APPID}-nav-console .${APPID}-nav-separator {
width: 1px;
height: 20px;
background-color: ${navStyles.separator_bg};
}
#${APPID}-nav-console .${APPID}-nav-label {
color: ${navStyles.label_text};
font-weight: 500;
cursor: pointer;
user-select: none;
}
#${APPID}-nav-console .${APPID}-nav-counter,
#${APPID}-nav-console .${APPID}-nav-jump-input {
box-sizing: border-box;
width: 85px;
height: 24px;
margin: 0;
background-color: ${navStyles.counter_bg};
color: ${navStyles.counter_text};
padding: 1px 4px;
border: 1px solid transparent;
border-color: ${navStyles.counter_border};
border-radius: 4px;
text-align: center;
vertical-align: middle;
font-family: monospace;
font: inherit;
}
#${APPID}-nav-console .${APPID}-nav-btn {
background-color: ${navStyles.btn_bg};
color: ${navStyles.btn_text};
border: 1px solid ${navStyles.btn_border};
border-radius: 5px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
transition: background-color 0.1s, color 0.1s;
}
#${APPID}-nav-console .${APPID}-nav-btn:hover {
background-color: ${navStyles.btn_hover_bg};
}
#${APPID}-nav-console .${APPID}-nav-btn svg {
width: 20px;
height: 20px;
fill: currentColor;
}
#${APPID}-bulk-collapse-btn svg {
width: 100%;
height: 100%;
}
#${APPID}-bulk-collapse-btn[data-state="expanded"] .icon-expand { display: none; }
#${APPID}-bulk-collapse-btn[data-state="expanded"] .icon-collapse { display: block; }
#${APPID}-bulk-collapse-btn[data-state="collapsed"] .icon-expand { display: block; }
#${APPID}-bulk-collapse-btn[data-state="collapsed"] .icon-collapse { display: none; }
#${APPID}-nav-console .${APPID}-nav-btn[data-nav$="-prev"],
#${APPID}-nav-console .${APPID}-nav-btn[data-nav$="-next"] {
color: ${navStyles.btn_accent_text};
}
#${APPID}-nav-console .${APPID}-nav-btn[data-nav="total-first"],
#${APPID}-nav-console .${APPID}-nav-btn[data-nav="total-last"] {
color: ${navStyles.btn_danger_text};
}
#${APPID}-autoscroll-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
${CONSTANTS.SELECTORS.FIXED_NAV_HIGHLIGHT_TARGETS} {
outline: 2px solid ${navStyles.highlight_outline} !important;
outline-offset: -2px;
border-radius: ${navStyles.highlight_border_radius} !important;
box-shadow: 0 0 8px ${navStyles.highlight_outline} !important;
}
#${APPID}-jump-list-container {
position: fixed;
z-index: ${CONSTANTS.Z_INDICES.NAV_CONSOLE + 1};
background: ${jumpListStyles.list_bg};
border: 1px solid ${jumpListStyles.list_border};
border-radius: 8px;
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
padding: 4px;
opacity: 0;
transform-origin: bottom;
transform: translateY(10px);
transition: opacity 0.15s ease, transform 0.15s ease;
visibility: hidden;
display: flex;
flex-direction: column;
}
#${APPID}-jump-list-container:focus, #${APPID}-jump-list:focus, .${APPID}-jump-list-scrollbox:focus {
outline: none;
}
#${APPID}-jump-list-container.is-visible {
opacity: 1;
transform: translateY(0);
visibility: visible;
}
.${APPID}-jump-list-scrollbox {
flex: 1 1 auto;
position: relative;
}
#${APPID}-jump-list {
list-style: none;
margin: 0;
padding: 0;
}
.${APPID}-jump-list-filter-container {
position: relative;
display: flex;
align-items: center;
border-top: 1px solid ${jumpListStyles.list_border};
margin: 4px 0 0 0;
flex-shrink: 0;
}
.${APPID}-jump-list-filter {
border: none;
background-color: transparent;
color: inherit;
padding: 8px 60px 8px 8px; /* Make space for the label on the right */
outline: none;
font-size: 0.85rem;
border-radius: 0 0 4px 4px;
width: 100%;
box-sizing: border-box;
}
.${APPID}-jump-list-filter.is-regex-valid {
border-color: ${jumpListStyles.current_outline};
}
.${APPID}-jump-list-mode-label {
position: absolute;
right: 8px;
padding: 1px 6px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: bold;
pointer-events: none;
transition: background-color 0.2s, color 0.2s;
line-height: 1.5;
}
.${APPID}-jump-list-mode-label.is-string {
background-color: transparent;
color: ${navStyles.label_text};
}
.${APPID}-jump-list-mode-label.is-regex {
background-color: #28a745;
color: #ffffff;
}
.${APPID}-jump-list-mode-label.is-regex-invalid {
background-color: #dc3545;
color: #ffffff;
}
#${APPID}-jump-list li {
padding: 6px 10px;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border-radius: 4px;
font-size: 0.85rem;
}
#${APPID}-jump-list li:hover, #${APPID}-jump-list li.is-focused {
outline: 1px solid ${jumpListStyles.hover_outline};
outline-offset: -1px;
}
#${APPID}-jump-list li.is-current {
outline: 2px solid ${jumpListStyles.current_outline};
outline-offset: -2px;
}
#${APPID}-jump-list li.is-current:hover, #${APPID}-jump-list li.is-current.is-focused {
outline-width: 2px;
outline-offset: -2px;
}
#${APPID}-jump-list li.user-item {
background-color: var(--${APPID}-user-bubble-bg, transparent);
color: var(--${APPID}-user-textColor, inherit);
}
#${APPID}-jump-list li.assistant-item {
background-color: var(--${APPID}-assistant-bubble-bg, transparent);
color: var(--${APPID}-assistant-textColor, inherit);
}
#${APPID}-jump-list-preview {
position: fixed;
z-index: ${CONSTANTS.Z_INDICES.JUMP_LIST_PREVIEW};
background: ${jumpListStyles.list_bg};
border: 1px solid ${jumpListStyles.list_border};
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 10%);
padding: 8px 12px;
max-width: 400px;
max-height: 300px;
overflow-y: auto;
font-size: 0.85rem;
opacity: 0;
transition: opacity 0.1s ease-in-out;
white-space: pre-wrap;
word-break: break-word;
visibility: hidden;
user-select: text;
cursor: auto;
}
#${APPID}-jump-list-preview.is-visible {
opacity: 1;
visibility: visible;
}
#${APPID}-jump-list-preview strong {
color: ${jumpListStyles.current_outline};
font-weight: bold;
background-color: transparent;
}
${firefoxScrollbarFix}
`,
});
document.head.appendChild(style);
}
}
// =================================================================================
// SECTION: Message Lifecycle Orchestrator
// Description: Listens for raw message additions from Sentinel and orchestrates
// the appropriate high-level events (avatar injection, UI setup).
// =================================================================================
class MessageLifecycleManager {
/**
* @param {MessageCacheManager} messageCacheManager
*/
constructor(messageCacheManager) {
this.messageCacheManager = messageCacheManager;
this.scanIntervalId = null;
this.scanAttempts = 0;
this.subscriptions = [];
this.boundStopPollingScan = this.stopPollingScan.bind(this);
this.isNavigating = true;
}
/**
* Helper to subscribe to EventBus and track the subscription for cleanup.
* Appends the listener name and a unique suffix to the key to avoid conflicts.
* @param {string} event
* @param {Function} listener
*/
_subscribe(event, listener) {
const baseKey = createEventKey(this, event);
// Use function name for debugging aid, fallback to 'anonymous'
const listenerName = listener.name || 'anonymous';
// Generate a short random suffix to guarantee uniqueness even for anonymous functions
const uniqueSuffix = Math.random().toString(36).substring(2, 7);
const key = `${baseKey}_${listenerName}_${uniqueSuffix}`;
EventBus.subscribe(event, listener, key);
this.subscriptions.push({ event, key });
}
init() {
this._subscribe(EVENTS.RAW_MESSAGE_ADDED, (elem) => this.processRawMessage(elem));
this._subscribe(EVENTS.APP_SHUTDOWN, () => this.destroy());
this._subscribe(EVENTS.NAVIGATION_END, () => {
PlatformAdapters.General.onNavigationEnd?.(this);
this.isNavigating = false;
});
this._subscribe(EVENTS.NAVIGATION_START, () => {
this.isNavigating = true;
});
}
destroy() {
this.stopPollingScan();
this.subscriptions.forEach(({ event, key }) => EventBus.unsubscribe(event, key));
this.subscriptions = [];
}
/**
* @description Starts a polling mechanism to repeatedly scan for unprocessed messages.
* The polling stops automatically after a set number of attempts, on user interaction, or once a new message is found.
*/
startPollingScan() {
this.stopPollingScan(); // Ensure any previous polling is stopped
this.scanAttempts = 0;
const MAX_ATTEMPTS = 7;
const INTERVAL_MS = 750;
Logger.log('Starting polling scan for unprocessed messages.');
this.scanIntervalId = setInterval(() => {
this.scanAttempts++;
Logger.log(`Executing polling scan (Attempt ${this.scanAttempts}/${MAX_ATTEMPTS})...`);
const newItemsFound = this.scanForUnprocessedMessages();
if (newItemsFound > 0) {
Logger.log(`Polling scan found ${newItemsFound} new message(s). Stopping early.`);
EventBus.publish(EVENTS.POLLING_MESSAGES_FOUND);
this.stopPollingScan();
return;
}
if (this.scanAttempts >= MAX_ATTEMPTS) {
Logger.log(`Polling scan finished after ${this.scanAttempts} attempts without finding new messages.`);
this.stopPollingScan();
}
}, INTERVAL_MS);
// Stop polling immediately on user interaction
window.addEventListener('wheel', this.boundStopPollingScan, { once: true, passive: true });
window.addEventListener('keydown', this.boundStopPollingScan, { once: true, passive: true });
}
/**
* @description Stops the polling scan and cleans up associated listeners.
*/
stopPollingScan() {
if (this.scanIntervalId) {
clearInterval(this.scanIntervalId);
this.scanIntervalId = null;
Logger.debug('Polling scan stopped.');
}
// Clean up interaction listeners regardless
window.removeEventListener('wheel', this.boundStopPollingScan);
window.removeEventListener('keydown', this.boundStopPollingScan);
}
/**
* @description Performs a one-time scan for any unprocessed messages after initial page load,
* complementing the real-time detection by Sentinel.
* @returns {number} The number of new items found and processed.
*/
scanForUnprocessedMessages() {
return PlatformAdapters.General.performInitialScan?.(this) || 0;
}
processRawMessage(contentElement) {
// Flag the specific content piece as processed to avoid re-triggering from the same element.
const contentProcessedFlag = `${APPID}ContentProcessed`;
if (contentElement.dataset[contentProcessedFlag]) {
return;
}
contentElement.dataset[contentProcessedFlag] = 'true';
let messageElement = PlatformAdapters.General.findMessageElement(contentElement);
// Platform-specific hook to handle elements that need a container
if (!messageElement && PlatformAdapters.General.ensureMessageContainerForImage) {
// Let the adapter create a wrapper if needed and return it.
// We only do this for the image selector, not for markdown.
if (contentElement.matches(CONSTANTS.SELECTORS.RAW_ASSISTANT_IMAGE_BUBBLE)) {
messageElement = PlatformAdapters.General.ensureMessageContainerForImage(contentElement);
}
}
// If we have a valid message container, proceed.
if (messageElement) {
// Publish the timestamp for this message as soon as it's identified.
// This is for real-time messages; historical ones are loaded via API.
// Find the correct messageId from the parent element
const messageIdHolder = messageElement.closest(CONSTANTS.SELECTORS.MESSAGE_ID_HOLDER);
const messageId = messageIdHolder instanceof HTMLElement ? messageIdHolder.dataset.messageId : null;
// Only publish TIMESTAMP_ADDED (real-time/current time) if we are NOT in the initial page load/navigation phase.
// Historical timestamps will be loaded separately via TIMESTAMPS_LOADED event.
if (messageId && !this.isNavigating) {
EventBus.publish(EVENTS.TIMESTAMP_ADDED, { messageId: messageId, timestamp: new Date() });
}
// Fire avatar injection event. The AvatarManager will handle the one-per-turn logic.
EventBus.publish(EVENTS.AVATAR_INJECT, messageElement);
// Fire message complete event for other managers.
// Use a different flag to ensure this only fires once per message container,
// even if it has multiple content parts detected (e.g. text and images).
const messageCompleteFlag = `${APPID}MessageCompleteFired`;
if (!messageElement.dataset[messageCompleteFlag]) {
messageElement.dataset[messageCompleteFlag] = 'true';
EventBus.publish(EVENTS.MESSAGE_COMPLETE, messageElement);
}
}
}
}
// =================================================================================
// SECTION: Timestamp Management
// Description: Manages message timestamps, handling both API-fetched historical
// data and real-time message additions.
// =================================================================================
class TimestampManager {
/**
* @param {ConfigManager} configManager
* @param {MessageCacheManager} messageCacheManager
*/
constructor(configManager, messageCacheManager) {
this.configManager = configManager;
this.messageCacheManager = messageCacheManager;
/** @type {Map<string, Date>} */
this.messageTimestamps = new Map();
/** @type {Map<HTMLElement, HTMLElement>} */
this.timestampDomCache = new Map();
this.subscriptions = [];
this.timestampContainerTemplate = h(`div.${APPID}-timestamp-container`);
this.timestampSpanTemplate = h(`span.${APPID}-timestamp`);
this.currentChatId = null;
this.isEnabled = false; // Add state tracking
}
/**
* Helper to subscribe to EventBus and track the subscription for cleanup.
* Appends the listener name and a unique suffix to the key to avoid conflicts.
* @param {string} event
* @param {Function} listener
*/
_subscribe(event, listener) {
const baseKey = createEventKey(this, event);
// Use function name for debugging aid, fallback to 'anonymous'
const listenerName = listener.name || 'anonymous';
// Generate a short random suffix to guarantee uniqueness even for anonymous functions
const uniqueSuffix = Math.random().toString(36).substring(2, 7);
const key = `${baseKey}_${listenerName}_${uniqueSuffix}`;
EventBus.subscribe(event, listener, key);
this.subscriptions.push({ event, key });
}
init() {
this.injectStyle();
// Subscribe to navigation events to clear the cache and load new data
// This must always run, even when disabled, to clear the cache on page change.
this._subscribe(EVENTS.NAVIGATION, () => this._handleNavigation());
// Subscribe to shutdown
this._subscribe(EVENTS.APP_SHUTDOWN, () => this.destroy());
// Subscribe to data events regardless of the feature toggle state.
this._subscribe(EVENTS.TIMESTAMP_ADDED, (data) => this._handleTimestampAdded(data));
this._subscribe(EVENTS.TIMESTAMPS_LOADED, (data) => this._loadHistoricalTimestamps(data));
}
/**
* Subscribes to events and performs an initial load and render.
* Called when the feature is enabled.
*/
enable() {
Logger.badge('TIMESTAMPS', LOG_STYLES.GRAY, 'debug', 'Enabling...');
this.isEnabled = true;
// Subscribe to cache updates (e.g., deletions)
this._subscribe(EVENTS.CACHE_UPDATED, () => this.updateAllTimestamps());
// Initial render
// This will render any data that was collected while the setting was OFF.
this.updateAllTimestamps();
}
/**
* Unsubscribes from events and clears DOM elements.
* Called when the feature is disabled.
* Does NOT clear the internal timestamp cache.
*/
disable() {
Logger.badge('TIMESTAMPS', LOG_STYLES.GRAY, 'debug', 'Disabling...');
this.isEnabled = false;
// Unsubscribe from events that trigger DOM updates
this._unsubscribe(EVENTS.CACHE_UPDATED);
// Clear all visible timestamps from the DOM
this._clearAllTimestampsDOM();
}
destroy() {
this.disable(); // Unsubscribe from active UI events
// Unsubscribe from all persistent listeners (data collection, navigation, shutdown)
this.subscriptions.forEach(({ event, key }) => EventBus.unsubscribe(event, key));
this.subscriptions = []; // Clear subscriptions array
this.messageTimestamps.clear(); // Clear internal cache
this._clearAllTimestampsDOM(); // Ensure DOM is clean
document.getElementById(`${APPID}-timestamp-style`)?.remove();
}
/**
* @private
* Clears caches on navigation and prepares for new data.
*/
_handleNavigation() {
this.messageTimestamps.clear();
this._clearAllTimestampsDOM();
this.currentChatId = null;
}
/**
* @private
* @param {object} detail - The event detail object.
* @param {string} detail.chatId - The ID of the chat.
* @param {Map<string, Date>} detail.timestamps - The map of historical timestamps.
*/
_loadHistoricalTimestamps({ chatId, timestamps }) {
// If the loaded data is for a different chat (e.g., race condition), clear cache.
if (chatId !== this.currentChatId) {
Logger.badge('TIMESTAMPS', LOG_STYLES.GRAY, 'debug', `New chat detected (${chatId}). Clearing previous timestamp cache.`);
this.messageTimestamps.clear();
this.currentChatId = chatId;
}
if (timestamps && timestamps.size > 0) {
Logger.badge('TIMESTAMPS', LOG_STYLES.GRAY, 'debug', `Loading ${timestamps.size} historical timestamps from adapter.`);
timestamps.forEach((date, id) => {
// Overwrite any existing (likely real-time) timestamp with the historical one
this.messageTimestamps.set(id, date);
});
}
// If enabled, trigger a DOM update now that historical data is loaded
if (this.isEnabled) {
this.updateAllTimestamps();
}
}
/**
* @private
* @param {object} detail - The event detail object.
* @param {string} detail.messageId - The ID of the message.
* @param {Date} detail.timestamp - The timestamp (Date object) of when the message was processed.
*/
_handleTimestampAdded({ messageId, timestamp }) {
if (messageId && timestamp && !this.messageTimestamps.has(messageId)) {
Logger.badge('TIMESTAMPS', LOG_STYLES.GRAY, 'debug', `Added real-time timestamp for ${messageId}.`);
this.messageTimestamps.set(messageId, timestamp);
// If enabled, trigger a DOM update for the new real-time timestamp
if (this.isEnabled) {
this.updateAllTimestamps();
}
}
}
/**
* @param {string} messageId The ID of the message.
* @returns {Date | undefined} The Date object for the message, or undefined if not found.
*/
getTimestamp(messageId) {
return this.messageTimestamps.get(messageId);
}
/**
* Injects the necessary CSS for positioning and styling the timestamps.
*/
injectStyle() {
const styleId = `${APPID}-timestamp-style`;
if (document.getElementById(styleId)) return;
const style = h('style', {
id: styleId,
textContent: `
.${APPID}-timestamp-container {
font-size: 10px;
line-height: 1.2;
padding: 0;
margin: 0;
color: rgb(255 255 255 / 0.7);
border-radius: 4px;
white-space: pre;
display: flex;
position: absolute;
top: -20px; /* Align vertically with the 24px collapse button */
}
.${APPID}-timestamp-container.${APPID}-timestamp-assistant {
left: 30px; /* (button left 4px + width 24px + margin 2px) */
}
.${APPID}-timestamp-container.${APPID}-timestamp-user {
right: 30px; /* (button right 4px + width 24px + margin 2px) */
}
.${APPID}-timestamp {
background-color: rgb(0 0 0 / 0.4);
padding: 0px 4px;
border-radius: 4px;
pointer-events: none;
}
.${APPID}-timestamp-hidden {
display: none !important;
}
`,
});
document.head.appendChild(style);
}
/**
* Removes all timestamp DOM elements from the page.
* @private
*/
_clearAllTimestampsDOM() {
this.timestampDomCache.forEach((container) => {
container.remove();
});
this.timestampDomCache.clear();
}
_syncCache() {
// Synchronizes the DOM by removing any timestamp elements whose corresponding message is no longer in the cache.
// This prevents DOM leaks when messages are deleted.
const currentMessages = new Set(this.messageCacheManager.getTotalMessages());
for (const [messageElement, domElement] of this.timestampDomCache.entries()) {
if (!currentMessages.has(messageElement)) {
domElement.remove(); // Remove the timestamp from the DOM
this.timestampDomCache.delete(messageElement); // Remove from the cache
}
}
}
/**
* Updates the text content of all visible timestamps.
* Creates the timestamp element if it doesn't exist.
*/
updateAllTimestamps() {
// 1. Sync cache and remove deleted DOM nodes
this._syncCache();
const config = this.configManager.get();
if (!config) return;
const allMessages = this.messageCacheManager.getTotalMessages();
const isTimestampEnabled = config.features.timestamp.enabled;
// 2. If the feature is disabled, ensure all DOM elements are removed and stop.
if (!isTimestampEnabled) {
this._clearAllTimestampsDOM(); // This removes all remaining DOM elements
return;
}
// 3. Run a single-pass batch operation to create/update all
processInBatches(
allMessages,
(message) => {
// Pass the feature flag to the update function
this._injectOrUpdateTimestamp(message, isTimestampEnabled);
},
CONSTANTS.BATCH_PROCESSING_SIZE
);
}
/**
* @private
* @description Ensures a single message element has the correct timestamp DOM and content.
* Creates, updates, and toggles visibility in one pass.
* @param {HTMLElement} messageElement The message element to process.
* @param {boolean} isTimestampEnabled The current state of the timestamp feature.
*/
_injectOrUpdateTimestamp(messageElement, isTimestampEnabled) {
let timestampContainer = this.timestampDomCache.get(messageElement);
// 1. Create DOM element if it doesn't exist
if (!timestampContainer) {
const messageIdHolder = messageElement.closest(CONSTANTS.SELECTORS.MESSAGE_ID_HOLDER);
if (!messageIdHolder) return; // Cannot find insertion point
const role = PlatformAdapters.General.getMessageRole(messageElement);
if (!role) return; // Cannot determine role
const roleClass = role === CONSTANTS.SELECTORS.FIXED_NAV_ROLE_USER ? `${APPID}-timestamp-user` : `${APPID}-timestamp-assistant`;
const containerNode = this.timestampContainerTemplate.cloneNode(true);
const spanNode = this.timestampSpanTemplate.cloneNode(true);
if (containerNode instanceof HTMLElement && spanNode instanceof Element) {
timestampContainer = containerNode; // Assign after type check
timestampContainer.classList.add(roleClass);
timestampContainer.appendChild(spanNode);
messageIdHolder.prepend(timestampContainer);
this.timestampDomCache.set(messageElement, timestampContainer);
} else {
return; // Failed to create element
}
}
// 2. Update content and visibility
const timestampSpan = timestampContainer.querySelector(`.${APPID}-timestamp`);
if (!(timestampSpan instanceof HTMLElement)) return;
let text = '';
if (isTimestampEnabled) {
const messageIdHolder = messageElement.closest(CONSTANTS.SELECTORS.MESSAGE_ID_HOLDER);
const messageId = messageIdHolder instanceof HTMLElement ? messageIdHolder.dataset.messageId : null;
const timestamp = messageId ? this.getTimestamp(messageId) : undefined;
text = this._formatTimestamp(timestamp);
}
timestampSpan.textContent = text;
timestampContainer.classList.toggle(`${APPID}-timestamp-hidden`, !isTimestampEnabled || !text);
}
/**
* Formats a Date object into a fixed string format.
* @param {Date} date The Date object to format.
* @returns {string} The formatted timestamp string.
* @private
*/
_formatTimestamp(date) {
if (!(date instanceof Date) || isNaN(date.getTime())) {
return ''; // Return empty string if date is invalid
}
const yyyy = date.getFullYear();
const mm = String(date.getMonth() + 1).padStart(2, '0');
const dd = String(date.getDate()).padStart(2, '0');
const hh = String(date.getHours()).padStart(2, '0');
const ii = String(date.getMinutes()).padStart(2, '0');
const ss = String(date.getSeconds()).padStart(2, '0');
return `${yyyy}-${mm}-${dd} ${hh}:${ii}:${ss}`;
}
/**
* Helper to unsubscribe from an event.
* @param {string} event The event name.
* @private
*/
_unsubscribe(event) {
const keyPrefix = `${this.constructor.name}.`;
const keysToRemove = [];
// Find all keys for this instance and event
for (const { event: subEvent, key } of this.subscriptions) {
if (subEvent === event && key.startsWith(keyPrefix)) {
keysToRemove.push(key);
}
}
// Unsubscribe and remove from internal tracking
keysToRemove.forEach((key) => {
EventBus.unsubscribe(event, key);
this.subscriptions = this.subscriptions.filter((sub) => sub.key !== key);
});
}
}
class MessageNumberManager {
/**
* @param {ConfigManager} configManager
* @param {MessageCacheManager} messageCacheManager
*/
constructor(configManager, messageCacheManager) {
this.configManager = configManager;
this.messageCacheManager = messageCacheManager;
this.numberSpanCache = new Map();
this.subscriptions = [];
this.numberSpanTemplate = h(`span.${APPID}-message-number`);
}
/**
* Helper to subscribe to EventBus and track the subscription for cleanup.
* Appends the listener name and a unique suffix to the key to avoid conflicts.
* @param {string} event
* @param {Function} listener
*/
_subscribe(event, listener) {
const baseKey = createEventKey(this, event);
// Use function name for debugging aid, fallback to 'anonymous'
const listenerName = listener.name || 'anonymous';
// Generate a short random suffix to guarantee uniqueness even for anonymous functions
const uniqueSuffix = Math.random().toString(36).substring(2, 7);
const key = `${baseKey}_${listenerName}_${uniqueSuffix}`;
EventBus.subscribe(event, listener, key);
this.subscriptions.push({ event, key });
}
/**
* Initializes the manager.
*/
init() {
this.injectStyle();
// Use :cacheUpdated for batch updates (re-numbering, visibility toggles after config changes).
this._subscribe(EVENTS.CACHE_UPDATED, () => this.updateAllMessageNumbers());
this._subscribe(EVENTS.NAVIGATION, () => {
this.numberSpanCache.clear();
});
this._subscribe(EVENTS.APP_SHUTDOWN, () => this.destroy());
}
/**
* Cleans up event listeners.
*/
destroy() {
this.subscriptions.forEach(({ event, key }) => EventBus.unsubscribe(event, key));
this.subscriptions = [];
document.getElementById(`${APPID}-message-number-style`)?.remove();
this.numberSpanCache.clear();
}
_syncCache() {
syncCacheWithMessages(this.numberSpanCache, this.messageCacheManager);
}
/**
* Injects the necessary CSS for positioning and styling the message numbers.
*/
injectStyle() {
const styleId = `${APPID}-message-number-style`;
if (document.getElementById(styleId)) return;
const style = h('style', {
id: styleId,
textContent: `
.${APPID}-parent-with-number {
position: relative !important;
}
.${APPID}-message-number {
position: absolute;
font-size: 0.6rem;
font-weight: bold;
color: rgb(255 255 255 / 0.7);
background-color: rgb(0 0 0 / 0.4);
padding: 0px 4px;
border-radius: 4px;
line-height: 1.5;
pointer-events: none;
z-index: 1;
white-space: nowrap;
}
.${APPID}-message-number-assistant {
top: -16px;
right: 100%;
margin-right: 0px;
}
.${APPID}-message-number-user {
top: -16px;
left: 100%;
margin-left: 0px;
}
.${APPID}-message-number-hidden {
display: none !important;
}
`,
});
document.head.appendChild(style);
}
/**
* Updates the text content of all visible message numbers.
* Creates the number element if it doesn't exist.
*/
updateAllMessageNumbers() {
this._syncCache();
const config = this.configManager.get();
if (!config) return;
const allMessages = this.messageCacheManager.getTotalMessages();
const isNavConsoleEnabled = config.features.fixed_nav_console.enabled;
// --- Measure Phase ---
const toCreate = [];
allMessages.forEach((message) => {
if (!this.numberSpanCache.has(message)) {
const anchor = PlatformAdapters.BubbleUI.getNavPositioningParent(message);
if (anchor) {
toCreate.push({ message, anchor });
}
}
});
// --- Mutate Phase (in batches) ---
const createSpans = () => {
processInBatches(
toCreate,
({ message, anchor }) => {
anchor.classList.add(`${APPID}-parent-with-number`);
const role = PlatformAdapters.General.getMessageRole(message);
if (role) {
const roleClass = role === CONSTANTS.SELECTORS.FIXED_NAV_ROLE_USER ? `${APPID}-message-number-user` : `${APPID}-message-number-assistant`;
const numberSpan = this.numberSpanTemplate.cloneNode(true);
if (numberSpan instanceof Element) {
numberSpan.classList.add(roleClass);
anchor.appendChild(numberSpan);
this.numberSpanCache.set(message, numberSpan);
}
}
},
CONSTANTS.BATCH_PROCESSING_SIZE,
updateNumbers // Chain to the next step
);
};
const updateNumbers = () => {
processInBatches(
allMessages,
(message, index) => {
const numberSpan = this.numberSpanCache.get(message);
if (numberSpan) {
numberSpan.textContent = `#${index + 1}`;
numberSpan.classList.toggle(`${APPID}-message-number-hidden`, !isNavConsoleEnabled);
}
},
CONSTANTS.BATCH_PROCESSING_SIZE
);
};
createSpans(); // Start the chain
}
}
// =================================================================================
// SECTION: UI Elements - Components and Manager
// =================================================================================
/**
* @abstract
* @description Base class for a UI component.
*/
class UIComponentBase {
constructor(callbacks = {}) {
this.callbacks = callbacks;
this.element = null;
}
/**
* @abstract
* Renders the component's DOM structure. Must be implemented by subclasses.
* @returns {void}
*/
render() {
throw new Error('Component must implement render method.');
}
/**
* Removes the component's element from the DOM and performs cleanup.
* @returns {void}
*/
destroy() {
this.element?.remove();
this.element = null;
}
}
/**
* @class ColorPickerPopupManager
* @description Manages the lifecycle and interaction of color picker popups within a given container.
*/
class ColorPickerPopupManager {
constructor(containerElement) {
this.container = containerElement;
this.activePicker = null;
this._isSyncing = false;
// Bind methods
this._handleClick = this._handleClick.bind(this);
this._handleOutsideClick = this._handleOutsideClick.bind(this);
this._handleTextInput = this._handleTextInput.bind(this);
this._handleScroll = this._handleScroll.bind(this);
}
init() {
this.container.addEventListener('click', this._handleClick);
this.container.addEventListener('input', this._handleTextInput);
// Capture scroll events from any child element to close the picker
this.container.addEventListener('scroll', this._handleScroll, { capture: true, passive: true });
}
destroy() {
this._closePicker();
this.container.removeEventListener('click', this._handleClick);
this.container.removeEventListener('input', this._handleTextInput);
this.container.removeEventListener('scroll', this._handleScroll, { capture: true });
}
_handleScroll() {
if (this.activePicker) {
this._closePicker();
}
}
_handleTextInput(e) {
const textInput = e.target;
if (!(textInput instanceof HTMLInputElement)) {
return;
}
const idSuffix = textInput.id.replace(new RegExp(`^${APPID}-form-`), '');
const wrapper = textInput.closest(`.${APPID}-color-field-wrapper`);
if (!wrapper || !wrapper.querySelector(`[data-controls-color="${idSuffix}"]`)) {
return;
}
if (this._isSyncing) return;
const value = textInput.value.trim();
let isValid = false;
// If a picker is active for this input, use its full validation and update logic.
if (this.activePicker && this.activePicker.textInput === textInput) {
this._isSyncing = true;
isValid = this.activePicker.picker.setColor(value);
this._isSyncing = false;
} else {
// Otherwise, use the static method for validation only.
isValid = !!CustomColorPicker.parseColorString(value);
}
textInput.classList.toggle('is-invalid', value !== '' && !isValid);
const swatch = wrapper.querySelector(`.${APPID}-color-swatch`);
if (swatch) {
const swatchValue = swatch.querySelector(`.${APPID}-color-swatch-value`);
if (swatchValue instanceof HTMLElement) {
swatchValue.style.backgroundColor = value === '' || isValid ? value : 'transparent';
}
}
}
_handleClick(e) {
const swatch = e.target.closest(`.${APPID}-color-swatch`);
if (swatch) {
this._togglePicker(swatch);
}
}
_togglePicker(swatchElement) {
if (this.activePicker && this.activePicker.swatch === swatchElement) {
this._closePicker();
return;
}
this._closePicker();
this._openPicker(swatchElement);
}
_openPicker(swatchElement) {
const targetId = swatchElement.dataset.controlsColor;
const textInput = this.container.querySelector(`#${APPID}-form-${targetId}`);
if (!(textInput instanceof HTMLInputElement)) {
return;
}
let pickerRoot;
const popupWrapper = h(`div.${APPID}-color-picker-popup`, [(pickerRoot = h('div'))]);
this.container.appendChild(popupWrapper);
const picker = new CustomColorPicker(pickerRoot, {
initialColor: textInput.value || 'rgb(128 128 128 / 1)',
cssPrefix: `${APPID}-ccp`,
});
picker.render();
this.activePicker = { picker, popupWrapper, textInput, swatch: swatchElement };
this._setupBindings();
requestAnimationFrame(() => {
this._positionPicker(popupWrapper, swatchElement);
document.addEventListener('click', this._handleOutsideClick, { capture: true });
});
}
_closePicker() {
if (!this.activePicker) return;
this.activePicker.picker.destroy();
this.activePicker.popupWrapper.remove();
this.activePicker = null;
document.removeEventListener('click', this._handleOutsideClick, { capture: true });
}
_setupBindings() {
const { picker, textInput, swatch } = this.activePicker;
// Sync picker to text input initially
this._isSyncing = true;
const initialColor = picker.getColor();
textInput.value = initialColor;
const swatchValue = swatch.querySelector(`.${APPID}-color-swatch-value`);
if (swatchValue instanceof HTMLElement) {
swatchValue.style.backgroundColor = initialColor;
}
textInput.classList.remove('is-invalid');
this._isSyncing = false;
// Picker -> Text Input: This remains crucial for updating the text when the user drags the picker.
picker.rootElement.addEventListener('color-change', (e) => {
if (this._isSyncing) return;
if (e instanceof CustomEvent) {
this._isSyncing = true;
textInput.value = e.detail.color;
if (swatchValue instanceof HTMLElement) {
swatchValue.style.backgroundColor = e.detail.color;
}
textInput.classList.remove('is-invalid');
this._isSyncing = false;
}
});
}
_positionPicker(popupWrapper, swatchElement) {
const dialogRect = this.container.getBoundingClientRect();
const swatchRect = swatchElement.getBoundingClientRect();
const pickerHeight = popupWrapper.offsetHeight;
const pickerWidth = popupWrapper.offsetWidth;
const margin = 4;
// Default to showing the picker above the swatch.
let top = swatchRect.top - dialogRect.top - pickerHeight - margin;
let left = swatchRect.left - dialogRect.left;
// If there's not enough space above, show it below instead.
if (top < 0) {
top = swatchRect.bottom - dialogRect.top + margin;
}
if (swatchRect.left + pickerWidth > dialogRect.right) {
left = swatchRect.right - dialogRect.left - pickerWidth;
}
left = Math.max(margin, left);
top = Math.max(margin, top);
popupWrapper.style.top = `${top}px`;
popupWrapper.style.left = `${left}px`;
}
_handleOutsideClick(e) {
if (!this.activePicker) return;
if (this.activePicker.swatch.contains(e.target)) {
return;
}
if (this.activePicker.popupWrapper.contains(e.target)) {
return;
}
this._closePicker();
}
}
/**
* @class CustomColorPicker
* @description A self-contained, reusable color picker UI component. It has no external
* dependencies and injects its own styles into the document head. All utility
* methods are included as static methods.
*/
class CustomColorPicker {
/**
* @param {Element} rootElement The DOM element to render the picker into.
* @param {object} [options]
* @param {string} [options.initialColor] The initial color to display.
* @param {string} [options.cssPrefix] A prefix for all CSS classes to avoid conflicts.
*/
constructor(rootElement, options = {}) {
this.rootElement = rootElement;
this.options = {
initialColor: 'rgb(255 0 0 / 1)',
cssPrefix: 'ccp',
...options,
};
this.state = { h: 0, s: 100, v: 100, a: 1 };
this.dom = {};
this.isUpdating = false;
this._handleSvPointerMove = this._handleSvPointerMove.bind(this);
this._handleSvPointerUp = this._handleSvPointerUp.bind(this);
}
// =================================================================================
// SECTION: Static Color Utility Methods
// =================================================================================
/**
* Converts HSV color values to RGB.
* @param {number} h - Hue (0-360)
* @param {number} s - Saturation (0-100)
* @param {number} v - Value (0-100)
* @returns {{r: number, g: number, b: number}} RGB object (0-255).
*/
static hsvToRgb(h, s, v) {
s /= 100;
v /= 100;
let r, g, b;
const i = Math.floor(h / 60);
const f = h / 60 - i,
p = v * (1 - s),
q = v * (1 - s * f),
t = v * (1 - s * (1 - f));
switch (i % 6) {
case 0: {
r = v;
g = t;
b = p;
break;
}
case 1: {
r = q;
g = v;
b = p;
break;
}
case 2: {
r = p;
g = v;
b = t;
break;
}
case 3: {
r = p;
g = q;
b = v;
break;
}
case 4: {
r = t;
g = p;
b = v;
break;
}
case 5: {
r = v;
g = p;
b = q;
break;
}
}
return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) };
}
/**
* Converts RGB color values to HSV.
* @param {number} r - Red (0-255)
* @param {number} g - Green (0-255)
* @param {number} b - Blue (0-255)
* @returns {{h: number, s: number, v: number}} HSV object.
*/
static rgbToHsv(r, g, b) {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b),
min = Math.min(r, g, b);
const v = max;
const d = max - min;
const s = max === 0 ? 0 : d / max;
let h;
if (max === min) {
h = 0;
} else {
switch (max) {
case r: {
h = (g - b) / d + (g < b ? 6 : 0);
break;
}
case g: {
h = (b - r) / d + 2;
break;
}
case b: {
h = (r - g) / d + 4;
break;
}
}
h /= 6;
}
return { h: Math.round(h * 360), s: Math.round(s * 100), v: Math.round(v * 100) };
}
/**
* Converts an RGB object to a CSS rgb() or rgba() string with modern space-separated syntax.
* @param {number} r - Red (0-255)
* @param {number} g - Green (0-255)
* @param {number} b - Blue (0-255)
* @param {number} [a] - Alpha (0-1)
* @returns {string} CSS color string.
*/
static rgbToString(r, g, b, a = 1) {
if (a < 1) {
return `rgb(${r} ${g} ${b} / ${a.toFixed(2).replace(/\.?0+$/, '') || 0})`;
}
return `rgb(${r} ${g} ${b})`;
}
/**
* Parses a color string into an RGBA object.
* @param {string | null} str - The CSS color string.
* @returns {{r: number, g: number, b: number, a: number} | null} RGBA object or null if invalid.
*/
static parseColorString(str) {
if (!str || String(str).trim() === '') return null;
const s = String(str).trim();
if (/^(rgb|rgba|hsl|hsla)\(/.test(s)) {
const openParenCount = (s.match(/\(/g) || []).length;
const closeParenCount = (s.match(/\)/g) || []).length;
if (openParenCount !== closeParenCount) {
return null;
}
}
const temp = h('div');
temp.style.color = 'initial';
temp.style.color = s;
if (temp.style.color === '' || temp.style.color === 'initial') {
return null;
}
document.body.appendChild(temp);
const computedColor = window.getComputedStyle(temp).color;
document.body.removeChild(temp);
const rgbaMatch = computedColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([0-9.]+))?\)/);
if (rgbaMatch) {
return {
r: parseInt(rgbaMatch[1], 10),
g: parseInt(rgbaMatch[2], 10),
b: parseInt(rgbaMatch[3], 10),
a: rgbaMatch[4] !== undefined ? parseFloat(rgbaMatch[4]) : 1,
};
}
return null;
}
// =================================================================================
// SECTION: Public and Private Instance Methods
// =================================================================================
render() {
this._injectStyles();
// Get references to created DOM elements from _createDom
Object.assign(this.dom, this._createDom());
this._attachEventListeners();
this.setColor(this.options.initialColor);
}
destroy() {
// Remove instance-specific window event listeners
window.removeEventListener('pointermove', this._handleSvPointerMove);
window.removeEventListener('pointerup', this._handleSvPointerUp);
// Clear the DOM content of this specific picker instance
if (this.rootElement) {
this.rootElement.textContent = '';
}
// Nullify references to prevent memory leaks and mark as destroyed
this.rootElement = null;
this.dom = {};
// After this instance's DOM is removed, check if any other pickers with the same prefix still exist.
const p = this.options.cssPrefix;
const remainingPickers = document.querySelector(`.${p}-color-picker`);
// If no other pickers are found, it is now safe to remove the shared style element.
if (!remainingPickers) {
const styleElement = document.getElementById(p + '-styles');
if (styleElement) {
styleElement.remove();
}
}
}
setColor(rgbString) {
const parsed = CustomColorPicker.parseColorString(rgbString);
if (parsed) {
const { r, g, b, a } = parsed;
const { h, s, v } = CustomColorPicker.rgbToHsv(r, g, b);
this.state = { h, s, v, a };
this._requestUpdate();
return true;
}
return false;
}
getColor() {
const { h, s, v, a } = this.state;
const { r, g, b } = CustomColorPicker.hsvToRgb(h, s, v);
return CustomColorPicker.rgbToString(r, g, b, a);
}
_injectStyles() {
const styleId = this.options.cssPrefix + '-styles';
if (document.getElementById(styleId)) return;
const p = this.options.cssPrefix;
const style = h('style', { id: styleId });
style.textContent = `
.${p}-color-picker { display: flex; flex-direction: column; gap: 16px; }
.${p}-sv-plane { position: relative; width: 100%; aspect-ratio: 1 / 1; cursor: crosshair; touch-action: none; border-radius: 4px; overflow: hidden; }
.${p}-sv-plane:focus { outline: 2px solid var(--${p}-focus-color, deepskyblue); }
.${p}-sv-plane .${p}-gradient-white, .${p}-sv-plane .${p}-gradient-black { position: absolute; inset: 0; pointer-events: none; }
.${p}-sv-plane .${p}-gradient-white { background: linear-gradient(to right, white, transparent); }
.${p}-sv-plane .${p}-gradient-black { background: linear-gradient(to top, black, transparent); }
.${p}-sv-thumb { position: absolute; width: 20px; height: 20px; border: 2px solid white; border-radius: 50%; box-shadow: 0 0 2px 1px rgb(0 0 0 / 0.5); box-sizing: border-box; transform: translate(-50%, -50%); pointer-events: none; }
.${p}-slider-group { position: relative; cursor: pointer; height: 20px; }
.${p}-slider-group .${p}-slider-track, .${p}-slider-group .${p}-alpha-checkerboard { position: absolute; top: 50%; transform: translateY(-50%); width: 100%; height: 12px; border-radius: 6px; pointer-events: none; }
.${p}-slider-group .${p}-alpha-checkerboard { background-image: repeating-conic-gradient(#808080 0% 25%, #c0c0c0 0% 50%); background-size: 12px 12px; }
.${p}-slider-group .${p}-hue-track { background: linear-gradient( to right, hsl(0,100%,50%), hsl(60,100%,50%), hsl(120,100%,50%), hsl(180,100%,50%), hsl(240,100%,50%), hsl(300,100%,50%), hsl(360,100%,50%) ); }
.${p}-slider-group input[type="range"] { -webkit-appearance: none; appearance: none; position: relative; width: 100%; height: 100%; margin: 0; padding: 0; background-color: transparent; cursor: pointer; }
.${p}-slider-group input[type="range"]:focus { outline: none; }
.${p}-slider-group input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 20px; height: 20px; border: 2px solid white; border-radius: 50%; background-color: #fff; box-shadow: 0 0 2px 1px rgb(0 0 0 / 0.5); }
.${p}-slider-group input[type="range"]::-moz-range-thumb { width: 20px; height: 20px; border: 2px solid white; border-radius: 50%; background-color: #fff; box-shadow: 0 0 2px 1px rgb(0 0 0 / 0.5); }
.${p}-slider-group input[type="range"]:focus::-webkit-slider-thumb { outline: 2px solid var(--${p}-focus-color, deepskyblue); outline-offset: 1px; }
.${p}-slider-group input[type="range"]:focus::-moz-range-thumb { outline: 2px solid var(--${p}-focus-color, deepskyblue); outline-offset: 1px; }
`;
document.head.appendChild(style);
}
_createDom() {
const p = this.options.cssPrefix;
this.rootElement.textContent = '';
// References to key elements will be captured during creation.
let svPlane, svThumb, hueSlider, alphaSlider, alphaTrack;
const colorPicker = h(`div.${p}-color-picker`, { 'aria-label': 'Color picker' }, [
(svPlane = h(
`div.${p}-sv-plane`,
{
role: 'slider',
tabIndex: 0,
'aria-label': 'Saturation and Value',
},
[h(`div.${p}-gradient-white`), h(`div.${p}-gradient-black`), (svThumb = h(`div.${p}-sv-thumb`))]
)),
h(`div.${p}-slider-group.${p}-hue-slider`, [
h(`div.${p}-slider-track.${p}-hue-track`),
(hueSlider = h('input', {
type: 'range',
min: '0',
max: '360',
step: '1',
'aria-label': 'Hue',
})),
]),
h(`div.${p}-slider-group.${p}-alpha-slider`, [
h(`div.${p}-alpha-checkerboard`),
(alphaTrack = h(`div.${p}-slider-track`)),
(alphaSlider = h('input', {
type: 'range',
min: '0',
max: '1',
step: '0.01',
'aria-label': 'Alpha',
})),
]),
]);
this.rootElement.appendChild(colorPicker);
// Return references to the created elements.
return { svPlane, svThumb, hueSlider, alphaSlider, alphaTrack };
}
_handleSvPointerDown(e) {
e.preventDefault();
this.dom.svPlane.focus();
this._updateSv(e.clientX, e.clientY);
window.addEventListener('pointermove', this._handleSvPointerMove);
window.addEventListener('pointerup', this._handleSvPointerUp);
}
_handleSvPointerMove(e) {
this._updateSv(e.clientX, e.clientY);
}
_handleSvPointerUp() {
window.removeEventListener('pointermove', this._handleSvPointerMove);
window.removeEventListener('pointerup', this._handleSvPointerUp);
}
_updateSv(clientX, clientY) {
const rect = this.dom.svPlane.getBoundingClientRect();
const x = Math.max(0, Math.min(rect.width, clientX - rect.left));
const y = Math.max(0, Math.min(rect.height, clientY - rect.top));
this.state.s = Math.round((x / rect.width) * 100);
this.state.v = Math.round((1 - y / rect.height) * 100);
this._requestUpdate();
}
_attachEventListeners() {
const { svPlane, hueSlider, alphaSlider } = this.dom;
svPlane.addEventListener('pointerdown', this._handleSvPointerDown.bind(this));
svPlane.addEventListener('keydown', (e) => {
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) return;
e.preventDefault();
const sStep = e.shiftKey ? 10 : 1;
const vStep = e.shiftKey ? 10 : 1;
switch (e.key) {
case 'ArrowLeft': {
this.state.s = Math.max(0, this.state.s - sStep);
break;
}
case 'ArrowRight': {
this.state.s = Math.min(100, this.state.s + sStep);
break;
}
case 'ArrowUp': {
this.state.v = Math.min(100, this.state.v + vStep);
break;
}
case 'ArrowDown': {
this.state.v = Math.max(0, this.state.v - vStep);
break;
}
}
this._requestUpdate();
});
hueSlider.addEventListener('input', () => {
this.state.h = parseInt(hueSlider.value, 10);
this._requestUpdate();
});
alphaSlider.addEventListener('input', () => {
this.state.a = parseFloat(alphaSlider.value);
this._requestUpdate();
});
}
_requestUpdate() {
if (this.isUpdating) return;
this.isUpdating = true;
requestAnimationFrame(() => {
this._updateUIDisplay();
this._dispatchChangeEvent();
this.isUpdating = false;
});
}
_updateUIDisplay() {
if (!this.rootElement) return; // Guard against updates after destruction
const { h, s, v, a } = this.state;
const { svPlane, svThumb, hueSlider, alphaSlider, alphaTrack } = this.dom;
const { r, g, b } = CustomColorPicker.hsvToRgb(h, s, v);
svPlane.style.backgroundColor = `hsl(${h}, 100%, 50%)`;
svThumb.style.left = `${s}%`;
svThumb.style.top = `${100 - v}%`;
svThumb.style.backgroundColor = `rgb(${r} ${g} ${b})`;
hueSlider.value = h;
alphaSlider.value = a;
alphaTrack.style.background = `linear-gradient(to right, transparent, rgb(${r} ${g} ${b}))`;
svPlane.setAttribute('aria-valuetext', `Saturation ${s}%, Value ${v}%`);
hueSlider.setAttribute('aria-valuenow', h);
alphaSlider.setAttribute('aria-valuenow', a.toFixed(2));
}
_dispatchChangeEvent() {
if (this.rootElement) {
this.rootElement.dispatchEvent(
new CustomEvent('color-change', {
detail: {
color: this.getColor(),
},
bubbles: true,
})
);
}
}
}
/**
* @class CustomModal
* @description A reusable, promise-based modal component. It provides a flexible
* structure with header, content, and footer sections, and manages its own lifecycle and styles.
* @callback ModalButtonOnClick
* @param {CustomModal} modalInstance - The instance of the modal that the button belongs to.
* @param {MouseEvent} event - The mouse click event.
* @returns {void}
*/
class CustomModal {
/**
* @param {object} [options]
* @param {string} [options.title] - The title displayed in the modal header.
* @param {string} [options.width] - The width of the modal.
* @param {string} [options.cssPrefix] - A prefix for all CSS classes.
* @param {boolean} [options.closeOnBackdropClick] - Whether to close the modal when clicking the backdrop.
* @param {Array<{text: string, id: string, className?: string, title?: string, onClick: ModalButtonOnClick}>} [options.buttons] - An array of button definitions for the footer.
* @param {function(): void} [options.onDestroy] - A callback function executed when the modal is destroyed.
* @param {object} [options.styles] - Site-specific styles to apply to the modal.
*/
constructor(options = {}) {
this.options = {
title: '',
width: '500px',
cssPrefix: 'cm',
closeOnBackdropClick: true,
buttons: [],
onDestroy: null,
styles: {}, // Add styles option
...options,
};
this.element = null;
this.dom = {}; // To hold references to internal elements like header, content, footer
this._injectStyles();
this._createModalElement();
}
_injectStyles() {
const styleId = this.options.cssPrefix + '-styles';
if (document.getElementById(styleId)) return;
const p = this.options.cssPrefix;
const style = h('style', { id: styleId });
// Use CSS variables for theming, which will be set on the element itself.
style.textContent = `
dialog.${p}-dialog {
padding: 0;
border: none;
background: transparent;
max-width: 100vw;
max-height: 100vh;
overflow: visible;
}
dialog.${p}-dialog::backdrop {
background: rgb(0 0 0 / 0.5);
pointer-events: auto;
}
.${p}-box {
display: flex;
flex-direction: column;
background: var(--${p}-bg);
color: var(--${p}-text);
border: 1px solid var(--${p}-border-color);
border-radius: 8px;
box-shadow: 0 4px 16px rgb(0 0 0 / 0.2);
}
.${p}-header, .${p}-footer {
flex-shrink: 0;
padding: 12px 16px;
}
.${p}-header {
font-size: 1.1em;
font-weight: 600;
border-bottom: 1px solid var(--${p}-border-color);
}
.${p}-content {
flex-grow: 1;
padding: 16px;
overflow-y: auto;
}
.${p}-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
border-top: 1px solid var(--${p}-border-color);
}
.${p}-footer-message {
flex-grow: 1;
font-size: 0.9em;
}
.${p}-button-group {
display: flex;
gap: 8px;
}
.${p}-button {
background: var(--${p}-btn-bg);
color: var(--${p}-btn-text);
border: 1px solid var(--${p}-btn-border-color);
border-radius: 5px;
padding: 5px 16px;
font-size: 13px;
cursor: pointer;
transition: background 0.12s;
}
.${p}-button:hover {
background: var(--${p}-btn-hover-bg);
}
`;
document.head.appendChild(style);
}
_createModalElement() {
const p = this.options.cssPrefix;
// Define variables to hold references to key elements.
let header, content, footer, modalBox, footerMessage;
// Create footer buttons declaratively using map and h().
const buttons = this.options.buttons.map((btnDef) => {
const fullClassName = [`${p}-button`, btnDef.className, `${APPID}-modal-button`].filter(Boolean).join(' ');
return h(
'button',
{
id: btnDef.id,
className: fullClassName,
onclick: (e) => btnDef.onClick(this, e),
},
btnDef.text
);
});
const buttonGroup = h(`div.${p}-button-group`, buttons);
// Create the entire modal structure using h().
const dialogElement = h(
`dialog.${p}-dialog`,
(modalBox = h(`div.${p}-box`, { style: { width: this.options.width } }, [
(header = h(`div.${p}-header`, this.options.title)),
(content = h(`div.${p}-content`)),
(footer = h(`div.${p}-footer`, [(footerMessage = h(`div.${p}-footer-message`)), buttonGroup])),
]))
);
if (!(dialogElement instanceof HTMLDialogElement)) {
Logger.error('Failed to create modal dialog element.');
return;
}
this.element = dialogElement;
// Apply site-specific styles via CSS custom properties.
const s = this.options.styles;
if (s) {
const bg = s.bg || '#fff';
const text = s.text || '#000';
const border = s.border || '#888';
modalBox.style.setProperty(`--${p}-bg`, bg);
modalBox.style.setProperty(`--${p}-text`, text);
modalBox.style.setProperty(`--${p}-border-color`, border);
modalBox.style.setProperty(`--${p}-btn-bg`, s.btn_bg || '#efefef');
modalBox.style.setProperty(`--${p}-btn-text`, s.btn_text || '#000');
modalBox.style.setProperty(`--${p}-btn-border-color`, s.btn_border || '#ccc');
modalBox.style.setProperty(`--${p}-btn-hover-bg`, s.btn_hover_bg || '#e0e0e0');
}
// The 'close' event is the single source of truth for when the dialog has been dismissed.
this.element.addEventListener('close', () => this.destroy());
if (this.options.closeOnBackdropClick) {
this.element.addEventListener('click', (e) => {
if (e.target === this.element) {
this.close();
}
});
}
// Store references and append the final element to the body.
this.dom = { header, content, footer, modalBox, footerMessage };
document.body.appendChild(this.element);
}
show(anchorElement = null) {
if (this.element && typeof this.element.showModal === 'function') {
this.element.showModal();
// Positioning logic
if (anchorElement && typeof anchorElement.getBoundingClientRect === 'function') {
// ANCHORED POSITIONING
const modalBox = this.dom.modalBox;
const btnRect = anchorElement.getBoundingClientRect();
const margin = 8;
const modalWidth = modalBox.offsetWidth || parseInt(this.options.width, 10);
const modalHeight = modalBox.offsetHeight;
let left = btnRect.left;
const top = btnRect.bottom + 4;
if (left + modalWidth > window.innerWidth - margin) {
left = window.innerWidth - modalWidth - margin;
}
// Vertical collision detection & adjustment
let finalTop = top;
if (finalTop + modalHeight > window.innerHeight - margin) {
// Try positioning above the anchor
const topAbove = btnRect.top - modalHeight - 4;
if (topAbove > margin) {
finalTop = topAbove;
} else {
// If it doesn't fit above, pin to the bottom edge of the window
finalTop = window.innerHeight - modalHeight - margin;
}
}
Object.assign(this.element.style, {
position: 'absolute',
left: `${Math.max(left, margin)}px`,
top: `${Math.max(finalTop, margin)}px`,
margin: '0',
transform: 'none',
});
} else {
// DEFAULT CENTERING
Object.assign(this.element.style, {
position: 'fixed',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
margin: '0',
});
}
}
}
close() {
if (this.element && this.element.open) {
this.element.close();
}
}
destroy() {
if (!this.element) return;
this.element.remove();
this.element = null;
if (this.options.onDestroy) {
this.options.onDestroy();
}
// Check if any other modals with the same prefix exist
const p = this.options.cssPrefix;
const remainingModals = document.querySelector(`dialog.${p}-dialog`);
if (!remainingModals) {
document.getElementById(`${p}-styles`)?.remove();
}
}
/**
* @param {Node} element
*/
setContent(element) {
this.dom.content.textContent = '';
this.dom.content.appendChild(element);
}
getContentContainer() {
return this.dom.content;
}
}
/**
* Manages a configurable, reusable settings button.
* This component is static and does not include drag-and-drop functionality.
*/
class CustomSettingsButton extends UIComponentBase {
/**
* @param {object} callbacks - Functions to be called on component events.
* @param {function(): void} callbacks.onClick - Called when the button is clicked.
* @param {object} options - Configuration for the button's appearance and behavior.
* @param {string} options.id - The DOM ID for the button element.
* @param {string} options.textContent - The text or emoji to display inside the button.
* @param {string} options.title - The tooltip text for the button.
* @param {object} options.config - The style configuration object generated by PlatformAdapters. Contains zIndex, styles, hoverStyles, and iconDef.
*/
constructor(callbacks, options) {
super(callbacks);
this.options = options;
this.id = this.options.id;
this.styleId = `${this.id}-style`;
}
destroy() {
this.element?.remove();
this.element = null;
document.getElementById(this.styleId)?.remove();
}
/**
* Renders the settings button element in memory.
* @returns {HTMLElement} The created button element.
*/
render() {
this._injectStyles();
const oldElement = document.getElementById(this.id);
if (oldElement) {
oldElement.remove();
}
this.element = h('button', {
id: this.id,
type: 'button',
title: this.options.title,
onclick: (e) => {
e.preventDefault();
e.stopPropagation();
this.callbacks.onClick?.();
},
});
const iconDef = this.options.config.iconDef;
if (iconDef) {
const svgElement = createIconFromDef(iconDef);
if (svgElement) {
this.element.appendChild(svgElement);
}
}
return this.element;
}
/**
* @private
* Injects the component's CSS into the document head, using options for configuration.
*/
_injectStyles() {
if (document.getElementById(this.styleId)) return;
const { styles, hoverStyles, zIndex } = this.options.config;
// Helper to convert camelCase keys to kebab-case CSS properties
const toCss = (obj) =>
Object.entries(obj || {})
.map(([k, v]) => `${k.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`)}: ${v};`)
.join('\n ');
const style = h('style', {
id: this.styleId,
textContent: `
#${this.id} {
z-index: ${zIndex};
${toCss(styles)}
/* Fixed base styles */
font-size: 16px;
cursor: pointer;
box-shadow: var(--drop-shadow-xs, 0 1px 1px #0000000d);
transition: background 0.12s, border-color 0.12s, box-shadow 0.12s;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
pointer-events: auto !important;
margin: 0;
}
#${this.id}:hover {
${toCss(hoverStyles)}
}
`,
});
document.head.appendChild(style);
}
}
/**
* @abstract
* @description Base class for a settings panel/submenu UI component.
*/
class SettingsPanelBase extends UIComponentBase {
constructor(callbacks) {
super(callbacks);
this.debouncedSave = debounce(this._handleDebouncedSave.bind(this), CONSTANTS.TIMING.DEBOUNCE_DELAYS.SETTINGS_SAVE, true);
this._handleDocumentClick = this._handleDocumentClick.bind(this);
this._handleDocumentKeydown = this._handleDocumentKeydown.bind(this);
}
render() {
if (document.getElementById(`${APPID}-settings-panel`)) {
document.getElementById(`${APPID}-settings-panel`).remove();
}
this._injectStyles();
this.element = this._createPanelContainer();
const content = this._createPanelContent();
this.element.appendChild(content);
document.body.appendChild(this.element);
this._setupEventListeners();
return this.element;
}
destroy() {
this.debouncedSave.cancel();
document.removeEventListener('click', this._handleDocumentClick, true);
document.removeEventListener('keydown', this._handleDocumentKeydown, true);
super.destroy();
}
toggle() {
const shouldShow = this.element.style.display === 'none';
if (shouldShow) {
this.show();
} else {
this.hide();
}
}
isOpen() {
return this.element && this.element.style.display !== 'none';
}
async show() {
await this._populateForm();
const anchorRect = this.callbacks.getAnchorElement().getBoundingClientRect();
let top = anchorRect.bottom + 4;
let left = anchorRect.left;
this.element.style.display = 'block';
const panelWidth = this.element.offsetWidth;
const panelHeight = this.element.offsetHeight;
if (left + panelWidth > window.innerWidth - 8) {
left = window.innerWidth - panelWidth - 8;
}
if (top + panelHeight > window.innerHeight - 8) {
top = window.innerHeight - panelHeight - 8;
}
this.element.style.left = `${Math.max(8, left)}px`;
this.element.style.top = `${Math.max(8, top)}px`;
document.addEventListener('click', this._handleDocumentClick, true);
document.addEventListener('keydown', this._handleDocumentKeydown, true);
}
hide() {
this.element.style.display = 'none';
document.removeEventListener('click', this._handleDocumentClick, true);
document.removeEventListener('keydown', this._handleDocumentKeydown, true);
}
/**
* @private
* Collects data and calls the onSave callback. Designed to be debounced.
*/
async _handleDebouncedSave() {
const newConfig = await this._collectDataFromForm();
try {
await this.callbacks.onSave?.(newConfig);
} catch (e) {
// Log the error to console. User notification is handled via EventBus (CONFIG_SIZE_EXCEEDED).
Logger.error('SettingsPanel save failed:', e);
}
}
_createPanelContainer() {
return h(`div#${APPID}-settings-panel`, { style: { display: 'none' }, role: 'menu' });
}
_handleDocumentClick(e) {
const anchor = this.callbacks.getAnchorElement();
if (this.element && !this.element.contains(e.target) && anchor && !anchor.contains(e.target)) {
this.hide();
}
}
_handleDocumentKeydown(e) {
if (e.key === 'Escape') {
this.hide();
}
}
// --- Abstract methods to be implemented by subclasses ---
_createPanelContent() {
throw new Error('Subclass must implement _createPanelContent()');
}
_injectStyles() {
throw new Error('Subclass must implement _injectStyles()');
}
_populateForm() {
throw new Error('Subclass must implement _populateForm()');
}
_collectDataFromForm() {
throw new Error('Subclass must implement _collectDataFromForm()');
}
_setupEventListeners() {
throw new Error('Subclass must implement _setupEventListeners()');
}
}
/**
* Manages the settings panel/submenu.
*/
class SettingsPanelComponent extends SettingsPanelBase {
constructor(callbacks) {
super(callbacks);
this.activeThemeSet = null;
this.subscriptions = [];
}
/**
* Helper to subscribe to EventBus and track the subscription for cleanup.
* Appends the listener name and a unique suffix to the key to avoid conflicts.
* @param {string} event
* @param {Function} listener
*/
_subscribe(event, listener) {
const baseKey = createEventKey(this, event);
// Use function name for debugging aid, fallback to 'anonymous'
const listenerName = listener.name || 'anonymous';
// Generate a short random suffix to guarantee uniqueness even for anonymous functions
const uniqueSuffix = Math.random().toString(36).substring(2, 7);
const key = `${baseKey}_${listenerName}_${uniqueSuffix}`;
EventBus.subscribe(event, listener, key);
this.subscriptions.push({ event, key });
}
init() {
this._subscribe(EVENTS.CONFIG_UPDATED, async () => {
if (this.isOpen()) {
await this._populateForm();
}
});
}
destroy() {
super.destroy();
this.subscriptions.forEach(({ event, key }) => EventBus.unsubscribe(event, key));
this.subscriptions = [];
document.getElementById(`${APPID}-ui-styles`)?.remove();
}
/**
* @override
* Updates the displayed theme name and then shows the panel.
* @returns {Promise<void>}
*/
async show() {
// Update applied theme name display (if callback is available)
if (this.callbacks.getCurrentThemeSet) {
this.activeThemeSet = this.callbacks.getCurrentThemeSet();
const themeName = this.activeThemeSet.metadata?.name || 'Default Settings';
const themeNameEl = this.element.querySelector(`#${APPID}-applied-theme-name`);
if (themeNameEl) {
themeNameEl.textContent = themeName;
}
}
await super.show();
}
_createPanelContent() {
const schema = this._getPanelSchema();
// Wrap the generated elements in a single parent div to match the original DOM structure
return h('div', [buildUIFromSchema(schema)]);
}
_getPanelSchema() {
// prettier-ignore
return [
...this._getAppliedThemeSchema(),
...this._getSubmenuButtonsSchema(),
...this._getOptionsSchema(),
...this._getFeaturesSchema(),
];
}
_getAppliedThemeSchema() {
return [
{
type: 'fieldset',
legend: 'Applied Theme',
children: [{ type: 'button', id: `${APPID}-applied-theme-name`, text: 'Loading...', title: 'Click to edit this theme', fullWidth: true }],
},
];
}
_getSubmenuButtonsSchema() {
return [
{
type: 'container',
className: `${APPID}-submenu-top-row`,
children: [
{ type: 'fieldset', legend: 'Themes', children: [{ type: 'button', id: `${APPID}-submenu-edit-themes-btn`, text: 'Edit Themes...', title: 'Open the theme editor to create and modify themes.', fullWidth: true }] },
{
type: 'fieldset',
legend: 'JSON',
children: [{ type: 'button', id: `${APPID}-submenu-json-btn`, text: 'JSON...', title: 'Opens the advanced settings modal to directly edit, import, or export the entire configuration in JSON format.', fullWidth: true }],
},
],
},
];
}
_getOptionsSchema() {
const widthConfig = CONSTANTS.SLIDER_CONFIGS.CHAT_WIDTH;
return [
{
type: 'fieldset',
legend: 'Options',
children: [
{
type: 'slider',
containerClass: `${APPID}-submenu-row-stacked`,
id: 'options.icon_size',
label: 'Icon size:',
tooltip: 'Specifies the size of the chat icons in pixels.',
min: 0,
max: CONSTANTS.ICON_SIZE_VALUES.length - 1,
step: 1,
dataset: { sliderFor: 'options.icon_size', valueMapKey: 'ICON_SIZE_VALUES' },
},
{
type: 'slider',
containerClass: `${APPID}-submenu-row-stacked`,
id: 'options.chat_content_max_width',
label: 'Chat content max width:',
tooltip: `Adjusts the maximum width of the chat content.\nMove slider to the far left for default.\nRange: ${widthConfig.NULL_THRESHOLD}vw to ${widthConfig.MAX}vw.`,
min: widthConfig.MIN,
max: widthConfig.MAX,
step: 1,
dataset: { sliderFor: 'options.chat_content_max_width', unit: 'vw', nullThreshold: widthConfig.NULL_THRESHOLD },
},
{ type: 'submenu-separator' },
{
type: 'container-row',
children: [
{
type: 'label',
for: `${APPID}-form-options-respect_avatar_space`,
title: 'When enabled, adjusts the standing image area to not overlap the avatar icon.\nWhen disabled, the standing image is maximized but may overlap the icon.',
text: 'Prevent image/avatar overlap:',
},
{ type: 'toggle', id: 'options.respect_avatar_space', configKey: 'options.respect_avatar_space' },
],
},
],
},
];
}
_getFeaturesSchema() {
const commonFeatures = [
{ id: 'features.collapsible_button.enabled', label: 'Collapsible button', title: 'Enables a button to collapse large message bubbles.' },
{ id: 'features.sequential_nav_buttons.enabled', label: 'Sequential nav buttons', title: 'Enables buttons to jump to the previous/next message.' },
{ id: 'features.scroll_to_top_button.enabled', label: 'Scroll to top button', title: 'Enables a button to scroll to the top of a message.' },
{ id: 'features.fixed_nav_console.enabled', label: 'Navigation console', title: 'When enabled, a navigation console with message counters will be displayed next to the text input area.' },
];
const platformFeatures = PlatformAdapters.SettingsPanel.getPlatformSpecificFeatureToggles().map((f) => ({ ...f, id: f.configKey }));
const allFeatures = [...platformFeatures, ...commonFeatures];
const featureGroups = allFeatures.map((feature) => {
const formId = `${APPID}-form-${feature.id.replace(/\./g, '-')}`;
return {
type: 'container',
className: `${APPID}-feature-group`,
children: [
{
type: 'container-row',
children: [
{ type: 'label', for: formId, title: feature.title, text: feature.label },
{ type: 'toggle', id: feature.id, configKey: feature.id },
],
},
],
};
});
return [{ type: 'fieldset', legend: 'Features', children: featureGroups }];
}
async _populateForm() {
const config = await this.callbacks.getCurrentConfig();
if (!config || !this.element) return;
populateFormFromSchema(this._getPanelSchema(), this.element, config, this);
this._updateDependencies();
}
async _collectDataFromForm() {
const currentConfig = await this.callbacks.getCurrentConfig();
const newConfig = deepClone(currentConfig);
if (!this.element) return newConfig;
collectDataFromSchema(this._getPanelSchema(), this.element, newConfig);
return newConfig;
}
_updateSliderDisplay(slider) {
const displayId = slider.dataset.sliderFor;
if (!displayId) return;
const display = this.element.querySelector(`[data-slider-display-for="${displayId}"]`);
if (!display) return;
updateSliderDisplay(slider, display);
}
_setupEventListeners() {
// Modal Buttons
this.element.querySelector(`#${APPID}-applied-theme-name`).addEventListener('click', () => {
if (this.activeThemeSet) {
let themeKey = this.activeThemeSet.metadata?.id;
// If the ID is 'default', map it to 'defaultSet' to match the <select> option value.
if (themeKey === 'default') {
themeKey = 'defaultSet';
}
this.callbacks.onShowThemeModal?.(themeKey || 'defaultSet');
this.hide();
}
});
this.element.querySelector(`#${APPID}-submenu-json-btn`).addEventListener('click', () => {
this.callbacks.onShowJsonModal?.();
this.hide();
});
this.element.querySelector(`#${APPID}-submenu-edit-themes-btn`).addEventListener('click', () => {
this.callbacks.onShowThemeModal?.();
this.hide();
});
// Input event delegation
this.element.addEventListener('input', (e) => {
if (e.target.matches('input[type="range"]')) {
this._updateSliderDisplay(e.target);
// Special handling for width preview
if (e.target.dataset.sliderFor === 'options.chat_content_max_width') {
const sliderValue = parseInt(e.target.value, 10);
const widthConfig = CONSTANTS.SLIDER_CONFIGS.CHAT_WIDTH;
const newWidthValue = sliderValue < widthConfig.NULL_THRESHOLD ? null : `${sliderValue}vw`;
EventBus.publish(EVENTS.WIDTH_PREVIEW, newWidthValue);
}
this.debouncedSave();
}
});
this.element.addEventListener('change', (e) => {
if (e.target.matches('input[type="checkbox"]')) {
this._updateDependencies();
this.debouncedSave();
}
});
}
_updateDependencies() {
if (!this.element) return;
// The IDs are generated based on the config key structure: features.collapsible_button.enabled -> features-collapsible_button-enabled
// Note: Underscores in the config key are NOT replaced with hyphens in the ID generation logic.
const collapseToggle = this.element.querySelector(`#${APPID}-form-features-collapsible_button-enabled`);
const autoCollapseToggle = this.element.querySelector(`#${APPID}-form-features-auto_collapse_user_message-enabled`);
// Note: The auto-collapse toggle might not exist in all platforms (e.g. Gemini), so we check for its existence.
if (collapseToggle && autoCollapseToggle) {
const isEnabled = collapseToggle.checked;
autoCollapseToggle.disabled = !isEnabled;
// Visually indicate disabled state on the parent container row
const containerRow = autoCollapseToggle.closest(`.${APPID}-submenu-row`);
if (containerRow) {
containerRow.style.opacity = isEnabled ? '1' : '0.5';
containerRow.style.pointerEvents = isEnabled ? 'auto' : 'none';
}
}
}
_injectStyles() {
const styleId = `${APPID}-ui-styles`;
if (document.getElementById(styleId)) return;
const styles = this.callbacks.siteStyles;
const style = h('style', {
id: styleId,
textContent: `
#${APPID}-settings-panel {
position: fixed;
width: min(340px, 95vw);
max-height: 85vh;
overflow-y: auto;
overscroll-behavior: contain;
background: ${styles.bg};
color: ${styles.text_primary};
border-radius: 0.5rem;
box-shadow: 0 4px 20px 0 rgb(0 0 0 / 15%);
padding: 12px;
z-index: ${CONSTANTS.Z_INDICES.SETTINGS_PANEL};
border: 1px solid ${styles.border_medium};
font-size: 0.9em;
}
#${APPID}-applied-theme-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.${APPID}-submenu-top-row {
display: flex;
gap: 12px;
margin-bottom: 12px;
}
.${APPID}-submenu-top-row .${APPID}-submenu-fieldset {
flex: 1 1 0px;
margin-bottom: 0;
}
.${APPID}-submenu-fieldset {
border: 1px solid ${styles.border_default};
border-radius: 4px;
padding: 8px 12px 12px;
margin: 0 0 12px 0;
min-width: 0;
}
.${APPID}-submenu-fieldset legend {
padding: 0 4px;
font-weight: 500;
color: ${styles.text_secondary};
}
.${APPID}-submenu-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 8px;
}
.${APPID}-submenu-row label {
flex-shrink: 0;
}
.${APPID}-submenu-row-stacked {
flex-direction: column;
align-items: stretch;
gap: 4px;
}
.${APPID}-submenu-row-stacked label {
margin-inline-end: 0;
flex-shrink: 1;
color: ${styles.text_secondary};
}
.${APPID}-submenu-separator {
border-top: 1px solid ${styles.border_light};
margin: 12px 0;
}
.${APPID}-slider-container {
display: flex;
align-items: center;
gap: 12px;
flex-grow: 1;
}
.${APPID}-slider-container input[type="range"] {
flex-grow: 1;
margin: 0;
}
.${APPID}-slider-display {
min-width: 4.5em;
text-align: right;
font-family: monospace;
color: ${styles.text_primary};
}
.${APPID}-slider-subgroup-control.is-default .${APPID}-slider-display {
color: ${styles.text_secondary};
}
.${APPID}-feature-group {
padding: 8px 0;
}
.${APPID}-feature-group:not(:first-child) {
border-top: 1px solid ${styles.border_light};
}
.${APPID}-feature-group .${APPID}-submenu-row:first-child {
margin-top: 0;
}
`,
});
document.head.appendChild(style);
}
}
/**
* Manages the JSON editing modal by using the CustomModal component.
*/
class JsonModalComponent extends UIComponentBase {
constructor(callbacks) {
super(callbacks);
this.modal = null; // To hold the CustomModal instance
this.debouncedUpdateSize = debounce((text) => this._updateSizeDisplay(text), 300, true);
}
/**
* @override
*/
render() {
// No-op: DOM generation is delegated to CustomModal in open().
// Implemented to satisfy UIComponentBase contract.
}
async open(anchorElement) {
if (this.modal) return;
this.callbacks.onModalOpenStateChange?.(true);
// Inject styles lazily when the modal is opened
this._injectStyles();
const p = APPID;
this.modal = new CustomModal({
title: `${APPNAME} Settings`,
width: `${CONSTANTS.MODAL.WIDTH}px`,
cssPrefix: `${p}-modal-shell`,
styles: this.callbacks.siteStyles, // Pass styles to the modal
buttons: [
{ text: 'Export', id: `${p}-json-modal-export-btn`, className: '', onClick: () => this._handleExport() },
{ text: 'Import', id: `${p}-json-modal-import-btn`, className: '', onClick: () => this._handleImport() },
{ text: 'Cancel', id: `${p}-json-modal-cancel-btn`, className: '-btn-push-right', onClick: () => this.close() },
{ text: 'Save', id: `${p}-json-modal-save-btn`, className: '-btn-primary', onClick: () => this._handleSave() },
],
onDestroy: () => {
this.debouncedUpdateSize.cancel();
this.callbacks.onModalOpenStateChange?.(false);
this.modal = null;
},
});
const contentContainer = this.modal.getContentContainer();
this._createContent(contentContainer);
this.callbacks.onModalOpen?.(); // Notify UIManager to check for warnings
const config = await this.callbacks.getCurrentConfig();
const textarea = contentContainer.querySelector('textarea');
if (textarea) {
const initialText = JSON.stringify(config, null, 2);
textarea.value = initialText;
// Immediate update for initial state
this._updateSizeDisplay(initialText);
}
this.modal.show(anchorElement);
// Defer focus and scroll adjustment until the modal is visible
if (textarea) {
requestAnimationFrame(() => {
textarea.focus();
textarea.scrollTop = 0;
textarea.selectionStart = 0;
textarea.selectionEnd = 0;
});
}
}
close() {
if (this.modal) {
this.modal.close();
}
}
destroy() {
this.debouncedUpdateSize.cancel();
this.modal?.destroy();
super.destroy();
}
_createContent(parent) {
const styles = this.callbacks.siteStyles;
parent.style.paddingTop = '16px';
parent.style.paddingBottom = '8px';
const textarea = h('textarea', {
style: {
width: '100%',
height: `${CONSTANTS.MODAL.TEXTAREA_HEIGHT}px`,
boxSizing: 'border-box',
fontFamily: 'monospace',
fontSize: '13px',
marginBottom: '0',
border: `1px solid ${styles.textarea_border}`,
background: styles.textarea_bg,
color: styles.textarea_text,
},
oninput: (e) => this.debouncedUpdateSize(e.target.value),
});
const msgDiv = h(`div.${APPID}-modal-msg`, {
style: {
color: styles.msg_error_text,
fontSize: '0.9em',
flex: '1',
marginRight: '8px',
minHeight: '1.2em', // Reserve space
},
});
const sizeInfoDiv = h(`div.${APPID}-modal-size-info`, {
textContent: 'Checking size...',
style: {
color: styles.textarea_text,
fontSize: '0.85em',
whiteSpace: 'nowrap',
textAlign: 'right',
marginTop: '2px', // Slight adjustment for alignment
},
});
const statusContainer = h(
`div.${APPID}-json-status-container`,
{
style: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginTop: '4px',
},
},
[msgDiv, sizeInfoDiv]
);
parent.append(textarea, statusContainer);
}
async _handleSave() {
const contentContainer = this.modal.getContentContainer();
const textarea = contentContainer.querySelector('textarea');
const msgDiv = contentContainer.querySelector(`.${APPID}-modal-msg`);
if (!(textarea && msgDiv instanceof HTMLElement)) {
return;
}
// Clear previous error messages before attempting to save.
msgDiv.textContent = '';
try {
const obj = JSON.parse(textarea.value);
await this.callbacks.onSave(obj);
this.close();
} catch (e) {
// Display the specific error message from the save process.
msgDiv.textContent = e.message;
msgDiv.style.color = this.callbacks.siteStyles.msg_error_text;
}
}
async _handleExport() {
const msgDiv = this.modal.getContentContainer().querySelector(`.${APPID}-modal-msg`);
if (!(msgDiv instanceof HTMLElement)) {
return;
}
try {
// Clear previous messages before starting.
msgDiv.textContent = '';
const config = await this.callbacks.getCurrentConfig();
const jsonString = JSON.stringify(config, null, 2);
const blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = h('a', {
href: url,
download: `${APPID}_config.json`,
});
if (a instanceof HTMLElement) {
a.click();
}
// Revoke the URL after a delay to ensure the download has time to start.
setTimeout(() => URL.revokeObjectURL(url), 10000);
msgDiv.textContent = 'Export successful.';
msgDiv.style.color = this.callbacks.siteStyles.msg_success_text;
} catch (e) {
msgDiv.textContent = `Export failed: ${e.message}`;
msgDiv.style.color = this.callbacks.siteStyles.msg_error_text;
}
}
_handleImport() {
const contentContainer = this.modal.getContentContainer();
const textarea = contentContainer.querySelector('textarea');
const msgDiv = contentContainer.querySelector(`.${APPID}-modal-msg`);
if (!(textarea && msgDiv instanceof HTMLElement)) {
return;
}
const fileInput = h('input', {
type: 'file',
accept: 'application/json',
onchange: (event) => {
const target = event.target;
if (!(target instanceof HTMLInputElement)) return;
const file = target.files?.[0];
// Reset input value to allow re-importing the same file if needed
target.value = '';
if (!file) return;
const reader = new FileReader();
// Step 1: Update UI immediately upon load completion
reader.onload = (e) => {
msgDiv.textContent = 'Processing...'; // Use standard text color (Case B)
msgDiv.style.color = '';
document.body.style.cursor = 'wait';
// Step 2: Defer heavy processing to allow UI to render
requestAnimationFrame(() => {
// Guard: Check if modal is still open before proceeding
if (!this.modal || !textarea.isConnected) {
document.body.style.cursor = '';
return;
}
try {
const readerTarget = e.target;
if (readerTarget && typeof readerTarget.result === 'string') {
// Heavy operations
const importedConfig = JSON.parse(readerTarget.result);
const jsonString = JSON.stringify(importedConfig, null, 2);
textarea.value = jsonString;
this._updateSizeDisplay(jsonString);
msgDiv.textContent = 'Import successful. Click "Save" to apply.';
msgDiv.style.color = this.callbacks.siteStyles.msg_success_text;
}
} catch (err) {
msgDiv.textContent = `Import failed: ${err.message}`;
msgDiv.style.color = this.callbacks.siteStyles.msg_error_text;
} finally {
document.body.style.cursor = '';
}
});
};
reader.onerror = () => {
msgDiv.textContent = 'Failed to read file.';
msgDiv.style.color = this.callbacks.siteStyles.msg_error_text;
};
// Initial status
msgDiv.textContent = 'Reading file...';
msgDiv.style.color = '';
reader.readAsText(file);
},
});
if (fileInput instanceof HTMLElement) {
fileInput.click();
}
}
_injectStyles() {
const styleId = `${APPID}-json-modal-styles`;
if (document.getElementById(styleId)) return;
const style = h('style', {
id: styleId,
textContent: `
.${APPID}-modal-shell-footer-message {
display: none !important;
}
.${APPID}-modal-shell-button-group {
flex-grow: 1;
}
.${APPID}-modal-shell-button {
min-width: 80px;
}
.${APPID}-json-status-container {
width: 100%;
}
`,
});
document.head.appendChild(style);
}
getContextForReopen() {
return { type: 'json' };
}
_updateSizeDisplay(text) {
const container = this.modal?.getContentContainer();
if (!container) return;
const sizeInfoDiv = container.querySelector(`.${APPID}-modal-size-info`);
if (!sizeInfoDiv) return;
let sizeInBytes = 0;
let isRaw = false;
try {
// Try to parse and minify to get the actual storage size
const obj = JSON.parse(text);
const minified = JSON.stringify(obj);
sizeInBytes = new Blob([minified]).size;
} catch (e) {
// Fallback to raw text size if parsing fails
sizeInBytes = new Blob([text]).size;
isRaw = true;
}
const sizeStr = this._formatBytes(sizeInBytes);
const recommendedStr = this._formatBytes(CONSTANTS.CONFIG_SIZE_RECOMMENDED_LIMIT_BYTES);
const limitStr = this._formatBytes(CONSTANTS.CONFIG_SIZE_LIMIT_BYTES);
// Display format: 1.23 MB / 5.00 MB (Max: 10.00 MB)
sizeInfoDiv.textContent = `${isRaw ? '(Raw) ' : ''}${sizeStr} / ${recommendedStr} (Max: ${limitStr})`;
// Tooltip with detailed limits explanation
sizeInfoDiv.title = `Recommended Limit: ${recommendedStr} (Warns if exceeded)\nHard Limit: ${limitStr} (Cannot save if exceeded)`;
// Visual warning based on size limits
if (sizeInBytes >= CONSTANTS.CONFIG_SIZE_LIMIT_BYTES) {
sizeInfoDiv.style.color = this.callbacks.siteStyles.size_danger_text;
sizeInfoDiv.style.fontWeight = 'bold';
} else if (sizeInBytes >= CONSTANTS.CONFIG_SIZE_RECOMMENDED_LIMIT_BYTES) {
sizeInfoDiv.style.color = this.callbacks.siteStyles.size_warning_text;
sizeInfoDiv.style.fontWeight = 'bold';
} else {
// Restore default color
sizeInfoDiv.style.color = '';
sizeInfoDiv.style.fontWeight = 'normal';
}
}
_formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
}
/**
* Manages the Theme Settings modal by leveraging the CustomModal component.
*/
class ThemeModalComponent extends UIComponentBase {
static _PREVIEW_STYLE_DEFINITIONS = [
// Actor-specific previews (user & assistant)
{ target: 'actor', property: 'backgroundColor', configKeySuffix: 'bubbleBackgroundColor', fallbackKey: 'bubbleBackgroundColor' },
{ target: 'actor', property: 'color', configKeySuffix: 'textColor', fallbackKey: 'textColor' },
{ target: 'actor', property: 'fontFamily', configKeySuffix: 'font', fallbackKey: 'font' },
{ target: 'actor', handler: '_updatePaddingPreview' },
{ target: 'actor', handler: '_updateRadiusPreview' },
{ target: 'actor', handler: '_updateMaxWidthPreview' },
// InputArea preview
{ target: 'inputArea', property: 'backgroundColor', configKeySuffix: 'backgroundColor', fallbackKey: 'backgroundColor' },
{ target: 'inputArea', property: 'color', configKeySuffix: 'textColor', fallbackKey: 'textColor' },
// Window preview
{ target: 'window', property: 'backgroundColor', configKeySuffix: 'backgroundColor', fallbackKey: 'backgroundColor' },
];
constructor(callbacks) {
super(callbacks);
this.modal = null;
this.colorPickerManager = null;
this.dataConverter = callbacks.dataConverter;
this.debouncedUpdateUserPreview = debounce(() => this._updatePreviewFor('user'), CONSTANTS.TIMING.DEBOUNCE_DELAYS.THEME_PREVIEW, true);
this.debouncedUpdateAssistantPreview = debounce(() => this._updatePreviewFor('assistant'), CONSTANTS.TIMING.DEBOUNCE_DELAYS.THEME_PREVIEW, true);
this.debouncedUpdateInputAreaPreview = debounce(() => this._updatePreviewFor('inputArea'), CONSTANTS.TIMING.DEBOUNCE_DELAYS.THEME_PREVIEW, true);
this.debouncedUpdateWindowPreview = debounce(() => this._updatePreviewFor('window'), CONSTANTS.TIMING.DEBOUNCE_DELAYS.THEME_PREVIEW, true);
// Centralized state management
this.state = {
activeThemeKey: null,
uiMode: 'NORMAL', // 'NORMAL', 'RENAMING_THEME', 'CONFIRM_DELETE'
pendingDeletionKey: null,
config: null, // Holds the working copy of the config
};
// This will hold cached references to DOM elements within the modal.
this.domCache = null;
// Centralized UI definition
this.uiDefinition = [
{
type: 'container',
className: `${APPID}-theme-general-settings`,
isDefaultHidden: true,
children: [
{
type: 'compound-container',
children: [
{
type: 'textarea',
id: 'metadata.matchPatterns',
label: 'Title Patterns (one per line):',
tooltip: 'Enter one RegEx pattern per line to automatically apply this theme (e.g., /My Project/i).',
rows: 3,
validation: { type: 'regexArray' },
},
{
type: 'textarea',
id: 'metadata.urlPatterns',
label: 'URL Patterns (one per line):',
tooltip: 'Enter one RegEx pattern per line to match against the URL path (pathname).\nExample: /\\/c\\/[a-z0-9-]+$/i',
rows: 3,
validation: { type: 'regexArray' },
},
],
},
],
},
{ type: 'separator', isDefaultHidden: true },
{
type: 'container',
className: `${APPID}-theme-scrollable-area`,
children: [
{
type: 'grid',
className: `${APPID}-theme-grid`,
children: [
this._createActorUiDefinition('assistant'),
this._createActorUiDefinition('user'),
{
type: 'fieldset',
legend: 'Background',
children: [
{ type: 'colorfield', id: 'window.backgroundColor', label: 'Background color:', tooltip: 'Main background color of the chat window.' },
{
type: 'textfield',
id: 'window.backgroundImageUrl',
label: 'Background image:',
tooltip: 'URL or Data URI for the main background image.',
fieldType: 'image',
validation: { type: 'imageString', imageType: 'image' },
},
{
type: 'compound-container',
children: [
{ type: 'select', id: 'window.backgroundSize', label: 'Size:', options: ['auto', 'cover', 'contain'], tooltip: 'How the background image is sized.' },
{
type: 'select',
id: 'window.backgroundPosition',
label: 'Position:',
options: ['top left', 'top center', 'top right', 'center left', 'center center', 'center right', 'bottom left', 'bottom center', 'bottom right'],
tooltip: 'Position of the background image.',
},
],
},
{
type: 'compound-container',
children: [{ type: 'select', id: 'window.backgroundRepeat', label: 'Repeat:', options: ['no-repeat', 'repeat'], tooltip: 'How the background image is repeated.' }, { type: 'preview-background' }],
},
],
},
{
type: 'fieldset',
legend: 'Input area',
children: [
{ type: 'colorfield', id: 'inputArea.backgroundColor', label: 'Background color:', tooltip: 'Background color of the text input area.' },
{ type: 'colorfield', id: 'inputArea.textColor', label: 'Text color:', tooltip: 'Color of the text you type.' },
{ type: 'separator' },
{ type: 'preview-input' },
],
},
],
},
],
},
];
}
_createActorUiDefinition(actor) {
return {
type: 'fieldset',
legend: actor.charAt(0).toUpperCase() + actor.slice(1),
children: [
{ type: 'textfield', id: `${actor}.name`, label: 'Name:', tooltip: `The name displayed for the ${actor}.`, fieldType: 'name' },
{ type: 'textfield', id: `${actor}.icon`, label: 'Icon:', tooltip: `URL, Data URI, or <svg> for the ${actor}'s icon.`, fieldType: 'icon', validation: { type: 'imageString', imageType: 'icon' } },
{
type: 'textfield',
id: `${actor}.standingImageUrl`,
label: 'Standing image:',
tooltip: `URL or Data URI for the character's standing image.`,
fieldType: 'image',
validation: { type: 'imageString', imageType: 'image' },
},
{
type: 'fieldset',
legend: 'Bubble Settings',
children: [
{ type: 'colorfield', id: `${actor}.bubbleBackgroundColor`, label: 'Background color:', tooltip: 'Background color of the message bubble.' },
{ type: 'colorfield', id: `${actor}.textColor`, label: 'Text color:', tooltip: 'Color of the text inside the bubble.' },
{ type: 'textfield', id: `${actor}.font`, label: 'Font:', tooltip: 'Font family for the text.\nFont names with spaces must be quoted (e.g., "Times New Roman").' },
{ type: 'paddingslider', id: `${actor}.bubblePadding`, actor },
{
type: 'compound-slider',
children: [
{
type: 'slider',
containerClass: `${APPID}-slider-subgroup`,
label: 'Radius:',
id: `${actor}.bubbleBorderRadius`,
min: -1,
max: 50,
step: 1,
tooltip: 'Corner roundness of the bubble (e.g., 10px).\nSet to the far left for (auto).',
dataset: { sliderFor: `${actor}.bubbleBorderRadius`, unit: 'px', nullThreshold: 0 },
},
{
type: 'slider',
containerClass: `${APPID}-slider-subgroup`,
label: 'max Width:',
id: `${actor}.bubbleMaxWidth`,
min: 29,
max: 100,
step: 1,
tooltip: 'Maximum width of the bubble.\nSet to the far left for (auto).',
dataset: { sliderFor: `${actor}.bubbleMaxWidth`, unit: '%', nullThreshold: 30 },
},
],
},
{ type: 'separator' },
{ type: 'preview', actor },
],
},
],
};
}
render() {
// No-op: DOM generation is delegated to CustomModal in open().
// Implemented to satisfy UIComponentBase contract.
}
async open(selectThemeKey) {
if (this.modal) return;
this.callbacks.onModalOpenStateChange?.(true);
// Inject styles lazily when the modal is opened
this._injectStyles();
const initialConfig = await this.callbacks.getCurrentConfig();
if (!initialConfig) return;
// Initialize state for the new session
this.state = {
activeThemeKey: selectThemeKey || 'defaultSet',
uiMode: 'NORMAL',
pendingDeletionKey: null,
config: deepClone(initialConfig), // Create a deep copy for editing
};
this.modal = new CustomModal({
title: `${APPNAME} - Theme settings`,
width: 'min(880px, 95vw)',
cssPrefix: `${APPID}-theme-modal-shell`,
closeOnBackdropClick: false,
styles: this.callbacks.siteStyles, // Pass styles to the modal
buttons: [
{ text: 'Cancel', id: `${APPID}-theme-modal-cancel-btn`, className: ``, title: 'Discard changes and close the modal.', onClick: () => this.close() },
{ text: 'Apply', id: `${APPID}-theme-modal-apply-btn`, className: ``, title: 'Save changes and keep the modal open.', onClick: () => this._handleThemeAction(false) },
{ text: 'Save', id: `${APPID}-theme-modal-save-btn`, className: `-btn-primary`, title: 'Save changes and close the modal.', onClick: () => this._handleThemeAction(true) },
],
onDestroy: () => {
this.callbacks.onModalOpenStateChange?.(false);
this.colorPickerManager?.destroy();
this.colorPickerManager = null;
this.modal = null;
this.domCache = null; // Clear the cache on destroy
},
});
const headerControls = this._createHeaderControls();
const mainContent = this._createMainContent();
// CustomModal now handles its own base styling, so we just add content.
Object.assign(this.modal.dom.header.style, {
borderBottom: `1px solid ${this.callbacks.siteStyles.border}`,
paddingBottom: '12px',
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
gap: '12px',
});
Object.assign(this.modal.dom.footer.style, {
borderTop: `1px solid ${this.callbacks.siteStyles.border}`,
paddingTop: '16px',
});
this.modal.dom.header.appendChild(headerControls);
this.modal.setContent(mainContent);
this._cacheDomReferences(); // Cache DOM element references once.
this._setupEventListeners();
this.colorPickerManager = new ColorPickerPopupManager(this.modal.element);
this.colorPickerManager.init();
this.callbacks.onModalOpen?.();
await this._populateFormWithThemeData();
this._renderUI();
this.modal.show();
requestAnimationFrame(() => {
const scrollableArea = this.modal.element.querySelector(`.${APPID}-theme-scrollable-area`);
if (scrollableArea) scrollableArea.scrollTop = 0;
});
}
destroy() {
this.debouncedUpdateUserPreview.cancel();
this.debouncedUpdateAssistantPreview.cancel();
this.debouncedUpdateInputAreaPreview.cancel();
this.debouncedUpdateWindowPreview.cancel();
this.modal?.destroy();
super.destroy();
}
/**
* @private
* Caches references to all frequently accessed DOM elements within the modal
* to avoid repeated querySelector calls during preview updates.
*/
_cacheDomReferences() {
if (!this.modal) return;
const modalElement = this.modal.element;
this.domCache = {
inputs: {},
sliders: {},
previews: {},
paddingSliders: {
user: {},
assistant: {},
},
};
this._traverseUIDefinition(this.uiDefinition, (def) => {
const formId = `${APPID}-form-${def.id.replace(/\./g, '-')}`;
switch (def.type) {
case 'textfield':
case 'textarea':
case 'select':
case 'colorfield':
this.domCache.inputs[def.id] = modalElement.querySelector(`#${formId}`);
break;
case 'slider':
this.domCache.sliders[def.id] = modalElement.querySelector(`#${formId}-slider`);
break;
}
});
['user', 'assistant'].forEach((actor) => {
this.domCache.previews[actor] = modalElement.querySelector(`[data-preview-for="${actor}"]`);
this.domCache.paddingSliders[actor].tb = modalElement.querySelector(`#${APPID}-form-${actor}-bubblePadding-tb`);
this.domCache.paddingSliders[actor].lr = modalElement.querySelector(`#${APPID}-form-${actor}-bubblePadding-lr`);
});
this.domCache.previews.inputArea = modalElement.querySelector('[data-preview-for="inputArea"]');
this.domCache.previews.window = modalElement.querySelector('[data-preview-for="window"]');
}
close() {
this.modal?.close();
}
_renderUI() {
if (!this.modal) return;
const { uiMode, activeThemeKey, config } = this.state;
const isDefault = activeThemeKey === 'defaultSet';
const isRenaming = uiMode === 'RENAMING_THEME';
const isDeleting = uiMode === 'CONFIRM_DELETE';
const headerRow = this.modal.element.querySelector(`.${APPID}-header-row`);
const generalSettingsArea = this.modal.element.querySelector(`.${APPID}-theme-general-settings`);
const scrollArea = this.modal.element.querySelector(`.${APPID}-theme-scrollable-area`);
// --- UI Element References ---
const select = headerRow.querySelector('select');
const renameInput = headerRow.querySelector('input[type="text"]');
const mainActions = headerRow.querySelector(`#${APPID}-theme-main-actions`);
const renameActions = headerRow.querySelector(`#${APPID}-theme-rename-actions`);
const deleteConfirmGroup = headerRow.querySelector(`#${APPID}-theme-delete-confirm-group`);
// --- Toggle visibility based on mode ---
select.style.display = isRenaming ? 'none' : 'block';
renameInput.style.display = isRenaming ? 'block' : 'none';
mainActions.style.visibility = uiMode === 'NORMAL' ? 'visible' : 'hidden';
renameActions.style.display = isRenaming ? 'flex' : 'none';
deleteConfirmGroup.style.display = isDeleting ? 'flex' : 'none';
// --- Populate select box if not renaming ---
if (!isRenaming) {
const scroll = select.scrollTop;
select.textContent = '';
select.appendChild(h('option', { value: 'defaultSet' }, 'Default Settings'));
config.themeSets.forEach((theme, index) => {
const themeName = (theme.metadata?.name || '').trim() || `Theme ${index + 1}`;
select.appendChild(h('option', { value: theme.metadata.id }, themeName));
});
select.value = activeThemeKey;
select.scrollTop = scroll;
}
// --- Populate rename input if renaming ---
if (isRenaming) {
const theme = isDefault ? { metadata: { name: 'Default Settings' } } : config.themeSets.find((t) => t.metadata.id === activeThemeKey);
renameInput.value = theme?.metadata?.name || '';
}
// --- Set enabled/disabled state of all controls ---
const isActionInProgress = uiMode !== 'NORMAL';
const index = config.themeSets.findIndex((t) => t.metadata.id === activeThemeKey);
headerRow.querySelector(`#${APPID}-theme-up-btn`).disabled = isActionInProgress || isDefault || index <= 0;
headerRow.querySelector(`#${APPID}-theme-down-btn`).disabled = isActionInProgress || isDefault || index >= config.themeSets.length - 1;
headerRow.querySelector(`#${APPID}-theme-delete-btn`).disabled = isActionInProgress || isDefault;
headerRow.querySelector(`#${APPID}-theme-new-btn`).disabled = isActionInProgress;
headerRow.querySelector(`#${APPID}-theme-copy-btn`).disabled = isActionInProgress;
headerRow.querySelector(`#${APPID}-theme-rename-btn`).disabled = isActionInProgress || isDefault;
// --- Disable content areas and footer buttons during actions ---
if (generalSettingsArea) generalSettingsArea.classList.toggle('is-disabled', isActionInProgress);
scrollArea.classList.toggle('is-disabled', isActionInProgress);
this.modal.element.querySelector(`#${APPID}-theme-modal-apply-btn`).disabled = isActionInProgress;
this.modal.element.querySelector(`#${APPID}-theme-modal-save-btn`).disabled = isActionInProgress;
this.modal.element.querySelector(`#${APPID}-theme-modal-cancel-btn`).disabled = isActionInProgress;
}
_createHeaderControls() {
const type = 'theme';
return h(`div.${APPID}-theme-modal-header-controls`, [
h(`div.${APPID}-header-row`, { 'data-type': type }, [
h('label', { htmlFor: `${APPID}-${type}-select` }, 'Theme:'),
h(`div.${APPID}-rename-area`, [h(`select#${APPID}-${type}-select`), h('input', { type: 'text', id: `${APPID}-${type}-rename-input`, style: { display: 'none' } })]),
h(`div.${APPID}-action-area`, [
h(`div#${APPID}-${type}-main-actions`, [
h(`button#${APPID}-${type}-rename-btn.${APPID}-modal-button`, 'Rename'),
h(`button#${APPID}-${type}-up-btn.${APPID}-modal-button.${APPID}-move-btn`, [createIconFromDef(SITE_STYLES.ICONS.arrowUp)]),
h(`button#${APPID}-${type}-down-btn.${APPID}-modal-button.${APPID}-move-btn`, [createIconFromDef(SITE_STYLES.ICONS.arrowDown)]),
h(`button#${APPID}-${type}-new-btn.${APPID}-modal-button`, 'New'),
h(`button#${APPID}-${type}-copy-btn.${APPID}-modal-button`, 'Copy'),
h(`button#${APPID}-${type}-delete-btn.${APPID}-modal-button`, 'Delete'),
]),
h(`div#${APPID}-${type}-rename-actions`, { style: { display: 'none' } }, [h(`button#${APPID}-${type}-rename-ok-btn.${APPID}-modal-button`, 'OK'), h(`button#${APPID}-${type}-rename-cancel-btn.${APPID}-modal-button`, 'Cancel')]),
h(`div#${APPID}-${type}-delete-confirm-group.${APPID}-delete-confirm-group`, { style: { display: 'none' } }, [
h(`span.${APPID}-delete-confirm-label`, 'Are you sure?'),
h(`button#${APPID}-${type}-delete-confirm-btn.${APPID}-modal-button.${APPID}-delete-confirm-btn-yes`, 'Confirm Delete'),
h(`button#${APPID}-${type}-delete-cancel-btn.${APPID}-modal-button`, 'Cancel'),
]),
]),
]),
]);
}
_createMainContent() {
return h(`div.${APPID}-theme-modal-content`, [this._buildUIFromDefinition(this.uiDefinition)]);
}
_buildUIFromDefinition(definitions) {
return buildUIFromSchema(definitions);
}
_traverseUIDefinition(definitions, callback) {
if (!definitions) return;
for (const def of definitions) {
if (def.id) {
callback(def);
}
if (def.children) {
this._traverseUIDefinition(def.children, callback);
}
}
}
_updateAllPreviews() {
this._updatePreviewFor('user');
this._updatePreviewFor('assistant');
this._updatePreviewFor('inputArea');
this._updatePreviewFor('window');
}
/**
* @private
* @param {'user' | 'assistant' | 'inputArea' | 'window'} target The UI section to update the preview for.
*/
_updatePreviewFor(target) {
if (!this.modal || !this.domCache) return;
const config = this.state.config;
if (!config) return;
const isActor = target === 'user' || target === 'assistant';
const configPath = isActor ? target : target;
const isEditingDefaultSet = this.state.activeThemeKey === 'defaultSet';
const defaultSet = config.defaultSet[configPath] || {};
const fallbackSet = isEditingDefaultSet ? {} : defaultSet;
requestAnimationFrame(() => {
const previewElement = this.domCache.previews[target];
if (!previewElement) return;
const definitions = ThemeModalComponent._PREVIEW_STYLE_DEFINITIONS.filter((def) => def.target === (isActor ? 'actor' : target));
for (const def of definitions) {
if (def.handler) {
this[def.handler](target, previewElement, fallbackSet);
} else {
const configKey = `${configPath}.${def.configKeySuffix}`;
const inputElement = this.domCache.inputs[configKey];
const currentValue = inputElement ? inputElement.value.trim() || null : null;
const finalValue = currentValue ?? fallbackSet[def.fallbackKey] ?? '';
previewElement.style[def.property] = finalValue;
}
}
});
}
/**
* @private
* @param {'user' | 'assistant'} actor
* @param {HTMLElement} previewElement
* @param {object} fallbackSet
*/
_updatePaddingPreview(actor, previewElement, fallbackSet) {
const paddingTBSlider = this.domCache.paddingSliders[actor].tb;
const paddingLRSlider = this.domCache.paddingSliders[actor].lr;
const tbVal = paddingTBSlider && paddingTBSlider.value < 0 ? null : paddingTBSlider?.value;
const lrVal = paddingLRSlider && paddingLRSlider.value < 0 ? null : paddingLRSlider?.value;
const defaultPaddingValue = fallbackSet.bubblePadding;
if (tbVal === null && lrVal === null && defaultPaddingValue === null) {
previewElement.style.padding = '';
} else {
const defaultPaddingParts = (defaultPaddingValue || '6px 10px').split(' ');
const defaultTB = parseInt(defaultPaddingParts[0], 10);
const defaultLR = parseInt(defaultPaddingParts[1] || defaultPaddingParts[0], 10);
const finalTB = tbVal !== null ? tbVal : defaultTB;
const finalLR = lrVal !== null ? lrVal : defaultLR;
previewElement.style.padding = `${finalTB}px ${finalLR}px`;
}
}
/**
* @private
* @param {'user' | 'assistant'} actor
* @param {HTMLElement} previewElement
* @param {object} fallbackSet
*/
_updateRadiusPreview(actor, previewElement, fallbackSet) {
const radiusSlider = this.domCache.sliders[`${actor}.bubbleBorderRadius`];
if (radiusSlider) {
const radiusVal = parseInt(radiusSlider.value, 10);
const nullThreshold = parseInt(radiusSlider.dataset.nullThreshold, 10);
const currentRadius = !isNaN(nullThreshold) && radiusVal < nullThreshold ? null : `${radiusVal}px`;
previewElement.style.borderRadius = currentRadius ?? fallbackSet.bubbleBorderRadius ?? '';
}
}
/**
* @private
* @param {'user' | 'assistant'} actor
* @param {HTMLElement} previewElement
* @param {object} fallbackSet
*/
_updateMaxWidthPreview(actor, previewElement, fallbackSet) {
const widthSlider = this.domCache.sliders[`${actor}.bubbleMaxWidth`];
if (widthSlider) {
const widthVal = parseInt(widthSlider.value, 10);
const nullThreshold = parseInt(widthSlider.dataset.nullThreshold, 10);
const currentWidth = !isNaN(nullThreshold) && widthVal < nullThreshold ? null : `${widthVal}%`;
const finalWidth = currentWidth ?? fallbackSet.bubbleMaxWidth ?? (actor === 'user' ? '50%' : '90%');
previewElement.style.width = finalWidth;
previewElement.style.maxWidth = finalWidth;
}
}
_setFieldError(fieldName, message) {
if (!this.modal) return;
const errorElement = this.modal.element.querySelector(`[data-error-for="${fieldName}"]`);
const inputElement = this.modal.element.querySelector(`#${APPID}-form-${fieldName}`);
if (errorElement) {
errorElement.textContent = message;
}
if (inputElement) {
inputElement.closest(`.${APPID}-input-wrapper, .${APPID}-form-field`)?.querySelector('input, textarea')?.classList.add('is-invalid');
}
}
_clearAllFieldErrors() {
if (!this.modal) return;
this.modal.element.querySelectorAll(`.${APPID}-form-error-msg`).forEach((el) => {
el.textContent = '';
});
this.modal.element.querySelectorAll('.is-invalid').forEach((el) => {
el.classList.remove('is-invalid');
});
}
/**
* Determines the resize options for an image based on the input field's ID.
* @param {string} targetId The ID of the target input field.
* @param {object} config The current configuration object to retrieve settings like icon_size.
* @returns {object} The options object for imageToOptimizedDataUrl.
*/
_getImageOptions(targetId, config) {
if (targetId.includes('backgroundImageUrl')) {
return { maxWidth: 1920, quality: 0.85 };
}
if (targetId.includes('standingImageUrl')) {
return { maxHeight: 1080, quality: 0.85 };
}
// For icons, resize to the currently configured icon size.
if (targetId.includes('icon')) {
const iconSize = config?.options?.icon_size ?? CONSTANTS.ICON_SIZE;
return { maxWidth: iconSize, maxHeight: iconSize, quality: 0.85 };
}
return { quality: 0.85 }; // Default
}
/**
* Handles the local file selection process.
* @param {HTMLElement} button The clicked file selection button.
*/
async _handleLocalFileSelect(button) {
const targetId = button.dataset.targetId;
const targetInput = document.getElementById(`${APPID}-form-${targetId}`);
if (!(targetInput instanceof HTMLInputElement)) {
return;
}
const fileInput = h('input', { type: 'file', accept: 'image/*' });
fileInput.onchange = async (event) => {
const target = event.target;
if (!(target instanceof HTMLInputElement)) return;
const file = target.files?.[0];
if (!file) return;
const errorField = this.modal.element.querySelector(`[data-error-for="${targetId.replace(/\./g, '-')}"]`);
try {
// Clear any previous error and show a neutral "Processing..." message.
if (errorField instanceof HTMLElement) {
errorField.textContent = 'Processing...';
errorField.style.color = SITE_STYLES.JSON_MODAL.msg_success_text;
}
const options = this._getImageOptions(targetId, this.state.config);
const dataUrl = await this.dataConverter.imageToOptimizedDataUrl(file, options);
targetInput.value = dataUrl;
targetInput.dispatchEvent(new Event('input', { bubbles: true }));
// Clear the "Processing..." message on success.
if (errorField instanceof HTMLElement) {
errorField.textContent = '';
errorField.style.color = ''; // Reset color to inherit from CSS
}
} catch (error) {
Logger.badge('IMAGE PROC FAILED', LOG_STYLES.RED, 'error', 'Image processing failed:', error);
// Show a proper error message with the error color on failure.
if (errorField instanceof HTMLElement) {
errorField.textContent = `Error: ${error.message}`;
errorField.style.color = SITE_STYLES.THEME_MODAL.error_text;
}
}
};
if (fileInput instanceof HTMLElement) {
fileInput.click();
}
}
/**
* @private
* @param {string} elementId The ID of the form element that triggered the update.
*/
_dispatchPreviewUpdate(elementId) {
if (!elementId) return;
// Dispatch to the correct debounced preview updater based on the input's ID
if (elementId.includes('-user-')) {
this.debouncedUpdateUserPreview();
} else if (elementId.includes('-assistant-')) {
this.debouncedUpdateAssistantPreview();
} else if (elementId.includes('-inputArea-')) {
this.debouncedUpdateInputAreaPreview();
} else if (elementId.includes('-window-')) {
this.debouncedUpdateWindowPreview();
}
}
_setupEventListeners() {
if (!this.modal) return;
const modalElement = this.modal.element;
// Listen for custom color picker events
modalElement.addEventListener('color-change', (e) => {
// The event bubbles, but the target is the picker's root.
// We must get the context from the ColorPickerPopupManager, which knows which input is active.
if (!this.colorPickerManager || !this.colorPickerManager.activePicker) return;
const textInput = this.colorPickerManager.activePicker.textInput;
if (!textInput || !textInput.id) return;
this._dispatchPreviewUpdate(textInput.id);
});
modalElement.addEventListener('click', (e) => {
const target = e.target.closest('button');
if (!target) return;
// Handle local file selection button
if (target.matches(`.${APPID}-local-file-btn`)) {
this._handleLocalFileSelect(target);
return;
}
const actionMap = {
[`${APPID}-theme-new-btn`]: () => this._handleThemeNew(),
[`${APPID}-theme-copy-btn`]: () => this._handleThemeCopy(),
[`${APPID}-theme-delete-btn`]: () => this._handleDeleteClick(),
[`${APPID}-theme-delete-confirm-btn`]: () => this._handleThemeDeleteConfirm(),
[`${APPID}-theme-delete-cancel-btn`]: () => this._handleActionCancel(),
[`${APPID}-theme-up-btn`]: () => this._handleThemeMove(-1),
[`${APPID}-theme-down-btn`]: () => this._handleThemeMove(1),
[`${APPID}-theme-rename-btn`]: () => this._handleRenameClick(),
[`${APPID}-theme-rename-ok-btn`]: () => this._handleRenameConfirm(),
[`${APPID}-theme-rename-cancel-btn`]: () => this._handleActionCancel(),
};
const action = actionMap[target.id];
if (action) action();
});
modalElement.addEventListener('change', (e) => {
if (e.target.matches(`#${APPID}-theme-select`)) {
this.state.activeThemeKey = e.target.value;
this._populateFormWithThemeData();
this._renderUI();
}
});
modalElement.addEventListener('input', (e) => {
const target = e.target;
if (!(target instanceof HTMLElement) || !target.id) return;
// Handle slider display update for range inputs
if (target.matches('input[type="range"]')) {
this._updateSliderDisplay(target);
}
this._dispatchPreviewUpdate(target.id);
});
modalElement.addEventListener('mouseover', (e) => {
if (e.target.matches('input[type="text"], textarea') && (e.target.offsetWidth < e.target.scrollWidth || e.target.offsetHeight < e.target.scrollHeight)) {
e.target.title = e.target.value;
}
});
modalElement.addEventListener('mouseout', (e) => {
if (e.target.matches('input[type="text"], textarea')) e.target.title = '';
});
modalElement.addEventListener('keydown', (e) => {
if (e.target.matches(`#${APPID}-theme-rename-input`)) {
if (e.key === 'Enter') {
e.preventDefault();
this._handleRenameConfirm();
}
if (e.key === 'Escape') {
e.preventDefault();
this._handleActionCancel();
}
}
});
}
_updateSliderDisplay(slider) {
const displayId = slider.dataset.sliderFor;
if (!displayId) return;
const display = this.modal.element.querySelector(`[data-slider-display-for="${displayId}"]`);
if (!display) return;
updateSliderDisplay(slider, display);
}
async _populateFormWithThemeData() {
if (!this.modal || !this.domCache) return;
const { activeThemeKey, config } = this.state;
const modalElement = this.modal.element;
const scrollableArea = modalElement.querySelector(`.${APPID}-theme-scrollable-area`);
if (scrollableArea) scrollableArea.style.visibility = 'hidden';
this._clearAllFieldErrors();
const isDefault = activeThemeKey === 'defaultSet';
const theme = isDefault ? config.defaultSet : config.themeSets.find((t) => t.metadata.id === activeThemeKey);
if (!theme) {
if (scrollableArea) scrollableArea.style.visibility = 'visible';
return;
}
populateFormFromSchema(this.uiDefinition, modalElement, theme, this);
// Show/hide fields specific to non-default themes
modalElement.querySelectorAll('[data-is-default-hidden]').forEach((el) => {
el.style.display = isDefault ? 'none' : '';
});
this._updateAllPreviews();
if (scrollableArea) scrollableArea.style.visibility = 'visible';
}
_collectThemeDataFromForm() {
if (!this.modal || !this.domCache) return null;
const themeData = { metadata: {}, user: {}, assistant: {}, window: {}, inputArea: {} };
const modalElement = this.modal.element;
collectDataFromSchema(this.uiDefinition, modalElement, themeData);
return themeData;
}
async _saveConfigAndHandleFeedback(newConfig, onSuccessCallback) {
if (!this.modal) return false;
const footerMessage = this.modal.dom.footerMessage;
if (footerMessage) footerMessage.textContent = '';
try {
await this.callbacks.onSave(newConfig);
this.state.config = deepClone(newConfig); // Update local state on success
if (onSuccessCallback) await onSuccessCallback();
return true;
} catch (e) {
if (footerMessage) {
footerMessage.textContent = e.message;
footerMessage.style.color = this.callbacks.siteStyles.error_text;
}
return false;
}
}
async _handleThemeAction(shouldClose) {
// Clear all previous field errors before validating again.
this._clearAllFieldErrors();
// Clear the global footer message on a new action
if (this.modal?.dom?.footerMessage) this.modal.dom.footerMessage.textContent = '';
const themeData = this._collectThemeDataFromForm();
if (!themeData) return;
const validationResult = ConfigProcessor.validate(themeData, this.state.activeThemeKey === 'defaultSet');
if (!validationResult.isValid) {
validationResult.errors.forEach((err) => this._setFieldError(err.field, err.message));
return;
}
const newConfig = deepClone(this.state.config);
if (this.state.activeThemeKey === 'defaultSet') {
// Update defaultSet values while enforcing strict schema validation
resolveConfig(newConfig.defaultSet, themeData);
// metadata is not part of defaultSet, so clear it
delete newConfig.defaultSet.metadata;
} else {
const index = newConfig.themeSets.findIndex((t) => t.metadata.id === this.state.activeThemeKey);
if (index !== -1) {
// Preserve existing metadata not edited in this form (like name and id)
const existingMetadata = newConfig.themeSets[index].metadata;
themeData.metadata = {
...existingMetadata,
matchPatterns: themeData.metadata.matchPatterns,
urlPatterns: themeData.metadata.urlPatterns,
};
newConfig.themeSets[index] = themeData;
}
}
const onSuccess = async () => (shouldClose ? this.close() : this._renderUI());
await this._saveConfigAndHandleFeedback(newConfig, onSuccess);
}
_handleThemeNew() {
const { config } = this.state;
const existingNames = new Set(config.themeSets.map((t) => t.metadata.name?.trim().toLowerCase()));
const newName = proposeUniqueName('New Theme', existingNames);
const newTheme = {
metadata: { id: generateUniqueId(), name: newName, matchPatterns: [] },
user: {},
assistant: {},
window: {},
inputArea: {},
};
const newConfig = deepClone(config);
newConfig.themeSets.push(newTheme);
const onSuccess = () => {
this.state.activeThemeKey = newTheme.metadata.id;
this.state.uiMode = 'RENAMING_THEME';
this._populateFormWithThemeData();
this._renderUI();
const input = this.modal.element.querySelector(`#${APPID}-theme-rename-input`);
if (input) {
input.focus();
input.select();
}
};
this._saveConfigAndHandleFeedback(newConfig, onSuccess);
}
_handleThemeCopy() {
const { config, activeThemeKey } = this.state;
const isDefault = activeThemeKey === 'defaultSet';
const themeToCopy = isDefault ? { metadata: { name: 'Default' }, ...config.defaultSet } : config.themeSets.find((t) => t.metadata.id === activeThemeKey);
if (!themeToCopy) return;
const baseName = `${themeToCopy.metadata.name || 'Theme'} Copy`;
const existingNames = new Set(config.themeSets.map((t) => t.metadata.name?.trim().toLowerCase()));
const newName = proposeUniqueName(baseName, existingNames);
const newTheme = deepClone(themeToCopy);
newTheme.metadata = { ...newTheme.metadata, id: generateUniqueId(), name: newName };
if (isDefault) newTheme.metadata.matchPatterns = [];
const newConfig = deepClone(config);
// Determine insertion index: after the current item, or at the start if default is selected
let insertIndex = 0;
if (!isDefault) {
const currentIndex = newConfig.themeSets.findIndex((t) => t.metadata.id === activeThemeKey);
if (currentIndex !== -1) {
insertIndex = currentIndex + 1;
}
}
newConfig.themeSets.splice(insertIndex, 0, newTheme);
const onSuccess = () => {
this.state.activeThemeKey = newTheme.metadata.id;
this._populateFormWithThemeData();
this._renderUI();
};
this._saveConfigAndHandleFeedback(newConfig, onSuccess);
}
_handleThemeMove(direction) {
const { config, activeThemeKey } = this.state;
if (activeThemeKey === 'defaultSet') return;
const currentIndex = config.themeSets.findIndex((t) => t.metadata.id === activeThemeKey);
if (currentIndex === -1) return;
const newIndex = currentIndex + direction;
if (newIndex < 0 || newIndex >= config.themeSets.length) return;
const newConfig = deepClone(config);
const item = newConfig.themeSets.splice(currentIndex, 1)[0];
newConfig.themeSets.splice(newIndex, 0, item);
this._saveConfigAndHandleFeedback(newConfig, () => this._renderUI());
}
_handleRenameClick() {
this.state.uiMode = 'RENAMING_THEME';
this._renderUI();
const input = this.modal.element.querySelector(`#${APPID}-theme-rename-input`);
if (input) {
input.focus();
input.select();
}
}
_handleRenameConfirm() {
const { config, activeThemeKey } = this.state;
const footerMessage = this.modal?.dom?.footerMessage;
if (footerMessage) footerMessage.textContent = '';
const input = this.modal.element.querySelector(`#${APPID}-theme-rename-input`);
const newName = input.value.trim();
if (!newName) {
if (footerMessage) footerMessage.textContent = 'Theme name cannot be empty.';
return;
}
const isNameTaken = config.themeSets.some((t) => t.metadata.id !== activeThemeKey && t.metadata.name?.toLowerCase() === newName.toLowerCase());
if (isNameTaken) {
if (footerMessage) footerMessage.textContent = `Name "${newName}" is already in use.`;
return;
}
const newConfig = deepClone(config);
const themeToUpdate = newConfig.themeSets.find((t) => t.metadata.id === activeThemeKey);
if (themeToUpdate) {
themeToUpdate.metadata.name = newName;
this._saveConfigAndHandleFeedback(newConfig, () => {
this.state.uiMode = 'NORMAL';
this._renderUI();
});
}
}
_handleDeleteClick() {
this.state.uiMode = 'CONFIRM_DELETE';
this.state.pendingDeletionKey = this.state.activeThemeKey;
this._renderUI();
}
_handleThemeDeleteConfirm() {
const { config, pendingDeletionKey } = this.state;
if (pendingDeletionKey === 'defaultSet' || !pendingDeletionKey) {
this._handleActionCancel();
return;
}
// Determine the next ID to select *before* deletion modifying the array
let nextActiveId = 'defaultSet';
const currentIndex = config.themeSets.findIndex((t) => t.metadata.id === pendingDeletionKey);
const currentLength = config.themeSets.length;
if (currentLength > 1) {
if (currentIndex === currentLength - 1) {
// Deleting the last item: select the previous item
nextActiveId = config.themeSets[currentIndex - 1].metadata.id;
} else {
// Deleting from start or middle: select the next item (which shifts into this index)
// In the current array, this is index + 1
nextActiveId = config.themeSets[currentIndex + 1].metadata.id;
}
}
const newConfig = deepClone(config);
newConfig.themeSets = newConfig.themeSets.filter((t) => t.metadata.id !== pendingDeletionKey);
this._saveConfigAndHandleFeedback(newConfig, () => {
this.state.activeThemeKey = nextActiveId;
this.state.pendingDeletionKey = null;
this.state.uiMode = 'NORMAL';
this._populateFormWithThemeData();
this._renderUI();
});
}
_handleActionCancel() {
this.state.uiMode = 'NORMAL';
this.state.pendingDeletionKey = null;
this._renderUI();
}
getContextForReopen() {
return { type: 'theme', key: this.state.activeThemeKey };
}
_injectStyles() {
const styleId = `${APPID}-theme-modal-styles`;
if (document.getElementById(styleId)) return;
const styles = SITE_STYLES.THEME_MODAL;
const style = document.createElement('style');
style.id = styleId;
style.textContent = `
/* --- New styles for rename UI --- */
.${APPID}-theme-modal-header-controls {
display: flex;
flex-direction: column;
gap: 12px;
}
.${APPID}-header-row {
display: grid;
grid-template-columns: 5.5rem 1fr auto;
gap: 8px;
align-items: center;
}
@media (max-width: 800px) {
.${APPID}-theme-grid {
grid-template-columns: 1fr !important;
}
}
.${APPID}-header-row > label {
grid-column: 1;
text-align: right;
color: ${styles.label_text};
font-size: 0.9em;
}
.${APPID}-header-row > .${APPID}-rename-area {
grid-column: 2;
min-width: 180px; /* Ensure a minimum width */
}
.${APPID}-header-row > .${APPID}-action-area {
grid-column: 3;
display: grid; /* Use grid for stacking */
align-items: center;
}
.${APPID}-action-area > * {
grid-area: 1 / 1;
display: flex;
align-items: center;
gap: 8px;
}
.${APPID}-theme-general-settings.is-disabled,
.${APPID}-theme-scrollable-area.is-disabled {
pointer-events: none;
opacity: 0.5;
}
.${APPID}-input-wrapper {
display: flex;
align-items: center;
gap: 4px;
}
.${APPID}-input-wrapper input {
flex-grow: 1;
}
.${APPID}-local-file-btn {
flex-shrink: 0;
padding: 4px 6px;
height: 32px; /* Match input height */
line-height: 1;
font-size: 16px;
background: ${styles.btn_bg};
border: 1px solid ${styles.btn_border};
border-radius: 4px;
cursor: pointer;
color: ${styles.btn_text};
}
.${APPID}-local-file-btn:hover {
background: ${styles.btn_hover_bg};
}
/* --- Existing styles --- */
.${APPID}-form-error-msg {
color: ${styles.error_text};
font-size: 0.8em;
margin-top: 2px;
white-space: pre-wrap;
}
.${APPID}-theme-modal-shell-box {
max-height: 90vh;
overflow: hidden;
}
.${APPID}-theme-modal-shell-box .is-invalid {
border-color: ${styles.error_text} !important;
}
.${APPID}-delete-confirm-group {
display: none;
}
.${APPID}-delete-confirm-group:not([hidden]) {
align-items: center;
display: flex;
gap: 8px;
}
.${APPID}-delete-confirm-label {
color: ${styles.delete_confirm_label_text};
font-style: italic;
margin-right: auto;
}
.${APPID}-delete-confirm-btn-yes {
background-color: ${styles.delete_confirm_btn_bg} !important;
color: ${styles.delete_confirm_btn_text} !important;
}
.${APPID}-delete-confirm-btn-yes:hover {
background-color: ${styles.delete_confirm_btn_hover_bg} !important;
color: ${styles.delete_confirm_btn_hover_text} !important;
}
.${APPID}-modal-button.${APPID}-move-btn {
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
min-width: 24px;
padding: 4px;
height: 24px;
width: 24px;
}
.${APPID}-theme-modal-content {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 0;
flex: 1;
overflow: hidden;
}
.${APPID}-theme-separator {
border: none;
border-top: 1px solid ${styles.border};
margin: 0;
}
fieldset > .${APPID}-theme-separator {
margin: 8px 0;
}
.${APPID}-theme-general-settings {
display: grid;
gap: 16px;
grid-template-columns: 1fr;
transition: opacity 0.2s;
}
.${APPID}-theme-scrollable-area {
flex-grow: 1;
overflow-y: auto;
padding-bottom: 8px;
padding-right: 8px;
transition: opacity 0.2s;
}
.${APPID}-theme-scrollable-area:focus {
outline: none;
}
.${APPID}-theme-grid {
display: grid;
gap: 16px;
grid-template-columns: 1fr 1fr;
}
.${APPID}-theme-modal-shell-box fieldset {
border: 1px solid ${styles.fieldset_border};
border-radius: 4px;
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
padding: 12px;
}
.${APPID}-theme-modal-shell-box fieldset legend {
color: ${styles.legend_text};
font-weight: 500;
padding: 0 4px;
}
.${APPID}-form-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.${APPID}-form-field > label {
color: ${styles.label_text};
font-size: 0.9em;
}
.${APPID}-color-field-wrapper {
display: flex;
gap: 8px;
}
.${APPID}-color-field-wrapper input[type="text"] {
flex-grow: 1;
}
.${APPID}-color-field-wrapper input[type="text"].is-invalid {
outline: 2px solid ${styles.error_text};
outline-offset: -2px;
}
.${APPID}-color-swatch {
background-color: transparent;
border: 1px solid ${styles.input_border};
border-radius: 4px;
cursor: pointer;
flex-shrink: 0;
height: 32px;
padding: 2px;
position: relative;
width: 32px;
}
.${APPID}-color-swatch-checkerboard, .${APPID}-color-swatch-value {
border-radius: 2px;
height: auto;
inset: 2px;
position: absolute;
width: auto;
}
.${APPID}-color-swatch-checkerboard {
background-image: repeating-conic-gradient(#808080 0% 25%, #c0c0c0 0% 50%);
background-size: 12px 12px;
}
.${APPID}-color-swatch-value {
transition: background-color: 0.1s;
}
.${APPID}-theme-modal-shell-box input,
.${APPID}-theme-modal-shell-box textarea,
.${APPID}-theme-modal-shell-box select {
background: ${styles.input_bg};
border: 1px solid ${styles.input_border};
border-radius: 4px;
box-sizing: border-box;
color: ${styles.input_text};
padding: 6px 8px;
width: 100%;
}
.${APPID}-theme-modal-shell-box textarea {
resize: vertical;
}
.${APPID}-compound-slider-container {
display: flex;
gap: 16px;
margin-top: 4px;
}
.${APPID}-compound-form-field-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.${APPID}-slider-subgroup {
flex: 1;
}
.${APPID}-slider-subgroup > label {
color: ${styles.label_text};
display: block;
font-size: 0.9em;
margin-bottom: 4px;
}
.${APPID}-preview-container {
margin-top: 0;
}
.${APPID}-preview-container > label {
color: ${styles.label_text};
display: block;
font-size: 0.9em;
margin-bottom: 4px;
}
.${APPID}-preview-bubble-wrapper {
background-image: repeating-conic-gradient(#cccccc 0% 25%, #a9a9a9 0% 50%);
background-size: 20px 20px;
border-radius: 4px;
box-sizing: border-box;
min-height: 80px;
overflow: hidden;
padding: 16px;
text-align: left;
width: 100%;
}
.${APPID}-preview-bubble-wrapper.user-preview {
text-align: right;
}
.${APPID}-preview-bubble {
box-sizing: border-box;
display: inline-block;
text-align: left;
transition: all 0.1s linear;
word-break: break-all;
}
.${APPID}-preview-input-area {
display: block;
width: 75%;
margin: 0 auto;
padding: 8px;
border-radius: 6px;
background: ${styles.input_bg};
color: ${styles.input_text};
border: 1px solid ${styles.input_border};
transition: all 0.1s linear;
}
.${APPID}-preview-background {
width: 100%;
height: 100%;
border-radius: 4px;
transition: all 0.1s linear;
border: 1px solid ${styles.input_border};
}
.${APPID}-compound-form-field-container .${APPID}-form-field > .${APPID}-preview-bubble-wrapper {
flex-grow: 1;
}
.${APPID}-color-picker-popup {
background-color: ${styles.popup_bg};
border: 1px solid ${styles.popup_border};
border-radius: 4px;
box-shadow: 0 4px 12px rgb(0 0 0 / 0.2);
padding: 16px;
position: absolute;
width: 280px;
z-index: 10;
}
.${APPID}-theme-modal-shell-footer-message.${APPID}-conflict-text {
color: ${styles.error_text};
display: flex;
align-items: center;
}
#${APPID}-conflict-reload-btn {
border-color: ${styles.error_text};
}
.${APPID}-text-item.drag-over-top {
border-top: 2px solid ${styles.dnd_indicator_color};
}
.${APPID}-text-item.drag-over-bottom {
border-bottom: 2px solid ${styles.dnd_indicator_color};
}
`;
document.head.appendChild(style);
}
}
class JumpListComponent extends UIComponentBase {
constructor(role, messages, highlightedMessage, callbacks, siteStyles, initialFilterValue = '') {
super(callbacks);
this.role = role;
this.messages = messages;
this.siteStyles = siteStyles;
// Pre-cache the display text for each message to avoid repeated DOM queries during filtering.
this.searchableMessages = this.messages.map((msg) => ({
element: msg,
text: PlatformAdapters.General.getJumpListDisplayText(msg),
}));
// --- Component state ---
this.state = {
highlightedMessage: highlightedMessage,
initialFilterValue: initialFilterValue,
filteredMessages: [],
scrollTop: 0,
focusedIndex: -1,
isRendering: false,
};
// --- Virtual scroll properties ---
this.itemHeight = 34; // The fixed height of each list item in pixels.
this.element = null; // The main component container
this.scrollBox = null; // The dedicated scrolling element
this.listElement = null; // The inner element that provides the virtual height
this.previewTooltip = null;
this.hideTimeout = null;
this.hoveredItem = null;
// Bind event handlers
this._handleClick = this._handleClick.bind(this);
this._handleFilter = this._handleFilter.bind(this);
this._handleKeyDown = this._handleKeyDown.bind(this);
this._handleFilterKeyDown = this._handleFilterKeyDown.bind(this);
this._handleScroll = this._handleScroll.bind(this);
}
render() {
// 1. The inner list (ul) acts as a "sizer" or "spacer".
// It has no overflow and its height is set to the total virtual height of all items.
this.listElement = h(`ul#${APPID}-jump-list`, {
style: { position: 'relative', overflow: 'hidden', height: '0px' },
});
// 2. The scrollBox (div) is the "viewport".
// It is the element that actually scrolls and has a fixed visible height.
this.scrollBox = h(`div.${APPID}-jump-list-scrollbox`, {
onkeydown: this._handleKeyDown,
tabindex: -1,
style: {
overflowY: 'auto',
position: 'relative',
flex: '1 1 auto', // Allows this box to fill the available space in the flex container.
},
});
this.scrollBox.appendChild(this.listElement);
this.scrollBox.addEventListener('scroll', this._handleScroll, { passive: true });
// 3. The filter input container.
const filterInput = h('input', {
type: 'text',
placeholder: 'Filter with text or /pattern/flags',
title: 'Filter by plain text or a regular expression.\nEnter text for a simple search.\nUse /regex/flags format for advanced filtering.',
className: `${APPID}-jump-list-filter`,
value: this.state.initialFilterValue,
oninput: this._handleFilter,
onkeydown: this._handleFilterKeyDown,
onclick: (e) => e.stopPropagation(),
});
const modeLabel = h('span', { className: `${APPID}-jump-list-mode-label` });
const inputContainer = h(`div.${APPID}-jump-list-filter-container`, [filterInput, modeLabel]);
// 4. The main element (div) handles the overall layout using flexbox.
this.element = h(`div#${APPID}-jump-list-container`, {
onclick: this._handleClick,
style: {
display: 'flex',
flexDirection: 'column',
overflow: 'hidden', // Important to prevent the main container itself from scrolling.
},
});
this.element.append(this.scrollBox, inputContainer);
this._createPreviewTooltip();
return this.element;
}
show(anchorElement) {
if (!this.element) this.render();
document.body.appendChild(this.element);
// Manually trigger the filter once on show to apply the initial value
this._handleFilter({ target: this.element.querySelector(`.${APPID}-jump-list-filter`) });
requestAnimationFrame(() => {
const anchorRect = anchorElement.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const margin = 8;
const topLimit = viewportHeight * 0.3;
this.element.style.left = `${anchorRect.left}px`;
this.element.style.bottom = `${viewportHeight - anchorRect.top + 4}px`;
this.element.style.width = `360px`;
const maxHeight = anchorRect.top - topLimit - margin;
this.element.style.maxHeight = `${Math.max(100, maxHeight)}px`;
this.element.classList.add('is-visible');
const filterInput = this.element.querySelector(`.${APPID}-jump-list-filter`);
if (filterInput instanceof HTMLInputElement) {
filterInput.focus();
filterInput.select();
}
});
}
destroy() {
if (!this.element) return;
clearTimeout(this.hideTimeout); // Cancel pending preview revert
this._hidePreview();
this.previewTooltip?.remove();
this.element.remove();
this.element = null;
this.previewTooltip = null;
this.listElement = null; // Ensure references are cleared
this.scrollBox = null;
}
updateHighlightedMessage(newMessage) {
this.state.highlightedMessage = newMessage;
// Re-render visible items to update the '.is-current' class
this._renderUI();
}
/**
* Checks if a DOM element is contained within the jump list or its preview tooltip.
* @param {Node} target The element to check.
* @returns {boolean} True if the element is inside the component.
*/
contains(target) {
if (!target) return false;
// Check if inside the main list element
if (this.element?.contains(target)) return true;
// Check if inside the preview tooltip (which is attached to body)
if (this.previewTooltip?.contains(target)) return true;
return false;
}
_createPreviewTooltip() {
if (this.previewTooltip) return;
this.previewTooltip = h(`div#${APPID}-jump-list-preview`);
// Prevent clicks and selections inside the tooltip from bubbling up and closing the UI
['pointerdown', 'click', 'mouseup'].forEach((eventType) => {
this.previewTooltip.addEventListener(eventType, (e) => e.stopPropagation());
});
this.previewTooltip.addEventListener('mouseenter', () => clearTimeout(this.hideTimeout));
this.previewTooltip.addEventListener('mouseleave', (e) => {
// Guard: Do not close if the user is dragging (mouse button down)
// or if text is currently selected (user might be moving to copy).
// e.buttons & 1 checks if the primary (left) button is pressed.
const isDragging = e instanceof MouseEvent && (e.buttons & 1) === 1;
const hasSelection = window.getSelection()?.toString().length > 0;
if (isDragging || hasSelection) {
return;
}
this._revertToFocusedPreview();
});
document.body.appendChild(this.previewTooltip);
}
_showPreview(index) {
if (!this.previewTooltip || index < 0 || index >= this.state.filteredMessages.length) {
this._hidePreview();
return;
}
const searchableMessage = this.state.filteredMessages[index];
if (!searchableMessage) {
this._hidePreview();
return;
}
const fullText = (searchableMessage.text || '').replace(/\s+/g, ' ').trim();
const filterInput = this.element.querySelector(`.${APPID}-jump-list-filter`);
const searchTerm = filterInput instanceof HTMLInputElement ? filterInput.value : '';
const contentFragment = document.createDocumentFragment();
contentFragment.appendChild(document.createTextNode(`${this.messages.indexOf(searchableMessage.element) + 1}: `));
let regex = null;
if (searchTerm.trim()) {
const regexMatch = searchTerm.match(/^\/(.*)\/([gimsuy]*)$/);
if (regexMatch && filterInput?.classList.contains('is-regex-valid')) {
// This will be a valid regex because it's pre-validated in _handleFilter
regex = new RegExp(regexMatch[1], regexMatch[2]);
} else {
// Fallback to plain string search for highlighting
regex = new RegExp(escapeRegExp(searchTerm), 'gi');
}
}
if (regex) {
const parts = fullText.split(regex);
const matches = fullText.match(regex) || [];
parts.forEach((part, i) => {
contentFragment.appendChild(document.createTextNode(part));
if (i < parts.length - 1) {
contentFragment.appendChild(h('strong', matches[i]));
}
});
} else {
contentFragment.appendChild(document.createTextNode(fullText));
}
this.previewTooltip.textContent = '';
this.previewTooltip.appendChild(contentFragment);
withLayoutCycle({
measure: () => {
const listItem = this.listElement.querySelector(`li[data-filtered-index="${index}"]`);
if (!this.element || !this.previewTooltip || !(listItem instanceof HTMLElement)) {
return null;
}
return {
listRect: this.element.getBoundingClientRect(),
itemRect: listItem.getBoundingClientRect(),
tooltipRect: this.previewTooltip.getBoundingClientRect(),
windowWidth: window.innerWidth,
windowHeight: window.innerHeight,
};
},
mutate: (measured) => {
if (!measured) {
this._hidePreview();
return;
}
const { listRect, itemRect, tooltipRect, windowWidth, windowHeight } = measured;
const margin = 12;
let top = itemRect.top;
let left = listRect.right + margin;
if (left + tooltipRect.width > windowWidth - margin) {
left = listRect.left - tooltipRect.width - margin;
}
if (top + tooltipRect.height > windowHeight - margin) {
top = windowHeight - tooltipRect.height - margin;
}
top = Math.max(margin, top);
left = Math.max(margin, left);
this.previewTooltip.style.left = `${left}px`;
this.previewTooltip.style.top = `${top}px`;
this.previewTooltip.classList.add('is-visible');
},
});
}
_hidePreview() {
if (this.previewTooltip) {
this.previewTooltip.classList.remove('is-visible');
}
}
_revertToFocusedPreview() {
if (this.state.focusedIndex > -1) {
this._showPreview(this.state.focusedIndex);
} else {
this._hidePreview();
}
}
_createListItem(searchableMessage, index) {
const messageElement = searchableMessage.element;
const originalIndex = this.messages.indexOf(messageElement);
const role = PlatformAdapters.General.getMessageRole(messageElement);
// Use the adapter to get the appropriate display text, handling platform differences.
const textContent = (searchableMessage.text || '').replace(/\s+/g, ' ').trim();
const displayText = `${originalIndex + 1}: ${textContent}`;
const item = h(
'li',
{
dataset: {
messageIndex: originalIndex,
filteredIndex: index,
},
style: {
position: 'absolute',
top: `${index * this.itemHeight}px`,
height: `${this.itemHeight}px`,
width: '100%',
boxSizing: 'border-box',
display: 'flex',
alignItems: 'center',
},
onmouseenter: (e) => {
clearTimeout(this.hideTimeout);
this.hoveredItem = e.currentTarget;
this._showPreview(index);
},
onmouseleave: () => {
this.hoveredItem = null;
this.hideTimeout = setTimeout(() => this._revertToFocusedPreview(), 200);
},
},
displayText
);
if (this.state.highlightedMessage === messageElement) {
item.classList.add('is-current');
}
if (this.state.focusedIndex === index) {
item.classList.add('is-focused');
}
if (role) {
item.classList.add(role === CONSTANTS.SELECTORS.FIXED_NAV_ROLE_USER ? 'user-item' : 'assistant-item');
}
return item;
}
_filterMessages(searchTerm, inputElement) {
const modeLabel = this.element.querySelector(`.${APPID}-jump-list-mode-label`);
let regex = null;
inputElement.classList.remove('is-regex-valid');
modeLabel.setAttribute('class', `${APPID}-jump-list-mode-label is-string`);
modeLabel.textContent = 'Text';
const regexMatch = searchTerm.match(/^\/(.*)\/([gimsuy]*)$/);
if (regexMatch) {
try {
regex = new RegExp(regexMatch[1], regexMatch[2]);
inputElement.classList.add('is-regex-valid');
modeLabel.setAttribute('class', `${APPID}-jump-list-mode-label is-regex`);
modeLabel.textContent = 'RegExp';
} catch {
// Invalid regex, remains null and will be treated as a plain string.
modeLabel.setAttribute('class', `${APPID}-jump-list-mode-label is-regex-invalid`);
modeLabel.textContent = 'Invalid';
}
}
const lowerCaseSearchTerm = searchTerm.toLowerCase();
return this.searchableMessages.filter((msg) => {
const originalItemText = msg.text;
if (regex) {
// For regex, test against the original, case-preserved text.
// The user controls case-sensitivity with the 'i' flag.
return regex.test(originalItemText);
} else {
// For plain text, perform a case-insensitive search.
const lowerCaseItemText = originalItemText.toLowerCase();
return lowerCaseSearchTerm === '' || lowerCaseItemText.includes(lowerCaseSearchTerm);
}
});
}
_getVisibleRange() {
if (!this.scrollBox) return { startIndex: 0, endIndex: -1 };
const containerHeight = this.scrollBox.clientHeight;
const buffer = 5;
const startIndex = Math.max(0, Math.floor(this.state.scrollTop / this.itemHeight) - buffer);
if (containerHeight === 0) {
// Initial render, container height not yet known. Render a default batch.
const initialBatchSize = 20;
const endIndex = Math.min(this.state.filteredMessages.length - 1, initialBatchSize);
return { startIndex: 0, endIndex };
}
const endIndex = Math.min(this.state.filteredMessages.length - 1, Math.ceil((this.state.scrollTop + containerHeight) / this.itemHeight) + buffer);
return { startIndex, endIndex };
}
_renderUI() {
if (!this.listElement || !this.scrollBox) return;
// Step 1: Update the virtual height immediately to ensure correct layout calculations.
this.listElement.style.height = `${this.state.filteredMessages.length * this.itemHeight}px`;
// Step 2: Determine the new visible range.
const { startIndex, endIndex } = this._getVisibleRange();
const visibleIndices = new Set();
for (let i = startIndex; i <= endIndex; i++) {
visibleIndices.add(i);
}
// Step 3: Map existing DOM elements for efficient lookup.
const existingElements = new Map(
Array.from(this.listElement.children)
.filter((el) => el instanceof HTMLElement)
.map((el) => [parseInt(el.dataset.filteredIndex, 10), el])
);
// Step 4: Reconcile the DOM against the new state.
const fragment = document.createDocumentFragment();
// First, remove any elements that are no longer in the visible range.
for (const [index, element] of existingElements.entries()) {
if (!visibleIndices.has(index)) {
element.remove();
}
}
// Then, add or update elements that should be visible.
for (let i = startIndex; i <= endIndex; i++) {
const message = this.state.filteredMessages[i];
const existingEl = existingElements.get(i);
if (existingEl) {
// Element exists, just update its state (e.g., current/focused classes).
existingEl.classList.toggle('is-current', this.state.highlightedMessage === message);
existingEl.classList.toggle('is-focused', this.state.focusedIndex === i);
} else {
// Element is missing, create it and add to the fragment for batch insertion.
const newItem = this._createListItem(message, i);
fragment.appendChild(newItem);
}
}
// Append all new items at once.
if (fragment.children.length > 0) {
this.listElement.appendChild(fragment);
}
}
_updateFocus(shouldScroll = true) {
if (!this.scrollBox) return;
if (shouldScroll && this.state.focusedIndex > -1) {
const itemTop = this.state.focusedIndex * this.itemHeight;
const itemBottom = itemTop + this.itemHeight;
const viewTop = this.scrollBox.scrollTop;
const viewBottom = viewTop + this.scrollBox.clientHeight;
if (itemTop < viewTop) {
this.scrollBox.scrollTop = itemTop;
} else if (itemBottom > viewBottom) {
this.scrollBox.scrollTop = itemBottom - this.scrollBox.clientHeight;
}
// Update state after potential scroll change
this.state.scrollTop = this.scrollBox.scrollTop;
}
this._renderUI();
// Defer the preview update to the next animation frame.
// This ensures that the DOM updates from _renderUI have been rendered by the browser,
// making the target <li> element available for _showPreview to find.
requestAnimationFrame(() => {
if (this.state.focusedIndex > -1) {
this._showPreview(this.state.focusedIndex);
} else {
this._hidePreview();
}
});
}
_handleScroll(event) {
this.state.scrollTop = event.target.scrollTop;
if (!this.state.isRendering) {
requestAnimationFrame(() => {
this._renderUI();
this.state.isRendering = false;
});
this.state.isRendering = true;
}
}
_handleFilter(event) {
const inputElement = event.target;
const searchTerm = inputElement.value;
if (this.listElement) {
this.listElement.textContent = '';
}
// Update state
this.state.filteredMessages = this._filterMessages(searchTerm, inputElement);
this.state.focusedIndex = -1;
this.state.scrollTop = 0;
if (this.scrollBox) this.scrollBox.scrollTop = 0;
this._renderUI();
this._hidePreview();
}
_handleFilterKeyDown(event) {
if (this.state.filteredMessages.length === 0) return;
switch (event.key) {
case 'ArrowDown':
case 'Tab':
if (!event.shiftKey) {
event.preventDefault();
this.state.focusedIndex = 0;
this._updateFocus(true);
this.scrollBox.focus({ preventScroll: true });
}
break;
case 'ArrowUp':
event.preventDefault();
this.state.focusedIndex = this.state.filteredMessages.length - 1;
this._updateFocus(true);
this.scrollBox.focus({ preventScroll: true });
break;
case 'Enter':
event.preventDefault();
if (this.state.filteredMessages.length > 0) {
this.state.focusedIndex = 0;
this._updateFocus(false);
const targetMessage = this.state.filteredMessages[this.state.focusedIndex].element;
if (targetMessage) this.callbacks.onSelect?.(targetMessage);
}
break;
}
if (event.shiftKey && event.key === 'Tab') {
event.preventDefault();
this.state.focusedIndex = this.state.filteredMessages.length - 1;
this._updateFocus(true);
this.scrollBox.focus({ preventScroll: true });
}
}
/** @param {KeyboardEvent} event */
_handleKeyDown(event) {
if (!this.scrollBox || document.activeElement !== this.scrollBox || this.state.filteredMessages.length === 0) return;
const totalItems = this.state.filteredMessages.length;
let newFocusedIndex = this.state.focusedIndex;
switch (event.key) {
case 'ArrowDown': {
event.preventDefault();
newFocusedIndex = newFocusedIndex === -1 ? 0 : (newFocusedIndex + 1) % totalItems;
break;
}
case 'ArrowUp': {
event.preventDefault();
newFocusedIndex = newFocusedIndex === -1 ? totalItems - 1 : (newFocusedIndex - 1 + totalItems) % totalItems;
break;
}
case 'Home': {
event.preventDefault();
newFocusedIndex = 0;
break;
}
case 'End': {
event.preventDefault();
newFocusedIndex = totalItems - 1;
break;
}
case 'PageDown': {
event.preventDefault();
if (newFocusedIndex === -1) newFocusedIndex = 0;
const itemsPerPage = Math.floor(this.scrollBox.clientHeight / this.itemHeight);
newFocusedIndex = Math.min(totalItems - 1, newFocusedIndex + itemsPerPage);
break;
}
case 'PageUp': {
event.preventDefault();
if (newFocusedIndex === -1) newFocusedIndex = 0;
const itemsPerPage = Math.floor(this.scrollBox.clientHeight / this.itemHeight);
newFocusedIndex = Math.max(0, newFocusedIndex - itemsPerPage);
break;
}
case 'Enter': {
event.preventDefault();
if (this.state.focusedIndex > -1) {
const targetMessage = this.state.filteredMessages[this.state.focusedIndex].element;
if (targetMessage) this.callbacks.onSelect?.(targetMessage);
}
return; // Don't update focus on enter
}
case 'Tab': {
event.preventDefault();
const filterInput = this.element.querySelector(`.${APPID}-jump-list-filter`);
if (filterInput instanceof HTMLInputElement) {
filterInput.focus();
filterInput.select();
}
this.state.focusedIndex = -1;
this._updateFocus(false);
return; // Don't update focus on tab
}
default:
return;
}
if (newFocusedIndex !== this.state.focusedIndex) {
this.state.focusedIndex = newFocusedIndex;
this._updateFocus(true);
}
}
getFilterValue() {
const filterInput = this.element?.querySelector(`.${APPID}-jump-list-filter`);
if (filterInput instanceof HTMLInputElement) {
return filterInput.value || '';
}
return '';
}
/** @param {MouseEvent} event */
_handleClick(event) {
const target = event.target;
if (!(target instanceof Element)) return;
const listItem = target.closest('li');
if (!listItem) return;
const originalIndex = parseInt(listItem.dataset.messageIndex, 10);
if (!isNaN(originalIndex) && this.messages[originalIndex]) {
this.callbacks.onSelect?.(this.messages[originalIndex]);
}
}
}
class UIManager {
/**
* @param {(config: AppConfig) => Promise<void>} onSaveCallback
* @param {() => Promise<AppConfig>} getCurrentConfigCallback
* @param {DataConverter} dataConverter
* @param {() => void} onModalClose
* @param {object} siteStyles
* @param getCurrentThemeSetCallback
*/
constructor(onSaveCallback, getCurrentConfigCallback, dataConverter, onModalClose, siteStyles, getCurrentThemeSetCallback) {
this.onSave = onSaveCallback;
this.getCurrentConfig = getCurrentConfigCallback;
this.dataConverter = dataConverter;
this.onModalClose = onModalClose;
this.siteStyles = siteStyles;
this.isModalOpen = false;
this.isWarningActive = false;
this.warningMessage = '';
this.subscriptions = [];
this.isRepositionScheduled = false;
// Bind the reposition logic
this.scheduleReposition = this.scheduleReposition.bind(this);
const modalCallbacks = {
onSave: (newConfig) => this.onSave(newConfig),
getCurrentConfig: () => this.getCurrentConfig(),
onModalOpenStateChange: (isOpen) => this.setModalState(isOpen),
};
this.settingsButton = new CustomSettingsButton(
{
// Callbacks
onClick: () => this.settingsPanel.toggle(),
},
{
// Options
id: `${APPID}-settings-button`,
textContent: '',
title: `Settings (${APPNAME})`,
// Pass the complete config generated by PlatformAdapters
config: PlatformAdapters.StyleManager.getSettingsButtonConfig(this.siteStyles.SETTINGS_BUTTON),
}
);
this.settingsPanel = new SettingsPanelComponent({
onSave: (newConfig) => this.onSave(newConfig),
onShowJsonModal: () => this.jsonModal.open(this.settingsButton.element),
onShowThemeModal: (themeKey) => this.themeModal.open(themeKey),
getCurrentConfig: () => this.getCurrentConfig(),
getAnchorElement: () => this.settingsButton.element,
siteStyles: this.siteStyles.SETTINGS_PANEL,
onShow: () => this.updateWarningBanners(),
getCurrentThemeSet: getCurrentThemeSetCallback, // Pass the callback directly
});
this.jsonModal = new JsonModalComponent({
...modalCallbacks,
siteStyles: this.siteStyles.JSON_MODAL,
onModalOpen: () => this.updateWarningBanners(),
});
this.themeModal = new ThemeModalComponent({
...modalCallbacks,
dataConverter: this.dataConverter,
siteStyles: this.siteStyles.THEME_MODAL,
onModalOpen: () => this.updateWarningBanners(),
});
}
/**
* Helper to subscribe to EventBus and track the subscription for cleanup.
* Appends the listener name and a unique suffix to the key to avoid conflicts.
* @param {string} event
* @param {Function} listener
*/
_subscribe(event, listener) {
const baseKey = createEventKey(this, event);
// Use function name for debugging aid, fallback to 'anonymous'
const listenerName = listener.name || 'anonymous';
// Generate a short random suffix to guarantee uniqueness even for anonymous functions
const uniqueSuffix = Math.random().toString(36).substring(2, 7);
const key = `${baseKey}_${listenerName}_${uniqueSuffix}`;
EventBus.subscribe(event, listener, key);
this.subscriptions.push({ event, key });
}
init() {
this._injectSharedStyles();
this.settingsButton.render();
this.repositionSettingsButton();
this.settingsPanel.init();
this.settingsPanel.render();
// Note: themeModal and jsonModal are transient components (Transient Pattern).
// Their render() is an empty implementation to satisfy the interface,
// and style injection/DOM creation happens lazily in open().
// Therefore, we do not call render() here.
this._subscribe(EVENTS.REOPEN_MODAL, ({ type, key }) => {
if (type === 'json') {
this.jsonModal.open(this.settingsButton.element);
} else if (type === 'theme') {
this.themeModal.open(key);
}
});
this._subscribe(EVENTS.UI_REPOSITION, this.scheduleReposition);
this._subscribe(EVENTS.CONFIG_WARNING_UPDATE, ({ show, message }) => {
this.isWarningActive = show;
this.warningMessage = message;
this.updateWarningBanners();
});
this._subscribe(EVENTS.APP_SHUTDOWN, () => this.destroy());
this._subscribe(EVENTS.DEFERRED_LAYOUT_UPDATE, () => this.scheduleReposition());
this._subscribe(EVENTS.NAVIGATION_END, this.scheduleReposition);
}
destroy() {
this.subscriptions.forEach(({ event, key }) => EventBus.unsubscribe(event, key));
this.subscriptions = [];
this.settingsButton?.destroy();
this.settingsPanel?.destroy();
this.jsonModal?.close(); // Modals destroy themselves on close
this.themeModal?.close();
document.getElementById(`${APPID}-shared-styles`)?.remove();
}
_injectSharedStyles() {
const styleId = `${APPID}-shared-styles`;
if (document.getElementById(styleId)) return;
// Use the centralized UI palette for consistent styling across components
const palette = this.siteStyles.PALETTE;
const style = h('style', {
id: styleId,
textContent: `
/* --- Common Modal Buttons --- */
.${APPID}-modal-button {
background: ${palette.btn_bg};
border: 1px solid ${palette.btn_border};
border-radius: var(--radius-md, ${CONSTANTS.MODAL.BTN_RADIUS}px);
color: ${palette.btn_text};
cursor: pointer;
font-size: ${CONSTANTS.MODAL.BTN_FONT_SIZE}px;
padding: ${CONSTANTS.MODAL.BTN_PADDING};
transition: background 0.12s;
min-width: 80px;
}
.${APPID}-modal-button:hover {
background: ${palette.btn_hover_bg} !important;
border-color: ${palette.btn_border};
}
.${APPID}-modal-button:disabled {
background: ${palette.btn_bg} !important;
cursor: not-allowed;
opacity: 0.5;
}
/* --- Utility Buttons --- */
.-btn-primary {
background-color: #1a73e8 !important;
color: #ffffff !important;
border: 1px solid transparent !important;
}
.-btn-primary:hover {
background-color: #1557b0 !important;
}
.-btn-push-right {
margin-left: auto !important;
}
/* --- Common Sliders --- */
.${APPID}-slider-subgroup-control {
align-items: center;
display: flex;
gap: 8px;
}
.${APPID}-slider-subgroup-control input[type=range] {
flex-grow: 1;
}
.${APPID}-slider-subgroup-control span {
color: ${palette.slider_display_text};
font-family: monospace;
min-width: 4em;
text-align: right;
}
.${APPID}-slider-subgroup-control.is-default span {
color: ${palette.label_text};
}
/* --- Toggle Switch --- */
.${APPID}-toggle-switch {
position: relative;
display: inline-block;
width: 40px;
height: 22px;
flex-shrink: 0;
}
.${APPID}-toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.${APPID}-toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: ${palette.toggle_bg_off};
transition: .3s;
border-radius: 22px;
}
.${APPID}-toggle-slider:before {
position: absolute;
content: "";
height: 16px;
width: 16px;
left: 3px;
bottom: 3px;
background-color: ${palette.toggle_knob};
transition: .3s;
border-radius: 50%;
}
.${APPID}-toggle-switch input:checked + .${APPID}-toggle-slider {
background-color: ${palette.toggle_bg_on};
}
.${APPID}-toggle-switch input:checked + .${APPID}-toggle-slider:before {
transform: translateX(18px);
}
`,
});
document.head.appendChild(style);
}
scheduleReposition() {
if (this.isRepositionScheduled) return;
this.isRepositionScheduled = true;
EventBus.queueUIWork(() => {
this.repositionSettingsButton();
this.isRepositionScheduled = false;
});
}
repositionSettingsButton() {
if (!this.settingsButton?.element) return;
PlatformAdapters.UIManager.repositionSettingsButton(this.settingsButton);
}
getActiveModal() {
if (this.jsonModal?.modal?.element?.open) {
return this.jsonModal;
}
if (this.themeModal?.modal?.element?.open) {
return this.themeModal;
}
return null;
}
showConflictNotification(modalComponent, reloadCallback) {
if (!modalComponent?.modal) return;
this.clearConflictNotification(modalComponent); // Clear previous state first
const styles = modalComponent.callbacks.siteStyles;
const messageArea = modalComponent.modal.dom.footerMessage;
if (messageArea) {
const messageText = h('span', {
textContent: 'Settings updated in another tab.',
style: { display: 'flex', alignItems: 'center' },
});
const reloadBtn = h('button', {
id: `${APPID}-conflict-reload-btn`,
className: `${APPID}-modal-button`,
textContent: 'Reload UI',
title: 'Discard local changes and load the settings from the other tab.',
style: {
borderColor: styles.error_text || 'red',
marginLeft: '12px',
},
onclick: reloadCallback,
});
messageArea.textContent = '';
messageArea.classList.add(`${APPID}-conflict-text`);
messageArea.style.color = styles.error_text || 'red';
messageArea.append(messageText, reloadBtn);
}
}
clearConflictNotification(modalComponent) {
if (!modalComponent?.modal) return;
const messageArea = modalComponent.modal.dom.footerMessage;
if (messageArea) {
messageArea.textContent = '';
messageArea.classList.remove(`${APPID}-conflict-text`);
}
}
setModalState(isOpen) {
this.isModalOpen = isOpen;
if (!isOpen) {
this.onModalClose?.();
}
}
_createWarningBanner() {
return h(
`div.${APPID}-config-warning-banner`,
{
style: {
backgroundColor: 'var(--bg-danger, #ffdddd)',
color: 'var(--text-on-danger, #a00)',
padding: '8px 12px',
fontSize: '0.85em',
textAlign: 'center',
borderRadius: '4px',
margin: '0 0 12px 0',
border: '1px solid var(--border-danger-heavy, #c00)',
whiteSpace: 'pre-wrap',
},
},
this.warningMessage
);
}
updateWarningBanners() {
const components = [this.settingsPanel, this.jsonModal, this.themeModal];
// First, remove any existing banners from all components
components.forEach((component) => {
let modalElement = null;
if (component instanceof SettingsPanelComponent) {
modalElement = component.element;
} else if (component instanceof JsonModalComponent || component instanceof ThemeModalComponent) {
modalElement = component.modal?.element;
}
if (modalElement) {
modalElement.querySelector(`.${APPID}-config-warning-banner`)?.remove();
}
});
if (this.isWarningActive) {
const newBanner = this._createWarningBanner();
// Add banner to any visible settings UI
if (this.settingsPanel?.isOpen()) {
this.settingsPanel.element.prepend(newBanner.cloneNode(true));
}
if (this.jsonModal?.modal?.element?.open) {
this.jsonModal.modal.getContentContainer().prepend(newBanner.cloneNode(true));
}
if (this.themeModal?.modal?.element?.open) {
const target = this.themeModal.modal.element.querySelector(`.${APPID}-theme-general-settings`);
target?.before(newBanner.cloneNode(true));
}
}
}
}
// =================================================================================
// SECTION: Debugging
// =================================================================================
class DebugManager {
/**
* @param {ThemeAutomator} automatorInstance An instance of the main controller to access its methods and properties.
*/
constructor(automatorInstance) {
this.automator = automatorInstance;
this.isBordersVisible = false;
}
/**
* Exposes the debug object to the global scope (unsafeWindow).
*/
expose() {
try {
const debugApi = {};
const proto = Object.getPrototypeOf(this);
const methodNames = Object.getOwnPropertyNames(proto).filter((key) => typeof this[key] === 'function' && key !== 'constructor' && !key.startsWith('_'));
for (const key of methodNames) {
debugApi[key] = this[key].bind(this);
}
// fallback help if not defined
if (typeof debugApi.help !== 'function') {
debugApi.help = () => {
console.table(Object.keys(debugApi));
Logger.log('All available debug commands listed above.');
};
Logger.warn('debugManager.help not found, fallback help() defined.');
}
if (typeof unsafeWindow !== 'undefined') {
/** @type {any} */ (unsafeWindow)[`${APPID}Debug`] = debugApi;
}
Logger.badge('DEBUG READY', LOG_STYLES.BLUE, 'log', `Debug tools available. Use \`${APPID}Debug.help()\` for commands.`);
} catch (e) {
Logger.badge('DEBUG INIT FAILED', LOG_STYLES.RED, 'error', 'Could not expose debug object to console.', e);
}
}
/**
* @description Checks if all CSS selectors defined in the CONSTANTS.SELECTORS object are valid and exist in the current DOM.
* @returns {boolean} True if all selectors are valid, otherwise false.
*/
checkSelectors() {
// Automatically create the checklist from the CONSTANTS.SELECTORS object.
const selectorsToCheck = Object.entries(CONSTANTS.SELECTORS).map(([key, selector]) => {
// Create a description from the key name.
const desc = key
.replace(/_/g, ' ')
.toLowerCase()
.replace(/ \w/g, (L) => L.toUpperCase());
return {
selector,
desc,
};
});
let allOK = true;
console.groupCollapsed(LOG_PREFIX, 'CSS Selector Check');
for (const { selector, desc } of selectorsToCheck) {
try {
const el = document.querySelector(selector);
if (el) {
Logger.badge('SELECTOR OK', LOG_STYLES.GREEN, 'log', `"${selector}"\n description: ${desc}\n element found:`, el);
} else {
Logger.badge('SELECTOR NG', LOG_STYLES.YELLOW, 'warn', `"${selector}"\n description: ${desc}\n element NOT found.`);
allOK = false;
}
} catch (e) {
Logger.badge('SELECTOR INVALID', LOG_STYLES.RED, 'error', `Invalid selector "${selector}"\n description: ${desc}\n error:`, e.message);
allOK = false;
}
}
if (allOK) {
Logger.badge('SELECTOR CHECK', LOG_STYLES.GREEN, 'log', 'All essential selectors are currently valid!');
} else {
Logger.badge('SELECTOR CHECK', LOG_STYLES.YELLOW, 'warn', 'One or more essential selectors are NOT found or invalid. The script might not function correctly.');
}
console.groupEnd();
return allOK;
}
/**
* Toggles the visibility of debug layout borders.
* @param {boolean} [forceState] - If true, shows borders. If false, hides them. If undefined, toggles the current state.
*/
toggleBorders(forceState) {
this.isBordersVisible = forceState === undefined ? !this.isBordersVisible : forceState;
const styleId = `${APPID}-debug-style`;
const existingStyle = document.getElementById(styleId);
if (this.isBordersVisible) {
// Already visible
if (existingStyle) return;
const debugStyle = h('style', {
id: styleId,
textContent: PlatformAdapters.Debug.getBordersCss(),
});
document.head.appendChild(debugStyle);
Logger.log('Borders ON');
} else {
if (existingStyle) {
existingStyle.remove();
Logger.log('Borders OFF');
}
}
}
/**
* Logs the current configuration object to the console.
*/
logConfig() {
Logger.log('Current Config:', this.automator.configManager.get());
}
/**
* Displays available debug commands in the console.
*/
help() {
console.group(LOG_PREFIX, 'Debug Commands');
Logger.log(`${APPID}Debug.help() - Displays this help message.`);
Logger.log(`${APPID}Debug.toggleBorders() - Toggles visibility of layout borders.`);
Logger.log(`${APPID}Debug.checkSelectors() - Validates all critical CSS selectors.`);
Logger.log(`${APPID}Debug.logConfig() - Prints the current configuration object.`);
console.groupEnd();
}
}
// =================================================================================
// SECTION: Main Application Controller
// =================================================================================
/**
* @class Sentinel
* @description Detects DOM node insertion using a shared, prefixed CSS animation trick.
* @property {Map<string, Set<(element: Element) => void>>} listeners
* @property {Set<string>} rules
* @property {HTMLElement | null} styleElement
* @property {CSSStyleSheet | null} sheet
* @property {string[]} pendingRules
* @property {WeakMap<CSSRule, string>} ruleSelectors
*/
class Sentinel {
/**
* @param {string} prefix - A unique identifier for this Sentinel instance to avoid CSS conflicts. Required.
*/
constructor(prefix) {
if (!prefix) {
throw new Error('[Sentinel] "prefix" argument is required to avoid CSS conflicts.');
}
// Validate prefix for CSS compatibility
// 1. Must contain only alphanumeric characters, hyphens, or underscores.
// 2. Cannot start with a digit.
// 3. Cannot start with a hyphen followed by a digit.
if (!/^[a-zA-Z0-9_-]+$/.test(prefix) || /^[0-9]|^-[0-9]/.test(prefix)) {
throw new Error(`[Sentinel] Prefix "${prefix}" is invalid. It must contain only alphanumeric characters, hyphens, or underscores, and cannot start with a digit or a hyphen followed by a digit.`);
}
/** @type {any} */
const globalScope = window;
globalScope.__global_sentinel_instances__ = globalScope.__global_sentinel_instances__ || {};
if (globalScope.__global_sentinel_instances__[prefix]) {
return globalScope.__global_sentinel_instances__[prefix];
}
// Use a unique, prefixed animation name shared by all scripts in a project.
this.animationName = `${prefix}-global-sentinel-animation`;
this.styleId = `${prefix}-sentinel-global-rules`; // A single, unified style element
this.listeners = new Map();
this.rules = new Set(); // Tracks all active selectors
this.styleElement = null; // Holds the reference to the single style element
this.sheet = null; // Cache the CSSStyleSheet reference
this.pendingRules = []; // Queue for rules requested before sheet is ready
/** @type {WeakMap<CSSRule, string>} */
this.ruleSelectors = new WeakMap(); // Tracks selector strings associated with CSSRule objects
this._injectStyleElement();
document.addEventListener('animationstart', this._handleAnimationStart.bind(this), true);
globalScope.__global_sentinel_instances__[prefix] = this;
}
_injectStyleElement() {
// Ensure the style element is injected only once per project prefix.
this.styleElement = document.getElementById(this.styleId);
if (this.styleElement instanceof HTMLStyleElement) {
/** @type {HTMLStyleElement} */
const styleNode = this.styleElement;
const pollExisting = () => {
if (styleNode.sheet) {
this.sheet = styleNode.sheet;
this._flushPendingRules();
} else {
// Poll infinitely until sheet is ready
setTimeout(pollExisting, 50);
}
};
pollExisting();
return;
}
// Create empty style element
this.styleElement = h('style', {
id: this.styleId,
});
// CSP Fix: Try to fetch a valid nonce from existing scripts/styles
// "nonce" property exists on HTMLScriptElement/HTMLStyleElement, not basic Element.
let nonce;
// 1. Try to get nonce from scripts collection
const scripts = document.scripts;
for (let i = 0; i < scripts.length; i++) {
if (scripts[i].nonce) {
nonce = scripts[i].nonce;
break;
}
}
// 2. Fallback: Using querySelector (content attribute)
if (!nonce) {
const style = document.querySelector('style[nonce]');
const script = document.querySelector('script[nonce]');
if (style instanceof HTMLStyleElement && style.nonce) {
nonce = style.nonce;
} else if (script instanceof HTMLScriptElement && script.nonce) {
nonce = script.nonce;
}
}
if (nonce) {
this.styleElement.nonce = nonce;
}
// Try to inject immediately.
// If the document is not yet ready (e.g. extremely early document-start), wait for the root element.
const target = document.head || document.documentElement;
const initSheet = () => {
if (this.styleElement instanceof HTMLStyleElement) {
/** @type {HTMLStyleElement} */
const styleNode = this.styleElement;
if (styleNode.sheet) {
this.sheet = styleNode.sheet;
// Insert the shared keyframes rule at index 0.
try {
const keyframes = `@keyframes ${this.animationName} { from { outline: 1px solid transparent;
} to { outline: 0px solid transparent; } }`;
this.sheet.insertRule(keyframes, 0);
} catch (e) {
Logger.error('SENTINEL', LOG_STYLES.RED, 'Failed to insert keyframes rule:', e);
}
this._flushPendingRules();
} else {
// Poll infinitely until sheet is ready
setTimeout(initSheet, 50);
}
}
};
if (target) {
target.appendChild(this.styleElement);
initSheet();
} else {
const observer = new MutationObserver(() => {
const retryTarget = document.head || document.documentElement;
if (retryTarget) {
observer.disconnect();
retryTarget.appendChild(this.styleElement);
initSheet();
}
});
observer.observe(document, { childList: true });
}
}
/**
* Ensures the style element is connected to the DOM and restores rules if it was removed.
*/
_ensureStyleGuard() {
if (this.styleElement && !this.styleElement.isConnected) {
const target = document.head || document.documentElement;
if (target) {
target.appendChild(this.styleElement);
if (this.styleElement instanceof HTMLStyleElement && this.styleElement.sheet) {
this.sheet = this.styleElement.sheet;
try {
const keyframes = `@keyframes ${this.animationName} { from { outline: 1px solid transparent; } to { outline: 0px solid transparent; } }`;
this.sheet.insertRule(keyframes, 0);
} catch (e) {
Logger.error('SENTINEL', LOG_STYLES.RED, 'Failed to restore keyframes rule:', e);
}
this.rules.forEach((selector) => {
this._insertRule(selector);
});
}
}
}
}
_flushPendingRules() {
if (!this.sheet || this.pendingRules.length === 0) return;
const rulesToInsert = [...this.pendingRules];
this.pendingRules = [];
rulesToInsert.forEach((selector) => {
this._insertRule(selector);
});
}
/**
* Helper to insert a single rule into the stylesheet
* @param {string} selector
*/
_insertRule(selector) {
try {
const index = this.sheet.cssRules.length;
const ruleText = `${selector} { animation-duration: 0.001s; animation-name: ${this.animationName}; }`;
this.sheet.insertRule(ruleText, index);
// Associate the inserted rule with the selector via WeakMap for safer removal later.
// This mimics sentinel.js behavior to handle index shifts and selector normalization.
const insertedRule = this.sheet.cssRules[index];
if (insertedRule) {
this.ruleSelectors.set(insertedRule, selector);
}
} catch (e) {
Logger.error('SENTINEL', LOG_STYLES.RED, `Failed to insert rule for selector "${selector}":`, e);
}
}
_handleAnimationStart(event) {
// Check if the animation is the one we're listening for.
if (event.animationName !== this.animationName) return;
const target = event.target;
if (!(target instanceof Element)) {
return;
}
// Check if the target element matches any of this instance's selectors.
for (const [selector, callbacks] of this.listeners.entries()) {
if (target.matches(selector)) {
// Use a copy of the callbacks Set in case a callback removes itself.
[...callbacks].forEach((cb) => cb(target));
}
}
}
/**
* @param {string} selector
* @param {(element: Element) => void} callback
*/
on(selector, callback) {
this._ensureStyleGuard();
// Add callback to listeners
if (!this.listeners.has(selector)) {
this.listeners.set(selector, new Set());
}
this.listeners.get(selector).add(callback);
// If selector is already registered in rules, do nothing
if (this.rules.has(selector)) return;
this.rules.add(selector);
// Apply rule
if (this.sheet) {
this._insertRule(selector);
} else {
this.pendingRules.push(selector);
}
}
/**
* @param {string} selector
* @param {(element: Element) => void} callback
*/
off(selector, callback) {
const callbacks = this.listeners.get(selector);
if (!callbacks) return;
const wasDeleted = callbacks.delete(callback);
if (!wasDeleted) {
return;
// Callback not found, do nothing.
}
if (callbacks.size === 0) {
// Remove listener and rule
this.listeners.delete(selector);
this.rules.delete(selector);
if (this.sheet) {
// Iterate backwards to avoid index shifting issues during deletion
for (let i = this.sheet.cssRules.length - 1; i >= 0; i--) {
const rule = this.sheet.cssRules[i];
// Check for recorded selector via WeakMap or fallback to selectorText match
const recordedSelector = this.ruleSelectors.get(rule);
if (recordedSelector === selector || (rule instanceof CSSStyleRule && rule.selectorText === selector)) {
try {
this.sheet.deleteRule(i);
} catch (e) {
Logger.error('SENTINEL', LOG_STYLES.RED, `Failed to delete rule for selector "${selector}":`, e);
}
// We assume one rule per selector, so we can break after deletion
break;
}
}
}
}
}
suspend() {
if (this.styleElement instanceof HTMLStyleElement) {
this.styleElement.disabled = true;
}
Logger.debug('SENTINEL', LOG_STYLES.CYAN, 'Suspended.');
}
resume() {
if (this.styleElement instanceof HTMLStyleElement) {
this.styleElement.disabled = false;
}
Logger.debug('SENTINEL', LOG_STYLES.CYAN, 'Resumed.');
}
}
// =================================================================================
// SECTION: Toast Manager
// Description: Manages the display of temporary toast notifications.
// =================================================================================
class ToastManager {
constructor() {
this.toastElement = null;
this.subscriptions = [];
}
/**
* Helper to subscribe to EventBus and track the subscription for cleanup.
* Appends the listener name and a unique suffix to the key to avoid conflicts.
* @param {string} event
* @param {Function} listener
*/
_subscribe(event, listener) {
const baseKey = createEventKey(this, event);
// Use function name for debugging aid, fallback to 'anonymous'
const listenerName = listener.name || 'anonymous';
// Generate a short random suffix to guarantee uniqueness even for anonymous functions
const uniqueSuffix = Math.random().toString(36).substring(2, 7);
const key = `${baseKey}_${listenerName}_${uniqueSuffix}`;
EventBus.subscribe(event, listener, key);
this.subscriptions.push({ event, key });
}
init() {
this._injectStyles();
const message = PlatformAdapters.Toast.getAutoScrollMessage();
this._subscribe(EVENTS.AUTO_SCROLL_START, () => this.show(message, true));
this._subscribe(EVENTS.AUTO_SCROLL_COMPLETE, () => this.hide());
this._subscribe(EVENTS.APP_SHUTDOWN, () => this.destroy());
}
destroy() {
this.subscriptions.forEach(({ event, key }) => EventBus.unsubscribe(event, key));
this.subscriptions = [];
this.hide();
}
_injectStyles() {
const styleId = `${APPID}-toast-style`;
if (document.getElementById(styleId)) return;
// Define custom warning styles for the toast
const warnStyles = {
bg: 'rgb(255 165 0 / 0.9)', // Orange background for warning
text: '#ffffff', // White text
border: '#ffa000', // Darker orange border
cancel_btn_bg: 'rgb(255 255 255 / 0.2)', // Semi-transparent white for button background
cancel_btn_text: '#ffffff', // White text for button
};
const style = h('style', {
id: styleId,
textContent: `
.${APPID}-toast-container {
position: fixed; /* Changed to fixed for viewport relative positioning */
top: 30%; /* Position from the top */
left: 50%; /* Center horizontally */
transform: translate(-50%, -50%); /* Adjust for exact centering */
z-index: 10002; /* Higher z-index */
background-color: ${warnStyles.bg};
color: ${warnStyles.text};
padding: 15px 25px; /* Slightly more padding */
border-radius: 12px; /* More rounded corners */
border: 1px solid ${warnStyles.border};
box-shadow: 0 6px 20px rgb(0 0 0 / 0.2); /* Stronger shadow */
display: flex;
align-items: center;
gap: 15px; /* Increased gap */
font-size: 1.1em; /* Larger font */
font-weight: bold; /* Bold text */
opacity: 0;
transition: opacity 0.4s ease, transform 0.4s ease; /* Smoother transition */
pointer-events: none;
white-space: nowrap; /* Prevent text wrapping */
}
.${APPID}-toast-container.is-visible {
opacity: 1;
transform: translate(-50%, 0); /* Move into view from adjusted vertical position */
pointer-events: auto;
}
.${APPID}-toast-cancel-btn {
background: ${warnStyles.cancel_btn_bg};
color: ${warnStyles.cancel_btn_text};
border: none;
padding: 8px 15px; /* Larger button padding */
margin-left: 10px; /* Adjusted margin */
cursor: pointer;
font-weight: bold;
border-radius: 6px; /* Rounded button */
transition: background-color 0.2s ease;
}
.${APPID}-toast-cancel-btn:hover {
background-color: rgb(255 255 255 / 0.3); /* Lighter hover background */
}
`,
});
document.head.appendChild(style);
}
_renderToast(message, showCancelButton) {
const children = [h('span', message)];
if (showCancelButton) {
const cancelButton = h(
'button',
{
className: `${APPID}-toast-cancel-btn`,
title: 'Stop action',
onclick: () => EventBus.publish(EVENTS.AUTO_SCROLL_CANCEL_REQUEST),
},
'Cancel'
);
children.push(cancelButton);
}
return h(`div.${APPID}-toast-container`, children);
}
show(message, showCancelButton = false) {
// Remove existing toast if any
if (this.toastElement) {
this.hide();
}
this.toastElement = this._renderToast(message, showCancelButton);
document.body.appendChild(this.toastElement);
// Trigger the transition
setTimeout(() => {
this.toastElement?.classList.add('is-visible');
}, 10);
}
hide() {
if (!this.toastElement) return;
const el = this.toastElement;
el.classList.remove('is-visible');
// Remove from DOM after transition ends
setTimeout(() => {
el.remove();
}, 300);
this.toastElement = null;
}
}
class MigrationNoticeManager {
constructor() {
this.DIALOG_ID = `${OWNERID}-${APPID}-migration-dialog`;
this.STORAGE_KEY = `${APPID}_migration_notice_last_shown`;
this.SHOW_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours
}
async init() {
const lastShown = await GM_getValue(this.STORAGE_KEY, 0);
const now = Date.now();
if (now - lastShown > this.SHOW_INTERVAL) {
this._showDialog();
await GM_setValue(this.STORAGE_KEY, now);
}
}
_showDialog() {
if (document.getElementById(this.DIALOG_ID)) return;
const dialog = h(
`dialog#${this.DIALOG_ID}`,
{
style: {
top: '10%',
left: '0',
right: '0',
margin: '0 auto',
padding: '24px',
border: 'none',
borderRadius: '12px',
maxWidth: '500px',
width: '90%',
background: 'white',
color: '#1f1f1f',
boxShadow: '0 10px 30px rgb(0 0 0 / 0.3)',
fontFamily: 'sans-serif',
fontSize: '14px',
lineHeight: '1.6',
zIndex: '99999',
position: 'fixed',
},
},
[
h('h2', { style: { marginTop: '0', fontSize: '20px', color: '#d93025', display: 'flex', alignItems: 'center', gap: '8px' } }, [h('span', '⚠️'), ' Notice: Userscript Update']),
h('p', { style: { marginBottom: '16px', fontSize: '15px' } }, ['The Userscript ', h('strong', APPNAME), ' has been discontinued.']),
h('div', { style: { background: '#f1f3f4', padding: '16px', borderRadius: '8px', marginBottom: '20px' } }, [
h('p', { style: { margin: '0' } }, [
'This script is no longer maintained. It has been merged into the new unified script:',
h('br'),
h(
'a',
{ href: 'https://greasyfork.org/en/scripts/570496-ai-ux-customizer', target: '_blank', style: { fontSize: '1.1em', fontWeight: 'bold', color: '#1a73e8', textDecoration: 'none', display: 'block', marginTop: '4px' } },
'AI UX Customizer (AIUXC)'
),
]),
]),
h('h3', { style: { fontSize: '15px', margin: '0 0 8px 0', color: '#d93025' } }, '⚠️ Action Required:'),
h('ol', { style: { margin: '0 0 20px 0', paddingLeft: '24px' } }, [
h('li', { style: { marginBottom: '4px' } }, [h('strong', 'Close'), ' this dialog.']),
h('li', { style: { marginBottom: '4px' } }, ['Open the settings panel and ', h('strong', 'Export'), ' your configuration.']),
h('li', { style: { marginBottom: '4px' } }, [h('strong', 'Disable'), ' this script in your userscript manager.']),
h('li', 'Install the new script and migrate your data.'),
]),
h('div', { style: { borderTop: '1px solid #eee', paddingTop: '16px', marginBottom: '24px' } }, [
h('p', { style: { margin: '0 0 8px 0', fontWeight: 'bold', color: '#444' } }, '🔗 Migration Guide:'),
h('ul', { style: { margin: '0', paddingLeft: '24px' } }, [h('li', [h('a', { href: 'https://github.com/p65536/AI-UX-Customizer', target: '_blank', style: { color: '#1a73e8', textDecoration: 'none' } }, 'View on GitHub')])]),
]),
h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } }, [
h('span', { style: { color: '#666', fontSize: '12px' } }, 'This dialog appears once a day.'),
h('button', {
textContent: 'Close',
style: {
padding: '10px 20px',
fontSize: '14px',
fontWeight: 'bold',
background: '#1a73e8',
color: '#fff',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
transition: 'background 0.2s',
},
onmouseover: (e) => (e.target.style.background = '#1557b0'),
onmouseout: (e) => (e.target.style.background = '#1a73e8'),
onclick: () => {
const el = document.getElementById(this.DIALOG_ID);
if (el instanceof HTMLDialogElement) {
el.close();
el.remove();
}
},
}),
]),
]
);
if (!(dialog instanceof HTMLDialogElement)) return;
document.body.appendChild(dialog);
dialog.showModal();
}
}
/**
* @class ThemeAutomator
* @property {ConfigManager} configManager
* @property {ImageDataManager} imageDataManager
* @property {UIManager} uiManager
* @property {ObserverManager} observerManager
* @property {DebugManager} debugManager
* @property {MessageCacheManager} messageCacheManager
* @property {AvatarManager} avatarManager
* @property {StandingImageManager} standingImageManager
* @property {ThemeManager} themeManager
* @property {BubbleUIManager} bubbleUIManager
* @property {MessageLifecycleManager} messageLifecycleManager
* @property {FixedNavigationManager | null} fixedNavManager
* @property {MessageNumberManager} messageNumberManager
* @property {SyncManager} syncManager
* @property {any} autoScrollManager
* @property {any} toastManager
*/
class ThemeAutomator {
constructor() {
this.dataConverter = new DataConverter();
this.configManager = new ConfigManager(this.dataConverter);
this.imageDataManager = new ImageDataManager(this.dataConverter);
this.uiManager = null;
this.observerManager = null;
this.debugManager = new DebugManager(this);
this.messageCacheManager = null;
this.avatarManager = null;
this.standingImageManager = null;
this.themeManager = null;
this.bubbleUIManager = null;
this.messageLifecycleManager = null;
this.timestampManager = null;
this.fixedNavManager = null;
this.messageNumberManager = null;
this.syncManager = null;
this.autoScrollManager = null;
this.toastManager = null;
this.navigationMonitor = new NavigationMonitor();
this.isConfigSizeExceeded = false;
this.configWarningMessage = '';
this.isNavigating = true;
this.pendingRemoteConfig = null;
this.subscriptions = [];
// Flag to track if the application is fully initialized.
// Guards against double-initialization and controls message processing availability.
this.isInitialized = false;
// Flag to prevent re-entrant destruction calls during the shutdown process.
this.isDestroying = false;
}
/**
* Helper to subscribe to EventBus and track the subscription for cleanup.
* Appends the listener name and a unique suffix to the key to avoid conflicts.
* @param {string} event
* @param {Function} listener
*/
_subscribe(event, listener) {
const baseKey = createEventKey(this, event);
// Use function name for debugging aid, fallback to 'anonymous'
const listenerName = listener.name || 'anonymous';
// Generate a short random suffix to guarantee uniqueness even for anonymous functions
const uniqueSuffix = Math.random().toString(36).substring(2, 7);
const key = `${baseKey}_${listenerName}_${uniqueSuffix}`;
EventBus.subscribe(event, listener, key);
this.subscriptions.push({ event, key });
}
async init() {
if (this.isDestroying) return;
await this.configManager.load();
// Set logger level from config, which includes developer settings.
// The setLevel method itself handles invalid values gracefully.
Logger.setLevel(this.configManager.get().developer.logger_level);
Logger.log(`Logger level is set to '${Logger.level}'.`);
// Check for migration notice
try {
const migrationNotice = new MigrationNoticeManager();
await migrationNotice.init();
} catch (e) {
Logger.badge('NOTICE ERROR', LOG_STYLES.RED, 'error', 'Failed to show migration notice:', e);
}
const config = this.configManager.get();
config.themeSets = this._ensureUniqueThemeIds(config.themeSets);
// Create managers that other managers depend on
this.themeManager = new ThemeManager(this.configManager, this.imageDataManager);
this.messageCacheManager = new MessageCacheManager();
this.syncManager = new SyncManager();
// Create the rest of the managers, injecting their dependencies
this.observerManager = new ObserverManager(this.messageCacheManager);
this.uiManager = new UIManager(
(newConfig) => this.handleSave(newConfig),
() => Promise.resolve(this.configManager.get()),
this.dataConverter,
() => this.applyPendingUpdateOnModalClose(),
SITE_STYLES,
() => this.themeManager.getThemeSet() // Pass the callback directly
);
this.avatarManager = new AvatarManager(this.configManager, this.messageCacheManager);
this.standingImageManager = new StandingImageManager(this.configManager, this.messageCacheManager);
this.bubbleUIManager = new BubbleUIManager(this.configManager, this.messageCacheManager);
this.messageLifecycleManager = new MessageLifecycleManager(this.messageCacheManager);
this.toastManager = new ToastManager();
// Initialize platform-specific managers, which depend on core managers (like messageLifecycleManager)
PlatformAdapters.ThemeAutomator.initializePlatformManagers(this);
if (PlatformAdapters.Timestamp.hasTimestampLogic()) {
this.timestampManager = new TimestampManager(this.configManager, this.messageCacheManager);
} else {
this.timestampManager = null;
}
if (config.features.fixed_nav_console.enabled) {
this.fixedNavManager = new FixedNavigationManager({
messageCacheManager: this.messageCacheManager,
configManager: this.configManager,
autoScrollManager: this.autoScrollManager,
messageLifecycleManager: this.messageLifecycleManager,
});
}
this.messageNumberManager = new MessageNumberManager(this.configManager, this.messageCacheManager);
// Initialize all created managers
const allManagers = [
this.themeManager,
this.messageCacheManager,
this.avatarManager,
this.standingImageManager,
this.bubbleUIManager,
this.messageLifecycleManager,
this.timestampManager,
this.uiManager,
this.messageNumberManager,
this.observerManager,
this.syncManager,
this.autoScrollManager,
this.toastManager,
];
allManagers.filter(Boolean).forEach((manager) => manager.init());
// Manually enable timestamp manager if config says so
if (this.timestampManager && config.features.timestamp.enabled) {
this.timestampManager.enable();
}
if (this.fixedNavManager) {
await this.fixedNavManager.init();
}
// Expose debug tools
this.debugManager.expose();
// Start the main observers
this.observerManager.start();
// Subscribe to app-wide events
this._subscribe(EVENTS.APP_SHUTDOWN, () => this.destroy());
this._subscribe(EVENTS.NAVIGATION_START, () => (this.isNavigating = true));
this._subscribe(EVENTS.NAVIGATION_END, () => (this.isNavigating = false));
this._subscribe(EVENTS.NAVIGATION, () => {
PerfMonitor.reset();
this.observerManager.processedTurnNodes.clear();
});
this._subscribe(EVENTS.CONFIG_SIZE_EXCEEDED, ({ message }) => {
this.isConfigSizeExceeded = true;
this.configWarningMessage = message;
EventBus.publish(EVENTS.CONFIG_WARNING_UPDATE, { show: true, message });
});
this._subscribe(EVENTS.CONFIG_SAVE_SUCCESS, () => {
this.isConfigSizeExceeded = false;
this.configWarningMessage = '';
EventBus.publish(EVENTS.CONFIG_WARNING_UPDATE, { show: false, message: '' });
});
this._subscribe(EVENTS.MESSAGE_COMPLETE, (messageElement) => {
const turnNode = messageElement.closest(CONSTANTS.SELECTORS.CONVERSATION_UNIT);
if (turnNode) {
this.observerManager.observeTurnForCompletion(turnNode);
}
});
this._subscribe(EVENTS.SUSPEND_OBSERVERS, () => sentinel.suspend());
this._subscribe(EVENTS.RESUME_OBSERVERS_AND_REFRESH, () => sentinel.resume());
this._subscribe(EVENTS.REMOTE_CONFIG_CHANGED, ({ newValue }) => this._handleRemoteConfigChange(newValue));
}
destroy() {
// Prevent double destruction or destruction of uninitialized instance
if (!this.isInitialized || this.isDestroying) return;
this.isDestroying = true;
// Stop network monitoring immediately
PlatformAdapters.Timestamp.cleanup();
// Explicitly destroy managers that don't self-destroy via event bus subscription.
this.themeManager?.destroy();
// Unsubscribe from all events this controller listens to.
this.subscriptions.forEach(({ event, key }) => EventBus.unsubscribe(event, key));
this.subscriptions = [];
Logger.log('ThemeAutomator destroyed.');
// Reset flags to allow clean re-initialization (re-launch) if needed.
this.isInitialized = false;
this.isDestroying = false;
}
/**
* Handles raw message elements detected by Sentinel.
* @param {HTMLElement} contentElement
*/
handleRawMessage(contentElement) {
if (!this.isInitialized) return;
EventBus.publish(EVENTS.RAW_MESSAGE_ADDED, contentElement);
}
/**
* Launches the application.
* Sets up hooks, waits for the anchor element, and initializes the main logic.
* Note: This method assumes that the global `sentinel` instance has already been created.
*/
launch() {
// 1. Setup Hooks
this.navigationMonitor.init();
// 2. Wait for Anchor to Initialize (We use the global sentinel instance here)
const initApp = () => {
// Run the full initialization only once.
if (this.isInitialized) return;
// Guard: Even if the anchor is found, abort if the current page is on the exclusion list.
if (PlatformAdapters.General.isExcludedPage()) {
Logger.badge('INIT ABORT', LOG_STYLES.YELLOW, 'log', 'Target element detected, but the page is on the exclusion list. Initialization aborted.');
return;
}
this.isInitialized = true;
Logger.badge('INIT', LOG_STYLES.GREEN, 'log', 'Target element detected. Initializing...');
this.init();
};
sentinel.on(CONSTANTS.SELECTORS.INPUT_TEXT_FIELD_TARGET, initApp);
const existingAnchor = document.querySelector(CONSTANTS.SELECTORS.INPUT_TEXT_FIELD_TARGET);
if (existingAnchor) {
Logger.badge('INIT', LOG_STYLES.GRAY, 'debug', 'Target already exists. Triggering immediate init.');
initApp();
}
// 3. Setup Message Processor using Platform Adapter
// This connects the low-level Sentinel detection to the high-level EventBus.
PlatformAdapters.General.initializeSentinel((el) => this.handleRawMessage(el));
}
_handleRemoteConfigChange(newValue) {
try {
const newConfig = JSON.parse(newValue);
const activeModal = this.uiManager.getActiveModal?.();
if (activeModal) {
Logger.badge('SYNC', LOG_STYLES.GRAY, 'debug', 'Modal open. Storing remote update & showing conflict.');
this.pendingRemoteConfig = newConfig;
const reloadCallback = () => {
const reopenContext = activeModal.getContextForReopen?.();
activeModal.close();
// applyPendingUpdateOnModalClose will handle applying the pending update.
// Request to reopen the modal after a short delay to ensure sync completion.
setTimeout(() => {
EventBus.publish(EVENTS.REOPEN_MODAL, reopenContext);
}, CONSTANTS.TIMING.TIMEOUTS.MODAL_REOPEN_DELAY);
};
this.uiManager.showConflictNotification(activeModal, reloadCallback);
} else {
Logger.badge('SYNC', LOG_STYLES.GRAY, 'debug', 'No modal open. Applying silent remote update.');
this.applyUpdate(newConfig);
}
} catch (e) {
Logger.badge('SYNC FAILED', LOG_STYLES.RED, 'error', 'Failed to handle remote config change:', e);
}
}
applyPendingUpdateOnModalClose() {
if (this.pendingRemoteConfig) {
Logger.badge('SYNC', LOG_STYLES.GRAY, 'debug', 'Modal closed with pending update. Applying now.');
this.applyUpdate(this.pendingRemoteConfig);
this.pendingRemoteConfig = null;
}
}
// Method required by the SyncManager's interface for silent updates
async applyUpdate(newConfig) {
try {
const { completeConfig, themeChanged } = this._processConfig(newConfig);
this.configManager.config = completeConfig; // Update in-memory config
await this._applyUiUpdates(completeConfig, themeChanged);
} catch (e) {
Logger.error('Failed to apply remote config update:', e.message);
}
}
_processConfig(newConfig) {
const currentConfig = this.configManager.get();
const themeChanged = JSON.stringify(currentConfig.themeSets) !== JSON.stringify(newConfig.themeSets) || JSON.stringify(currentConfig.defaultSet) !== JSON.stringify(newConfig.defaultSet);
// Create a complete config object by merging the incoming data with defaults.
let completeConfig = resolveConfig(deepClone(DEFAULT_THEME_CONFIG), newConfig);
// Ensure all theme IDs are unique before proceeding.
completeConfig.themeSets = this._ensureUniqueThemeIds(completeConfig.themeSets);
// Sanitize and validate the entire configuration using the central processor.
completeConfig = ConfigProcessor.process(completeConfig);
return { completeConfig, themeChanged };
}
async _applyUiUpdates(completeConfig, themeChanged, oldTimestampEnabled) {
this.avatarManager.updateIconSizeCss();
this.bubbleUIManager.updateAll();
this.messageNumberManager.updateAllMessageNumbers();
// Publish an event to notify components of the configuration update.
// The settings panel will listen for this to repopulate itself if it's open.
EventBus.publish(EVENTS.CONFIG_UPDATED, completeConfig);
// Only trigger a full theme update if theme-related data has changed.
if (themeChanged) {
this.themeManager.cachedThemeSet = null;
this.themeManager.updateTheme(themeChanged);
} else {
// Otherwise, just apply the layout-specific changes.
this.themeManager.applyChatContentMaxWidth();
}
// Handle TimestampManager lifecycle
if (this.timestampManager) {
const newTimestampEnabled = completeConfig.features.timestamp.enabled;
if (newTimestampEnabled && !oldTimestampEnabled) {
this.timestampManager.enable();
} else if (!newTimestampEnabled && oldTimestampEnabled) {
this.timestampManager.disable();
} else if (newTimestampEnabled) {
// If already enabled, just force an update (e.g., if nav console was toggled)
this.timestampManager.updateAllTimestamps();
}
}
// Handle FixedNavigationManager lifecycle
const navConsoleEnabled = completeConfig.features.fixed_nav_console.enabled;
if (navConsoleEnabled && !this.fixedNavManager) {
this.fixedNavManager = new FixedNavigationManager(
{
messageCacheManager: this.messageCacheManager,
configManager: this.configManager,
autoScrollManager: this.autoScrollManager,
messageLifecycleManager: this.messageLifecycleManager,
},
{ isReEnabling: true }
);
await this.fixedNavManager.init();
// Explicitly notify the new instance with the current cache state
this.messageCacheManager.notify();
} else if (!navConsoleEnabled && this.fixedNavManager) {
this.fixedNavManager.destroy();
this.fixedNavManager = null;
} else if (this.fixedNavManager) {
// If the manager already exists, tell it to re-render with the new config.
this.fixedNavManager.updateUI();
}
PlatformAdapters.ThemeAutomator.applyPlatformSpecificUiUpdates(this, completeConfig);
}
/** @param {AppConfig} newConfig */
async handleSave(newConfig) {
try {
const oldConfig = this.configManager.get();
const oldIconSize = oldConfig.options.icon_size;
const oldTimestampEnabled = oldConfig.features.timestamp.enabled;
const processResult = this._processConfig(newConfig);
const completeConfig = processResult.completeConfig;
let themeChanged = processResult.themeChanged;
// If the icon size has changed, we must treat it as a theme content change
// to force reprocessing of image URLs with the new dimensions.
const newIconSize = completeConfig.options.icon_size;
if (oldIconSize !== newIconSize) {
themeChanged = true;
}
await this.configManager.save(completeConfig);
// Apply the new logger level immediately and provide feedback.
Logger.setLevel(completeConfig.developer.logger_level);
// Use console.warn to ensure the message is visible regardless of the new level.
console.warn(LOG_PREFIX, `Logger level is '${Logger.level}'.`);
// A local save overwrites any pending remote changes.
this.pendingRemoteConfig = null;
const activeModal = this.uiManager.getActiveModal?.();
if (activeModal) {
this.uiManager.clearConflictNotification(activeModal);
}
// The _applyUiUpdates method now handles the enable/disable logic.
// We pass the old enabled state to it.
await this._applyUiUpdates(completeConfig, themeChanged, oldTimestampEnabled);
} catch (e) {
Logger.badge('SAVE FAILED', LOG_STYLES.RED, 'error', 'Configuration save failed:', e.message);
throw e; // Re-throw the error for the UI layer to catch
}
}
/**
* Ensures all themes have a unique themeId, assigning one if missing or duplicated.
* This method operates immutably by returning a new array.
* @param {ThemeSet[]} themeSets The array of theme sets to sanitize.
* @returns {ThemeSet[]} A new, sanitized array of theme sets.
* @private
*/
_ensureUniqueThemeIds(themeSets) {
if (!Array.isArray(themeSets)) return [];
const seenIds = new Set();
// Use map to create a new array with unique IDs
return themeSets.map((originalTheme) => {
// Deep copy to avoid mutating the original theme object in the array
const theme = deepClone(originalTheme);
if (!theme.metadata) {
theme.metadata = { id: '', name: 'Unnamed Theme', matchPatterns: [], urlPatterns: [] };
}
const id = theme.metadata.id;
if (typeof id !== 'string' || id.trim() === '' || seenIds.has(id)) {
theme.metadata.id = generateUniqueId();
}
seenIds.add(theme.metadata.id);
return theme;
});
}
}
// =================================================================================
// SECTION: Entry Point
// =================================================================================
// Exit if already executed
if (ExecutionGuard.hasExecuted()) return;
// Set executed flag if not executed yet
ExecutionGuard.setExecuted();
// Initialize network interception immediately at document-start, but only if not on an excluded page.
if (!PlatformAdapters.General.isExcludedPage()) {
PlatformAdapters.Timestamp.init();
}
// Singleton instance for observing DOM node insertions.
const sentinel = new Sentinel(OWNERID);
// Main controller for the entire application.
const automator = new ThemeAutomator();
// Launch application
automator.launch();
})();