Fully customize the chat UI of ChatGPT and Gemini. Automatically applies themes based on chat names to control everything from avatar icons and standing images to bubble styles and backgrounds. Adds powerful navigation features like a message jump list with search.
// ==UserScript==
// @name AI-UX-Customizer
// @namespace https://github.com/p65536
// @version 1.0.0
// @license MIT
// @description Fully customize the chat UI of ChatGPT and Gemini. Automatically applies themes based on chat names to control everything from avatar icons and standing images to bubble styles and backgrounds. Adds powerful navigation features like a message jump list with search.
// @icon https://raw.githubusercontent.com/p65536/p65536/main/images/icons/aiuxc.svg
// @author p65536
// @match https://chatgpt.com/*
// @match https://gemini.google.com/*
// @grant GM.setValue
// @grant GM.getValue
// @grant GM.deleteValue
// @grant GM.listValues
// @grant GM_addValueChangeListener
// @grant GM_removeValueChangeListener
// @grant GM_xmlhttpRequest
// @grant unsafeWindow
// @connect raw.githubusercontent.com
// @connect *
// @run-at document-start
// @noframes
// ==/UserScript==
(() => {
'use strict';
// --- Common Script Definitions ---
const OWNERID = 'p65536';
const APPID = 'aiuxc';
const APPNAME = 'AI UX Customizer';
const LOG_PREFIX = `[${APPID.toUpperCase()}]`;
// =================================================================================
// SECTION: Global Constants & Base Configuration
// Description: Defines event names, validation rules, and the base configuration.
// =================================================================================
// --- Basic Platform Definitions ---
const PLATFORM_DEFS = {
CHATGPT: {
NAME: 'ChatGPT',
HOST: 'chatgpt.com',
},
GEMINI: {
NAME: 'Gemini',
HOST: 'gemini.google.com',
},
};
/**
* Identifies the current platform based on the hostname.
* @returns {string | null}
*/
function identifyPlatform() {
const hostname = window.location.hostname;
if (hostname.endsWith(PLATFORM_DEFS.CHATGPT.HOST)) {
return PLATFORM_DEFS.CHATGPT.NAME;
}
if (hostname.endsWith(PLATFORM_DEFS.GEMINI.HOST)) {
return PLATFORM_DEFS.GEMINI.NAME;
}
return null;
}
/**
* Identify the current platform based on the hostname.
* @returns {string | null}
*/
const detectedPlatform = identifyPlatform();
if (!detectedPlatform) {
console.warn(`${APPID} Unsupported platform. Script execution stopped.`);
return;
}
/** @type {string} */
const PLATFORM = detectedPlatform;
if (!PLATFORM) {
console.warn(`${APPID} Unsupported platform. Script execution stopped.`);
return;
}
/**
* Helper function to add a prefix to all string values in an object.
* @template {Record<string, string>} T
* @param {string} prefix
* @param {T} obj
* @returns {T}
*/
const addPrefix = (prefix, obj) => /** @type {T} */ (Object.fromEntries(Object.entries(obj).map(([key, value]) => [key, `${prefix}${value}`])));
/**
* @constant CSS_VARS
* @description Centralized definition of all CSS variables used in the application.
* Keys are derived dynamically using APPID to prevent collisions.
*/
const CSS_VARS = addPrefix(`--${APPID}-`, {
// Actor: User
USER_NAME: 'user-name',
USER_NAME_DISPLAY: 'user-name-display',
USER_ICON: 'user-icon',
USER_ICON_DISPLAY: 'user-icon-display',
USER_STANDING_IMAGE: 'user-standing-image',
USER_TEXT_COLOR: 'user-text-color',
USER_FONT: 'user-font',
USER_BUBBLE_BG: 'user-bubble-bg',
USER_BUBBLE_PADDING: 'user-bubble-padding',
USER_BUBBLE_RADIUS: 'user-bubble-radius',
USER_BUBBLE_MAXWIDTH: 'user-bubble-maxwidth',
// Actor: Assistant
ASSISTANT_NAME: 'assistant-name',
ASSISTANT_NAME_DISPLAY: 'assistant-name-display',
ASSISTANT_ICON: 'assistant-icon',
ASSISTANT_ICON_DISPLAY: 'assistant-icon-display',
ASSISTANT_STANDING_IMAGE: 'assistant-standing-image',
ASSISTANT_TEXT_COLOR: 'assistant-text-color',
ASSISTANT_FONT: 'assistant-font',
ASSISTANT_BUBBLE_BG: 'assistant-bubble-bg',
ASSISTANT_BUBBLE_PADDING: 'assistant-bubble-padding',
ASSISTANT_BUBBLE_RADIUS: 'assistant-bubble-radius',
ASSISTANT_BUBBLE_MAXWIDTH: 'assistant-bubble-maxwidth',
// Window & Input
WINDOW_BG_COLOR: 'window-bg-color',
WINDOW_BG_IMAGE: 'window-bg-image',
WINDOW_BG_SIZE: 'window-bg-size',
WINDOW_BG_POS: 'window-bg-pos',
WINDOW_BG_REPEAT: 'window-bg-repeat',
INPUT_BG: 'input-bg',
INPUT_FIELD_BG: 'input-field-bg',
INPUT_COLOR: 'input-color',
// Layout & Global Styles
CHAT_CONTENT_MAX_WIDTH: 'chat-content-max-width',
MESSAGE_MARGIN_TOP: 'message-margin-top',
ICON_SIZE: 'icon-size',
ICON_MARGIN: 'icon-margin',
// Standing Image Layout
STANDING_IMG_USER_WIDTH: 'standing-image-user-width',
STANDING_IMG_ASST_WIDTH: 'standing-image-assistant-width',
STANDING_IMG_ASST_LEFT: 'standing-image-assistant-left',
STANDING_IMG_USER_MASK: 'standing-image-user-mask',
STANDING_IMG_ASST_MASK: 'standing-image-assistant-mask',
});
/**
* @constant SHARED_CONSTANTS
* @description Common configuration constants shared across platforms.
* Platform-specific overrides are applied in their respective definition functions.
*/
const SHARED_CONSTANTS = {
// Storage Configuration & Limits
STORAGE_SETTINGS: {
ROOT_KEY: `${APPID}-manifest`,
THEME_PREFIX: `${APPID}-theme-`,
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
},
// Processing & Performance Settings
PROCESSING: {
BATCH_SIZE: 50,
},
RETRY: {
SCROLL_OFFSET_FOR_NAV: 40,
AVATAR_INJECTION_LIMIT: 5,
},
IMAGE_PROCESSING: {
QUALITY: 0.85,
MAX_WIDTH_BG: 1920,
MAX_HEIGHT_STANDING: 1080,
},
TIMING: {
DEBOUNCE_DELAYS: {
VISIBILITY_CHECK: 250,
CACHE_UPDATE: 250,
THEME_UPDATE: 150,
SETTINGS_SAVE: 300,
AVATAR_INJECTION: 25,
JUMP_LIST_PREVIEW_HOVER: 50,
JUMP_LIST_PREVIEW_KEY_NAV: 150,
JUMP_LIST_PREVIEW_RESET: 200,
FILTER_INPUT_DEBOUNCE: 150,
SIZE_CALCULATION: 300,
},
TIMEOUTS: {
POST_NAVIGATION_DOM_SETTLE: 200,
SCROLL_OFFSET_CLEANUP: 1500,
ZERO_MESSAGE_GRACE_PERIOD: 2000,
WAIT_FOR_MAIN_CONTENT: 10000,
BLOB_URL_REVOKE_DELAY: 10000, // The time to wait before revoking a Blob URL after export, allowing the download to start.
SELF_HEAL_IDLE_TIMEOUT_MS: 1000,
},
THRESHOLDS: {
SUSPEND_LIMIT_MS: 5 * 60 * 1000, // 5 minutes threshold for heavy throttling/suspension
},
ANIMATIONS: {
TOAST_LEAVE_DURATION: 300,
LAYOUT_STABILIZATION_MS: 500,
},
POLLING: {
IDLE_INDEXING_MS: 1000, // Interval for background text indexing task
HEARTBEAT_INTERVAL_MS: 2000, // Interval for checking DOM integrity
},
PERF_MONITOR_THROTTLE: 1000,
KEYBOARD_THROTTLE: 120,
},
UI_SPECS: {
STANDING_IMAGE_MASK_THRESHOLD_PX: 32,
PREVIEW_BUBBLE_MAX_WIDTH: {
USER: '50%',
ASSISTANT: '90%',
},
MODAL_MARGIN: 8,
PANEL_MARGIN: 8,
ANCHOR_OFFSET: 4,
THEME_MODAL_HEADER_PADDING: '12px',
THEME_MODAL_FOOTER_PADDING: '16px',
// Avatar Specifications
AVATAR: {
DEFAULT_SIZE: 64,
SIZE_OPTIONS: [64, 96, 128, 160, 192],
MARGIN: 20,
},
// Collapsible Button Specifications
COLLAPSIBLE: {
HEIGHT_THRESHOLD: 128,
},
},
OBSERVED_ELEMENT_TYPES: {
BODY: 'body',
INPUT_AREA: 'inputArea',
SIDE_PANEL: 'sidePanel',
},
Z_INDICES: {
// Some settings are configured on the SECTION: Platform Constants
SETTINGS_BUTTON: 10000,
SETTINGS_PANEL: 11000,
JUMP_LIST_PREVIEW: 12000,
THEME_MODAL: 13000,
COLOR_PICKER: 14000,
JSON_MODAL: 15000,
TOAST: 20000,
},
INTERNAL_ROLES: {
USER: 'user',
ASSISTANT: 'assistant',
},
THEME_IDS: {
DEFAULT: 'defaultSet',
},
NAV_ROLES: {
USER: 'user',
ASSISTANT: 'asst',
TOTAL: 'total',
},
UI_STATES: {
EXPANDED: 'expanded',
COLLAPSED: 'collapsed',
},
INPUT_MODES: {
NORMAL: 'normal',
SHIFT: 'shift',
},
CONSOLE_POSITIONS: {
INPUT_TOP: 'input_top',
HEADER: 'header',
},
DATA_KEYS: {
ORIGINAL_TITLE: 'originalTitle',
STATE: 'state',
FILTERED_INDEX: 'filteredIndex',
MESSAGE_INDEX: 'messageIndex',
PREVIEW_FOR: 'previewFor',
ICON_TYPE: 'iconType',
},
STORE_KEYS: {
SYSTEM_ROOT: '_system',
SYSTEM_WARNING: 'warning',
SYSTEM_ERRORS: 'errors',
SYSTEM_SIZE_EXCEEDED: 'isSizeExceeded',
WARNING_PATH: '_system.warning',
WARNING_MSG_PATH: '_system.warning.message',
WARNING_SHOW_PATH: '_system.warning.show',
ERRORS_PATH: '_system.errors',
SIZE_EXCEEDED_PATH: '_system.isSizeExceeded',
LOCAL_TIMESTAMP_ENABLED: `${APPID}_timestamp_enabled`,
},
RESOURCE_KEYS: {
// UI Components
SETTINGS_BUTTON: 'settingsButton',
SETTINGS_PANEL: 'settingsPanel',
JSON_MODAL: 'jsonModal',
THEME_MODAL: 'themeModal',
// Sub-controllers
WIDGET_CONTROLLER: 'widgetController',
MODAL_COORDINATOR: 'modalCoordinator',
// Managers
THEME_MANAGER: 'themeManager',
MESSAGE_CACHE_MANAGER: 'messageCacheManager',
SYNC_MANAGER: 'syncManager',
OBSERVER_MANAGER: 'observerManager',
UI_MANAGER: 'uiManager',
AVATAR_MANAGER: 'avatarManager',
STANDING_IMAGE_MANAGER: 'standingImageManager',
BUBBLE_UI_MANAGER: 'bubbleUIManager',
MESSAGE_LIFECYCLE_MANAGER: 'messageLifecycleManager',
TOAST_MANAGER: 'toastManager',
TIMESTAMP_MANAGER: 'timestampManager',
FIXED_NAV_MANAGER: 'fixedNavManager',
MESSAGE_NUMBER_MANAGER: 'messageNumberManager',
AUTO_SCROLL_MANAGER: 'autoScrollManager',
// Observer Resources
LAYOUT_RESIZE_OBSERVER: 'layoutResizeObserver',
INTEGRITY_SCAN: 'integrityScan',
// Task Resources
BATCH_TASK: 'batchTask',
BATCH_TASK_SINGLE: 'batchTaskSingle',
BATCH_TASK_TURN: 'batchTaskTurn',
ZERO_MSG_TIMER: 'zeroMsgTimer',
BUTTON_STATE_TASK: 'buttonStateTask',
// Lifecycle Resources
NAVIGATION_MONITOR: 'navigationMonitor',
APP_CONTROLLER: 'appController',
ANCHOR_LISTENER: 'anchorListener',
// Dynamic UI Resources
JUMP_LIST: 'jumpList',
// System Resources
HEARTBEAT_TIMER: 'heartbeatTimer',
SELF_HEAL_TASK: 'selfHealTask',
},
};
/**
* Generates the default configuration for a platform.
* Defined as a factory function to prevent reference sharing between platforms.
* @returns {object}
*/
function createDefaultPlatformConfig() {
return {
options: {
icon_size: 64,
chat_content_max_width: null,
respect_avatar_space: true,
},
features: {
load_full_history_on_chat_load: { enabled: true },
timestamp: { enabled: true },
collapsible_button: {
enabled: true,
auto_collapse_user_message: { enabled: false },
},
bubble_nav_buttons: { enabled: true },
fixed_nav_console: {
enabled: true,
position: SHARED_CONSTANTS.CONSOLE_POSITIONS.INPUT_TOP,
keyboard_shortcuts: { enabled: true },
},
},
defaultSet: {
assistant: {
name: 'Assistant',
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: 8,
bubbleBorderRadius: 10,
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: 8,
bubbleBorderRadius: 10,
bubbleMaxWidth: null,
standingImageUrl: null,
},
window: {
backgroundColor: null,
backgroundImageUrl: null,
backgroundSize: 'cover',
backgroundPosition: 'center center',
backgroundRepeat: 'no-repeat',
},
inputArea: {
backgroundColor: null,
textColor: null,
},
},
};
}
/** @type {AppConfig} */
const DEFAULT_THEME_CONFIG = {
// Platform-agnostic settings
developer: {
logger_level: 'log', // 'error', 'warn', 'info', 'log', 'debug'
},
// Platform specific settings
platforms: {
ChatGPT: createDefaultPlatformConfig(),
Gemini: createDefaultPlatformConfig(),
},
themeSets: [
{
metadata: {
id: `${APPID}-theme-example-1`,
name: 'Theme Example',
matchPatterns: ['/Sample/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,
},
},
],
};
/** @type {AppEvents} */
const EVENTS = /** @type {AppEvents} */ (
/** @type {unknown} */ (
addPrefix(`${APPID}:`, {
// Theme & Style
TITLE_CHANGED: 'TITLE_CHANGED',
THEME_UPDATE: 'THEME_UPDATE',
THEME_APPLIED: 'THEME_APPLIED',
WIDTH_PREVIEW: 'WIDTH_PREVIEW',
// UI & Layout
CHAT_CONTENT_WIDTH_UPDATED: 'CHAT_CONTENT_WIDTH_UPDATED',
WINDOW_RESIZED: 'WINDOW_RESIZED',
SIDEBAR_LAYOUT_CHANGED: 'SIDEBAR_LAYOUT_CHANGED',
VISIBILITY_RECHECK: 'VISIBILITY_RECHECK',
UI_REPOSITION: 'UI_REPOSITION',
INPUT_AREA_RESIZED: 'INPUT_AREA_RESIZED',
// Navigation & Cache
NAVIGATION_START: 'NAVIGATION_START',
NAVIGATION_END: 'NAVIGATION_END',
NAVIGATION: 'NAVIGATION',
CACHE_UPDATE_REQUEST: 'CACHE_UPDATE_REQUEST',
CACHE_UPDATED: 'CACHE_UPDATED',
NAV_HIGHLIGHT_MESSAGE: 'NAV_HIGHLIGHT_MESSAGE',
// Message Lifecycle
RAW_MESSAGE_ADDED: 'RAW_MESSAGE_ADDED',
AVATAR_INJECT: 'AVATAR_INJECT',
MESSAGE_COMPLETE: 'MESSAGE_COMPLETE',
TURN_COMPLETE: 'TURN_COMPLETE',
STREAMING_START: 'STREAMING_START',
STREAMING_END: 'STREAMING_END',
DEFERRED_LAYOUT_UPDATE: 'DEFERRED_LAYOUT_UPDATE',
TIMESTAMPS_LOADED: 'TIMESTAMPS_LOADED',
TIMESTAMP_ADDED: 'TIMESTAMP_ADDED',
// System & Config
REMOTE_CONFIG_CHANGED: 'REMOTE_CONFIG_CHANGED',
SUSPEND_OBSERVERS: 'SUSPEND_OBSERVERS',
RESUME_OBSERVERS: 'RESUME_OBSERVERS',
CONFIG_SIZE_EXCEEDED: 'CONFIG_SIZE_EXCEEDED',
CONFIG_WARNING_UPDATE: 'CONFIG_WARNING_UPDATE',
CONFIG_SAVE_SUCCESS: 'CONFIG_SAVE_SUCCESS',
CONFIG_UPDATED: 'CONFIG_UPDATED',
// Platform Specific
INTEGRITY_SCAN_MESSAGES_FOUND: 'INTEGRITY_SCAN_MESSAGES_FOUND',
AUTO_SCROLL_REQUEST: 'AUTO_SCROLL_REQUEST',
AUTO_SCROLL_CANCEL_REQUEST: 'AUTO_SCROLL_CANCEL_REQUEST',
AUTO_SCROLL_START: 'AUTO_SCROLL_START',
AUTO_SCROLL_COMPLETE: 'AUTO_SCROLL_COMPLETE',
})
)
);
/**
* @constant CONFIG_SCHEMA
* @description Centralized definition of configuration properties, validation rules, and UI metadata.
* Structured into 'theme' (per-theme settings) and 'platform' (global settings).
*/
const CONFIG_SCHEMA = {
// ========================================================================
// THEME SCOPE
// Used by ThemeModal and ThemeManager. Paths are relative to the theme object root.
// ========================================================================
theme: {
// --- Numeric Fields ---
'assistant.bubblePadding': {
type: 'numeric',
def: { unit: 'px', nullable: true },
validators: { min: -1, max: 50, step: 1 },
ui: { label: 'Padding:' },
},
'user.bubblePadding': {
type: 'numeric',
def: { unit: 'px', nullable: true },
validators: { min: -1, max: 50, step: 1 },
ui: { label: 'Padding:' },
},
'assistant.bubbleBorderRadius': {
type: 'numeric',
def: { unit: 'px', nullable: true },
validators: { min: -1, max: 50, step: 1 },
ui: { label: 'Radius:' },
},
'user.bubbleBorderRadius': {
type: 'numeric',
def: { unit: 'px', nullable: true },
validators: { min: -1, max: 50, step: 1 },
ui: { label: 'Radius:' },
},
'assistant.bubbleMaxWidth': {
type: 'numeric',
def: { unit: '%', nullable: true },
validators: { min: 29, max: 100, step: 1 },
ui: { label: 'max Width:' },
},
'user.bubbleMaxWidth': {
type: 'numeric',
def: { unit: '%', nullable: true },
validators: { min: 29, max: 100, step: 1 },
ui: { label: 'max Width:' },
},
// --- Color Fields ---
'assistant.bubbleBackgroundColor': {
type: 'color',
ui: { label: 'Bubble bg color:', tooltip: 'Background color of the message bubble.\nNote: When unset (Auto), it defaults to the site\'s base color. To make it transparent, enter "transparent".' },
},
'user.bubbleBackgroundColor': {
type: 'color',
ui: { label: 'Bubble bg color:', tooltip: 'Background color of the message bubble.\nTo make it transparent, enter "transparent".' },
},
'assistant.textColor': {
type: 'color',
ui: { label: 'Text color:', tooltip: 'Color of the text inside the bubble.' },
},
'user.textColor': {
type: 'color',
ui: { label: 'Text color:', tooltip: 'Color of the text inside the bubble.' },
},
'window.backgroundColor': {
type: 'color',
ui: { label: 'Window bg color:', tooltip: 'Main background color of the chat window.\nTo make it transparent, enter "transparent".' },
},
'inputArea.backgroundColor': {
type: 'color',
ui: { label: 'Input bg color:', tooltip: 'Background color of the text input area.\nTo make it transparent, enter "transparent".' },
},
'inputArea.textColor': {
type: 'color',
ui: { label: 'Input text color:', tooltip: 'Color of the text you type.' },
},
// --- Image Fields ---
'assistant.icon': {
type: 'image',
def: { imageType: 'icon' },
ui: { label: 'Icon:', tooltip: "URL, Data URI, or <svg> for the assistant's icon." },
},
'user.icon': {
type: 'image',
def: { imageType: 'icon' },
ui: { label: 'Icon:', tooltip: "URL, Data URI, or <svg> for the user's icon." },
},
'assistant.standingImageUrl': {
type: 'image',
def: { imageType: 'image' },
ui: { label: 'Standing image:', tooltip: "URL or Data URI for the assistant's standing image." },
},
'user.standingImageUrl': {
type: 'image',
def: { imageType: 'image' },
ui: { label: 'Standing image:', tooltip: "URL or Data URI for the user's standing image." },
},
'window.backgroundImageUrl': {
type: 'image',
def: { imageType: 'image' },
ui: { label: 'Background image:', tooltip: 'URL or Data URI for the main background image.' },
},
// --- Text/Pattern Fields ---
'assistant.name': {
type: 'text',
ui: { label: 'Name:', tooltip: 'The name displayed for the assistant.' },
},
'user.name': {
type: 'text',
ui: { label: 'Name:', tooltip: 'The name displayed for the user.' },
},
'assistant.font': {
type: 'text',
ui: { label: 'Font:', tooltip: 'Font family for the text.\nFont names with spaces must be quoted (e.g., "Times New Roman").' },
},
'user.font': {
type: 'text',
ui: { label: 'Font:', tooltip: 'Font family for the text.\nThis font is also applied to the input area.\nFont names with spaces must be quoted (e.g., "Times New Roman").' },
},
'metadata.matchPatterns': {
type: 'regexArray',
ui: {
label: 'Title Patterns (one per line):',
tooltip: 'Enter one RegEx pattern per line to automatically apply this theme (e.g., /My Project/i).\nNote: "g" (global) and "y" (sticky) flags are ignored for performance.',
},
},
'metadata.urlPatterns': {
type: 'regexArray',
ui: {
label: 'URL Patterns (one per line):',
tooltip: 'Enter one RegEx pattern per line to match against the decoded URL path.\nExample: /\\/c\\/.*$/i\nNote: "g" (global) and "y" (sticky) flags are ignored for performance.',
},
},
// --- Select Fields (Window Background) ---
'window.backgroundSize': {
type: 'select',
def: { options: [{ value: '', label: '(Default)' }, 'auto', 'cover', 'contain'] },
ui: { label: 'Size:', tooltip: 'How the background image is sized.' },
},
'window.backgroundPosition': {
type: 'select',
def: { options: [{ value: '', label: '(Default)' }, 'top left', 'top center', 'top right', 'center left', 'center center', 'center right', 'bottom left', 'bottom center', 'bottom right'] },
ui: { label: 'Position:', tooltip: 'Position of the background image.' },
},
'window.backgroundRepeat': {
type: 'select',
def: { options: [{ value: '', label: '(Default)' }, 'no-repeat', 'repeat'] },
ui: { label: 'Repeat:', tooltip: 'How the background image is repeated.' },
},
},
// ========================================================================
// PLATFORM SCOPE
// Used by SettingsPanel and Platform Adapters. Paths are relative to `platforms.${PLATFORM}.`.
// ========================================================================
platform: {
// --- Options ---
'options.icon_size': {
type: 'numeric',
def: { unit: 'px' },
validators: { allowedValues: SHARED_CONSTANTS.UI_SPECS.AVATAR.SIZE_OPTIONS, step: 1 },
ui: { label: 'Icon size:', tooltip: 'Specifies the size of the chat icons in pixels.' },
},
'options.chat_content_max_width': {
type: 'numeric',
def: { unit: 'vw', nullable: true },
validators: { min: 30, max: 80, step: 1 },
ui: { label: 'Chat content max width:', tooltip: 'Adjusts the maximum width of the chat content.\nMove slider to the far left for default.' },
},
'options.respect_avatar_space': {
type: 'toggle',
ui: {
label: 'Prevent image/avatar overlap',
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.',
},
},
// --- Features ---
'features.timestamp.enabled': {
type: 'toggle',
ui: { label: 'Show timestamp', title: 'Displays the timestamp for each message.\nNote: Enabling this feature will automatically reload the page.' },
},
'features.collapsible_button.enabled': {
type: 'toggle',
ui: { label: 'Collapsible button', title: 'Enables a button to collapse large message bubbles.' },
},
'features.collapsible_button.auto_collapse_user_message.enabled': {
type: 'toggle',
ui: {
label: 'Auto collapse user message',
title: 'Automatically collapses user messages that exceed the height threshold.\nApplies to new messages and existing history on load.\nRequires "Collapsible button" to be enabled.',
dependencies: ['features.collapsible_button.enabled'],
disabledIf: (pConfig) => !getPropertyByPath(pConfig, 'features.collapsible_button.enabled'),
},
},
'features.bubble_nav_buttons.enabled': {
type: 'toggle',
ui: { label: 'Bubble nav buttons', title: 'Enables navigation buttons (Prev/Next/Top) attached to each message.' },
},
'features.fixed_nav_console.enabled': {
type: 'toggle',
ui: { label: 'Navigation console', title: 'When enabled, a navigation console with message counters will be displayed.' },
},
'features.fixed_nav_console.keyboard_shortcuts.enabled': {
type: 'toggle',
ui: {
label: 'Keyboard shortcuts',
title:
'Enable keyboard shortcuts for message navigation and Jump List.\n\nAlt + ↑ / ↓ : Previous / Next message\nAlt + Shift + ↑ / ↓ : First / Last message\nAlt + J : Open Jump List\nAlt + N : Input message number\n\nRequires "Navigation console" to be enabled.',
dependencies: ['features.fixed_nav_console.enabled'],
disabledIf: (pConfig) => !getPropertyByPath(pConfig, 'features.fixed_nav_console.enabled'),
},
},
'features.fixed_nav_console.position': {
type: 'select',
def: {
options: [
{ value: SHARED_CONSTANTS.CONSOLE_POSITIONS.INPUT_TOP, label: 'Input Top' },
{ value: SHARED_CONSTANTS.CONSOLE_POSITIONS.HEADER, label: 'Header' },
],
},
ui: {
label: 'Console Position',
title: 'Choose where to display the navigation console.\nInput Top: Floating above the input area (Default).\nHeader: Embedded in the top toolbar.',
dependencies: ['features.fixed_nav_console.enabled'],
disabledIf: (pConfig) => !getPropertyByPath(pConfig, 'features.fixed_nav_console.enabled'),
},
},
'features.load_full_history_on_chat_load.enabled': {
type: 'toggle',
ui: {
label: PLATFORM === PLATFORM_DEFS.CHATGPT.NAME ? 'Scan layout on chat load' : 'Load full history on chat load',
title:
PLATFORM === PLATFORM_DEFS.CHATGPT.NAME
? 'When enabled, automatically scans the layout of all messages when a chat is opened. This prevents layout shifts from images loading later.'
: 'When enabled, automatically scrolls back through the history when a chat is opened to load all messages.',
},
},
},
};
/**
* @constant ConfigPathResolver
* @description Centralizes logic for resolving configuration paths based on scope.
* Abstraction layer to prevent hardcoding paths like `platforms.${PLATFORM}`.
*/
const ConfigPathResolver = {
/**
* Returns the root path prefix for the current platform's settings.
* @returns {string} e.g., "platforms.ChatGPT"
*/
get PLATFORM_ROOT() {
return `platforms.${PLATFORM}`;
},
/**
* Converts a schema key to a full storage path (dot-notation).
* @param {'theme'|'platform'} scope - The scope of the setting.
* @param {string} key - The relative key defined in CONFIG_SCHEMA.
* @returns {string} The full path for accessing the value in the store.
*/
resolve(scope, key) {
if (scope === 'platform') {
return `${this.PLATFORM_ROOT}.${key}`;
}
// Theme scope paths are already relative to the theme object root
return key;
},
/**
* Retrieves the platform-specific configuration object from the full app config.
* @param {AppConfig} config - The full configuration object.
* @returns {object} The platform specific config (e.g. config.platforms.ChatGPT).
*/
getPlatformConfig(config) {
return config?.platforms?.[PLATFORM] ?? {};
},
};
/**
* @constant StyleTemplates
* @description Shared CSS generation logic to reduce code duplication between platforms.
*/
const StyleTemplates = {
/**
* Generates common styles scoped to elements with the data-aiuxc-scope attribute.
* @param {Record<string, string>} cls
*/
getSharedCommonCss(cls) {
const palette = SITE_STYLES.PALETTE;
const root = `[data-${APPID}-scope]`;
return `
/* --- Common Modal Buttons --- */
${root} .${cls.modalButton} {
background: ${palette.btn_bg};
border: 1px solid ${palette.btn_border};
border-radius: var(--radius-md, 5px);
color: ${palette.btn_text};
cursor: pointer;
font-size: 13px;
padding: 5px 16px;
transition: background 0.12s;
min-width: 80px;
}
${root} .${cls.modalButton}:not(:disabled):hover {
background: ${palette.btn_hover_bg};
border-color: ${palette.btn_border};
}
${root} .${cls.modalButton}:disabled {
background: ${palette.btn_bg};
cursor: not-allowed;
opacity: 0.5;
}
/* --- Utility Buttons --- */
${root} .${cls.primaryBtn} {
background-color: #1a73e8;
color: #ffffff;
border: 1px solid transparent;
}
${root} .${cls.primaryBtn}:not(:disabled):hover {
background-color: #1557b0;
}
${root} .${cls.pushRightBtn} {
margin-left: auto;
}
/* Danger Button (Red/Warning) */
${root} .${cls.dangerBtn} {
background-color: ${palette.delete_confirm_btn_bg};
color: ${palette.delete_confirm_btn_text};
}
${root} .${cls.dangerBtn}:not(:disabled):hover {
background-color: ${palette.delete_confirm_btn_hover_bg};
color: ${palette.delete_confirm_btn_hover_text};
}
/* --- Common Sliders --- */
${root} .${cls.sliderSubgroupControl} {
align-items: center;
display: flex;
gap: 6px;
}
${root} .${cls.sliderSubgroupControl} input[type=range] {
flex-grow: 1;
min-width: 0;
}
${root} .${cls.sliderDisplay} {
color: ${palette.slider_display_text};
font-family: monospace;
min-width: 7ch;
text-align: right;
}
${root} .${cls.sliderSubgroupControl}.${cls.sliderDefault} .${cls.sliderDisplay} {
color: ${palette.label_text};
}
${root} .${cls.sliderContainer} {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 4px;
margin-top: 8px;
}
${root} .${cls.sliderContainer} input[type="range"] {
flex-grow: 1;
margin: 0;
}
${root} .${cls.sliderContainer} label {
margin-inline-end: 0;
flex-shrink: 1;
color: ${palette.text_secondary};
}
${root} .${cls.compoundSliderContainer} {
display: flex;
gap: 16px;
margin-top: 4px;
}
${root} .${cls.sliderSubgroup} {
flex: 1;
min-width: 0;
}
${root} .${cls.sliderSubgroup} > label {
color: ${palette.text_secondary};
display: block;
font-size: 0.9em;
margin-bottom: 4px;
}
/* --- Toggle Switch --- */
${root} .${cls.toggleSwitch} {
position: relative;
display: inline-block;
width: 40px;
height: 22px;
flex-shrink: 0;
}
${root} .${cls.toggleSwitch} input {
opacity: 0;
width: 0;
height: 0;
}
${root} .${cls.toggleSlider} {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: ${palette.toggle_bg_off};
transition: .3s;
border-radius: 22px;
}
${root} .${cls.toggleSlider}:before {
position: absolute;
content: "";
height: 16px;
width: 16px;
left: 3px;
top: 0;
bottom: 0;
margin: auto 0;
background-color: ${palette.toggle_knob};
transition: .3s;
border-radius: 50%;
}
${root} .${cls.toggleSwitch} input:checked + .${cls.toggleSlider} {
background-color: ${palette.toggle_bg_on};
}
${root} .${cls.toggleSwitch} input:checked + .${cls.toggleSlider}:before {
transform: translateX(18px);
}
/* --- Form Fields & Layout --- */
${root} .${cls.formField} {
display: flex;
flex-direction: column;
gap: 4px;
}
${root} .${cls.formField} > label {
color: ${palette.text_secondary};
font-size: 0.9em;
}
${root} .${cls.labelRow} {
display: flex;
align-items: baseline;
gap: 8px;
}
${root} .${cls.labelRow} > label {
color: ${palette.text_secondary};
font-size: 0.9em;
margin: 0;
}
${root} .${cls.statusText} {
font-size: 0.8em;
font-weight: 500;
/* No auto margin to keep it next to the label */
}
${root} .${cls.inputWrapper} {
display: flex;
align-items: center;
gap: 4px;
}
${root} .${cls.inputWrapper} input {
flex-grow: 1;
}
${root} .${cls.formErrorMsg} {
color: ${palette.error_text};
font-size: 0.8em;
margin-top: 2px;
white-space: pre-wrap;
}
${root} .${cls.compoundFormFieldContainer} {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
/* Common Inputs */
${root} .${cls.commonInput},
${root} .${cls.formField} input[type="text"],
${root} .${cls.formField} textarea,
${root} .${cls.formField} select,
${root} .${cls.submenuRow} select {
background: ${palette.input_bg};
border: 1px solid ${palette.border};
border-radius: 4px;
box-sizing: border-box;
color: ${palette.text_primary};
padding: 6px 8px;
width: 100%;
}
${root} .${cls.submenuRow} select {
width: auto;
min-width: 120px;
max-width: 50%;
}
/* Disabled State for Inputs */
${root} .${cls.commonInput}:disabled,
${root} .${cls.formField} input[type="text"]:disabled,
${root} .${cls.formField} textarea:disabled,
${root} .${cls.formField} select:disabled,
${root} .${cls.submenuRow} select:disabled {
opacity: 0.6;
cursor: not-allowed;
color: ${palette.text_secondary};
}
${root} .${cls.formField} input[type="text"].${cls.invalidInput},
${root} .${cls.formField} textarea.${cls.invalidInput} {
border-color: ${palette.error_text};
outline: 1px solid ${palette.error_text};
}
${root} .${cls.formField} textarea {
resize: vertical;
}
/* --- Local File Button --- */
${root} .${cls.localFileBtn} {
flex-shrink: 0;
padding: 4px 6px;
height: 32px;
line-height: 1;
font-size: 16px;
background: ${palette.btn_bg};
border: 1px solid ${palette.btn_border};
border-radius: 4px;
cursor: pointer;
color: ${palette.btn_text};
}
${root} .${cls.localFileBtn}:hover {
background: ${palette.btn_hover_bg};
}
/* --- Color Picker Fields --- */
${root} .${cls.colorFieldWrapper} {
display: flex;
gap: 8px;
}
${root} .${cls.colorFieldWrapper} input[type="text"] {
flex-grow: 1;
}
${root} .${cls.colorSwatch} {
background-color: transparent;
border: 1px solid ${palette.border};
border-radius: 4px;
cursor: pointer;
flex-shrink: 0;
height: 32px;
padding: 2px;
position: relative;
width: 32px;
}
${root} .${cls.colorSwatchChecker},
${root} .${cls.colorSwatchValue} {
border-radius: 2px;
height: auto;
inset: 2px;
position: absolute;
width: auto;
}
${root} .${cls.colorSwatchChecker} {
background-image: repeating-conic-gradient(#808080 0% 25%, #c0c0c0 0% 50%);
background-size: 12px 12px;
}
${root} .${cls.colorSwatchValue} {
transition: background-color 0.1s;
}
/* --- Preview Components --- */
${root} .${cls.previewContainer} {
margin-top: 0;
}
${root} .${cls.previewContainer} > label {
color: ${palette.text_secondary};
display: block;
font-size: 0.9em;
margin-bottom: 4px;
}
${root} .${cls.previewBubbleWrapper} {
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%;
}
${root} .${cls.previewBubbleWrapper}.${cls.userPreview} {
text-align: right;
}
${root} .${cls.previewBubble} {
box-sizing: border-box;
display: inline-block;
text-align: left;
transition: all 0.1s linear;
word-break: break-all;
}
${root} .${cls.previewInputArea} {
display: block;
width: 75%;
margin: 0 auto;
padding: 8px;
border-radius: 6px;
background: ${palette.input_bg};
color: ${palette.text_primary};
border: 1px solid ${palette.border};
transition: all 0.1s linear;
}
${root} .${cls.previewBackground} {
width: 100%;
height: 100%;
border-radius: 4px;
transition: all 0.1s linear;
border: 1px solid ${palette.border};
}
${root} .${cls.compoundFormFieldContainer} .${cls.formField} > .${cls.previewBubbleWrapper} {
flex-grow: 1;
}
/* --- Submenu / Panels --- */
${root} .${cls.submenuRow} {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 8px;
}
${root} .${cls.submenuRow} .${cls.submenuRow} {
margin-top: 0;
}
${root} .${cls.submenuRow} label {
flex-shrink: 0;
margin: 0;
padding: 0;
line-height: 1.5;
}
${root} .${cls.submenuFieldset} {
border: 1px solid ${palette.border};
border-radius: 4px;
padding: 8px 12px 12px;
margin: 0 0 12px 0;
min-width: 0;
}
${root} .${cls.submenuFieldset} legend {
padding: 0 4px;
font-weight: 500;
color: ${palette.text_secondary};
}
${root} .${cls.submenuSeparator} {
border-top: 1px solid ${palette.border_light};
margin: 4px 0;
}
${root} .${cls.featureGroup} {
padding: 6px 0;
}
${root} .${cls.featureGroup}:not(:first-child) {
border-top: 1px solid ${palette.border_light};
}
${root} .${cls.featureGroup}.${cls.submenuRow} {
margin-top: 0;
}
/* --- Warnings & Notifications --- */
${root} .${cls.warningBanner} {
background-color: var(--bg-danger, #ffdddd);
color: var(--text-on-danger, #a00);
padding: 8px 12px;
font-size: 0.85em;
text-align: center;
border-radius: 4px;
margin: 0 0 12px 0;
border: 1px solid var(--border-danger-heavy, #c00);
white-space: pre-wrap;
}
${root} .${cls.conflictText} {
color: ${palette.error_text};
}
`;
},
/**
* Generates modal styles scoped to elements with the data-[APPID]-scope attribute.
* @param {Record<string, string>} cls
*/
getSharedModalCss(cls) {
const palette = SITE_STYLES.PALETTE;
// Attribute selector strategy for shared scoping
const root = `[data-${APPID}-scope]`;
// Target specific dialog class to avoid affecting other scoped elements like SettingsPanel
const dialog = `${root}.${cls.dialog}`;
return `
${dialog} {
padding: 0;
border: none;
background: transparent;
max-width: 100vw;
max-height: 100vh;
overflow: visible;
}
${dialog}::backdrop {
background: rgb(0 0 0 / 0.5);
pointer-events: auto;
}
${dialog} .${cls.box} {
display: flex;
flex-direction: column;
background: ${palette.bg};
color: ${palette.text_primary};
border: 1px solid ${palette.border};
border-radius: 8px;
box-shadow: 0 4px 16px rgb(0 0 0 / 0.2);
max-height: 90vh;
width: 100%;
}
${dialog} .${cls.header},
${dialog} .${cls.footer} {
flex-shrink: 0;
padding: 12px 16px;
}
${dialog} .${cls.header} {
font-size: 1.1em;
font-weight: 600;
border-bottom: 1px solid ${palette.border};
}
${dialog} .${cls.content} {
flex-grow: 1;
padding: 0;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 0;
}
${dialog} .${cls.footer} {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
border-top: 1px solid ${palette.border};
}
${dialog} .${cls.footerMessage} {
flex-grow: 1;
font-size: 0.9em;
}
${dialog} .${cls.buttonGroup} {
display: flex;
gap: 8px;
}
`;
},
getSettingsPanelCss(rootId, cls) {
const common = StyleDefinitions.COMMON_CLASSES;
const palette = SITE_STYLES.PALETTE;
const zIndex = SITE_STYLES.Z_INDICES.SETTINGS_PANEL;
const root = `#${rootId}`;
return `
${root} {
position: fixed;
width: min(340px, 95vw);
max-height: 85vh;
overflow-y: auto;
overscroll-behavior: contain;
background: ${palette.bg};
color: ${palette.text_primary};
border-radius: 0.5rem;
box-shadow: 0 4px 20px 0 rgb(0 0 0 / 15%);
padding: 12px;
z-index: ${zIndex};
border: 1px solid ${palette.border_medium};
font-size: 0.9em;
}
${root} #${cls.appliedThemeName} {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
${root} .${cls.topRow} {
display: flex;
gap: 12px;
margin-bottom: 12px;
}
/* Target the common fieldset within the top row */
${root} .${cls.topRow} .${common.submenuFieldset} {
flex: 1 1 0px;
margin-bottom: 0;
}
/* Target common slider display within the panel */
${root} .${common.sliderSubgroupControl}.is-default .${common.sliderDisplay} {
color: ${palette.text_secondary};
}
`;
},
getJsonModalCss(rootId, cls, prefix) {
const modal = StyleDefinitions.MODAL_CLASSES;
const common = StyleDefinitions.COMMON_CLASSES;
const palette = SITE_STYLES.PALETTE;
const root = `#${rootId}`;
return `
/* Hide footer message area to allow buttons to take full width, unless conflict text is present */
${root} .${modal.footerMessage}:not(.${common.conflictText}) {
display: none;
}
/* Explicitly show and style the conflict message when present */
${root} .${modal.footerMessage}.${common.conflictText} {
display: flex;
width: 100%;
}
/* Allow the button group to expand and fill the footer */
${root} .${modal.buttonGroup} {
flex-grow: 1;
width: 100%;
}
/* Allow wrapping in footer to prevent overflow when warning message is displayed */
${root} .${modal.footer} {
flex-wrap: wrap;
}
/* Editor Style */
${root} .${cls.jsonEditor} {
width: 100%;
height: 200px;
box-sizing: border-box;
font-family: monospace, Consolas, "Courier New";
font-size: 13px;
line-height: 1.4;
white-space: pre-wrap; /* Enable wrapping */
overflow-y: auto;
overflow-x: hidden;
margin-bottom: 0;
border: 1px solid ${palette.border};
background: ${palette.input_bg};
color: ${palette.text_primary};
border-radius: 4px;
resize: none;
padding: 8px;
}
${root} .${cls.jsonEditor}:focus {
outline: 1px solid ${palette.accent_text};
}
/* Status container specific style */
${root} .${cls.statusContainer} {
width: 100%;
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-top: 6px;
font-size: 0.85em;
}
/* Mapped from UI Schema className */
${root} .${prefix}-form-field {
width: 100%;
}
${root} .status-msg-display {
flex: 1;
margin-right: 8px;
}
${root} .size-info-display {
white-space: nowrap;
text-align: right;
}
`;
},
getThemeModalCss(rootId, cls) {
const common = StyleDefinitions.COMMON_CLASSES;
const palette = SITE_STYLES.PALETTE;
const root = `#${rootId}`;
return `
/* Make the content area expand to fill the modal */
${root} .${cls.content} {
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
min-height: 0;
overflow: hidden;
}
/* Header Controls Layout */
${root} .${cls.headerControls} {
display: flex;
flex-direction: column;
gap: 12px;
flex-shrink: 0;
}
${root} .${cls.headerRow} {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 8px;
align-items: center;
padding-left: 1.2rem;
}
${root} .${cls.headerRow} > label {
grid-column: 1;
text-align: left;
color: ${palette.text_secondary};
font-size: 0.9em;
white-space: nowrap;
}
${root} .${cls.headerRow} > .${cls.renameArea} {
grid-column: 2;
min-width: 180px;
}
${root} .${cls.headerRow} > .${cls.actionArea} {
grid-column: 3;
display: grid;
align-items: center;
}
${root} .${cls.actionArea} > * {
grid-area: 1 / 1;
display: flex;
align-items: center;
gap: 8px;
}
/* Content Areas */
${root} .${cls.generalSettings} {
display: grid;
gap: 16px;
grid-template-columns: 1fr;
transition: opacity 0.2s;
flex-shrink: 0;
}
${root} .${cls.scrollableArea} {
flex-grow: 1;
overflow-y: auto;
overflow-x: hidden; /* Prevent horizontal scroll */
padding: 16px;
transition: opacity 0.2s;
min-height: 0; /* Enable scrolling */
}
${root} .${cls.scrollableArea}:focus {
outline: none;
}
${root} .${cls.grid} {
display: grid;
gap: 16px;
grid-template-columns: 1fr 1fr;
}
@media (max-width: 800px) {
${root} .${cls.grid} {
grid-template-columns: 1fr !important;
}
}
/* Separator */
${root} .${cls.separator} {
border: none;
border-top: 1px solid ${palette.border};
margin: 0;
flex-shrink: 0;
}
/* Reset separator margins inside fieldset to rely on gap */
${root} fieldset > .${cls.separator} {
margin: 0;
}
/* Spacing Overrides for Theme Editor Forms (Scoped to Theme Modal) */
${root} .${cls.content} .${common.submenuFieldset} {
display: flex;
flex-direction: column;
gap: 8px;
}
/* Reset individual margins to rely on the parent gap */
${root} .${cls.content} .${common.sliderContainer},
${root} .${cls.content} .${common.submenuRow},
${root} .${cls.content} .${common.compoundSliderContainer} {
margin-top: 0;
}
/* Disabled State */
${root} .${cls.generalSettings}.is-disabled,
${root} .${cls.scrollableArea}.is-disabled {
pointer-events: none;
opacity: 0.5;
}
/* Move Buttons (Arrows) */
${root} .${common.modalButton}.${cls.moveBtn} {
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
min-width: 24px;
padding: 4px;
height: 24px;
width: 24px;
}
/* Delete Confirm Group */
${root} .${cls.deleteConfirmGroup} {
display: none;
}
${root} .${cls.deleteConfirmGroup}:not([hidden]) {
align-items: center;
display: flex;
gap: 8px;
}
${root} .${cls.deleteConfirmLabel} {
color: ${palette.danger_text};
font-style: italic;
margin-right: auto;
}
/* Mobile Responsive Styles */
@media (max-width: 600px) {
${root} .${cls.headerRow} {
grid-template-columns: 1fr;
gap: 12px;
padding-left: 0;
}
${root} .${cls.headerRow} > label {
grid-column: 1;
text-align: left;
}
${root} .${cls.headerRow} > .${cls.renameArea} {
grid-column: 1;
min-width: 0;
}
${root} .${cls.headerRow} > .${cls.actionArea} {
grid-column: 1;
}
/* Allow button groups to wrap */
${root} .${cls.actionArea} > * {
flex-wrap: wrap;
}
}
`;
},
getColorPickerCss(rootId, cls) {
const palette = SITE_STYLES.PALETTE;
const zIndex = SITE_STYLES.Z_INDICES.COLOR_PICKER;
const root = `#${rootId}`;
return `
/* Popup Wrapper Style */
${root} {
background-color: ${palette.bg};
border: 1px solid ${palette.border};
border-radius: 4px;
box-shadow: 0 4px 12px rgb(0 0 0 / 0.2);
padding: 16px;
position: fixed; /* Fixed positioning to handle scroll/overflow issues */
width: 280px;
z-index: ${zIndex};
}
${root} .${cls.picker} { display: flex; flex-direction: column; gap: 16px; }
${root} .${cls.svPlane} { position: relative; width: 100%; aspect-ratio: 1 / 1; cursor: crosshair; touch-action: none; border-radius: 4px; overflow: hidden; flex-shrink: 0; }
${root} .${cls.svPlane}:focus { outline: 2px solid ${palette.accent_text}; }
${root} .${cls.svPlane} .${cls.gradientWhite},
${root} .${cls.svPlane} .${cls.gradientBlack} { position: absolute; inset: 0; pointer-events: none; }
${root} .${cls.svPlane} .${cls.gradientWhite} { background: linear-gradient(to right, white, transparent); }
${root} .${cls.svPlane} .${cls.gradientBlack} { background: linear-gradient(to top, black, transparent); }
${root} .${cls.svThumb} { 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; }
${root} .${cls.sliderGroup} { position: relative; cursor: pointer; height: 20px; flex-shrink: 0; }
${root} .${cls.sliderGroup} .${cls.sliderTrack},
${root} .${cls.sliderGroup} .${cls.alphaCheckerboard} { position: absolute; top: 50%; transform: translateY(-50%); width: 100%; height: 12px; border-radius: 6px; pointer-events: none; }
${root} .${cls.sliderGroup} .${cls.alphaCheckerboard} { background-image: repeating-conic-gradient(#808080 0% 25%, #c0c0c0 0% 50%); background-size: 12px 12px; }
${root} .${cls.sliderGroup} .${cls.hueTrack} { 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%) ); }
${root} .${cls.sliderGroup} input[type="range"] { -webkit-appearance: none; appearance: none; position: relative; width: 100%; height: 100%; margin: 0; padding: 0; background-color: transparent; cursor: pointer; }
${root} .${cls.sliderGroup} input[type="range"]:focus { outline: none; }
${root} .${cls.sliderGroup} 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); }
${root} .${cls.sliderGroup} 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); }
${root} .${cls.sliderGroup} input[type="range"]:focus::-webkit-slider-thumb { outline: 2px solid ${palette.accent_text}; outline-offset: 1px; }
${root} .${cls.sliderGroup} input[type="range"]:focus::-moz-range-thumb { outline: 2px solid ${palette.accent_text}; outline-offset: 1px; }
`;
},
getFixedNavCss(rootId, cls) {
const palette = SITE_STYLES.PALETTE;
const zIndex = SITE_STYLES.Z_INDICES.NAV_CONSOLE;
const selectors = CONSTANTS.SELECTORS;
const root = `#${rootId}`;
const S_USER_BUBBLE = selectors.RAW_USER_BUBBLE;
const S_ASST_BUBBLE = selectors.RAW_ASSISTANT_BUBBLE;
// Highlight Selectors
const highlightCommon = `.${cls.highlightMessage} ${S_USER_BUBBLE}, .${cls.highlightMessage} ${S_ASST_BUBBLE}, .${cls.highlightMessage} ${selectors.RAW_USER_IMAGE_BUBBLE}, .${cls.highlightTurn} ${selectors.RAW_ASSISTANT_IMAGE_BUBBLE}`;
const highlightUserText = `.${cls.highlightMessage} ${S_USER_BUBBLE}`;
const highlightAsstText = `.${cls.highlightMessage} ${S_ASST_BUBBLE}`;
return `
/* --- Fixed Nav Container --- */
${root} .${cls.isHidden} {
display: none;
}
${root}.${cls.unpositioned} {
visibility: hidden;
opacity: 0;
}
${root} {
position: fixed;
z-index: ${zIndex};
display: flex;
align-items: center;
gap: 6px;
background-color: ${palette.fixed_nav_bg};
padding: 4px 8px;
border-radius: 8px;
border: 1px solid ${palette.fixed_nav_border};
box-shadow: 0 2px 10px rgb(0 0 0 / 0.05);
font-size: 0.8rem;
opacity: 1;
transform-origin: bottom;
}
/* Embedded Mode (Header Integration) */
${root}.is-embedded {
position: static !important;
background-color: transparent !important;
border: none !important;
box-shadow: none !important;
padding: 0 8px !important;
height: 100%;
z-index: auto;
}
${root}.${cls.hidden} {
display: none;
}
/* --- Fixed Nav Content --- */
${root} .${cls.group} {
display: flex;
align-items: center;
gap: 6px;
}
${root} .${cls.separator} {
width: 1px;
height: 20px;
background-color: ${palette.fixed_nav_separator_bg};
}
${root} .${cls.roleBtn} {
color: ${palette.fixed_nav_label_text};
font-weight: 500;
cursor: pointer;
user-select: none;
background: transparent;
border: none;
padding: 2px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.1s;
width: 24px;
height: 24px;
}
${root} .${cls.roleBtn}:hover {
background-color: ${palette.btn_hover_bg};
}
${root} .${cls.roleBtn} svg {
width: 20px;
height: 20px;
fill: currentColor;
}
/* Role Colors */
${root} .${cls.roleTotal} {
color: ${palette.text_secondary};
}
${root} .${cls.roleUser} {
color: ${palette.accent_text};
}
${root} .${cls.roleAssistant} {
color: ${palette.fixed_nav_assistant_text};
}
${root} .${cls.counter},
${root} .${cls.jumpInput} {
box-sizing: border-box;
width: 85px;
height: 24px;
margin: 0;
background-color: ${palette.fixed_nav_counter_bg};
color: ${palette.fixed_nav_counter_text};
padding: 1px 4px;
border: 1px solid ${palette.fixed_nav_counter_border};
border-radius: 4px;
text-align: center;
vertical-align: middle;
font-family: monospace;
font: inherit;
user-select: none;
}
${root} .${cls.btn} {
background-color: ${palette.btn_bg};
color: ${palette.btn_text};
border: 1px solid ${palette.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;
}
${root} .${cls.btn}:not(:disabled):hover {
background-color: ${palette.btn_hover_bg};
}
${root} .${cls.btn} svg {
width: 20px;
height: 20px;
fill: currentColor;
}
${root} #${cls.bulkCollapseBtnId} svg {
width: 100%;
height: 100%;
}
${root} #${cls.bulkCollapseBtnId}[data-state="expanded"] .icon-expand { display: none; }
${root} #${cls.bulkCollapseBtnId}[data-state="expanded"] .icon-collapse { display: block; }
${root} #${cls.bulkCollapseBtnId}[data-state="collapsed"] .icon-expand { display: block; }
${root} #${cls.bulkCollapseBtnId}[data-state="collapsed"] .icon-collapse { display: none; }
${root} .${cls.btn}.${cls.btnAccent} {
color: ${palette.fixed_nav_btn_accent_text};
}
${root} .${cls.btn}.${cls.btnDanger} {
color: ${palette.fixed_nav_btn_danger_text};
}
${root} #${cls.autoscrollBtnId}:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* --- Highlight (Global Scope) --- */
/* These rules target messages outside the root container, so they cannot use #rootId */
${highlightCommon} {
outline: 2px solid ${palette.fixed_nav_highlight_outline} !important;
outline-offset: -2px;
box-shadow: 0 0 8px ${palette.fixed_nav_highlight_outline} !important;
}
${highlightUserText} {
border-radius: var(${CSS_VARS.USER_BUBBLE_RADIUS}) !important;
}
${highlightAsstText} {
border-radius: var(${CSS_VARS.ASSISTANT_BUBBLE_RADIUS}) !important;
}
`;
},
getJumpListCss(rootId, cls) {
const palette = SITE_STYLES.PALETTE;
const baseZ = SITE_STYLES.Z_INDICES.NAV_CONSOLE;
// Increment only if z-index is a number; use as is for strings like 'auto'
const zIndex = typeof baseZ === 'number' ? baseZ + 1 : baseZ;
const root = `#${rootId}`;
const firefoxScrollbarFix = /firefox/i.test(navigator.userAgent) ? `${root} .${cls.scrollbox} { padding-right: 12px; }` : '';
return `
/* --- Jump List Container --- */
${root} {
position: fixed;
z-index: ${zIndex};
background: ${palette.jump_list_bg};
border: 1px solid ${palette.jump_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;
}
${root}.${cls.expandDown} {
transform-origin: top;
transform: translateY(-10px);
}
${root}:focus,
${root} #${cls.listId}:focus,
${root} .${cls.scrollbox}:focus {
outline: none;
}
${root}.${cls.visible} {
opacity: 1;
transform: translateY(0);
visibility: visible;
}
${root} .${cls.scrollbox} {
flex: 1 1 auto;
position: relative;
}
${firefoxScrollbarFix}
/* --- Jump List Items --- */
${root} #${cls.listId} {
list-style: none;
margin: 0;
padding: 0;
}
${root} .${cls.filterContainer} {
position: relative;
display: flex;
align-items: center;
border-top: 1px solid ${palette.jump_list_border};
margin: 4px 0 0 0;
flex-shrink: 0;
}
${root} .${cls.filter} {
border: none;
background-color: transparent;
color: inherit;
padding: 8px 60px 8px 8px;
outline: none;
font-size: 0.85rem;
border-radius: 0 0 4px 4px;
width: 100%;
box-sizing: border-box;
}
${root} .${cls.filter}.${cls.filterRegexValid} {
border-color: ${palette.jump_list_current_outline};
}
${root} .${cls.modeLabel} {
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;
}
${root} .${cls.modeLabel}.${cls.modeString} {
background-color: transparent;
color: ${palette.label_text};
}
${root} .${cls.modeLabel}.${cls.modeRegex} {
background-color: #28a745;
color: #ffffff;
}
${root} .${cls.modeLabel}.${cls.modeInvalid} {
background-color: #dc3545;
color: #ffffff;
}
${root} #${cls.listId} li {
padding: 6px 10px;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border-radius: 4px;
font-size: 0.85rem;
}
${root} #${cls.listId} li:hover,
${root} #${cls.listId} li.${cls.focused} {
box-shadow: inset 3px 0 0 rgb(128 128 128 / 0.8), inset 0 0 0 100px rgb(128 128 128 / 0.4);
}
${root} #${cls.listId} li.${cls.current} {
box-shadow: inset 8px 0 0 ${palette.jump_list_current_outline};
font-weight: bold;
}
${root} #${cls.listId} li.${cls.current}:hover,
${root} #${cls.listId} li.${cls.current}.${cls.focused} {
box-shadow: inset 8px 0 0 ${palette.jump_list_current_outline}, inset 0 0 0 100px rgb(128 128 128 / 0.4);
}
${root} #${cls.listId} li.${cls.userItem} {
background-color: var(${CSS_VARS.USER_BUBBLE_BG}, transparent);
color: var(${CSS_VARS.USER_TEXT_COLOR}, inherit);
}
${root} #${cls.listId} li.${cls.asstItem} {
background-color: var(${CSS_VARS.ASSISTANT_BUBBLE_BG}, transparent);
color: var(${CSS_VARS.ASSISTANT_TEXT_COLOR}, inherit);
}
/* --- Jump List Preview (Global) --- */
#${cls.previewId} {
position: fixed;
z-index: ${CONSTANTS.Z_INDICES.JUMP_LIST_PREVIEW};
background: ${palette.jump_list_bg};
border: 1px solid ${palette.jump_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.15s ease-in-out;
white-space: pre-wrap;
word-break: break-word;
visibility: hidden;
user-select: text;
cursor: auto;
}
#${cls.previewId} strong {
color: ${palette.jump_list_current_outline};
font-weight: bold;
background-color: transparent;
}
#${cls.previewId}.${cls.visible} {
opacity: 1;
visibility: visible;
}
`;
},
getSettingsButtonCss(rootId, cls, prefix) {
const palette = SITE_STYLES.PALETTE;
const zIndex = SITE_STYLES.Z_INDICES.SETTINGS_BUTTON;
const animationName = `${prefix}-spin`;
const root = `#${rootId}`;
return `
@keyframes ${animationName} {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
${root} {
z-index: ${zIndex};
background: transparent;
border: none;
border-radius: 50%;
border-color: transparent;
position: static;
margin: 0 2px 0 0;
width: ${palette.settings_btn_width};
height: ${palette.settings_btn_height};
align-self: center;
color: ${palette.settings_btn_color};
/* 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, color 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
pointer-events: auto !important;
}
${root}:hover {
background: ${palette.settings_btn_hover_bg};
border-color: transparent;
}
${root}.is-loading {
color: ${palette.loading_spinner};
}
${root}.is-loading svg {
animation: ${animationName} 1.5s linear infinite;
}
`;
},
getToastCss(rootId, cls) {
const zIndex = SITE_STYLES.Z_INDICES.TOAST;
const root = `#${rootId}`;
return `
${root} {
position: fixed;
top: 30%;
left: 50%;
transform: translate(-50%, -50%);
z-index: ${zIndex};
background-color: rgb(255 165 0 / 0.9);
color: #ffffff;
padding: 15px 25px;
border-radius: 12px;
border: 1px solid #ffa000;
box-shadow: 0 6px 20px rgb(0 0 0 / 0.2);
display: flex;
align-items: center;
gap: 15px;
font-size: 1.1em;
font-weight: bold;
opacity: 0;
transition: opacity 0.4s ease, transform 0.4s ease;
pointer-events: none;
white-space: nowrap;
}
${root}.${cls.visible} {
opacity: 1;
transform: translate(-50%, 0);
pointer-events: auto;
}
${root} .${cls.cancelBtn} {
background: rgb(255 255 255 / 0.2);
color: #ffffff;
border: none;
padding: 8px 15px;
margin-left: 10px;
cursor: pointer;
font-weight: bold;
border-radius: 6px;
transition: background-color 0.2s ease;
}
${root} .${cls.cancelBtn}:hover {
background-color: rgb(255 255 255 / 0.3);
}
`;
},
getTimestampCss(cls) {
return `
.${cls.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 */
}
.${cls.container}.${cls.assistant} {
left: 30px; /* (button left 4px + width 24px + margin 2px) */
}
.${cls.container}.${cls.user} {
right: 30px; /* (button right 4px + width 24px + margin 2px) */
}
.${cls.text} {
background-color: rgb(0 0 0 / 0.4);
padding: 0px 4px;
border-radius: 4px;
pointer-events: none;
}
.${cls.hidden} {
display: none !important;
}
`;
},
getMessageNumberCss(cls) {
return `
.${cls.parent} {
position: relative !important;
}
.${cls.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;
}
.${cls.number}.${cls.assistant} {
top: -20px;
right: 100%;
margin-right: 0px;
}
.${cls.number}.${cls.user} {
top: -20px;
left: 100%;
margin-left: 0px;
}
.${cls.hidden} {
display: none !important;
}
`;
},
getStandingImageCss(cls) {
const zIndex = SITE_STYLES.Z_INDICES.STANDING_IMAGE;
const assistantImage = CSS_VARS.ASSISTANT_STANDING_IMAGE;
const userImage = CSS_VARS.USER_STANDING_IMAGE;
const assistantWidth = CSS_VARS.STANDING_IMG_ASST_WIDTH;
const assistantLeft = CSS_VARS.STANDING_IMG_ASST_LEFT;
const assistantMask = CSS_VARS.STANDING_IMG_ASST_MASK;
const userWidth = CSS_VARS.STANDING_IMG_USER_WIDTH;
const userMask = CSS_VARS.STANDING_IMG_USER_MASK;
return `
#${cls.userImageId},
#${cls.assistantImageId} {
position: fixed;
bottom: 0;
height: 100vh;
max-height: 100vh;
pointer-events: none;
z-index: ${zIndex};
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;
}
#${cls.assistantImageId} {
background-image: var(${assistantImage}, none);
left: var(${assistantLeft}, 0px);
width: var(${assistantWidth}, 0px);
max-width: var(${assistantWidth}, 0px);
mask-image: var(${assistantMask}, none);
-webkit-mask-image: var(${assistantMask}, none);
}
#${cls.userImageId} {
background-image: var(${userImage}, none);
right: 0;
width: var(${userWidth}, 0px);
max-width: var(${userWidth}, 0px);
mask-image: var(${userMask}, none);
-webkit-mask-image: var(${userMask}, none);
}
`;
},
getBubbleUiCss(cls, options = {}) {
const { collapsibleParentSelector, collapsibleCollapsedContentExtraCss, collapsibleBtnExtraCss } = options;
const palette = SITE_STYLES.PALETTE;
const selectors = CONSTANTS.SELECTORS;
return `
/* --- Collapsible Button --- */
${collapsibleParentSelector} {
position: relative;
}
/* Create a transparent hover area above the button */
${collapsibleParentSelector}::before {
content: '';
position: absolute;
top: -20px;
height: 20px;
inset-inline: 0;
}
/* Add a transparent border in the normal state to prevent width changes on collapse */
.${cls.collapsibleContent} {
border: 1px solid transparent;
box-sizing: border-box;
}
.${cls.collapsibleBtn} {
position: absolute;
top: -20px;
width: 20px;
height: 20px;
padding: 2px;
border-radius: 4px;
box-sizing: border-box;
cursor: pointer;
visibility: hidden;
opacity: 0;
transition: visibility 0s linear 0.1s, opacity 0.1s ease-in-out, background-color 0.15s ease-in-out, color 0.15s ease-in-out;
background-color: rgb(0 0 0 / 0.4);
color: rgb(255 255 255 / 0.7);
border: 0;
}
.${cls.collapsibleBtn}.${cls.hidden} {
display: none;
}
/* Platform specific collapsible button positioning */
${collapsibleBtnExtraCss}
${collapsibleParentSelector}:hover .${cls.collapsibleBtn} {
visibility: visible;
opacity: 1;
transition-delay: 0s;
}
.${cls.collapsibleBtn}:hover {
background-color: rgb(0 0 0 / 0.6);
color: rgb(255 255 255 / 1);
}
.${cls.collapsibleBtn} svg {
width: 100%;
height: 100%;
transition: transform 0.2s ease-in-out;
}
.${cls.collapsibleParent}.${cls.collapsed} .${cls.collapsibleContent} {
max-height: ${CONSTANTS.UI_SPECS.COLLAPSIBLE.HEIGHT_THRESHOLD}px;
overflow: hidden;
border: 1px dashed ${palette.text_secondary};
box-sizing: border-box;
${collapsibleCollapsedContentExtraCss}
}
.${cls.collapsibleParent}.${cls.collapsed} .${cls.collapsibleBtn} svg {
transform: rotate(-180deg);
}
/* --- Bubble Navigation --- */
.${cls.navContainer} {
position: absolute;
top: 0;
bottom: 0;
width: 24px;
z-index: ${CONSTANTS.Z_INDICES.BUBBLE_NAVIGATION};
}
.${cls.navButtons} {
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;
}
.${cls.navParent}:hover .${cls.navButtons},
.${cls.navContainer}:hover .${cls.navButtons} {
visibility: visible;
opacity: 1;
transition-delay: 0s;
}
/* Nav container positioning */
${selectors.ASSISTANT_MESSAGE} .${cls.navContainer} {
left: -25px;
}
${selectors.USER_MESSAGE} .${cls.navContainer} {
right: -25px;
}
.${cls.navGroupTop}, .${cls.navGroupBottom} {
position: relative;
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
}
.${cls.navGroupBottom} {
margin-top: auto;
}
.${cls.navGroupTop}.${cls.hidden}, .${cls.navGroupBottom}.${cls.hidden} {
display: none !important;
}
.${cls.navBtn} {
width: 20px;
height: 20px;
padding: 2px;
border-radius: 4px;
box-sizing: border-box;
cursor: pointer;
background-color: rgb(0 0 0 / 0.4);
color: rgb(255 255 255 / 0.7);
border: 0;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease-in-out;
margin: 0 auto;
}
.${cls.navBtn}:not(:disabled):hover {
background-color: rgb(0 0 0 / 0.6);
color: rgb(255 255 255 / 1);
}
.${cls.navBtn}:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.${cls.navBtn} svg {
width: 100%;
height: 100%;
}
`;
},
getAvatarCss(extraCss) {
const selectors = CONSTANTS.SELECTORS;
return `
/* Common Avatar CSS */
${selectors.AVATAR_USER},
${selectors.AVATAR_ASSISTANT} {
position: relative !important;
overflow: visible !important;
min-height: calc(var(${CSS_VARS.ICON_SIZE}) + 3em);
}
${selectors.SIDE_AVATAR_CONTAINER} {
position: absolute;
top: 0;
display: flex;
flex-direction: column;
align-items: center;
width: var(${CSS_VARS.ICON_SIZE});
pointer-events: none;
white-space: normal;
word-break: break-word;
}
${selectors.SIDE_AVATAR_ICON} {
width: var(${CSS_VARS.ICON_SIZE});
height: var(${CSS_VARS.ICON_SIZE});
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;
}
${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;
}
/* User avatar (Right) */
${selectors.AVATAR_USER} ${selectors.SIDE_AVATAR_CONTAINER} {
left: 100%;
margin-left: var(${CSS_VARS.ICON_MARGIN});
}
/* Assistant avatar (Left) */
${selectors.AVATAR_ASSISTANT} ${selectors.SIDE_AVATAR_CONTAINER} {
right: 100%;
margin-right: var(${CSS_VARS.ICON_MARGIN});
}
/* Theme Variables Mapping */
${selectors.AVATAR_USER} ${selectors.SIDE_AVATAR_ICON} {
background-image: var(${CSS_VARS.USER_ICON});
display: var(${CSS_VARS.USER_ICON_DISPLAY}, none);
}
${selectors.AVATAR_USER} ${selectors.SIDE_AVATAR_NAME} {
color: var(${CSS_VARS.USER_TEXT_COLOR});
display: var(${CSS_VARS.USER_NAME_DISPLAY}, none);
}
${selectors.AVATAR_USER} ${selectors.SIDE_AVATAR_NAME}::after {
content: var(${CSS_VARS.USER_NAME});
}
${selectors.AVATAR_ASSISTANT} ${selectors.SIDE_AVATAR_ICON} {
background-image: var(${CSS_VARS.ASSISTANT_ICON});
display: var(${CSS_VARS.ASSISTANT_ICON_DISPLAY}, none);
}
${selectors.AVATAR_ASSISTANT} ${selectors.SIDE_AVATAR_NAME} {
color: var(${CSS_VARS.ASSISTANT_TEXT_COLOR});
display: var(${CSS_VARS.ASSISTANT_NAME_DISPLAY}, none);
}
${selectors.AVATAR_ASSISTANT} ${selectors.SIDE_AVATAR_NAME}::after {
content: var(${CSS_VARS.ASSISTANT_NAME});
}
/* Platform Specific Overrides */
${extraCss}
`;
},
getThemeBaseCss(cls, activeVars, styleOverrides) {
const selectors = CONSTANTS.SELECTORS;
const S_USER_MSG = selectors.USER_MESSAGE;
const S_ASST_MSG = selectors.ASSISTANT_MESSAGE;
const S_USER_BUBBLE = selectors.RAW_USER_BUBBLE;
const S_ASST_BUBBLE = selectors.RAW_ASSISTANT_BUBBLE;
// Helper to generate CSS property only if the variable is active
const prop = (propName, varName) => {
if (activeVars && !activeVars.has(varName)) return '';
return `${propName}: var(${varName}) !important;`;
};
// Generate assistant text color selectors for all child elements.
const assistantTextSelectors = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul li', 'ol li', 'ul li::marker', 'ol li::marker', 'strong', 'em', 'blockquote', 'table', 'th', 'td']
.map((tag) => `${S_ASST_MSG} ${selectors.ASSISTANT_TEXT_CONTENT} ${tag}`)
.join(',\n');
// Generate CSS with conditional properties
return `
/* --- Window Background --- */
${selectors.MAIN_APP_CONTAINER} {
${prop('background-color', CSS_VARS.WINDOW_BG_COLOR)}
${prop('background-image', CSS_VARS.WINDOW_BG_IMAGE)}
${prop('background-size', CSS_VARS.WINDOW_BG_SIZE)}
${prop('background-position', CSS_VARS.WINDOW_BG_POS)}
${prop('background-repeat', CSS_VARS.WINDOW_BG_REPEAT)}
${activeVars && activeVars.has(CSS_VARS.WINDOW_BG_IMAGE) ? `background-attachment: fixed !important;` : ''}
}
/* --- Input Area --- */
${selectors.INPUT_AREA_BG_TARGET} {
${prop('background-color', CSS_VARS.INPUT_BG)}
}
${selectors.INPUT_TEXT_FIELD_TARGET} {
/* Variable --input-field-bg is set to 'transparent' via transformer when bg color is active */
${prop('background-color', CSS_VARS.INPUT_FIELD_BG)}
${prop('color', CSS_VARS.INPUT_COLOR)}
${prop('font-family', CSS_VARS.USER_FONT)}
}
/* --- Assistant Bubble & Text --- */
${S_ASST_MSG} ${S_ASST_BUBBLE} {
${activeVars && !activeVars.has(CSS_VARS.ASSISTANT_BUBBLE_BG) ? `background-color: ${SITE_STYLES.PALETTE.bg} !important;` : prop('background-color', CSS_VARS.ASSISTANT_BUBBLE_BG)}
${prop('padding', CSS_VARS.ASSISTANT_BUBBLE_PADDING)}
${prop('border-radius', CSS_VARS.ASSISTANT_BUBBLE_RADIUS)}
${prop('max-width', CSS_VARS.ASSISTANT_BUBBLE_MAXWIDTH)}
${styleOverrides.assistant || ''}
}
${S_ASST_MSG} ${selectors.ASSISTANT_TEXT_CONTENT} {
${prop('color', CSS_VARS.ASSISTANT_TEXT_COLOR)}
${prop('font-family', CSS_VARS.ASSISTANT_FONT)}
}
/* Assistant Child Elements Text Color */
${assistantTextSelectors} {
${prop('color', CSS_VARS.ASSISTANT_TEXT_COLOR)}
}
/* --- Assistant Rich Content Styling (Tone-on-Tone) --- */
/* Table: Header 10% opacity, Cell 5% opacity, Border 20% opacity */
${S_ASST_MSG} ${selectors.ASSISTANT_TEXT_CONTENT} table,
${S_ASST_MSG} ${selectors.ASSISTANT_TEXT_CONTENT} th,
${S_ASST_MSG} ${selectors.ASSISTANT_TEXT_CONTENT} td {
${activeVars && activeVars.has(CSS_VARS.ASSISTANT_TEXT_COLOR) ? `border-color: color-mix(in srgb, var(${CSS_VARS.ASSISTANT_TEXT_COLOR}), transparent 80%) !important;` : ''}
}
${S_ASST_MSG} ${selectors.ASSISTANT_TEXT_CONTENT} th {
${activeVars && activeVars.has(CSS_VARS.ASSISTANT_TEXT_COLOR) ? `background-color: color-mix(in srgb, var(${CSS_VARS.ASSISTANT_TEXT_COLOR}), transparent 90%) !important;` : ''}
}
${S_ASST_MSG} ${selectors.ASSISTANT_TEXT_CONTENT} td {
${activeVars && activeVars.has(CSS_VARS.ASSISTANT_TEXT_COLOR) ? `background-color: color-mix(in srgb, var(${CSS_VARS.ASSISTANT_TEXT_COLOR}), transparent 95%) !important;` : ''}
}
/* Blockquote: Background 5% opacity, Border 30% opacity */
${S_ASST_MSG} ${selectors.ASSISTANT_TEXT_CONTENT} blockquote {
${activeVars && activeVars.has(CSS_VARS.ASSISTANT_TEXT_COLOR) ? `background-color: color-mix(in srgb, var(${CSS_VARS.ASSISTANT_TEXT_COLOR}), transparent 95%) !important; border-left-color: color-mix(in srgb, var(${CSS_VARS.ASSISTANT_TEXT_COLOR}), transparent 70%) !important;` : ''}
}
/* --- User Bubble & Text --- */
${S_USER_MSG} ${S_USER_BUBBLE} {
${prop('background-color', CSS_VARS.USER_BUBBLE_BG)}
${prop('padding', CSS_VARS.USER_BUBBLE_PADDING)}
${prop('border-radius', CSS_VARS.USER_BUBBLE_RADIUS)}
${prop('max-width', CSS_VARS.USER_BUBBLE_MAXWIDTH)}
${styleOverrides.user || ''}
}
${S_USER_MSG} ${selectors.USER_TEXT_CONTENT} {
${prop('color', CSS_VARS.USER_TEXT_COLOR)}
${prop('font-family', CSS_VARS.USER_FONT)}
}
`;
},
};
// =================================================================================
// SECTION: Base Platform Adapters
// Description: Base classes for platform adapters defining the interface and default behaviors.
// =================================================================================
/**
* @class BaseGeneralAdapter
* @description Provides general utility methods for platform interaction.
*/
class BaseGeneralAdapter {
/**
* Checks if the Canvas feature (or equivalent immersive mode) is currently active.
* @returns {boolean} True if active, default is false.
*/
isCanvasModeActive() {
return false;
}
/**
* Checks if the current page URL indicates a page where the script should be disabled.
* @returns {boolean} True if the page should be excluded, default is false.
*/
isExcludedPage() {
return false;
}
/**
* Checks if the File Panel (or context sidebar) is currently active.
* @returns {boolean} True if active, default is false.
*/
isFilePanelActive() {
return false;
}
/**
* Checks if the current page is a "New Chat" page (empty state).
* @returns {boolean} True if it is a new chat page.
* @throws {Error} Must be implemented by subclasses.
*/
isNewChatPage() {
throw new Error('isNewChatPage must be implemented by the platform adapter.');
}
/**
* Checks if the current page is an active chat page with history.
* @returns {boolean} True if it is a chat page.
* @throws {Error} Must be implemented by subclasses.
*/
isChatPage() {
throw new Error('isChatPage must be implemented by the platform adapter.');
}
/**
* Gets the root element that contains all chat messages to limit the search scope.
* @returns {HTMLElement} The root element.
* @throws {Error} Must be implemented by subclasses.
*/
getMessagesRoot() {
throw new Error('getMessagesRoot must be implemented by the platform adapter.');
}
/**
* Gets the unique message ID from an element.
* @param {Element | null} element The element.
* @returns {string | null} The message ID.
* @throws {Error} Must be implemented by subclasses.
*/
getMessageId(element) {
throw new Error('getMessageId must be implemented by the platform adapter.');
}
/**
* Gets the platform-specific role identifier from a message element.
* @param {Element} element The message element.
* @returns {string | null} The role identifier (e.g., 'user', 'assistant').
* @throws {Error} Must be implemented by subclasses.
*/
getMessageRole(element) {
throw new Error('getMessageRole must be implemented by the platform adapter.');
}
/**
* Gets the current chat title from the DOM.
* @returns {string | null} The chat title, or null if not found.
* @throws {Error} Must be implemented by subclasses.
*/
getChatTitle() {
throw new Error('getChatTitle must be implemented by the platform adapter.');
}
/**
* Gets the display text for a message element to be used in the Jump List.
* @param {HTMLElement} element The message element.
* @returns {string} The text content.
* @throws {Error} Must be implemented by subclasses.
*/
getJumpListDisplayText(element) {
throw new Error('getJumpListDisplayText must be implemented by the platform adapter.');
}
/**
* Finds the root message container element for a given content element (e.g. text node or image).
* @param {Element} element The content element.
* @returns {HTMLElement | null} The parent message container.
* @throws {Error} Must be implemented by subclasses.
*/
findMessageElement(element) {
throw new Error('findMessageElement must be implemented by the platform adapter.');
}
/**
* Filters out ghost or invalid message containers before they are added to the cache.
* @param {Element} element The message element to check.
* @returns {boolean} True to keep the message, false to ignore it. Default is true.
*/
filterMessage(element) {
return true;
}
/**
* Ensures that a content element (like an image) is wrapped in a proper message container.
* Used for platforms where images might be direct children of the turn container.
* @param {HTMLElement} element The content element.
* @returns {HTMLElement | null} The message container. Default returns null (no action).
*/
ensureMessageContainerForImage(element) {
return null;
}
/**
* Sets up Sentinel listeners to detect new message content.
* @param {(element: HTMLElement) => void} callback The callback to execute when content is found.
* @returns {() => void} A cleanup function.
* @throws {Error} Must be implemented by subclasses.
*/
initializeSentinel(callback) {
throw new Error('initializeSentinel must be implemented by the platform adapter.');
}
/**
* Performs an initial scan of the DOM for messages that might have been missed by observers.
* @param {MessageLifecycleManager} lifecycleManager The lifecycle manager instance.
* @returns {number} The number of new items found. Default is 0.
*/
performInitialScan(lifecycleManager) {
return 0;
}
/**
* Lifecycle hook called when a page navigation event completes.
* @param {MessageLifecycleManager} lifecycleManager The lifecycle manager instance.
*/
onNavigationEnd(lifecycleManager) {
// No-op by default
}
/**
* Scrolls to a target element using platform-specific logic.
* @param {HTMLElement} element The target element.
* @throws {Error} Must be implemented by subclasses.
*/
scrollTo(element) {
throw new Error('scrollTo must be implemented by the platform adapter.');
}
}
/**
* @class BaseStyleManagerAdapter
* @description Generates platform-specific CSS.
*/
class BaseStyleManagerAdapter {
/**
* Returns the static CSS that does not depend on themes.
* @param {Record<string, string>} cls Map of logical class names to actual class names.
* @returns {string} The CSS string.
* @throws {Error} Must be implemented by subclasses.
*/
getStaticCss(cls) {
throw new Error('getStaticCss must be implemented by the platform adapter.');
}
/**
* Returns the CSS for bubble UI elements (buttons, etc.).
* @param {Record<string, string>} cls Map of logical class names to actual class names.
* @returns {string} The CSS string.
* @throws {Error} Must be implemented by subclasses.
*/
getBubbleCss(cls) {
throw new Error('getBubbleCss must be implemented by the platform adapter.');
}
}
/**
* @class BaseThemeManagerAdapter
* @description Handles platform-specific theme application logic.
*/
class BaseThemeManagerAdapter {
/**
* Determines if the initial theme application should be deferred (e.g. waiting for title load).
* @param {ThemeManager} manager The theme manager instance.
* @returns {boolean} True to defer, default is false.
*/
shouldDeferInitialTheme(manager) {
return false;
}
/**
* Selects the theme to update based on state changes.
* @param {ThemeManager} manager The theme manager instance.
* @param {AppConfig} config The current configuration.
* @param {boolean} urlChanged Whether the URL has changed.
* @param {boolean} titleChanged Whether the title has changed.
* @returns {ThemeSet | null} The theme to apply, or null to defer.
*/
selectThemeForUpdate(manager, config, urlChanged, titleChanged) {
if (urlChanged) {
manager.cachedThemeSet = null;
}
return manager.getThemeSet();
}
/**
* Returns platform-specific CSS overrides for theme styles.
* @returns {Record<string, string>} Map of actor ('user', 'assistant') to CSS string.
*/
getStyleOverrides() {
return {};
}
}
/**
* @class BaseBubbleUIAdapter
* @description Manages UI elements injected into message bubbles.
*/
class BaseBubbleUIAdapter {
/**
* Gets the element to which navigation buttons should be attached.
* @param {HTMLElement} messageElement The message element.
* @returns {HTMLElement | null} The positioning parent.
* @throws {Error} Must be implemented by subclasses.
*/
getNavPositioningParent(messageElement) {
throw new Error('getNavPositioningParent must be implemented by the platform adapter.');
}
/**
* Gets information required to render the collapsible button.
* @param {HTMLElement} messageElement The message element.
* @returns {{msgWrapper: HTMLElement, bubbleElement: HTMLElement, positioningParent: HTMLElement} | null} Info object or null.
* @throws {Error} Must be implemented by subclasses.
*/
getCollapsibleInfo(messageElement) {
throw new Error('getCollapsibleInfo must be implemented by the platform adapter.');
}
/**
* Gets information required to render bubble navigation buttons (Side Nav).
* @param {HTMLElement} messageElement The message element.
* @returns {object | null} Truthy object to enable, null to disable. Default is empty object.
*/
getBubbleNavButtonsInfo(messageElement) {
return {};
}
}
/**
* @class BaseToastAdapter
* @description Provides messages for toast notifications.
*/
class BaseToastAdapter {
/**
* Gets the message to display during auto-scroll.
* @returns {string} The message.
* @throws {Error} Must be implemented by subclasses.
*/
getAutoScrollMessage() {
throw new Error('getAutoScrollMessage must be implemented by the platform adapter.');
}
/**
* Calculates the horizontal center of the input area to align the toast.
* @returns {number | null} The X coordinate, or null to fallback to CSS default.
*/
getToastPositionX() {
return null;
}
}
/**
* @class BaseAppControllerAdapter
* @description Hooks for initializing platform-specific managers.
*/
class BaseAppControllerAdapter {
/**
* Initializes platform-specific managers (e.g. AutoScrollManager).
* @param {AppController} controller The main controller instance.
*/
initializePlatformManagers(controller) {
// No-op by default
}
/**
* Applies UI updates specific to the platform after config change.
* @param {AppController} controller The main controller instance.
* @param {AppConfig} newConfig The new configuration.
*/
applyPlatformSpecificUiUpdates(controller, newConfig) {
// No-op by default
}
}
/**
* @class BaseAvatarAdapter
* @description Handles avatar injection and styling.
*/
class BaseAvatarAdapter {
/**
* Returns CSS for avatars.
* @returns {string} The CSS string.
* @throws {Error} Must be implemented by subclasses.
*/
getCss() {
throw new Error('getCss must be implemented by the platform adapter.');
}
/**
* Measures the target message element to determine if and where an avatar should be injected.
* Performs READ operations only.
* @param {HTMLElement} msgElem The message element to process.
* @returns {AvatarMeasurement | null} The measurement result or null if processing should stop.
* @throws {Error} Must be implemented by subclasses.
*/
measureAvatarTarget(msgElem) {
throw new Error('measureAvatarTarget must be implemented by the platform adapter.');
}
/**
* Injects the avatar container into the DOM based on the measurement context.
* Performs WRITE operations only.
* @param {AvatarMeasurement} measurement The measurement result returned by measureAvatarTarget.
* @param {HTMLElement | null} avatarContainer The avatar container to inject (null if shouldInject is false).
* @throws {Error} Must be implemented by subclasses.
*/
injectAvatar(measurement, avatarContainer) {
throw new Error('injectAvatar must be implemented by the platform adapter.');
}
}
/**
* @class BaseStandingImageAdapter
* @description Manages standing image layout and visibility.
*/
class BaseStandingImageAdapter {
/**
* Recalculates the position and size of standing images.
* @param {StandingImageManager} instance The manager instance.
* @returns {Promise<void>}
* @throws {Error} Must be implemented by subclasses.
*/
async recalculateLayout(instance) {
throw new Error('recalculateLayout must be implemented by the platform adapter.');
}
/**
* Updates the visibility of standing images.
* @param {StandingImageManager} instance The manager instance.
* @throws {Error} Must be implemented by subclasses.
*/
updateVisibility(instance) {
throw new Error('updateVisibility must be implemented by the platform adapter.');
}
/**
* Sets up platform-specific event listeners.
* @param {StandingImageManager} instance The manager instance.
*/
setupEventListeners(instance) {
// No-op by default
}
}
/**
* @class BaseObserverAdapter
* @description Manages DOM observers.
*/
class BaseObserverAdapter {
/**
* Returns functions to start platform-specific observers.
* @returns {Array<(dependencies: ObserverDependencies) => () => void>} Array of starter functions.
* @throws {Error} Must be implemented by subclasses.
*/
getPlatformObserverStarters() {
throw new Error('getPlatformObserverStarters must be implemented by the platform adapter.');
}
/**
* Checks if a conversation turn is complete.
* @param {HTMLElement} turnNode The turn element.
* @returns {boolean} True if complete.
* @throws {Error} Must be implemented by subclasses.
*/
isTurnComplete(turnNode) {
throw new Error('isTurnComplete must be implemented by the platform adapter.');
}
}
/**
* @class BaseSettingsPanelAdapter
* @description Configures the settings panel.
*/
class BaseSettingsPanelAdapter {
/**
* Returns platform-specific feature toggles for the settings panel.
* @returns {Array<{configKey: string}>} Array of toggle definitions.
*/
getPlatformSpecificFeatureToggles() {
return [];
}
}
/**
* @class BaseFixedNavAdapter
* @description Adapters for the Fixed Navigation Console.
*/
class BaseFixedNavAdapter {
/**
* Checks if the header position is available based on the current window state.
* @param {number} [navConsoleWidth] Optional width of the console for strict checking.
* @returns {boolean} True if header positioning is allowed.
*/
isHeaderPositionAvailable(navConsoleWidth) {
return true;
}
/**
* Gets the container element in the header/toolbar where the nav console should be embedded.
* @returns {HTMLElement | null} The container element, or null if not found.
*/
getNavAnchorContainer() {
return null;
}
/**
* Handles logic for infinite scroll state updates.
* @param {FixedNavigationManager} manager The manager instance.
* @param {HTMLElement | null} highlightedMessage The currently highlighted message.
* @param {number} previousTotalMessages Previous count of messages.
*/
handleInfiniteScroll(manager, highlightedMessage, previousTotalMessages) {
// No-op by default
}
/**
* Applies additional highlighting to the message or turn.
* @param {HTMLElement} messageElement The message element.
* @param {StyleHandle} styleHandle The style handle.
*/
applyAdditionalHighlight(messageElement, styleHandle) {
// No-op by default
}
/**
* Returns platform-specific buttons for the nav console.
* @param {FixedNavigationManager} manager The manager instance.
* @param {StyleHandle} styleHandle The style handle.
* @returns {Element[]} Array of button elements.
*/
getPlatformSpecificButtons(manager, styleHandle) {
return [];
}
/**
* Updates the state of platform-specific buttons.
* @param {HTMLButtonElement} btn The button element.
* @param {boolean} isAutoScrolling Whether auto-scroll is active.
* @param {IAutoScrollManager} autoScrollManager The auto-scroll manager instance.
*/
updatePlatformSpecificButtonState(btn, isAutoScrolling, autoScrollManager) {
// No-op by default
}
}
/**
* @class BaseTimestampAdapter
* @description Manages timestamp fetching and processing.
*/
class BaseTimestampAdapter {
constructor() {
/** @type {Map<string, Date>} */
this.cache = new Map();
this.MAX_CACHE_SIZE = 10000;
this.isInitialized = false;
}
/**
* Initializes timestamp interception logic.
*/
init() {
// No-op by default
}
/**
* Cleans up timestamp interception logic.
* Does NOT clear the data cache to allow persistence across navigation.
*/
cleanup() {
// No-op by default
}
/**
* Checks if the platform has timestamp logic implemented.
* @returns {boolean} True if supported, default is false.
*/
hasTimestampLogic() {
return false;
}
/**
* Adds a timestamp to the persistent cache with LRU logic.
* @param {string} id
* @param {Date} date
*/
addTimestamp(id, date) {
if (this.cache.size >= this.MAX_CACHE_SIZE) {
const oldestKey = this.cache.keys().next().value;
this.cache.delete(oldestKey);
}
this.cache.set(id, date);
}
/**
* Retrieves a timestamp from the persistent cache.
* @param {string} id
* @returns {Date | undefined}
*/
getTimestamp(id) {
return this.cache.get(id);
}
/**
* Synchronously checks if the timestamp feature is enabled.
* @param {object} defaultConfig
* @returns {boolean}
*/
isTimestampEnabledSync(defaultConfig) {
return false;
}
}
/**
* @class BaseUIManagerAdapter
* @description Manages general UI positioning.
*/
class BaseUIManagerAdapter {
/**
* Ensures the settings button is correctly placed in the DOM.
* @param {CustomSettingsButton} settingsButton The button component.
* @throws {Error} Must be implemented by subclasses.
*/
ensureButtonPlacement(settingsButton) {
throw new Error('ensureButtonPlacement must be implemented by the platform adapter.');
}
}
// =================================================================================
// SECTION: Platform Definition Factories
// Description: Encapsulates platform-specific constants and adapters to abstract site differences.
// =================================================================================
/**
* Returns definitions and adapters specifically for ChatGPT.
* @returns {PlatformDefinitions} The set of constants and adapters.
*/
function defineChatGPTValues() {
// =============================================================================
// SECTION: Platform Constants
// =============================================================================
// ---- Default Settings & Theme Configuration ----
const CONSTANTS = {
...SHARED_CONSTANTS,
UI_SPECS: {
...SHARED_CONSTANTS.UI_SPECS,
HEADER_POSITION_MIN_WIDTH: 960,
},
OBSERVER_OPTIONS: {
childList: true,
subtree: false,
},
Z_INDICES: {
...SHARED_CONSTANTS.Z_INDICES,
BUBBLE_NAVIGATION: 'auto',
STANDING_IMAGE: 'auto',
NAV_CONSOLE: 'auto',
},
ATTRIBUTES: {
MESSAGE_ROLE: 'data-message-author-role',
TURN_ROLE: 'data-turn',
MESSAGE_ID: 'data-message-id',
},
SELECTORS: {
// [IMPORTANT] CSS Scoping & Dynamic Selectors:
// Avoid using comma-separated lists (e.g., 'A, B') for selectors that will be dynamically
// concatenated as parent selectors in CSS generation (e.g., `${SELECTOR} .child { ... }`).
// Doing so breaks the CSS scope, causing the first selector to apply globally.
// ALWAYS use the `:is()` pseudo-class instead (e.g., ':is(A, B)').
// --- Main containers ---
MAIN_APP_CONTAINER: 'div[data-scroll-root], div:has(> main#main)',
MESSAGE_WRAPPER_FINDER: '.w-full',
// Root container for message search optimization
MESSAGES_ROOT: 'main',
// --- Message containers ---
CONVERSATION_UNIT: ':is(section[data-testid^="conversation-turn-"], article[data-testid^="conversation-turn-"])',
MESSAGE_ID_HOLDER: '[data-message-id]',
MESSAGE_ROOT_NODE: ':is(section[data-testid^="conversation-turn-"], article[data-testid^="conversation-turn-"])',
// --- Selectors for messages ---
USER_MESSAGE: 'div[data-message-author-role="user"]',
ASSISTANT_MESSAGE: 'div[data-message-author-role="assistant"]',
// --- Selectors for finding elements to tag ---
RAW_USER_BUBBLE: 'div.user-message-bubble-color',
RAW_ASSISTANT_BUBBLE: 'div:has(> .markdown)',
RAW_ASSISTANT_BUBBLE_FINDER: '.markdown',
ASSISTANT_MESSAGE_CONTENT: 'div.markdown.prose',
RAW_USER_IMAGE_BUBBLE: 'div.overflow-hidden:has(img)',
RAW_ASSISTANT_IMAGE_BUBBLE: 'div.group\\/imagegen-image',
// --- Text content ---
USER_TEXT_CONTENT: '.whitespace-pre-wrap',
ASSISTANT_TEXT_CONTENT: '.markdown',
// --- Input area ---
INPUT_AREA_BG_TARGET: 'form[data-type="unified-composer"] div[style*="border-radius"]',
INPUT_TEXT_FIELD_TARGET: 'div.ProseMirror#prompt-textarea',
INPUT_RESIZE_TARGET: 'form[data-type="unified-composer"]',
// --- Input area (Button Injection) ---
INSERTION_ANCHOR: 'form[data-type="unified-composer"] div[class*="[grid-area:trailing]"]',
// --- Avatar area ---
AVATAR_USER: ':is(section[data-turn="user"], article[data-turn="user"])',
AVATAR_ASSISTANT: ':is(section[data-turn="assistant"], article[data-turn="assistant"])',
// --- Selectors for Avatar ---
SIDE_AVATAR_CONTAINER: '.side-avatar-container',
SIDE_AVATAR_CONTAINER_CLASS: 'side-avatar-container',
SIDE_AVATAR_ICON: '.side-avatar-icon',
SIDE_AVATAR_NAME: '.side-avatar-name',
// --- Other UI Selectors ---
SIDEBAR_WIDTH_TARGET: 'div[id="stage-slideover-sidebar"]',
SIDEBAR_STATE_INDICATOR: '#stage-sidebar-tiny-bar',
RIGHT_SIDEBAR: '[data-testid="stage-thread-flyout"], div.bg-token-sidebar-surface-primary.shrink-0:not(#stage-slideover-sidebar)',
CHAT_CONTENT_MAX_WIDTH: ':is(.group\\/turn-messages, div[class*="--thread-content-max-width"].grid)',
SCROLL_CONTAINER: 'div[data-scroll-root], div:has(> main#main)',
STANDING_IMAGE_ANCHOR: '.group\\/turn-messages, div[class*="--thread-content-max-width"].grid',
PLACEHOLDER_PREFIX: 'placeholder-request-',
SCROLL_TO_BOTTOM_BUTTON: '#thread-bottom-container button.absolute.z-30.rounded-full',
// --- Site Specific Selectors ---
BUTTON_SHARE_CHAT: '[data-testid="share-chat-button"]',
PAGE_HEADER: '#page-header',
TITLE_OBSERVER_TARGET: 'title',
// --- Header Integration Selectors ---
HEADER_ACTIONS: '#conversation-header-actions',
HEADER_FALLBACK_SECTION: '#page-header > div:last-child',
// --- BubbleFeature-specific Selectors ---
BUBBLE_FEATURE_MESSAGE_CONTAINERS: 'div[data-message-author-role]',
// --- FixedNav-specific Selectors ---
FIXED_NAV_INPUT_AREA_TARGET: 'form[data-type="unified-composer"]',
FIXED_NAV_MESSAGE_CONTAINERS: 'div[data-message-author-role]',
FIXED_NAV_ROLE_USER: 'user',
FIXED_NAV_ROLE_ASSISTANT: 'assistant',
// --- Turn Completion Selector ---
TURN_COMPLETE_SELECTOR: 'button[data-testid="copy-turn-action-button"]',
// --- Canvas ---
CANVAS_CONTAINER: 'section.popover button',
CANVAS_RESIZE_TARGET: 'section.popover',
// --- Research Panel ---
RESEARCH_PANEL: '[data-testid="screen-threadFlyOut"]',
SIDEBAR_SURFACE_PRIMARY: 'div[class*="bg-token-sidebar-surface-primary"]',
// --- Deep Research ---
DEEP_RESEARCH_RESULT: '.deep-research-result',
// --- Style Optimization Selectors (JS-based :has replacement) ---
PROJECT_PAGE_CLASS: `${APPID}-project-page`,
PROJECT_TITLE_INPUT: '[name="project-title"]',
CONTENT_FADE_TOP: '.content-fade-top',
// --- Sidebar Active Item (Title Fallback) ---
SIDEBAR_ACTIVE_LINK: 'a[data-active][data-sidebar-item="true"]',
SIDEBAR_LINK_TEXT: '.truncate',
},
URL_PATTERNS: {
EXCLUDED: [/^\/library/, /^\/codex/, /^\/gpts/, /^\/images/, /^\/apps/],
CONVERSATION_ENDPOINT: '/backend-api/conversation',
},
STRINGS: {
PAGE_TITLE_PREFIX: 'ChatGPT - ',
},
};
// ---- Site-specific Style Variables ----
const UI_PALETTE = {
bg: 'var(--main-surface-primary)',
input_bg: 'var(--bg-primary)',
text_primary: 'var(--text-primary)',
text_secondary: 'var(--text-secondary)',
border: 'var(--border-default)',
border_medium: 'var(--border-medium)',
border_light: 'var(--border-light)',
btn_bg: 'var(--interactive-bg-tertiary-default)',
btn_hover_bg: 'var(--interactive-bg-secondary-hover)',
btn_text: 'var(--text-primary)',
btn_border: 'var(--border-default)',
toggle_bg_off: 'var(--bg-primary)',
toggle_bg_on: 'var(--text-accent)',
toggle_knob: 'var(--text-primary)',
danger_text: 'var(--text-danger)',
accent_text: 'var(--text-accent)',
loading_spinner: '#ffca28',
// Shared properties
slider_display_text: 'var(--text-primary)',
label_text: 'var(--text-secondary)',
error_text: 'var(--text-danger)',
// Component Specifics: Settings Button
settings_btn_width: 'calc(var(--spacing)*9)',
settings_btn_height: 'calc(var(--spacing)*9)',
settings_btn_color: 'var(--text-primary)',
settings_btn_hover_bg: 'var(--interactive-bg-secondary-hover)',
// Component Specifics: Theme Modal
delete_confirm_btn_text: 'var(--interactive-label-danger-secondary-default)',
delete_confirm_btn_bg: 'var(--interactive-bg-danger-secondary-default)',
delete_confirm_btn_hover_text: 'var(--interactive-label-danger-secondary-hover)',
delete_confirm_btn_hover_bg: 'var(--interactive-bg-danger-secondary-hover)',
// Component Specifics: Fixed Nav
fixed_nav_bg: 'var(--sidebar-surface-primary)',
fixed_nav_border: 'var(--border-medium)',
fixed_nav_separator_bg: 'var(--border-default)',
fixed_nav_label_text: 'var(--text-secondary)',
fixed_nav_counter_bg: 'var(--bg-primary)',
fixed_nav_counter_text: 'var(--text-primary)',
fixed_nav_counter_border: 'var(--border-default)',
fixed_nav_assistant_text: '#e57373',
fixed_nav_btn_accent_text: 'var(--text-accent)',
fixed_nav_btn_danger_text: 'var(--text-danger)',
fixed_nav_highlight_outline: 'var(--text-accent)',
fixed_nav_highlight_radius: '12px',
// Component Specifics: Jump List
jump_list_bg: 'var(--sidebar-surface-primary)',
jump_list_border: 'var(--border-medium)',
jump_list_hover_outline: 'var(--text-accent)',
jump_list_current_outline: 'var(--text-accent)',
};
const SITE_STYLES = {
PALETTE: UI_PALETTE,
Z_INDICES: CONSTANTS.Z_INDICES,
};
// =================================================================================
// SECTION: Platform-Specific Adapter Classes
// Description: Implementation of Base Adapters for ChatGPT.
// =================================================================================
class ChatGPTGeneralAdapter extends BaseGeneralAdapter {
/** @override */
isCanvasModeActive() {
return !!document.querySelector(CONSTANTS.SELECTORS.CANVAS_CONTAINER);
}
/** @override */
isExcludedPage() {
const excludedPatterns = CONSTANTS.URL_PATTERNS.EXCLUDED;
const pathname = window.location.pathname;
return excludedPatterns.some((pattern) => pattern.test(pathname));
}
/** @override */
isNewChatPage() {
const path = window.location.pathname;
// Main new chat page or GPT/Project top page (no conversation ID)
return path === '/' || (path.startsWith('/g/') && !path.includes('/c/'));
}
/** @override */
isChatPage() {
// Any URL containing '/c/' is a conversation page
return window.location.pathname.includes('/c/');
}
/** @override */
getMessagesRoot() {
const root = document.querySelector(CONSTANTS.SELECTORS.MESSAGES_ROOT);
return root instanceof HTMLElement ? root : document.body;
}
/** @override */
getMessageId(element) {
if (!element) return null;
return element.getAttribute(CONSTANTS.ATTRIBUTES.MESSAGE_ID);
}
/** @override */
getMessageRole(messageElement) {
if (!messageElement) return null;
const role = messageElement.getAttribute(CONSTANTS.ATTRIBUTES.MESSAGE_ROLE);
if (role) {
return role;
}
// If not found, check for the turn attribute (article[data-turn])
return messageElement.getAttribute(CONSTANTS.ATTRIBUTES.TURN_ROLE);
}
/**
* @private
* @returns {string | null}
*/
_getSidebarTitle() {
// Initialize cache if it doesn't exist
this._sidebarTitleCache = this._sidebarTitleCache || { path: null, title: null };
const currentPath = window.location.pathname;
const activeLink = document.querySelector(CONSTANTS.SELECTORS.SIDEBAR_ACTIVE_LINK);
if (activeLink) {
const truncateEl = activeLink.querySelector(CONSTANTS.SELECTORS.SIDEBAR_LINK_TEXT);
if (truncateEl) {
const title = truncateEl.textContent.trim();
// Update cache on successful retrieval
this._sidebarTitleCache = { path: currentPath, title: title };
return title;
}
}
// Fallback to cache if the DOM element is temporarily missing (e.g., during renaming)
// but only if we are still on the same page.
if (this._sidebarTitleCache.path === currentPath) {
return this._sidebarTitleCache.title;
}
return null;
}
/** @override */
getChatTitle() {
// Gets the title from the document title, falling back to the sidebar if stale.
const docTitle = document.title.trim();
// If the title is the placeholder "ChatGPT" during navigation,
// do not apply the fallback logic to maintain the Defer mechanism in ThemeManager.
if (docTitle === 'ChatGPT') {
return docTitle;
}
const sidebarTitle = this._getSidebarTitle();
// If sidebar title exists and is NOT included in the document title,
// it means the document title is stale (e.g., in a Project context after new chat).
if (sidebarTitle && !docTitle.includes(sidebarTitle)) {
// Try to construct a proper title if it looks like a project context
// Format: "ChatGPT - [Project Name]"
const projectPrefix = CONSTANTS.STRINGS.PAGE_TITLE_PREFIX;
if (docTitle.startsWith(projectPrefix)) {
const projectName = docTitle.substring(projectPrefix.length);
// Construct the expected format: "[Project Name] - [Chat Title]"
return `${projectName} - ${sidebarTitle}`;
}
// Fallback to just returning the sidebar title if the structure is unexpected
return sidebarTitle;
}
return docTitle;
}
/** @override */
getJumpListDisplayText(messageElement) {
const role = this.getMessageRole(messageElement);
// 1. Check for text content first.
if (role === CONSTANTS.SELECTORS.FIXED_NAV_ROLE_USER) {
const contentEl = messageElement.querySelector(CONSTANTS.SELECTORS.USER_TEXT_CONTENT);
if (contentEl) return contentEl.textContent.trim();
} else {
const contentEl = messageElement.querySelector(CONSTANTS.SELECTORS.ASSISTANT_TEXT_CONTENT);
if (contentEl) return contentEl.textContent.trim();
}
// 2. If no text, check for an image within the message container.
if (messageElement.querySelector(CONSTANTS.SELECTORS.RAW_USER_IMAGE_BUBBLE) || messageElement.querySelector(CONSTANTS.SELECTORS.RAW_ASSISTANT_IMAGE_BUBBLE)) {
return '(Image)';
}
// 3. If neither, return empty.
return '';
}
/** @override */
findMessageElement(contentElement) {
const messageElement = contentElement.closest(CONSTANTS.SELECTORS.BUBBLE_FEATURE_MESSAGE_CONTAINERS);
// Filter out placeholder elements created during generation to prevent false positives (NAV SKIP logs).
// These elements often lack the necessary structure for UI injection immediately after creation.
if (messageElement instanceof HTMLElement) {
const messageId = this.getMessageId(messageElement);
if (messageId && messageId.startsWith(CONSTANTS.SELECTORS.PLACEHOLDER_PREFIX)) {
return null;
}
return messageElement;
}
return null;
}
/** @override */
filterMessage(messageElement) {
// Filter out placeholder elements created during generation.
// Including these would cause the message count to fluctuate incorrectly during streaming.
const messageId = this.getMessageId(messageElement);
if (messageId && messageId.startsWith(CONSTANTS.SELECTORS.PLACEHOLDER_PREFIX)) {
return false;
}
const role = this.getMessageRole(messageElement);
if (role === CONSTANTS.SELECTORS.FIXED_NAV_ROLE_ASSISTANT) {
// Check if the message has any visible content, either text or an image generated by our script.
const hasText = messageElement.querySelector(CONSTANTS.SELECTORS.ASSISTANT_TEXT_CONTENT)?.textContent?.trim();
const hasImage = messageElement.querySelector(CONSTANTS.SELECTORS.RAW_ASSISTANT_IMAGE_BUBBLE);
// If it has neither text nor an image inside it, check the turn context.
if (!hasText && !hasImage) {
const turnContainer = messageElement.closest(CONSTANTS.SELECTORS.CONVERSATION_UNIT);
// If the turn contains an image elsewhere, this empty message is likely a ghost artifact. Filter it out.
if (turnContainer && turnContainer.querySelector(CONSTANTS.SELECTORS.RAW_ASSISTANT_IMAGE_BUBBLE)) {
return false; // Exclude this ghost message
}
}
}
return true; // Keep all other messages
}
/** @override */
ensureMessageContainerForImage(imageContentElement) {
// If already inside a message container, do nothing and return it.
const existingContainer = imageContentElement.closest(CONSTANTS.SELECTORS.BUBBLE_FEATURE_MESSAGE_CONTAINERS);
if (existingContainer instanceof HTMLElement) {
return existingContainer;
}
// Create a new virtual message container.
const virtualMessage = h('div', {
'data-message-author-role': 'assistant',
});
if (!(virtualMessage instanceof HTMLElement)) {
return null;
}
// Replace the image element with the new wrapper, and move the image inside.
imageContentElement.parentNode.insertBefore(virtualMessage, imageContentElement);
virtualMessage.appendChild(imageContentElement);
return virtualMessage;
}
/** @override */
initializeSentinel(callback) {
// prettier-ignore
const combinedSelector = [
`${CONSTANTS.SELECTORS.USER_MESSAGE} ${CONSTANTS.SELECTORS.RAW_USER_BUBBLE}`,
`${CONSTANTS.SELECTORS.USER_MESSAGE} ${CONSTANTS.SELECTORS.RAW_USER_IMAGE_BUBBLE}`,
`${CONSTANTS.SELECTORS.ASSISTANT_MESSAGE} ${CONSTANTS.SELECTORS.ASSISTANT_MESSAGE_CONTENT}`,
`${CONSTANTS.SELECTORS.RAW_ASSISTANT_IMAGE_BUBBLE}`,
].join(', ');
sentinel.on(combinedSelector, callback);
return () => {
sentinel.off(combinedSelector, callback);
};
}
/** @override */
performInitialScan(lifecycleManager) {
Logger.debug('SCAN', LOG_STYLES.CYAN, 'Performing initial scan for message elements.');
// prettier-ignore
const selectors = [
CONSTANTS.SELECTORS.RAW_USER_BUBBLE,
CONSTANTS.SELECTORS.ASSISTANT_MESSAGE_CONTENT,
CONSTANTS.SELECTORS.RAW_USER_IMAGE_BUBBLE,
CONSTANTS.SELECTORS.RAW_ASSISTANT_IMAGE_BUBBLE
];
const selector = selectors.join(', ');
const nodes = document.querySelectorAll(selector);
nodes.forEach((node) => {
lifecycleManager.processRawMessage(node);
});
if (nodes.length > 0) {
Logger.log('', '', `Found ${nodes.length} item(s) on initial scan.`);
}
return nodes.length;
}
/** @override */
onNavigationEnd(lifecycleManager) {
// Schedule integrity scan for all browsers on existing chat pages.
if (!isNewChatPage()) {
Logger.log('', '', 'Scheduling integrity scan to capture any missed messages.');
lifecycleManager.scheduleIntegrityScan();
}
}
/** @override */
scrollTo(element) {
const offset = CONSTANTS.RETRY.SCROLL_OFFSET_FOR_NAV;
const behavior = 'auto';
const scrollContainerSelector = CONSTANTS.SELECTORS.SCROLL_CONTAINER;
const scrollContainer = scrollContainerSelector ? document.querySelector(scrollContainerSelector) : null;
if (scrollContainer) {
let scrollTargetElement = element;
// Determine the bubble element based on role to use as the scroll target
const role = this.getMessageRole(element);
if (role === CONSTANTS.SELECTORS.FIXED_NAV_ROLE_USER) {
const bubble = element.querySelector(CONSTANTS.SELECTORS.RAW_USER_BUBBLE);
if (bubble) scrollTargetElement = bubble;
} else if (role === CONSTANTS.SELECTORS.FIXED_NAV_ROLE_ASSISTANT) {
const content = element.querySelector(CONSTANTS.SELECTORS.RAW_ASSISTANT_BUBBLE_FINDER);
if (content && content.parentElement) scrollTargetElement = content.parentElement;
}
const targetScrollTop = scrollContainer.scrollTop + scrollTargetElement.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top - offset;
scrollContainer.scrollTo({
top: targetScrollTop,
behavior,
});
}
}
}
class ChatGPTStyleManagerAdapter extends BaseStyleManagerAdapter {
/** @override */
getStaticCss(cls) {
return `
:root {
${CSS_VARS.MESSAGE_MARGIN_TOP}: 24px;
}
${CONSTANTS.SELECTORS.MAIN_APP_CONTAINER} {
transition: background-image 0.3s ease-in-out;
}
/* Add margin between messages to prevent overlap */
${CONSTANTS.SELECTORS.USER_MESSAGE},
${CONSTANTS.SELECTORS.ASSISTANT_MESSAGE} {
margin-top: var(${CSS_VARS.MESSAGE_MARGIN_TOP});
}
${CONSTANTS.SELECTORS.USER_MESSAGE} ${CONSTANTS.SELECTORS.RAW_USER_BUBBLE},
${CONSTANTS.SELECTORS.ASSISTANT_MESSAGE} ${CONSTANTS.SELECTORS.RAW_ASSISTANT_BUBBLE} {
box-sizing: border-box;
}
/* (2025/12/17 updated) Hide borders, shadows, and backgrounds on the header */
#page-header {
background: none !important;
border: none !important;
box-shadow: none !important;
outline: none !important;
}
/* Remove pseudo-elements that might create borders or shadows */
#page-header::after,
#page-header::before {
display: none !important;
}
/* Remove standalone border elements */
div[data-edge="true"] {
display: none !important;
}
${CONSTANTS.SELECTORS.BUTTON_SHARE_CHAT} {
background: transparent;
}
${CONSTANTS.SELECTORS.BUTTON_SHARE_CHAT}:hover {
background-color: var(--interactive-bg-secondary-hover);
}
/* (2025/07/01) ChatGPT UI change fix: Remove bottom gradient that conflicts with theme backgrounds. */
.content-fade::after {
background: none !important;
}
/* (2025/12/06) Project page top fade fix: Remove top gradient and mask only for project headers. */
main ${CONSTANTS.SELECTORS.CONTENT_FADE_TOP}.${CONSTANTS.SELECTORS.PROJECT_PAGE_CLASS},
main ${CONSTANTS.SELECTORS.CONTENT_FADE_TOP}.${CONSTANTS.SELECTORS.PROJECT_PAGE_CLASS}::before,
main ${CONSTANTS.SELECTORS.CONTENT_FADE_TOP}.${CONSTANTS.SELECTORS.PROJECT_PAGE_CLASS}::after {
background: none !important;
mask-image: none !important;
-webkit-mask-image: none !important;
}
/* This rule is now conditional on a body class and scoped to the scroll container to avoid affecting other elements. */
body.${cls.maxWidthActive} main ${CONSTANTS.SELECTORS.CHAT_CONTENT_MAX_WIDTH} {
max-width: var(${CSS_VARS.CHAT_CONTENT_MAX_WIDTH}) !important;
}
/* Hide default scroll-to-bottom button */
${CONSTANTS.SELECTORS.SCROLL_TO_BOTTOM_BUTTON} {
display: none !important;
}
`;
}
/** @override */
getBubbleCss(cls) {
return StyleTemplates.getBubbleUiCss(cls, {
// ChatGPT: Default class selector is sufficient for parent
collapsibleParentSelector: `.${cls.collapsibleParent}`,
// ChatGPT: Button positioning depends on role attribute
collapsibleBtnExtraCss: `
${CONSTANTS.SELECTORS.ASSISTANT_MESSAGE} .${cls.collapsibleBtn} {left: 4px;}
${CONSTANTS.SELECTORS.USER_MESSAGE} .${cls.collapsibleBtn} {right: 4px;}
`,
// ChatGPT: No extra CSS needed (native reflow works)
collapsibleCollapsedContentExtraCss: ``,
});
}
}
class ChatGPTThemeManagerAdapter extends BaseThemeManagerAdapter {
/** @override */
shouldDeferInitialTheme(themeManager) {
const initialTitle = themeManager.getChatTitleAndCache();
// Defer if the title is the ambiguous "ChatGPT" and we are NOT on the "New Chat" page.
// This indicates a transition to a specific chat page that hasn't loaded its final title yet.
if (initialTitle === 'ChatGPT' && !isNewChatPage()) {
Logger.log('', '', 'Initial theme application deferred by platform adapter, waiting for final title.');
return true;
}
return false;
}
/** @override */
selectThemeForUpdate(themeManager, config, urlChanged, titleChanged) {
const currentTitle = themeManager.getChatTitleAndCache();
// 1. Invalidate cache on URL change to force pattern re-evaluation.
if (urlChanged) {
themeManager.cachedThemeSet = null;
}
// 2. Get the candidate theme based on the current context (URL, Title).
const candidateTheme = themeManager.getThemeSet();
// 3. Flicker prevention logic:
// If the URL changed, the title is currently "ChatGPT" (loading),
// and the resolved theme is the default one (meaning no URL pattern matched),
// then keep the previous theme to avoid a flash of the default theme before the title loads.
// Exception: Do not maintain the previous theme if we are navigating to the "New Chat" page.
const isDefaultTheme = candidateTheme.metadata.id === CONSTANTS.THEME_IDS.DEFAULT;
const shouldKeepPreviousTheme = urlChanged && currentTitle === 'ChatGPT' && isDefaultTheme && themeManager.lastAppliedThemeSet && !isNewChatPage();
if (shouldKeepPreviousTheme) {
return themeManager.lastAppliedThemeSet;
}
// Otherwise, apply the candidate theme immediately.
// This handles cases where:
// - URL patterns matched (candidate is not default) -> Instant switch
// - Title is already loaded -> Correct theme
// - Navigating to New Chat -> Default theme
return candidateTheme;
}
/** @override */
getStyleOverrides() {
return {
user: ' margin-left: auto; margin-right: 0;',
assistant: ' margin-left: 0; margin-right: auto;',
};
}
}
class ChatGPTBubbleUIAdapter extends BaseBubbleUIAdapter {
/** @override */
getNavPositioningParent(messageElement) {
// 1. Handle text content first (most common case)
const role = PlatformAdapters.General.getMessageRole(messageElement);
if (role === CONSTANTS.SELECTORS.FIXED_NAV_ROLE_ASSISTANT) {
const contentEl = messageElement.querySelector(CONSTANTS.SELECTORS.ASSISTANT_MESSAGE_CONTENT);
// Structure: Row (positioningParent) > Bubble > Content (contentEl)
if (contentEl && contentEl.parentElement && contentEl.parentElement.parentElement instanceof HTMLElement) {
return contentEl.parentElement.parentElement;
}
} else {
const textBubbleParent = messageElement.querySelector(CONSTANTS.SELECTORS.RAW_USER_BUBBLE)?.parentElement;
if (textBubbleParent instanceof HTMLElement) {
return textBubbleParent;
}
}
// 2. If no text, it might be an image-only message element.
if (role === CONSTANTS.SELECTORS.FIXED_NAV_ROLE_USER) {
// Find the image within this specific user message element
const userImageContainer = messageElement.querySelector(CONSTANTS.SELECTORS.RAW_USER_IMAGE_BUBBLE);
if (userImageContainer && userImageContainer.parentElement instanceof HTMLElement) {
return userImageContainer.parentElement;
}
} else if (role === CONSTANTS.SELECTORS.FIXED_NAV_ROLE_ASSISTANT) {
// For assistants, search *within* this messageElement for an image.
// This prevents empty message shells from finding images elsewhere in the turn.
const assistantImageContainer = messageElement.querySelector(CONSTANTS.SELECTORS.RAW_ASSISTANT_IMAGE_BUBBLE);
if (assistantImageContainer instanceof HTMLElement && assistantImageContainer.parentElement instanceof HTMLElement) {
// Return the PARENT of the image container as the anchor
return assistantImageContainer.parentElement;
}
}
return null;
}
/** @override */
getCollapsibleInfo(messageElement) {
const msgWrapper = messageElement.closest(CONSTANTS.SELECTORS.MESSAGE_WRAPPER_FINDER);
if (!(msgWrapper instanceof HTMLElement)) return null;
const role = messageElement.getAttribute(CONSTANTS.ATTRIBUTES.MESSAGE_ROLE);
let bubbleElement = null;
if (role === CONSTANTS.SELECTORS.FIXED_NAV_ROLE_USER) {
bubbleElement = messageElement.querySelector(CONSTANTS.SELECTORS.RAW_USER_BUBBLE);
} else {
const contentEl = messageElement.querySelector(CONSTANTS.SELECTORS.ASSISTANT_MESSAGE_CONTENT);
if (contentEl) {
bubbleElement = contentEl.parentElement;
}
}
if (!(bubbleElement instanceof HTMLElement)) return null;
const positioningParent = bubbleElement.parentElement;
if (!(positioningParent instanceof HTMLElement)) return null;
return { msgWrapper, bubbleElement, positioningParent };
}
}
class ChatGPTToastAdapter extends BaseToastAdapter {
/** @override */
getAutoScrollMessage() {
return 'Scanning layout to prevent scroll issues...';
}
/** @override */
getToastPositionX() {
// Use the input resize target (form container) as it spans the correct width
const inputArea = document.querySelector(CONSTANTS.SELECTORS.INPUT_RESIZE_TARGET);
if (inputArea instanceof HTMLElement && inputArea.offsetWidth > 0) {
const rect = inputArea.getBoundingClientRect();
return rect.left + rect.width / 2;
}
return null;
}
}
class ChatGPTAppControllerAdapter extends BaseAppControllerAdapter {
/** @override */
initializePlatformManagers(controller) {
// =================================================================================
// SECTION: Auto Scroll Manager
// Description: Manages the "layout scan" (simulated PageUp scroll)
// to force layout calculation and prevent scroll anchoring issues.
// =================================================================================
/**
* @class AutoScrollManager
* @extends BaseAutoScrollManager
*/
class AutoScrollManager extends BaseAutoScrollManager {
static CONFIG = {
// The minimum number of messages required to trigger the auto-scroll feature.
MESSAGE_THRESHOLD: 5, // Lower threshold for ChatGPT as it's for layout scanning
// Delay between simulated PageUp scrolls (in ms)
SCAN_INTERVAL_MS: 30,
// Multiplier for scroll step to speed up scanning
SCAN_STEP_MULTIPLIER: 5,
};
/**
* @param {ConfigManager} configManager
* @param {MessageCacheManager} messageCacheManager
* @param {MessageLifecycleManager} messageLifecycleManager
*/
constructor(configManager, messageCacheManager, messageLifecycleManager) {
super(configManager, messageCacheManager);
this.messageLifecycleManager = messageLifecycleManager;
this.scrollContainer = null;
this.isInitialScrollCheckDone = false;
this.scanLoopId = null; // Use for setTimeout loop
this.boundStop = null;
this.isLayoutScanComplete = false;
}
/** @override */
_onInit() {
super._onInit();
this.isLayoutScanComplete = false;
}
async start() {
if (!isFirefox()) return;
if (this.isScrolling || this.isLayoutScanComplete) return;
// Set the flag immediately to prevent re-entrancy from other events (e.g. button mashing).
this.isScrolling = true;
Logger.log('', '', 'AutoScrollManager: Starting layout scan (Unthrottled rAF).');
const scrollContainerEl = document.querySelector(CONSTANTS.SELECTORS.SCROLL_CONTAINER);
if (!(scrollContainerEl instanceof HTMLElement)) {
Logger.warn('AUTOSCROLL WARN', LOG_STYLES.YELLOW, 'Could not find scroll container.');
this.isScrolling = false;
return;
}
this.scrollContainer = scrollContainerEl;
EventBus.publish(EVENTS.AUTO_SCROLL_START);
EventBus.publish(EVENTS.SUSPEND_OBSERVERS);
// Hide the container to prevent visual flickering
this.scrollContainer.style.transition = 'none';
this.scrollContainer.style.opacity = '0';
this.boundStop = () => this.stop(false);
this.scrollContainer.addEventListener('wheel', this.boundStop, { passive: true, once: true });
this.scrollContainer.addEventListener('touchmove', this.boundStop, { passive: true, once: true });
const originalScrollTop = this.scrollContainer.scrollTop;
// Force scroll to the bottom to ensure the scan starts from the end.
this.scrollContainer.scrollTop = this.scrollContainer.scrollHeight;
const scanPageUp = () => {
if (!this.isScrolling || !this.scrollContainer) return; // Stop if cancelled
const currentTop = this.scrollContainer.scrollTop;
if (currentTop <= 0) {
// Reached the top, restore and stop
this.scrollContainer.scrollTop = originalScrollTop; // Restore original position
this.isLayoutScanComplete = true; // Set completion flag
this.stop(false);
return;
}
// Scroll up by multiple pages to speed up the scan
const stepSize = this.scrollContainer.clientHeight * AutoScrollManager.CONFIG.SCAN_STEP_MULTIPLIER;
this.scrollContainer.scrollTop = Math.max(0, currentTop - stepSize);
// Continue loop via requestAnimationFrame
this.scanLoopId = requestAnimationFrame(scanPageUp);
};
// Start the loop
// Add a minimal delay to ensure scrollTop change is registered before scan starts
this.scanLoopId = requestAnimationFrame(scanPageUp);
}
stop(isNavigation) {
if (!this.isScrolling && !this.scanLoopId) return; // Prevent multiple stops
Logger.log('', '', 'AutoScrollManager: Stopping layout scan.');
this.isScrolling = false;
if (this.scanLoopId) {
cancelAnimationFrame(this.scanLoopId);
this.scanLoopId = null;
}
// Restore visibility
if (this.scrollContainer) {
this.scrollContainer.style.opacity = '1';
this.scrollContainer.style.transition = '';
}
this.scrollContainer = null;
// Cleanup listeners
if (this.boundStop) {
this.scrollContainer?.removeEventListener('wheel', this.boundStop);
this.scrollContainer?.removeEventListener('touchmove', this.boundStop);
this.boundStop = null;
}
EventBus.publish(EVENTS.AUTO_SCROLL_COMPLETE);
// On navigation, ObserverManager handles observer resumption.
// All other post-scan logic (DOM rescan, cache update) is now handled by the listener that *requested* the scan.
if (!isNavigation) {
EventBus.publish(EVENTS.RESUME_OBSERVERS);
}
}
/**
* @private
* @description Defines the logic to run *after* a scan completes.
*/
_onScanComplete() {
// Run the manual scan to create any missing message wrappers
if (this.messageLifecycleManager) {
this.messageLifecycleManager.scanForUnprocessedMessages();
}
// Immediately request a cache update to reflect the scan
EventBus.publish(EVENTS.CACHE_UPDATE_REQUEST);
}
/** @override */
_onCacheUpdated() {
if (!isFirefox()) return;
if (!this.isEnabled || this.isInitialScrollCheckDone || this.isScrolling) {
return;
}
const messageCount = this.messageCacheManager.getTotalMessages().length;
// Wait for at least one message to ensure history has started loading (First Paint logic)
if (messageCount === 0) return;
// Latch on first data: Mark initialization as done immediately regardless of threshold.
// This prevents subsequent message additions (e.g. user sending a message) from triggering a delayed scan.
this.isInitialScrollCheckDone = true;
if (messageCount >= AutoScrollManager.CONFIG.MESSAGE_THRESHOLD) {
Logger.log('', '', `AutoScrollManager: ${messageCount} messages found. Triggering layout scan.`);
// Register the post-scan logic to run *once* on completion
this._subscribeOnce(EVENTS.AUTO_SCROLL_COMPLETE, () => this._onScanComplete());
// Start the scan
EventBus.publish(EVENTS.AUTO_SCROLL_REQUEST);
} else {
Logger.log('', '', `AutoScrollManager: ${messageCount} messages found (below threshold). Layout scan skipped.`);
}
}
/** @override */
_onNavigation() {
if (this.isScrolling) {
// Stop scroll without triggering a UI refresh, as a new page is loading.
this.stop(true);
}
this.isInitialScrollCheckDone = false;
this.isLayoutScanComplete = false;
}
}
controller.autoScrollManager = controller.manageFactory(CONSTANTS.RESOURCE_KEYS.AUTO_SCROLL_MANAGER, () => new AutoScrollManager(controller.configManager, controller.messageCacheManager, controller.messageLifecycleManager));
}
/** @override */
applyPlatformSpecificUiUpdates(controller, newConfig) {
// Enable or disable the auto-scroll manager based on the new config.
if (newConfig.platforms[PLATFORM].features.load_full_history_on_chat_load.enabled) {
controller.autoScrollManager?.enable();
} else {
controller.autoScrollManager?.disable();
}
}
}
class ChatGPTAvatarAdapter extends BaseAvatarAdapter {
/** @override */
getCss() {
const extraCss = `
/* Set the message ID holder as the positioning anchor for Timestamps */
${CONSTANTS.SELECTORS.CONVERSATION_UNIT} ${CONSTANTS.SELECTORS.MESSAGE_ID_HOLDER} {position: relative !important;}
`;
return StyleTemplates.getAvatarCss(extraCss);
}
/** @override */
measureAvatarTarget(msgElem) {
let turnContainer;
// Check if msgElem is the turn container (article) or a message element (div) inside it
if (msgElem.matches(CONSTANTS.SELECTORS.CONVERSATION_UNIT)) {
// Case 1: msgElem is the ARTICLE (from self-healing Sentinel)
turnContainer = msgElem;
} else {
// Case 2: msgElem is the DIV (from initial Sentinel or ensureMessageContainerForImage)
turnContainer = msgElem.closest(CONSTANTS.SELECTORS.CONVERSATION_UNIT);
}
if (!turnContainer) return null;
const centeredWrapper = turnContainer.querySelector(CONSTANTS.SELECTORS.CHAT_CONTENT_MAX_WIDTH);
if (!centeredWrapper) return null;
// Check if avatar container already exists *inside the centered wrapper*.
if (centeredWrapper.getElementsByClassName(CONSTANTS.SELECTORS.SIDE_AVATAR_CONTAINER_CLASS).length > 0) {
// Already present. Return context to ensure processed class is added, but do not inject.
return {
shouldInject: false,
targetElement: null,
processedTarget: turnContainer,
exclusionKey: turnContainer,
originalElement: msgElem,
};
}
// Find the *first* message element within this turn.
const firstMessageElement = turnContainer.querySelector(CONSTANTS.SELECTORS.BUBBLE_FEATURE_MESSAGE_CONTAINERS);
if (!(firstMessageElement instanceof HTMLElement)) {
return null; // No message element found to attach to
}
// Guard: Skip avatar injection for Deep Research result containers.
// These containers have their own layout that conflicts with the avatar.
if (firstMessageElement.querySelector(CONSTANTS.SELECTORS.DEEP_RESEARCH_RESULT)) {
return null;
}
return {
shouldInject: true,
targetElement: firstMessageElement,
processedTarget: turnContainer,
exclusionKey: turnContainer,
originalElement: msgElem,
};
}
/** @override */
injectAvatar(measurement, avatarContainer) {
const { shouldInject, targetElement } = measurement;
// Inject the avatar directly into the *first message element*
if (shouldInject && targetElement && avatarContainer) {
targetElement.prepend(avatarContainer);
}
}
}
class ChatGPTStandingImageAdapter extends BaseStandingImageAdapter {
/** @override */
async recalculateLayout(instance) {
const rootStyle = document.documentElement.style;
const cls = instance.style.classes;
const v = instance.style.vars;
// Check for Canvas mode
const isCanvasActive = PlatformAdapters.General.isCanvasModeActive();
// Check for Right Sidebar (Activity Panel)
const rightSidebar = document.querySelector(CONSTANTS.SELECTORS.RIGHT_SIDEBAR);
let isRightSidebarOpen = false;
if (rightSidebar instanceof HTMLElement && rightSidebar.offsetWidth > 0) {
const rect = rightSidebar.getBoundingClientRect();
// Robustness check: Ensure it's actually on the right side
if (rect.left > window.innerWidth / 2) {
isRightSidebarOpen = true;
}
}
// If canvas mode is active or the activity panel is open, hide standing images.
if (isCanvasActive || isRightSidebarOpen) {
rootStyle.setProperty(v.assistantWidth, '0px');
rootStyle.setProperty(v.userWidth, '0px');
return;
}
// --- Determine Message Area Rect ---
let chatRect = null;
const isNewChat = PlatformAdapters.General.isNewChatPage();
if (isNewChat) {
// On new chat pages, do not wait for the element. Calculate virtual rect immediately.
// We assume standard centering logic for the virtual area.
} else {
// On existing chat pages, find the content synchronously.
// If not found, abort immediately. Sentinel will trigger an update when it appears.
const chatContent = document.querySelector(CONSTANTS.SELECTORS.CHAT_CONTENT_MAX_WIDTH);
if (chatContent) {
chatRect = chatContent.getBoundingClientRect();
} else {
// Abort to avoid visual bugs.
return;
}
}
await withLayoutCycle({
measure: () => {
// --- Read Phase ---
const assistantImg = document.getElementById(cls.assistantImageId);
const userImg = document.getElementById(cls.userImageId);
return {
chatRect: chatRect, // Can be null (for virtual calculation) or pre-fetched rect
sidebarWidth: getSidebarWidth(),
windowWidth: window.innerWidth,
windowHeight: window.innerHeight,
assistantImgHeight: assistantImg ? assistantImg.offsetHeight : 0,
userImgHeight: userImg ? userImg.offsetHeight : 0,
};
},
mutate: (measured) => {
// --- Write Phase ---
if (instance.isDestroyed) return;
if (!measured) return;
const { sidebarWidth, windowWidth, windowHeight, assistantImgHeight, userImgHeight } = measured;
let { chatRect } = measured;
const config = instance.configManager.get();
// --- Virtual Rect Calculation (if needed) ---
if (!chatRect) {
// Default width fallback (50vw per requirement)
let targetWidth = windowWidth * 0.5;
const configWidth = config.platforms[PLATFORM].options.chat_content_max_width;
if (configWidth && typeof configWidth === 'string' && configWidth.endsWith('vw')) {
const vwValue = parseInt(configWidth, 10);
if (!isNaN(vwValue)) {
targetWidth = (windowWidth * vwValue) / 100;
}
}
// Calculate centered position relative to the available space (window - sidebar)
const availableSpace = windowWidth - sidebarWidth;
// If the configured width is wider than available space, clamp it
const effectiveWidth = Math.min(targetWidth, availableSpace);
const left = sidebarWidth + (availableSpace - effectiveWidth) / 2;
chatRect = new DOMRect(left, 0, effectiveWidth, 0);
}
// Set Assistant base position (Platform specific)
rootStyle.setProperty(v.assistantLeft, sidebarWidth + 'px');
// Calculate available widths
const assistantAvailableWidth = chatRect.left - sidebarWidth;
const userAvailableWidth = windowWidth - chatRect.right;
// Delegate common calculation
StandingImageLayout.apply(instance, {
assistantAvailableWidth,
userAvailableWidth,
assistantImgHeight,
userImgHeight,
windowHeight,
});
},
});
}
/** @override */
updateVisibility(instance) {
const isCanvasActive = PlatformAdapters.General.isCanvasModeActive();
const cls = instance.style.classes;
const v = instance.style.vars;
[cls.userImageId, cls.assistantImageId].forEach((id) => {
const imgElement = document.getElementById(id);
if (!imgElement) return;
// Determine actor based on index or ID check
const isUser = id === cls.userImageId;
const varName = isUser ? v.userImage : v.assistantImage;
const hasImage = !!document.documentElement.style.getPropertyValue(varName);
imgElement.style.opacity = hasImage && !isCanvasActive && !instance.isAutoScrolling ? '1' : '0';
});
}
}
class ChatGPTObserverAdapter extends BaseObserverAdapter {
/**
* @private
* @description Starts a stateful observer for the right sidebar (Activity/Thread flyout).
* @param {object} dependencies
* @returns {() => void}
*/
startRightSidebarObserver(dependencies) {
// Use shared logic from ObserverManager via dependencies
return dependencies.startGenericPanelObserver({
triggerSelector: CONSTANTS.SELECTORS.RIGHT_SIDEBAR,
observerType: CONSTANTS.OBSERVED_ELEMENT_TYPES.SIDE_PANEL,
targetResolver: (el) => el, // Target Resolver (Trigger is the Panel)
immediateCallback: () => EventBus.publish(EVENTS.SIDEBAR_LAYOUT_CHANGED),
});
}
/**
* @private
* @description Starts a stateful observer for the Research Panel.
* @param {object} dependencies
* @returns {() => void}
*/
startResearchPanelObserver(dependencies) {
// Use shared logic from ObserverManager via dependencies
return dependencies.startGenericPanelObserver({
triggerSelector: CONSTANTS.SELECTORS.RESEARCH_PANEL,
observerType: CONSTANTS.OBSERVED_ELEMENT_TYPES.SIDE_PANEL,
// Target Resolver: The trigger is inside a section, inside the main div.
// We need the parent div that holds the width.
targetResolver: (el) => el.closest(CONSTANTS.SELECTORS.SIDEBAR_SURFACE_PRIMARY),
immediateCallback: () => EventBus.publish(EVENTS.VISIBILITY_RECHECK),
});
}
/**
* @private
* @description Starts a stateful observer to detect the appearance and disappearance of the Canvas panel using a high-performance hybrid approach.
* @param {object} dependencies The required methods from ObserverManager.
* @returns {() => void} A cleanup function.
*/
startCanvasObserver(dependencies) {
// Use shared logic from ObserverManager via dependencies
return dependencies.startGenericPanelObserver({
triggerSelector: CONSTANTS.SELECTORS.CANVAS_CONTAINER, // Trigger (Button)
observerType: CONSTANTS.OBSERVED_ELEMENT_TYPES.SIDE_PANEL,
targetResolver: (el) => el.closest(CONSTANTS.SELECTORS.CANVAS_RESIZE_TARGET), // Target Resolver (Find Parent Panel)
immediateCallback: () => EventBus.publish(EVENTS.VISIBILITY_RECHECK),
});
}
/**
* @param {object} dependencies The dependencies passed from ObserverManager (unused in this method).
* @private
* @description Sets up the monitoring for title changes.
* @returns {() => void} A cleanup function to stop the observer.
*/
startGlobalTitleObserver(dependencies) {
let titleObserver = null;
const setupObserver = (targetElement) => {
titleObserver?.disconnect(); // Disconnect if already running
// Encapsulate state within the closure
let lastObservedTitle = (targetElement.textContent || '').trim();
const currentObservedTitleSource = targetElement;
titleObserver = new MutationObserver(() => {
const currentText = (currentObservedTitleSource?.textContent || '').trim();
if (currentText !== lastObservedTitle) {
lastObservedTitle = currentText;
EventBus.publish(EVENTS.TITLE_CHANGED);
}
});
titleObserver.observe(targetElement, {
childList: true,
characterData: true,
subtree: true,
});
};
const selector = CONSTANTS.SELECTORS.TITLE_OBSERVER_TARGET;
sentinel.on(selector, setupObserver);
const existingTarget = document.querySelector(selector);
if (existingTarget) {
setupObserver(existingTarget);
}
// Return the cleanup function for this observer.
return () => {
sentinel.off(selector, setupObserver);
titleObserver?.disconnect();
};
}
/**
* @param {object} dependencies The dependencies passed from ObserverManager (unused in this method).
* @private
* @description Sets up a robust, two-tiered observer for the sidebar.
* @returns {() => void} A cleanup function.
*/
startSidebarObserver(dependencies) {
let resizeObserver = null;
let titleObserver = null;
const debouncedTitleUpdate = debounce(() => EventBus.publish(EVENTS.TITLE_CHANGED), CONSTANTS.TIMING.DEBOUNCE_DELAYS.THEME_UPDATE, true);
const setupObserver = (sidebarContainer) => {
resizeObserver?.disconnect();
titleObserver?.disconnect();
let lastWidth = -1;
let lastHeight = -1;
resizeObserver = new ResizeObserver((entries) => {
// We only need to signal that the layout changed.
// Throttling is handled by the subscriber (ThemeManager).
let layoutChanged = false;
for (const entry of entries) {
const { width, height } = entry.contentRect;
if (width !== lastWidth || height !== lastHeight) {
lastWidth = width;
lastHeight = height;
layoutChanged = true;
}
}
if (layoutChanged) {
EventBus.publish(EVENTS.SIDEBAR_LAYOUT_CHANGED);
}
});
resizeObserver.observe(sidebarContainer);
// Added MutationObserver to detect chat renaming and selection changes
titleObserver = new MutationObserver((mutations) => {
let titleChanged = false;
for (const mutation of mutations) {
const target = mutation.target;
if (mutation.type === 'attributes' && mutation.attributeName === 'data-active') {
// Chat selection changed
titleChanged = true;
break;
} else if (mutation.type === 'childList') {
// New chat added to the list
titleChanged = true;
break;
} else if (mutation.type === 'characterData') {
// Chat renamed. Check if it's within the link text element
if (target.parentElement && target.parentElement.closest(CONSTANTS.SELECTORS.SIDEBAR_LINK_TEXT)) {
titleChanged = true;
break;
}
}
}
if (titleChanged) {
debouncedTitleUpdate();
}
});
titleObserver.observe(sidebarContainer, {
childList: true,
attributes: true,
attributeFilter: ['data-active'],
characterData: true,
subtree: true,
});
// Trigger once initially to ensure state capture
EventBus.publish(EVENTS.SIDEBAR_LAYOUT_CHANGED);
};
// Use Sentinel to detect when the sidebar container is added or re-added to the DOM.
const selector = CONSTANTS.SELECTORS.SIDEBAR_WIDTH_TARGET;
sentinel.on(selector, setupObserver);
// Initial check
const initialSidebar = document.querySelector(selector);
if (initialSidebar) {
setupObserver(initialSidebar);
}
// Cleanup
return () => {
sentinel.off(selector, setupObserver);
resizeObserver?.disconnect();
titleObserver?.disconnect();
};
}
/**
* @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) {
// Use shared logic from ObserverManager via dependencies
return dependencies.startGenericInputAreaObserver({
triggerSelector: CONSTANTS.SELECTORS.INPUT_RESIZE_TARGET,
resizeTargetSelector: CONSTANTS.SELECTORS.INPUT_RESIZE_TARGET,
});
}
/**
* @private
* @description Optimizes style application by detecting DOM elements via JS instead of using expensive CSS :has().
* Specifically handles the Project Page top gradient removal by adding a class to the container when the project title input is present.
* @param {object} dependencies
* @returns {() => void} A cleanup function.
*/
startStyleOptimizationObserver(dependencies) {
const triggerSelector = CONSTANTS.SELECTORS.PROJECT_TITLE_INPUT;
const targetSelector = CONSTANTS.SELECTORS.CONTENT_FADE_TOP;
const className = CONSTANTS.SELECTORS.PROJECT_PAGE_CLASS;
const listener = (element) => {
// Find the parent/ancestor container to apply the class to
const target = element.closest(targetSelector);
if (target instanceof HTMLElement) {
target.classList.add(className);
}
};
// Use Sentinel to efficiently detect the input element
sentinel.on(triggerSelector, listener);
// Initial check in case it's already present
const existing = document.querySelector(triggerSelector);
if (existing) {
listener(existing);
}
return () => {
sentinel.off(triggerSelector, listener);
};
}
/** @override */
getPlatformObserverStarters() {
// prettier-ignore
return [
this.startGlobalTitleObserver,
this.startSidebarObserver,
this.startCanvasObserver,
this.startRightSidebarObserver,
this.startResearchPanelObserver,
this.startInputAreaObserver,
this.startStyleOptimizationObserver,
];
}
/** @override */
isTurnComplete(turnNode) {
// A turn is complete if it's an assistant message that has rendered its action buttons.
// User message turns are handled implicitly and don't trigger this "complete" state in the context of streaming.
const assistantActions = turnNode.querySelector(CONSTANTS.SELECTORS.TURN_COMPLETE_SELECTOR);
return !!assistantActions;
}
}
class ChatGPTSettingsPanelAdapter extends BaseSettingsPanelAdapter {
/** @override */
getPlatformSpecificFeatureToggles() {
const toggles = [{ configKey: 'features.timestamp.enabled' }, { configKey: 'features.collapsible_button.auto_collapse_user_message.enabled' }];
if (isFirefox()) {
toggles.unshift({ configKey: 'features.load_full_history_on_chat_load.enabled' });
}
return toggles;
}
}
class ChatGPTFixedNavAdapter extends BaseFixedNavAdapter {
/** @override */
isHeaderPositionAvailable(navConsoleWidth) {
// 1. Check for panels that compress the layout (Canvas)
if (PlatformAdapters.General.isCanvasModeActive()) {
return false;
}
// 2. Check available width in the main container (accounts for sidebar)
const container = document.querySelector(CONSTANTS.SELECTORS.SCROLL_CONTAINER);
if (container instanceof HTMLElement) {
return container.offsetWidth >= CONSTANTS.UI_SPECS.HEADER_POSITION_MIN_WIDTH;
}
// Fallback to window width if container not found
return window.innerWidth >= CONSTANTS.UI_SPECS.HEADER_POSITION_MIN_WIDTH;
}
/** @override */
getNavAnchorContainer() {
// Try to find the specific action container first
const actionContainer = document.querySelector(CONSTANTS.SELECTORS.HEADER_ACTIONS);
if (actionContainer && actionContainer.parentElement) {
return actionContainer.parentElement;
}
// Fallback: If on a new chat page or structure changed, try to find the end of the header
// The header usually has 3 main divs (Left, Center, Right). We want the Right one.
const rightSection = document.querySelector(CONSTANTS.SELECTORS.HEADER_FALLBACK_SECTION);
if (rightSection instanceof HTMLElement) {
return rightSection;
}
return null;
}
/** @override */
handleInfiniteScroll(fixedNavManagerInstance, highlightedMessage, previousTotalMessages) {
// No-op for ChatGPT as it does not use infinite scrolling for chat history.
// This method exists to maintain architectural consistency with the Gemini version.
}
/** @override */
applyAdditionalHighlight(messageElement, styleHandle) {
const role = PlatformAdapters.General.getMessageRole(messageElement);
if (role === CONSTANTS.SELECTORS.FIXED_NAV_ROLE_ASSISTANT) {
const turnContainer = messageElement.closest(CONSTANTS.SELECTORS.CONVERSATION_UNIT);
const hasImage = turnContainer && turnContainer.querySelector(CONSTANTS.SELECTORS.RAW_ASSISTANT_IMAGE_BUBBLE);
const textContent = messageElement.querySelector(CONSTANTS.SELECTORS.ASSISTANT_TEXT_CONTENT);
const hasText = textContent && textContent.textContent.trim() !== '';
// Apply to turn container only if it's an image-only or effectively image-only message.
if (hasImage && !hasText) {
turnContainer.classList.add(styleHandle.classes.highlightTurn);
}
}
}
/** @override */
getPlatformSpecificButtons(fixedNavManagerInstance, styleHandle) {
const cls = styleHandle.classes;
const autoscrollBtn = h(
`button#${cls.autoscrollBtnId}.${cls.btn}`,
{
title: 'Run layout scan and rescan DOM',
dataset: { [CONSTANTS.DATA_KEYS.ORIGINAL_TITLE]: 'Run layout scan and rescan DOM' },
onclick: () => {
// 1. Subscribe once to the completion event
fixedNavManagerInstance.registerPlatformListenerOnce(EVENTS.AUTO_SCROLL_COMPLETE, () => {
// 2. Perform the "DOM Rescan" logic *after* scan is complete.
if (fixedNavManagerInstance.messageLifecycleManager) {
fixedNavManagerInstance.messageLifecycleManager.scanForUnprocessedMessages();
}
EventBus.publish(EVENTS.CACHE_UPDATE_REQUEST);
});
// 3. Start the scan.
EventBus.publish(EVENTS.AUTO_SCROLL_REQUEST);
},
},
[createIconFromDef(StyleDefinitions.ICONS.scrollToTop)]
);
return [autoscrollBtn];
}
/** @override */
updatePlatformSpecificButtonState(autoscrollBtn, isAutoScrolling, autoScrollManager) {
if (!isFirefox()) {
autoscrollBtn.disabled = true;
autoscrollBtn.title = 'Layout scan is not required on your browser.';
autoscrollBtn.style.opacity = '0.5';
return;
}
const isScanComplete = autoScrollManager?.isLayoutScanComplete;
const isDisabled = isAutoScrolling || isScanComplete;
autoscrollBtn.disabled = isDisabled;
autoscrollBtn.style.opacity = '1';
if (isScanComplete) {
autoscrollBtn.title = 'Layout scan complete';
} else if (isAutoScrolling) {
autoscrollBtn.title = 'Scanning layout...';
} else {
autoscrollBtn.title = DomState.get(autoscrollBtn, CONSTANTS.DATA_KEYS.ORIGINAL_TITLE);
}
}
}
class ChatGPTTimestampAdapter extends BaseTimestampAdapter {
constructor() {
super();
this.originalFetch = unsafeWindow.fetch.bind(unsafeWindow);
this.isInitialized = false;
this.isInterceptionEnabled = false;
this.fetchWrapperSymbol = Symbol.for(`${APPID}:FETCH_WRAPPER`);
this._lastFetchObserveErrorAt = 0; // Rate-limit observer errors to avoid log spam
}
/** @override */
init() {
this.isInterceptionEnabled = true;
if (this.isInitialized) return;
// Check if unsafeWindow is available and valid
if (typeof unsafeWindow === 'undefined' || !unsafeWindow.fetch) {
Logger.error('TIMESTAMP', '', 'unsafeWindow.fetch is unavailable. Adapter disabled.');
return;
}
try {
// Backup original fetch from the page context
// Bind to unsafeWindow to ensure correct 'this' context
this.originalFetch = unsafeWindow.fetch.bind(unsafeWindow);
// Create the wrapper logic
const wrappedFetch = this._wrappedFetch.bind(this);
// Mark our wrapper to prevent double-wrapping
wrappedFetch[this.fetchWrapperSymbol] = true;
// Override the page's fetch
unsafeWindow.fetch = wrappedFetch;
this.isInitialized = true;
Logger.debug('TIMESTAMP', LOG_STYLES.TEAL, 'Successfully intercepted unsafeWindow.fetch');
} catch (e) {
Logger.error('FETCH WRAP FAILED', LOG_STYLES.RED, 'Could not wrap fetch:', e);
// Attempt to restore if partially failed
if (this.originalFetch) {
unsafeWindow.fetch = this.originalFetch;
}
}
}
/** @override */
cleanup() {
// Disable interception logic safely without modifying the global fetch reference.
// Restoring the original fetch here can destroy site-specific polyfills or interceptors applied after our initialization.
if (this.isInitialized) {
this.isInterceptionEnabled = false;
Logger.debug('TIMESTAMP', LOG_STYLES.TEAL, 'Disabled fetch interception (pass-through mode activated).');
}
// Data cache is preserved (BaseTimestampAdapter behavior)
}
/** @override */
hasTimestampLogic() {
return true;
}
/** @override */
isTimestampEnabledSync(defaultConfig) {
try {
const storedValue = localStorage.getItem(CONSTANTS.STORE_KEYS.LOCAL_TIMESTAMP_ENABLED);
if (storedValue !== null) {
return storedValue === 'true';
}
} catch (e) {
Logger.warn('TIMESTAMP', '', 'Failed to access localStorage. Falling back to default config.', e);
}
return Boolean(defaultConfig?.features?.timestamp?.enabled);
}
_getChatIdFromUrl(url) {
if (!url) return null;
// Match .../conversation/[ID] only. Must end with the ID.
// The ID must contain at least 4 hyphens.
// (e.g., 8-4-4-4-12 format)
const endpoint = CONSTANTS.URL_PATTERNS.CONVERSATION_ENDPOINT;
const pattern = new RegExp(`${escapeRegExp(endpoint)}\\/([^/]*-[^/]*-[^/]*-[^/]*-[^/]+)$`);
const match = url.match(pattern);
return match ? match[1] : null;
}
_wrappedFetch(input, init) {
// Pass-through immediately if interception is disabled to avoid interfering with site logic.
if (!this.isInterceptionEnabled) return this.originalFetch(input, init);
// 1. Call the original fetch immediately.
// We return this Promise chain to the site code.
return this.originalFetch(input, init).then((response) => {
try {
// 2. Clone the response immediately upon resolution.
// This ensures we get a copy before the site's code consumes the stream.
// Wrapped in try-catch to act as a failsafe; if cloning fails (e.g. stream locked),
// we must still return the original response to avoid breaking the site.
const clonedResponse = response.clone();
// 3. Process the clone asynchronously (Fire-and-forget).
this._processIntercentedResponse(input, clonedResponse).catch((e) => {
// Rate-limit error logging
const now = Date.now();
if (now - this._lastFetchObserveErrorAt > 60000) {
this._lastFetchObserveErrorAt = now;
Logger.debug('FETCH', LOG_STYLES.ORANGE, 'Internal processing failed:', e);
}
});
} catch (e) {
// Log error but suppress it to protect the main application flow
Logger.debug('FETCH', LOG_STYLES.ORANGE, 'Response cloning failed:', e);
}
// 4. Return the ORIGINAL response to the site.
return response;
});
}
/**
* Processes the intercepted response to extract timestamps.
* @param {RequestInfo|URL} input
* @param {Response} response
*/
async _processIntercentedResponse(input, response) {
if (!this.isInterceptionEnabled) return;
// Check URL patterns
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
let normalizedUrl = url;
try {
// Resolve relative URLs relative to the current page
normalizedUrl = new URL(url, location.href).pathname;
} catch {
// Ignore URL parsing errors
}
// Try to get ID from URL first (GET request)
let chatId = this._getChatIdFromUrl(normalizedUrl);
// Only process conversation endpoints
if (!normalizedUrl.includes(CONSTANTS.URL_PATTERNS.CONVERSATION_ENDPOINT)) return;
// Check response status
if (!response.ok || response.status !== 200) return;
Logger.debug('FETCH', LOG_STYLES.ORANGE, 'Target API URL intercepted:', url);
try {
const data = await response.json();
// If ID wasn't in URL, try to find it in the response (POST request / New Chat)
// Added strict null check for 'data' to prevent TypeError if site polyfills or backend returns null
if (!chatId && data && data.conversation_id) {
chatId = data.conversation_id;
}
// This acts as a filter to ensure we are processing actual chat data,
// not other endpoints like /conversations (history list).
if (!chatId) return;
// Parse the JSON data
const timestamps = this._extractTimestamps(data);
if (timestamps.size > 0) {
// Store in persistent cache
timestamps.forEach((date, id) => this.addTimestamp(id, date));
// Publish event
EventBus.publish(EVENTS.TIMESTAMPS_LOADED, { timestamps });
}
} catch (e) {
Logger.error('TIMESTAMP ERROR', LOG_STYLES.RED, 'Failed to parse conversation JSON:', e);
}
}
/**
* Extracts timestamps from the parsed JSON object.
* @param {object} data - The parsed JSON response.
* @returns {Map<string, Date>}
*/
_extractTimestamps(data) {
/** @type {Map<string, Date>} */
const newTimestamps = new Map();
let added = 0;
if (data && data.mapping) {
Object.values(data.mapping).forEach((item) => {
if (item && item.message && item.message.id && item.message.create_time) {
// Add to our temporary map. We don't check for existence,
// TimestampManager will handle merging/overwriting.
newTimestamps.set(item.message.id, new Date(item.message.create_time * 1000));
added++;
}
});
if (added > 0) {
Logger.debug('TIMESTAMPS', LOG_STYLES.TEAL, `Parsed ${added} historical timestamps.`);
} else {
Logger.debug('TIMESTAMPS', LOG_STYLES.TEAL, 'API response processed, but no valid timestamps were found in data.mapping.');
}
} else {
Logger.debug('TIMESTAMPS', LOG_STYLES.TEAL, 'API response processed, but data.mapping was not found or was empty.');
}
return newTimestamps;
}
}
class ChatGPTUIManagerAdapter extends BaseUIManagerAdapter {
/** @override */
ensureButtonPlacement(settingsButton) {
ensureSettingsButtonPlacement(settingsButton, CONSTANTS.SELECTORS.INSERTION_ANCHOR, PlatformAdapters.General.isExcludedPage);
}
}
const PlatformAdapters = {
General: new ChatGPTGeneralAdapter(),
StyleManager: new ChatGPTStyleManagerAdapter(),
ThemeManager: new ChatGPTThemeManagerAdapter(),
BubbleUI: new ChatGPTBubbleUIAdapter(),
Toast: new ChatGPTToastAdapter(),
AppController: new ChatGPTAppControllerAdapter(),
Avatar: new ChatGPTAvatarAdapter(),
StandingImage: new ChatGPTStandingImageAdapter(),
Observer: new ChatGPTObserverAdapter(),
SettingsPanel: new ChatGPTSettingsPanelAdapter(),
FixedNav: new ChatGPTFixedNavAdapter(),
Timestamp: new ChatGPTTimestampAdapter(),
UIManager: new ChatGPTUIManagerAdapter(),
};
return {
CONSTANTS,
SITE_STYLES,
PlatformAdapters,
};
}
/**
* Returns definitions and adapters specifically for Gemini.
* @returns {PlatformDefinitions} The set of constants and adapters.
*/
function defineGeminiValues() {
// =============================================================================
// SECTION: Platform Constants
// =============================================================================
// ---- Default Settings & Theme Configuration ----
const CONSTANTS = {
...SHARED_CONSTANTS,
UI_SPECS: {
...SHARED_CONSTANTS.UI_SPECS,
HEADER_POSITION_MIN_WIDTH: 960,
},
OBSERVER_OPTIONS: {
childList: true,
subtree: true,
},
Z_INDICES: {
...SHARED_CONSTANTS.Z_INDICES,
BUBBLE_NAVIGATION: 'auto',
STANDING_IMAGE: 1,
NAV_CONSOLE: 500,
},
ATTRIBUTES: {
MESSAGE_ID: 'data-message-id',
},
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',
// Root container for message search optimization
MESSAGES_ROOT: 'chat-history',
// --- Message containers ---
CONVERSATION_UNIT: 'user-query, model-response',
MESSAGE_ID_HOLDER: '[data-message-id]',
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',
VISUALLY_HIDDEN_TEXT: '.cdk-visually-hidden',
// --- 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_CONTAINER_CLASS: '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 user-query, .conversation-container model-response, .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',
// --- FixedNav-specific Selectors ---
FIXED_NAV_INPUT_AREA_TARGET: 'input-area-v2',
FIXED_NAV_MESSAGE_CONTAINERS: 'user-query, model-response',
FIXED_NAV_ROLE_USER: 'user-query',
FIXED_NAV_ROLE_ASSISTANT: 'model-response',
// --- Turn Completion Selector ---
TURN_COMPLETE_SELECTOR: 'model-response message-actions',
// --- 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',
// --- Auto Scroll ---
PROGRESS_BAR: 'mat-progress-bar[role="progressbar"]',
// --- Header Integration Selectors ---
HEADER_RIGHT_SECTION: 'top-bar-actions .right-section',
},
URL_PATTERNS: {
EXCLUDED: [],
},
};
// ---- 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)',
border_medium: 'var(--gem-sys-color--outline)',
border_light: 'var(--gem-sys-color--outline-low)',
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)',
loading_spinner: '#ffca28',
// 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)',
// Component Specifics: Settings Button
settings_btn_width: '40px',
settings_btn_height: '40px',
settings_btn_color: 'var(--mat-icon-button-icon-color, var(--mat-sys-on-surface-variant))',
settings_btn_hover_bg: 'color-mix(in srgb, var(--mat-icon-button-state-layer-color) 8%, transparent)',
// Component Specifics: Theme Modal
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)',
// Component Specifics: Fixed Nav
fixed_nav_bg: 'var(--gem-sys-color--surface-container)',
fixed_nav_border: 'var(--gem-sys-color--outline)',
fixed_nav_separator_bg: 'var(--gem-sys-color--outline)',
fixed_nav_label_text: 'var(--text-secondary)',
fixed_nav_counter_bg: 'var(--gem-sys-color--surface-container-high)',
fixed_nav_counter_text: 'var(--gem-sys-color--on-surface-variant)',
fixed_nav_counter_border: 'var(--gem-sys-color--outline)',
fixed_nav_assistant_text: '#e57373',
fixed_nav_btn_accent_text: 'var(--gem-sys-color--primary)',
fixed_nav_btn_danger_text: 'var(--gem-sys-color--error)',
fixed_nav_highlight_outline: 'var(--gem-sys-color--primary)',
fixed_nav_highlight_radius: '12px',
// Component Specifics: Jump List
jump_list_bg: 'var(--gem-sys-color--surface-container)',
jump_list_border: 'var(--gem-sys-color--outline)',
jump_list_hover_outline: 'var(--gem-sys-color--outline)',
jump_list_current_outline: 'var(--gem-sys-color--primary)',
};
const SITE_STYLES = {
PALETTE: UI_PALETTE,
Z_INDICES: CONSTANTS.Z_INDICES,
};
// =================================================================================
// SECTION: Platform-Specific Adapter Classes
// Description: Implementation of Base Adapters for Gemini.
// =================================================================================
class GeminiGeneralAdapter extends BaseGeneralAdapter {
/** @override */
isCanvasModeActive() {
return !!document.querySelector(CONSTANTS.SELECTORS.CANVAS_CONTAINER);
}
/** @override */
isExcludedPage() {
const excludedPatterns = CONSTANTS.URL_PATTERNS.EXCLUDED;
const pathname = window.location.pathname;
return excludedPatterns.some((pattern) => pattern.test(pathname));
}
/** @override */
isFilePanelActive() {
return !!document.querySelector(CONSTANTS.SELECTORS.FILE_PANEL_CONTAINER);
}
/** @override */
isNewChatPage() {
const path = window.location.pathname;
// 1. /app (Standard New Chat)
// 2. /gems/view (Gems List)
// 3. /gem/[GemID] (Gem Top - no ChatID)
return /^\/app\/?$/.test(path) || /^\/gems\/view\/?$/.test(path) || /^\/gem\/[^/]+\/?$/.test(path);
}
/** @override */
isChatPage() {
const path = window.location.pathname;
// 1. /app/[ChatID] (Standard Chat)
// 2. /gem/[GemID]/[ChatID] (Gem Chat)
return /^\/app\/[^/]+/.test(path) || /^\/gem\/[^/]+\/[^/]+/.test(path);
}
/** @override */
getMessagesRoot() {
const root = document.getElementById(CONSTANTS.SELECTORS.MESSAGES_ROOT);
return root instanceof HTMLElement ? root : document.body;
}
/** @override */
getMessageId(element) {
if (!element) return null;
return element.getAttribute(CONSTANTS.ATTRIBUTES.MESSAGE_ID);
}
/** @override */
getMessageRole(messageElement) {
if (!messageElement) return null;
return messageElement.tagName.toLowerCase();
}
/** @override */
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;
}
/** @override */
getJumpListDisplayText(messageElement) {
const tagName = messageElement.tagName.toLowerCase();
if (tagName === CONSTANTS.SELECTORS.ASSISTANT_MESSAGE) {
// Assistant (model-response)
// Gemini has a specific structure: try the main answer container first.
const answerContainer = messageElement.querySelector(CONSTANTS.SELECTORS.ASSISTANT_ANSWER_CONTENT);
if (answerContainer) {
const textEl = answerContainer.querySelector(CONSTANTS.SELECTORS.ASSISTANT_TEXT_CONTENT);
if (textEl) return textEl.textContent.trim();
}
// Fallback: Try finding text content directly if container structure varies
const fallbackTextEl = messageElement.querySelector(CONSTANTS.SELECTORS.ASSISTANT_TEXT_CONTENT);
if (fallbackTextEl) return fallbackTextEl.textContent.trim();
} else if (tagName === CONSTANTS.SELECTORS.USER_MESSAGE) {
// User (user-query)
const contentEl = messageElement.querySelector(CONSTANTS.SELECTORS.USER_TEXT_CONTENT);
if (contentEl) {
const clone = contentEl.cloneNode(true);
// Remove visually hidden elements to prevent screen reader text from appearing in the jump list
const hiddenElements = clone.querySelectorAll(CONSTANTS.SELECTORS.VISUALLY_HIDDEN_TEXT);
hiddenElements.forEach((el) => el.remove());
return clone.textContent.trim();
}
}
return '';
}
/** @override */
findMessageElement(contentElement) {
return contentElement.closest(CONSTANTS.SELECTORS.BUBBLE_FEATURE_MESSAGE_CONTAINERS);
}
/** @override */
initializeSentinel(callback) {
// prettier-ignore
const combinedSelector = [
`${CONSTANTS.SELECTORS.USER_MESSAGE} ${CONSTANTS.SELECTORS.RAW_USER_BUBBLE}`,
`${CONSTANTS.SELECTORS.USER_MESSAGE} ${CONSTANTS.SELECTORS.RAW_USER_IMAGE_BUBBLE}`,
`${CONSTANTS.SELECTORS.ASSISTANT_MESSAGE} ${CONSTANTS.SELECTORS.RAW_ASSISTANT_BUBBLE}`,
`${CONSTANTS.SELECTORS.RAW_ASSISTANT_IMAGE_BUBBLE}`, // Assistant images are direct children usually
].join(', ');
sentinel.on(combinedSelector, callback);
return () => {
sentinel.off(combinedSelector, callback);
};
}
/** @override */
scrollTo(element) {
const offset = CONSTANTS.RETRY.SCROLL_OFFSET_FOR_NAV;
const behavior = 'auto';
// Use scrollIntoView + scroll-margin-top logic
const originalScrollMargin = element.style.scrollMarginTop;
element.style.scrollMarginTop = `${offset}px`;
element.scrollIntoView({ behavior, block: 'start' });
setTimeout(() => {
element.style.scrollMarginTop = originalScrollMargin;
}, CONSTANTS.TIMING.TIMEOUTS.SCROLL_OFFSET_CLEANUP);
}
}
class GeminiStyleManagerAdapter extends BaseStyleManagerAdapter {
/** @override */
getStaticCss(cls) {
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.${cls.maxWidthActive} ${CONSTANTS.SELECTORS.CHAT_CONTENT_MAX_WIDTH}{
max-width: var(${CSS_VARS.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.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;
}
`;
}
/** @override */
getBubbleCss(cls) {
return StyleTemplates.getBubbleUiCss(cls, {
// Gemini: Parent is specifically the model-response element
collapsibleParentSelector: `${CONSTANTS.SELECTORS.ASSISTANT_MESSAGE}.${cls.collapsibleParent}`,
// Gemini: Collapsible button is only for assistant
collapsibleBtnExtraCss: `.${cls.collapsibleBtn} {left: 4px;}`,
// Gemini: Content area needs overflow handling
collapsibleCollapsedContentExtraCss: 'overflow-y: auto;',
});
}
}
class GeminiThemeManagerAdapter extends BaseThemeManagerAdapter {
/** @override */
shouldDeferInitialTheme(themeManager) {
// This issue is specific to ChatGPT's title behavior, so Gemini never defers.
return false;
}
/** @override */
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();
}
/** @override */
getStyleOverrides() {
// The default block alignment is sufficient for Gemini.
return {};
}
}
class GeminiBubbleUIAdapter extends BaseBubbleUIAdapter {
/** @override */
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.
const container = messageElement.querySelector(CONSTANTS.SELECTORS.USER_QUERY_CONTAINER);
return container instanceof HTMLElement ? container : null;
} else {
// For model-response, the element itself remains the correct context.
return messageElement;
}
}
/** @override */
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,
};
}
}
class GeminiToastAdapter extends BaseToastAdapter {
/** @override */
getAutoScrollMessage() {
return 'Auto-scrolling to load history...';
}
/** @override */
getToastPositionX() {
// Use the input resize target (input-area-v2)
const inputArea = document.querySelector(CONSTANTS.SELECTORS.INPUT_RESIZE_TARGET);
if (inputArea instanceof HTMLElement && inputArea.offsetWidth > 0) {
const rect = inputArea.getBoundingClientRect();
return rect.left + rect.width / 2;
}
return null;
}
}
class GeminiAppControllerAdapter extends BaseAppControllerAdapter {
/** @override */
initializePlatformManagers(controller) {
// =================================================================================
// SECTION: Auto Scroll Manager
// Description: Manages the auto-scrolling feature to load the entire chat history.
// =================================================================================
/**
* @class AutoScrollManager
* @extends BaseAutoScrollManager
*/
class AutoScrollManager extends BaseAutoScrollManager {
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 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) {
super(configManager, messageCacheManager);
this.toastManager = toastManager;
this.scrollContainer = null;
this.observerContainer = null;
this.toastShown = false;
this.isInitialScrollCheckDone = false;
this.boundStop = null;
this.PROGRESS_BAR_SELECTOR = CONSTANTS.SELECTORS.PROGRESS_BAR;
this.progressObserver = null;
this.appearTimeout = null;
this.disappearTimeout = null;
}
/** @override */
_onInit() {
super._onInit();
this._subscribe(EVENTS.STREAMING_START, () => this._onStreamingStart());
this.isInitialScrollCheckDone = false;
}
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.debug('AUTOSCROLL', LOG_STYLES.CYAN, '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.warn('AUTOSCROLL', LOG_STYLES.YELLOW, 'Timed out waiting for Canvas to close. Aborting scroll.');
return;
}
await new Promise((r) => requestAnimationFrame(r));
}
} else {
Logger.warn('AUTOSCROLL', LOG_STYLES.YELLOW, '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;
// Polling to find both the observer container and scroll container.
// Maximum wait: 3 seconds (60 attempts * 50ms)
let attempts = 0;
const maxAttempts = 60;
let parentContainer = null;
let childContainer = null;
while (attempts < maxAttempts) {
if (!this.isScrolling) return; // Abort if cancelled during polling
parentContainer = document.querySelector(CONSTANTS.SELECTORS.CHAT_WINDOW_CONTENT);
if (parentContainer instanceof HTMLElement) {
childContainer = parentContainer.querySelector(CONSTANTS.SELECTORS.CHAT_HISTORY_SCROLL_CONTAINER);
if (childContainer instanceof HTMLElement) {
// Both parent and child are ready in the DOM
break;
}
}
await new Promise((r) => setTimeout(r, 50));
attempts++;
}
if (!(parentContainer instanceof HTMLElement) || !(childContainer instanceof HTMLElement)) {
Logger.warn('AUTOSCROLL WARN', LOG_STYLES.YELLOW, 'Could not find required containers.');
// Reset flags to allow re-triggering
this.isInitialScrollCheckDone = false;
this.isScrolling = false;
return;
}
// --- Yield to Render Pipeline for UI Stabilization ---
// Wait for the framework (e.g. Angular) to finish its internal DOM manipulation
// (like auto-scrolling to the bottom of the new messages) before we intercept it.
// This prevents race conditions where our scroll up is immediately overwritten by the framework's scroll down.
await new Promise((r) => requestAnimationFrame(r)); // 1. Queue into render cycle
await new Promise((r) => setTimeout(r, 0)); // 2. Clear macro-task queue (force framework tasks to execute)
await new Promise((r) => requestAnimationFrame(r)); // 3. Wait for the next paint to ensure UI is stable
if (!this.isScrolling) return; // Final abort check after yielding
this.observerContainer = parentContainer;
this.scrollContainer = childContainer;
Logger.log('', '', 'AutoScrollManager: Starting auto-scroll with MutationObserver.');
this.toastShown = false;
EventBus.publish(EVENTS.SUSPEND_OBSERVERS);
// Hide the container to prevent visual flickering
this.scrollContainer.style.transition = 'none';
this.scrollContainer.style.opacity = '0';
this.boundStop = () => this.stop(false);
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) {
if (!this.isScrolling && !this.progressObserver) return; // Prevent multiple stops
Logger.log('', '', 'AutoScrollManager: Stopping auto-scroll.');
this.isScrolling = false;
this.toastShown = false;
// Restore visibility
if (this.scrollContainer instanceof HTMLElement) {
this.scrollContainer.style.opacity = '1';
this.scrollContainer.style.transition = '';
}
// 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);
// 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.debug('AUTOSCROLL', LOG_STYLES.CYAN, '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(false);
}, AutoScrollManager.CONFIG.DISAPPEAR_TIMEOUT_MS);
}
if (progressBarDisappeared) {
Logger.debug('AUTOSCROLL', LOG_STYLES.CYAN, '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(false);
}, AutoScrollManager.CONFIG.APPEAR_TIMEOUT_MS);
}
/** @override */
_onCacheUpdated() {
if (!this.isEnabled || this.isInitialScrollCheckDone) {
return;
}
const messageCount = this.messageCacheManager.getTotalMessages().length;
// Wait for at least one message to ensure history has started loading (First Paint logic)
if (messageCount === 0) return;
// Latch on first data: Mark initialization as done immediately regardless of threshold.
this.isInitialScrollCheckDone = true;
if (messageCount >= AutoScrollManager.CONFIG.MESSAGE_THRESHOLD) {
Logger.log('', '', `AutoScrollManager: ${messageCount} messages found. Triggering auto-scroll.`);
EventBus.publish(EVENTS.AUTO_SCROLL_REQUEST);
} else {
Logger.log('', '', `AutoScrollManager: ${messageCount} messages found (below threshold). Auto-scroll skipped.`);
}
}
/**
* @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;
}
}
/** @override */
_onNavigation() {
if (this.isScrolling) {
// Stop scroll without triggering a UI refresh, as a new page is loading.
this.stop(true);
}
this.isInitialScrollCheckDone = false;
}
}
// Inject toastManager into the constructor
controller.autoScrollManager = controller.manageFactory(CONSTANTS.RESOURCE_KEYS.AUTO_SCROLL_MANAGER, () => new AutoScrollManager(controller.configManager, controller.messageCacheManager, controller.toastManager));
}
/** @override */
applyPlatformSpecificUiUpdates(controller, newConfig) {
// Enable or disable the auto-scroll manager based on the new config.
if (newConfig.platforms[PLATFORM].features.load_full_history_on_chat_load.enabled) {
controller.autoScrollManager?.enable();
} else {
controller.autoScrollManager?.disable();
}
}
}
class GeminiAvatarAdapter extends BaseAvatarAdapter {
/** @override */
getCss() {
const extraCss = `
/* 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;}
`;
return StyleTemplates.getAvatarCss(extraCss);
}
/** @override */
measureAvatarTarget(msgElem) {
// The guard should only check for the existence of the avatar container itself.
if (msgElem.getElementsByClassName(CONSTANTS.SELECTORS.SIDE_AVATAR_CONTAINER_CLASS).length > 0) {
// (Logic adapted: Return context to ensure processed check is maintained, but do not inject)
return {
shouldInject: false,
targetElement: null,
processedTarget: msgElem,
exclusionKey: msgElem,
originalElement: msgElem,
};
}
return {
shouldInject: true,
targetElement: msgElem,
processedTarget: msgElem,
exclusionKey: msgElem,
originalElement: msgElem,
};
}
/** @override */
injectAvatar(measurement, avatarContainer) {
const { shouldInject, targetElement } = measurement;
// Add the container to the message element
if (shouldInject && targetElement && avatarContainer) {
targetElement.prepend(avatarContainer);
}
}
}
class GeminiStandingImageAdapter extends BaseStandingImageAdapter {
/** @override */
async recalculateLayout(instance) {
// Handle early exits that don't require measurement.
const v = instance.style.vars;
const cls = instance.style.classes;
if (PlatformAdapters.General.isCanvasModeActive() || PlatformAdapters.General.isFilePanelActive()) {
const rootStyle = document.documentElement.style;
rootStyle.setProperty(v.assistantWidth, '0px');
rootStyle.setProperty(v.userWidth, '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(cls.assistantImageId);
const userImg = document.getElementById(cls.userImageId);
return {
chatRect: chatArea.getBoundingClientRect(),
messageRect: messageArea.getBoundingClientRect(),
windowHeight: window.innerHeight,
assistantImgHeight: assistantImg ? assistantImg.offsetHeight : 0,
userImgHeight: userImg ? userImg.offsetHeight : 0,
};
},
mutate: (measured) => {
// --- Write Phase ---
if (instance.isDestroyed) return;
const rootStyle = document.documentElement.style;
if (!measured) {
rootStyle.setProperty(v.assistantWidth, '0px');
rootStyle.setProperty(v.userWidth, '0px');
return;
}
const { chatRect, messageRect, windowHeight, assistantImgHeight, userImgHeight } = measured;
// Set Assistant base position (Platform specific)
rootStyle.setProperty(v.assistantLeft, `${chatRect.left}px`);
// Calculate available widths
const assistantAvailableWidth = messageRect.left - chatRect.left;
const userAvailableWidth = chatRect.right - messageRect.right;
// Delegate common calculation
StandingImageLayout.apply(instance, {
assistantAvailableWidth,
userAvailableWidth,
assistantImgHeight,
userImgHeight,
windowHeight,
});
},
});
}
/** @override */
updateVisibility(instance) {
const isCanvasActive = PlatformAdapters.General.isCanvasModeActive();
const isFilePanelActive = PlatformAdapters.General.isFilePanelActive();
const cls = instance.style.classes;
const v = instance.style.vars;
[cls.userImageId, cls.assistantImageId].forEach((id) => {
const imgElement = document.getElementById(id);
if (!imgElement) return;
const isUser = id === cls.userImageId;
const varName = isUser ? v.userImage : v.assistantImage;
const hasImage = !!document.documentElement.style.getPropertyValue(varName);
imgElement.style.opacity = hasImage && !isCanvasActive && !isFilePanelActive && !instance.isAutoScrolling ? '1' : '0';
});
}
/** @override */
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.registerPlatformListener(EVENTS.CACHE_UPDATED, instance.scheduleUpdate);
}
}
class GeminiObserverAdapter extends BaseObserverAdapter {
/**
* @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 shared logic from ObserverManager via dependencies
return dependencies.startGenericPanelObserver({
triggerSelector: `${CONSTANTS.SELECTORS.CANVAS_CONTAINER}, ${CONSTANTS.SELECTORS.FILE_PANEL_CONTAINER}`, // Trigger (Panel itself)
observerType: CONSTANTS.OBSERVED_ELEMENT_TYPES.SIDE_PANEL,
targetResolver: (el) => el, // Target Resolver (The trigger is the panel)
immediateCallback: () => EventBus.publish(EVENTS.VISIBILITY_RECHECK),
});
}
/**
* @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 resizeObserver = null;
let titleObserver = null;
const debouncedTitleUpdate = debounce(() => EventBus.publish(EVENTS.TITLE_CHANGED), CONSTANTS.TIMING.DEBOUNCE_DELAYS.THEME_UPDATE, true);
const setupObserver = (sidebar) => {
resizeObserver?.disconnect();
titleObserver?.disconnect();
let lastWidth = -1;
let lastHeight = -1;
// 1. Layout Observer (ResizeObserver)
resizeObserver = new ResizeObserver((entries) => {
let layoutChanged = false;
for (const entry of entries) {
// Only fire if size actually changed
const { width, height } = entry.contentRect;
if (width !== lastWidth || height !== lastHeight) {
lastWidth = width;
lastHeight = height;
layoutChanged = true;
}
}
if (layoutChanged) {
EventBus.publish(EVENTS.SIDEBAR_LAYOUT_CHANGED);
}
});
resizeObserver.observe(sidebar);
// 2. Title/Selection Observer (MutationObserver)
titleObserver = new MutationObserver((mutations) => {
let titleChanged = false;
for (const mutation of mutations) {
const target = mutation.target;
// Check for class changes on list items (Selection change implies title change)
if (mutation.type === 'attributes' && mutation.attributeName === 'class' && target instanceof Element) {
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 (titleChanged) {
debouncedTitleUpdate();
}
});
titleObserver.observe(sidebar, {
attributes: true,
attributeFilter: ['class'], // Only watch class for selection state
characterData: true, // For title text changes
subtree: true, // Needed for deep text nodes and list items
childList: false,
});
// Initial triggers
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 () => {
sentinel.off(selector, setupObserver);
resizeObserver?.disconnect();
titleObserver?.disconnect();
};
}
/**
* @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) {
// Use shared logic from ObserverManager via dependencies
return dependencies.startGenericInputAreaObserver({
triggerSelector: CONSTANTS.SELECTORS.INPUT_RESIZE_TARGET,
resizeTargetSelector: CONSTANTS.SELECTORS.INPUT_RESIZE_TARGET,
});
}
/** @override */
getPlatformObserverStarters() {
// prettier-ignore
return [
this.startSidebarObserver,
this.startPanelObserver,
this.startInputAreaObserver,
];
}
/** @override */
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;
}
}
class GeminiSettingsPanelAdapter extends BaseSettingsPanelAdapter {
/** @override */
getPlatformSpecificFeatureToggles() {
return [{ configKey: 'features.load_full_history_on_chat_load.enabled' }];
}
}
class GeminiFixedNavAdapter extends BaseFixedNavAdapter {
/** @override */
isHeaderPositionAvailable(navConsoleWidth) {
// 1. Check for panels that compress the layout (Canvas or File Panel)
if (PlatformAdapters.General.isCanvasModeActive() || PlatformAdapters.General.isFilePanelActive()) {
return false;
}
// 2. Check available width in the main container (accounts for sidebar)
const container = document.querySelector(CONSTANTS.SELECTORS.MAIN_APP_CONTAINER);
if (container instanceof HTMLElement) {
return container.offsetWidth >= CONSTANTS.UI_SPECS.HEADER_POSITION_MIN_WIDTH;
}
// Fallback to window width if container not found
return window.innerWidth >= CONSTANTS.UI_SPECS.HEADER_POSITION_MIN_WIDTH;
}
/** @override */
getNavAnchorContainer() {
// Target the right section of the top bar actions
const el = document.querySelector(CONSTANTS.SELECTORS.HEADER_RIGHT_SECTION);
return el instanceof HTMLElement ? el : null;
}
/** @override */
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);
}
}
/** @override */
getPlatformSpecificButtons(fixedNavManagerInstance, styleHandle) {
const cls = styleHandle.classes;
const autoscrollBtn = h(
`button#${cls.autoscrollBtnId}.${cls.btn}`,
{
title: 'Load full chat history',
dataset: { [CONSTANTS.DATA_KEYS.ORIGINAL_TITLE]: 'Load full chat history' },
onclick: () => EventBus.publish(EVENTS.AUTO_SCROLL_REQUEST),
},
[createIconFromDef(StyleDefinitions.ICONS.scrollToTop)]
);
return [autoscrollBtn];
}
/** @override */
updatePlatformSpecificButtonState(autoscrollBtn, isAutoScrolling, autoScrollManager) {
autoscrollBtn.disabled = isAutoScrolling;
if (isAutoScrolling) {
autoscrollBtn.title = 'Loading history...';
} else {
autoscrollBtn.title = DomState.get(autoscrollBtn, CONSTANTS.DATA_KEYS.ORIGINAL_TITLE);
}
}
}
class GeminiTimestampAdapter extends BaseTimestampAdapter {
// No-op adapter, inherits defaults
}
class GeminiUIManagerAdapter extends BaseUIManagerAdapter {
/** @override */
ensureButtonPlacement(settingsButton) {
ensureSettingsButtonPlacement(settingsButton, CONSTANTS.SELECTORS.INSERTION_ANCHOR, PlatformAdapters.General.isExcludedPage);
}
}
const PlatformAdapters = {
General: new GeminiGeneralAdapter(),
StyleManager: new GeminiStyleManagerAdapter(),
ThemeManager: new GeminiThemeManagerAdapter(),
BubbleUI: new GeminiBubbleUIAdapter(),
Toast: new GeminiToastAdapter(),
AppController: new GeminiAppControllerAdapter(),
Avatar: new GeminiAvatarAdapter(),
StandingImage: new GeminiStandingImageAdapter(),
Observer: new GeminiObserverAdapter(),
SettingsPanel: new GeminiSettingsPanelAdapter(),
FixedNav: new GeminiFixedNavAdapter(),
Timestamp: new GeminiTimestampAdapter(),
UIManager: new GeminiUIManagerAdapter(),
};
return {
CONSTANTS,
SITE_STYLES,
PlatformAdapters,
};
}
// =================================================================================
// SECTION: Platform Configuration & Constants Loader
// =================================================================================
// Load platform-specific definitions using factory functions.
// These functions are defined at the bottom of the file to keep the entry point clean.
let defs = null;
switch (PLATFORM) {
case PLATFORM_DEFS.CHATGPT.NAME:
defs = defineChatGPTValues();
break;
case PLATFORM_DEFS.GEMINI.NAME:
defs = defineGeminiValues();
break;
}
if (!defs) {
console.error(`${APPID} Failed to load definitions for platform: ${PLATFORM}`);
return;
}
// Expand definitions into the current scope.
// This allows subsequent common code to use these constants without modification.
const { CONSTANTS, SITE_STYLES, PlatformAdapters } = defs;
// =================================================================================
// 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
/**
* Defines the available badge styles.
* @property {object} styles
*/
static styles = {
BASE: 'color: white; padding: 2px 6px; border-radius: 4px; font-weight: bold;',
RED: 'background: #dc3545;',
YELLOW: 'background: #ffc107; color: black;',
GREEN: 'background: #28a745;',
BLUE: 'background: #007bff;',
GRAY: 'background: #6c757d;',
ORANGE: 'background: #fd7e14;',
PINK: 'background: #e83e8c;',
PURPLE: 'background: #6f42c1;',
CYAN: 'background: #17a2b8; color: black;',
TEAL: 'background: #20c997; color: black;',
};
/**
* Maps log levels to default badge styles.
* @private
*/
static _defaultStyles = {
error: this.styles.RED,
warn: this.styles.YELLOW,
info: this.styles.BLUE,
log: this.styles.GREEN,
debug: this.styles.GRAY,
};
/**
* 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.hasOwn(this.levels, level)) {
this.level = level;
} else {
// Use default style (empty string) for the badge
this._out('warn', 'INVALID LEVEL', '', `Invalid log level "${level}". Valid levels are: ${Object.keys(this.levels).join(', ')}. Level not changed.`);
}
}
/**
* Internal method to output logs if the level permits.
* @private
* @param {string} level - The log level ('error', 'warn', 'info', 'log', 'debug').
* @param {string} badgeText - The text inside the badge. If empty, no badge is shown.
* @param {string} badgeStyle - The background-color style (from Logger.styles). If empty, uses default.
* @param {...unknown} args - The messages to log.
*/
static _out(level, badgeText, badgeStyle, ...args) {
if (this.levels[this.level] >= this.levels[level]) {
const consoleMethod = console[level] || console.log;
if (badgeText !== '') {
// Badge mode: Use %c formatting
let style = badgeStyle;
if (style === '') {
style = this._defaultStyles[level] || this.styles.GRAY;
}
const combinedStyle = `${this.styles.BASE} ${style}`;
consoleMethod(
`%c${LOG_PREFIX}%c %c${badgeText}%c`,
'font-weight: bold;', // Style for the prefix
'color: inherit;', // Reset for space
combinedStyle, // Style for the badge
'color: inherit;', // Reset for the rest of the message
...args
);
} else {
// No badge mode: Direct output for better object inspection
consoleMethod(LOG_PREFIX, ...args);
}
}
}
/**
* Internal method to start a log group if the level permits (debug or higher).
* @private
* @param {'group'|'groupCollapsed'} method - The console method to use.
* @param {string} badgeText
* @param {string} badgeStyle
* @param {...unknown} args
*/
static _groupOut(method, badgeText, badgeStyle, ...args) {
if (this.levels[this.level] >= this.levels.debug) {
const consoleMethod = console[method];
if (badgeText !== '') {
let style = badgeStyle;
if (style === '') {
style = this.styles.GRAY;
}
const combinedStyle = `${this.styles.BASE} ${style}`;
consoleMethod(`%c${LOG_PREFIX}%c %c${badgeText}%c`, 'font-weight: bold;', 'color: inherit;', combinedStyle, 'color: inherit;', ...args);
} else {
consoleMethod(LOG_PREFIX, ...args);
}
}
}
/**
* @param {string} badgeText
* @param {string} badgeStyle
* @param {...unknown} args
*/
static error(badgeText, badgeStyle, ...args) {
this._out('error', badgeText, badgeStyle, ...args);
}
/**
* @param {string} badgeText
* @param {string} badgeStyle
* @param {...unknown} args
*/
static warn(badgeText, badgeStyle, ...args) {
this._out('warn', badgeText, badgeStyle, ...args);
}
/**
* @param {string} badgeText
* @param {string} badgeStyle
* @param {...unknown} args
*/
static info(badgeText, badgeStyle, ...args) {
this._out('info', badgeText, badgeStyle, ...args);
}
/**
* @param {string} badgeText
* @param {string} badgeStyle
* @param {...unknown} args
*/
static log(badgeText, badgeStyle, ...args) {
this._out('log', badgeText, badgeStyle, ...args);
}
/**
* Logs messages for debugging. Only active in 'debug' level.
* @param {string} badgeText
* @param {string} badgeStyle
* @param {...unknown} args
*/
static debug(badgeText, badgeStyle, ...args) {
this._out('debug', badgeText, badgeStyle, ...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}`);
}
}
/**
* Starts a log group. Only active in 'debug' level.
* @param {string} badgeText
* @param {string} badgeStyle
* @param {...unknown} args The title for the log group.
*/
static group(badgeText, badgeStyle, ...args) {
this._groupOut('group', badgeText, badgeStyle, ...args);
}
/**
* Starts a collapsed log group. Only active in 'debug' level.
* @param {string} badgeText
* @param {string} badgeStyle
* @param {...unknown} args The title for the log group.
*/
static groupCollapsed(badgeText, badgeStyle, ...args) {
this._groupOut('groupCollapsed', badgeText, badgeStyle, ...args);
}
/**
* Closes the current log group. Only active in 'debug' level.
* @returns {void}
*/
static groupEnd() {
if (this.levels[this.level] >= this.levels.debug) {
console.groupEnd();
}
}
}
// Alias for ease of use
const LOG_STYLES = Logger.styles;
/**
* @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) {
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.debug('PerfMonitor', LOG_STYLES.GRAY, `${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][this.#APP_KEY] = true;
}
}
// =================================================================================
// SECTION: Declarative Style Mapper
// Description: Single source of truth for all theme-driven style definitions.
// This section contains the mapping definitions between configuration properties
// and CSS variables.
// The ThemeManager processes these definitions to update CSS variables at runtime.
// =================================================================================
/**
* @param {string} actor - 'user' or 'assistant'
* @returns {object[]} An array of style definition objects for the given actor.
*/
function createActorStyleDefinitions(actor) {
const actorUpper = actor.toUpperCase();
// Helper to get unit from schema directly
const getUnit = (key) => CONFIG_SCHEMA.theme[`${actor}.${key}`]?.def?.unit;
const unitTransformer = (key) => (value) => (value !== null && value !== undefined ? `${value}${getUnit(key)}` : null);
return [
{
configKey: `${actor}.name`,
fallbackKey: `platforms.${PLATFORM}.defaultSet.${actor}.name`,
cssVar: CSS_VARS[`${actorUpper}_NAME`],
transformer: (value) => (value ? `'${value.replaceAll("'", "\\'")}'` : null),
},
// Display control variable for Avatar Name
{
configKey: `${actor}.name`,
fallbackKey: `platforms.${PLATFORM}.defaultSet.${actor}.name`,
cssVar: CSS_VARS[`${actorUpper}_NAME_DISPLAY`],
transformer: (value) => (value ? 'block' : 'none'),
},
{
configKey: `${actor}.icon`,
fallbackKey: `platforms.${PLATFORM}.defaultSet.${actor}.icon`,
cssVar: CSS_VARS[`${actorUpper}_ICON`],
},
// Display control variable for Avatar Icon
{
configKey: `${actor}.icon`,
fallbackKey: `platforms.${PLATFORM}.defaultSet.${actor}.icon`,
cssVar: CSS_VARS[`${actorUpper}_ICON_DISPLAY`],
transformer: (value) => (value ? 'block' : 'none'),
},
{
configKey: `${actor}.standingImageUrl`,
fallbackKey: `platforms.${PLATFORM}.defaultSet.${actor}.standingImageUrl`,
cssVar: CSS_VARS[`${actorUpper}_STANDING_IMAGE`],
},
{
configKey: `${actor}.textColor`,
fallbackKey: `platforms.${PLATFORM}.defaultSet.${actor}.textColor`,
cssVar: CSS_VARS[`${actorUpper}_TEXT_COLOR`],
},
{
configKey: `${actor}.font`,
fallbackKey: `platforms.${PLATFORM}.defaultSet.${actor}.font`,
cssVar: CSS_VARS[`${actorUpper}_FONT`],
},
{
configKey: `${actor}.bubbleBackgroundColor`,
fallbackKey: `platforms.${PLATFORM}.defaultSet.${actor}.bubbleBackgroundColor`,
cssVar: CSS_VARS[`${actorUpper}_BUBBLE_BG`],
},
{
configKey: `${actor}.bubblePadding`,
fallbackKey: `platforms.${PLATFORM}.defaultSet.${actor}.bubblePadding`,
cssVar: CSS_VARS[`${actorUpper}_BUBBLE_PADDING`],
transformer: unitTransformer('bubblePadding'),
},
{
configKey: `${actor}.bubbleBorderRadius`,
fallbackKey: `platforms.${PLATFORM}.defaultSet.${actor}.bubbleBorderRadius`,
cssVar: CSS_VARS[`${actorUpper}_BUBBLE_RADIUS`],
transformer: unitTransformer('bubbleBorderRadius'),
},
{
configKey: `${actor}.bubbleMaxWidth`,
fallbackKey: `platforms.${PLATFORM}.defaultSet.${actor}.bubbleMaxWidth`,
cssVar: CSS_VARS[`${actorUpper}_BUBBLE_MAXWIDTH`],
transformer: unitTransformer('bubbleMaxWidth'),
},
];
}
const STYLE_DEFINITIONS = {
user: createActorStyleDefinitions(CONSTANTS.INTERNAL_ROLES.USER),
assistant: createActorStyleDefinitions(CONSTANTS.INTERNAL_ROLES.ASSISTANT),
window: [
{
configKey: 'window.backgroundColor',
fallbackKey: `platforms.${PLATFORM}.defaultSet.window.backgroundColor`,
cssVar: CSS_VARS.WINDOW_BG_COLOR,
},
{
configKey: 'window.backgroundImageUrl',
fallbackKey: `platforms.${PLATFORM}.defaultSet.window.backgroundImageUrl`,
cssVar: CSS_VARS.WINDOW_BG_IMAGE,
transformer: (value) => (value ? value : 'none'),
},
{
configKey: 'window.backgroundSize',
fallbackKey: `platforms.${PLATFORM}.defaultSet.window.backgroundSize`,
cssVar: CSS_VARS.WINDOW_BG_SIZE,
},
{
configKey: 'window.backgroundPosition',
fallbackKey: `platforms.${PLATFORM}.defaultSet.window.backgroundPosition`,
cssVar: CSS_VARS.WINDOW_BG_POS,
},
{
configKey: 'window.backgroundRepeat',
fallbackKey: `platforms.${PLATFORM}.defaultSet.window.backgroundRepeat`,
cssVar: CSS_VARS.WINDOW_BG_REPEAT,
},
],
inputArea: [
{
configKey: 'inputArea.backgroundColor',
fallbackKey: `platforms.${PLATFORM}.defaultSet.inputArea.backgroundColor`,
cssVar: CSS_VARS.INPUT_BG,
},
{
configKey: 'inputArea.backgroundColor',
fallbackKey: `platforms.${PLATFORM}.defaultSet.inputArea.backgroundColor`,
cssVar: CSS_VARS.INPUT_FIELD_BG,
// If background color is set, make the inner field transparent to show it.
// Otherwise leave null to keep default styles.
transformer: (value) => (value ? 'transparent' : null),
},
{
configKey: 'inputArea.textColor',
fallbackKey: `platforms.${PLATFORM}.defaultSet.inputArea.textColor`,
cssVar: CSS_VARS.INPUT_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
/** @type {Set<string>} */
_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,
EVENTS.CHAT_CONTENT_WIDTH_UPDATED,
EVENTS.NAVIGATION,
EVENTS.NAVIGATION_START,
]),
_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;
}
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 {...unknown} 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) {
Logger.debug('EventBus', LOG_STYLES.PURPLE, `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()];
Logger.groupCollapsed('EventBus', LOG_STYLES.PURPLE, `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('', LOG_STYLES.PURPLE, `-> Executing: ${key}`);
listener(...args);
} catch (e) {
// Enhance error logging with the specific subscriber key
Logger.error('LISTENER ERROR', LOG_STYLES.RED, `Listener "${key}" failed for event "${event}":`, e);
}
});
Logger.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.error('LISTENER ERROR', LOG_STYLES.RED, `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.error('UI QUEUE ERROR', LOG_STYLES.RED, '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: Base Manager
// Description: Provides common lifecycle and event subscription management.
// =================================================================================
/**
* @class BaseManager
* @description Provides common lifecycle and event subscription management capabilities.
* Implements the Template Method pattern for init/destroy cycles.
* Manages all resources in a unified Set to ensure strict LIFO disposal and prevent memory leaks.
*/
class BaseManager {
constructor() {
/**
* @type {Set<AppDisposable>}
* Unified storage for all resources. Set preserves insertion order.
*/
this._disposables = new Set();
/**
* @type {Map<string, () => void>}
* Map to store dispose functions for keyed resources, allowing replacement by key.
*/
this._keyedDisposables = new Map();
this.isInitialized = false;
this.isDestroyed = false;
/** @type {Promise<void>|null} */
this._initPromise = null;
/** @type {AbortController} */
this._abortController = new AbortController();
}
/**
* Gets the AbortSignal associated with this manager's lifecycle.
* Aborted when the manager is destroyed.
* @returns {AbortSignal}
*/
get signal() {
return this._abortController.signal;
}
/**
* Registers a resource to be disposed of when the manager is destroyed.
* @param {AppDisposable} disposable A function or object with dispose/disconnect/abort/destroy method.
* @returns {() => void} A function to dispose of the resource early.
*/
addDisposable(disposable) {
return this._registerDisposable(disposable);
}
/**
* Initializes the manager.
* Prevents double initialization and supports async hooks.
* @param {...unknown} args Arguments to pass to the hook method.
* @returns {Promise<void>}
*/
async init(...args) {
if (this.isInitialized) return;
if (this._initPromise) {
await this._initPromise;
return;
}
this.isDestroyed = false;
if (this._abortController.signal.aborted) {
this._abortController = new AbortController();
}
this._initPromise = (async () => {
try {
await this._onInit(...args);
if (!this.isDestroyed) {
this.isInitialized = true;
}
} catch (e) {
this.destroy();
throw e;
}
})();
try {
await this._initPromise;
} finally {
this._initPromise = null;
}
}
/**
* Destroys the manager and cleans up resources.
* Idempotent: safe to call multiple times.
*/
destroy() {
if (this.isDestroyed) return;
this.isDestroyed = true;
this.isInitialized = false;
this._abortController.abort();
// 1. Hook for subclass specific cleanup (protected by try-catch)
try {
this._onDestroy();
} catch (e) {
Logger.error('BaseManager', '', 'Error in _onDestroy:', e);
}
// 2. Dispose all resources in LIFO order
// Convert Set to Array and reverse to ensure correct dependency teardown order.
const disposables = Array.from(this._disposables).reverse();
this._disposables.clear(); // Clear immediately to prevent double disposal
this._keyedDisposables.clear(); // Clear keyed map
for (const resource of disposables) {
this._disposeResource(resource);
}
}
/**
* Registers a platform-specific listener.
* @param {string} event
* @param {Function} callback
* @returns {() => void} A function to unsubscribe.
*/
registerPlatformListener(event, callback) {
return this._subscribe(event, callback);
}
/**
* Registers a one-time platform-specific listener.
* @param {string} event
* @param {Function} callback
* @returns {() => void} A function to unsubscribe (if not already fired).
*/
registerPlatformListenerOnce(event, callback) {
return this._subscribeOnce(event, callback);
}
/**
* Manages a dynamic resource by key.
* Replaces any existing resource registered with the same key.
* If null is passed as the resource, the existing resource (if any) is disposed and the key is removed; no new resource is registered.
* @param {string} key Unique identifier.
* @param {AppDisposable | null} resource The new resource. Pass null to remove existing without replacing.
* @returns {() => void} A function to dispose of the resource early.
*/
manageResource(key, resource) {
// 1. Dispose of existing resource with the same key, if any
if (this._keyedDisposables.has(key)) {
const oldDispose = this._keyedDisposables.get(key);
if (oldDispose) oldDispose();
// Ensure it's removed (oldDispose should handle it via wrapper, but for safety)
this._keyedDisposables.delete(key);
}
// 2. Register new resource
if (resource) {
// Register with the main set to handle LIFO disposal on destroy
const actualDispose = this._registerDisposable(resource);
// Create a wrapper that removes the entry from the map when disposed
const wrappedDispose = () => {
if (this._keyedDisposables.get(key) === wrappedDispose) {
this._keyedDisposables.delete(key);
}
actualDispose();
};
this._keyedDisposables.set(key, wrappedDispose);
return wrappedDispose;
}
return () => {};
}
/**
* Manages a dynamic resource created by a factory function.
* @template {AppDisposable} T
* @param {string} key Unique identifier for the resource.
* @param {() => T} factory A function that returns the resource.
* @returns {T | null} The created resource, or null if destroyed.
* @throws {Error} Propagates any error thrown by the factory function.
*/
manageFactory(key, factory) {
if (!this.isDestroyed) {
// 1. Dispose of existing resource with the same key
if (this._keyedDisposables.has(key)) {
const oldDispose = this._keyedDisposables.get(key);
if (oldDispose) oldDispose();
this._keyedDisposables.delete(key);
}
const resource = factory();
if (resource) {
const actualDispose = this._registerDisposable(resource);
const wrappedDispose = () => {
if (this._keyedDisposables.get(key) === wrappedDispose) {
this._keyedDisposables.delete(key);
}
actualDispose();
};
this._keyedDisposables.set(key, wrappedDispose);
return resource;
}
}
return null;
}
/**
* Hook method for initialization logic.
* @protected
* @param {...unknown} args
* @returns {void | Promise<void>}
*/
_onInit(...args) {
// To be implemented by subclasses
}
/**
* Hook method for cleanup logic.
* @protected
*/
_onDestroy() {
// To be implemented by subclasses
}
/**
* Helper to subscribe to EventBus.
* @protected
* @param {string} event
* @param {Function} listener
* @returns {() => void} A function to unsubscribe.
*/
_subscribe(event, listener) {
if (this.isDestroyed) return () => {};
// Wrap listener to guard against execution after destruction
const guardedListener = (...args) => {
if (this.isDestroyed) return;
listener(...args);
};
const baseKey = createEventKey(this, event);
const listenerName = listener.name || 'anonymous';
const uniqueSuffix = Math.random().toString(36).substring(2, 7);
const key = `${baseKey}_${listenerName}_${uniqueSuffix}`;
EventBus.subscribe(event, guardedListener, key);
// Create a cleanup task
const cleanup = () => EventBus.unsubscribe(event, key);
return this._registerDisposable(cleanup);
}
/**
* Helper to subscribe to EventBus once.
* @protected
* @param {string} event
* @param {Function} listener
* @returns {() => void} A function to unsubscribe.
*/
_subscribeOnce(event, listener) {
if (this.isDestroyed) return () => {};
// Wrap listener to guard against execution after destruction
const guardedListener = (...args) => {
if (this.isDestroyed) return;
listener(...args);
};
const baseKey = createEventKey(this, event);
const listenerName = listener.name || 'anonymous';
const uniqueSuffix = Math.random().toString(36).substring(2, 7);
const key = `${baseKey}_${listenerName}_${uniqueSuffix}`;
// Define cleanup first to establish dependency chain
const cleanup = () => EventBus.unsubscribe(event, key);
// Register disposable immediately to allow using 'const' and avoid TDZ
const disposeFn = this._registerDisposable(cleanup);
// Self-cleaning listener wrapper
const wrappedListener = (...args) => {
// Execute dispose to remove from manager and avoid memory leaks
disposeFn();
guardedListener(...args);
};
EventBus.once(event, wrappedListener, key);
return disposeFn;
}
/**
* Internal helper to register a resource into the Set and return a safe dispose function.
* @private
* @param {AppDisposable} resource
* @returns {() => void} A function that disposes the resource and removes it from the manager.
*/
_registerDisposable(resource) {
if (!resource) return () => {};
// If already destroyed, dispose immediately and return no-op
if (this.isDestroyed) {
this._disposeResource(resource);
return () => {};
}
this._disposables.add(resource);
let disposed = false;
// Return an idempotent dispose function
return () => {
if (disposed) return;
disposed = true;
if (this._disposables.has(resource)) {
this._disposables.delete(resource);
this._disposeResource(resource);
}
};
}
/**
* Helper to safely dispose a resource of various types.
* Execution priority:
* 1. Function call
* 2. AbortController.abort()
* 3. Observer.disconnect() (Mutation/Resize/Intersection)
* 4. object.dispose()
* 5. object.disconnect()
* 6. object.abort()
* 7. object.destroy()
* @private
* @param {AppDisposable} disposable
*/
_disposeResource(disposable) {
try {
if (typeof disposable === 'function') {
disposable();
} else if (disposable instanceof AbortController) {
disposable.abort();
} else if (disposable instanceof MutationObserver || disposable instanceof ResizeObserver || (typeof IntersectionObserver !== 'undefined' && disposable instanceof IntersectionObserver)) {
disposable.disconnect();
} else if (this._isDisposableObj(disposable)) {
disposable.dispose();
} else if (this._isDisconnectableObj(disposable)) {
disposable.disconnect();
} else if (this._isAbortableObj(disposable)) {
disposable.abort();
} else if (this._isDestructibleObj(disposable)) {
disposable.destroy();
}
} catch (e) {
Logger.warn('BaseManager', '', 'Error disposing resource type:', e);
}
}
// --- Type Guards ---
/**
* @param {unknown} obj
* @returns {obj is AppDestructibleObj}
*/
_isDestructibleObj(obj) {
return typeof obj === 'object' && obj !== null && 'destroy' in obj && typeof (/** @type {{ destroy: unknown }} */ (obj).destroy) === 'function';
}
/**
* @param {unknown} obj
* @returns {obj is AppDisposableObj}
*/
_isDisposableObj(obj) {
return typeof obj === 'object' && obj !== null && 'dispose' in obj && typeof (/** @type {{ dispose: unknown }} */ (obj).dispose) === 'function';
}
/**
* @param {unknown} obj
* @returns {obj is AppDisconnectableObj}
*/
_isDisconnectableObj(obj) {
return typeof obj === 'object' && obj !== null && 'disconnect' in obj && typeof (/** @type {{ disconnect: unknown }} */ (obj).disconnect) === 'function';
}
/**
* @param {unknown} obj
* @returns {obj is AppAbortableObj}
*/
_isAbortableObj(obj) {
return typeof obj === 'object' && obj !== null && 'abort' in obj && typeof (/** @type {{ abort: unknown }} */ (obj).abort) === 'function';
}
}
/**
* @description Helper object to calculate and apply common standing image layout logic.
*/
const StandingImageLayout = {
/**
* @param {StandingImageManager} instance
* @param {object} params
* @param {number} params.assistantAvailableWidth - Width available for assistant image before gap.
* @param {number} params.userAvailableWidth - Width available for user image before gap.
* @param {number} params.assistantImgHeight
* @param {number} params.userImgHeight
* @param {number} params.windowHeight
*/
apply(instance, params) {
const { assistantAvailableWidth, userAvailableWidth, assistantImgHeight, userImgHeight, windowHeight } = params;
const v = instance.style.vars;
const rootStyle = document.documentElement.style;
// 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.platforms[PLATFORM].options.respect_avatar_space;
// Resolve current icon/name settings based on the active theme
const themeSet = instance.themeManager.getThemeSet();
const defaultSet = config.platforms[PLATFORM].defaultSet;
// Helper to resolve property (Theme > Default)
const resolveProp = (actor, prop) => {
const val = themeSet?.[actor]?.[prop];
return val !== undefined ? val : defaultSet?.[actor]?.[prop];
};
// Determine if avatar space should be reserved for each actor
const hasUserAvatar = !!resolveProp('user', 'icon') || !!resolveProp('user', 'name');
const hasAssistantAvatar = !!resolveProp('assistant', 'icon') || !!resolveProp('assistant', 'name');
const userAvatarGap = respectAvatarSpace && hasUserAvatar ? iconSize + CONSTANTS.UI_SPECS.AVATAR.MARGIN * 2 : 0;
const assistantAvatarGap = respectAvatarSpace && hasAssistantAvatar ? iconSize + CONSTANTS.UI_SPECS.AVATAR.MARGIN * 2 : 0;
const assistantWidth = Math.max(0, assistantAvailableWidth - assistantAvatarGap);
const userWidth = Math.max(0, userAvailableWidth - userAvatarGap);
rootStyle.setProperty(v.assistantWidth, `${assistantWidth}px`);
rootStyle.setProperty(v.userWidth, `${userWidth}px`);
// Masking
const maskThreshold = CONSTANTS.UI_SPECS.STANDING_IMAGE_MASK_THRESHOLD_PX;
const maskValue = `linear-gradient(to bottom, transparent 0px, rgb(0 0 0 / 1) 60px, rgb(0 0 0 / 1) 100%)`;
if (assistantImgHeight >= windowHeight - maskThreshold) {
rootStyle.setProperty(v.assistantMask, maskValue);
} else {
rootStyle.setProperty(v.assistantMask, 'none');
}
if (userImgHeight >= windowHeight - maskThreshold) {
rootStyle.setProperty(v.userMask, maskValue);
} else {
rootStyle.setProperty(v.userMask, 'none');
}
},
};
// =================================================================================
// SECTION: Style System & Definitions
// Description: Centralizes all CSS generation logic, class name definitions, and DOM injection mechanics.
// =================================================================================
/**
* @class StyleDefinitions
* @description Manages pure style definitions, class names, and static CSS templates.
*/
class StyleDefinitions {
static ICONS = (() => {
const COMMON_PROPS = {
xmlns: 'http://www.w3.org/2000/svg',
height: '24px',
viewBox: '0 -960 960 960',
width: '24px',
fill: 'currentColor',
};
/**
* Helper to generate a standardized SVG icon definition.
* @param {string} d - The path data string (d attribute).
* @param {object} options - Settings object. Must be explicitly provided.
* @param {object} [options.props] - Attributes for the <svg> element (e.g., className). Merged with COMMON_PROPS.
* @param {object} [options.pathProps] - Attributes for the inner <path> element (e.g., transform).
*/
const def = (d, options) => ({
tag: 'svg',
props: { ...COMMON_PROPS, ...(options.props || {}) },
children: [{ tag: 'path', props: { d, ...(options.pathProps || {}) } }],
});
// prettier-ignore
return {
folder: def('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', {}),
arrowUp: def('M480-528 296-344l-56-56 240-240 240 240-56 56-184-184Z', {}),
arrowDown: def('M480-344 240-584l56-56 184 184 184-184 56 56-240 240Z', {}),
scrollToTop: def('M440-160v-480L280-480l-56-56 256-256 256 256-56 56-160-160v480h-80Zm-200-640v-80h400v80H240Z', {}),
scrollToFirst: def('m280-280 200-200 200 200-56 56-144-144-144 144-56-56Zm-40-360v-80h480v80H240Z', {}),
scrollToLast: def('M240-200v-80h480v80H240Zm240-160L280-560l56-56 144 144 144-144 56 56-200 200Z', {}),
bulkCollapse: def('M440-440v240h-80v-160H200v-80h240Zm160-320v160h160v80H520v-240h80Z', { props: { className: 'icon-collapse' } }),
bulkExpand: def('M200-200v-240h80v160h160v80H200Zm480-320v-160H520v-80h240v240h-80Z', { props: { className: 'icon-expand' } }),
list: def('M120-240v-80h720v80H120Zm0-200v-80h720v80H120Zm0-200v-80h720v80H120Z', {}),
chatLeft: def('M240-400h320v-80H240v80Zm0-120h480v-80H240v80Zm0-120h480v-80H240v80ZM80-80v-720q0-33 23.5-56.5T160-880h640q33 0 56.5 23.5T880-800v480q0 33-23.5 56.5T800-240H240L80-80Zm126-240h594v-480H160v525l46-45Zm-46 0v-480 480Z', {}),
chatRight: def('M240-400h320v-80H240v80Zm0-120h480v-80H240v80Zm0-120h480v-80H240v80ZM80-80v-720q0-33 23.5-56.5T160-880h640q33 0 56.5 23.5T880-800v480q0 33-23.5 56.5T800-240H240L80-80Zm126-240h594v-480H160v525l46-45Zm-46 0v-480 480Z', { pathProps: { transform: 'translate(960, 0) scale(-1, 1)' } }),
settings: def('M480-80q-82 0-155-31.5t-127.5-86Q143-252 111.5-325T80-480q0-83 32.5-156t88-127Q256-817 330-848.5T488-880q80 0 151 27.5t124.5 76q53.5 48.5 85 115T880-518q0 115-70 176.5T640-280h-74q-9 0-12.5 5t-3.5 11q0 12 15 34.5t15 51.5q0 50-27.5 74T480-80Zm0-400Zm-220 40q26 0 43-17t17-43q0-26-17-43t-43-17q-26 0-43 17t-17 43q0 26 17 43t43 17Zm120-160q26 0 43-17t17-43q0-26-17-43t-43-17q-26 0-43 17t-17 43q0 26 17 43t43 17Zm200 0q26 0 43-17t17-43q0-26-17-43t-43-17q-26 0-43 17t-17 43q0 26 17 43t43 17Zm120 160q26 0 43-17t17-43q0-26-17-43t-43-17q-26 0-43 17t-17 43q0 26 17 43t43 17ZM480-160q9 0 14.5-5t5.5-13q0-14-15-33t-15-57q0-42 29-67t71-25h70q66 0 113-38.5T800-518q0-121-92.5-201.5T488-800q-136 0-232 93t-96 227q0 133 93.5 226.5T480-160Z', {}),
};
})();
static COMMON_CLASSES = {
...addPrefix(`${APPID}-common-`, {
// Modal Buttons
modalButton: 'btn',
primaryBtn: 'btn-primary',
pushRightBtn: 'btn-push-right',
dangerBtn: 'btn-danger',
// Sliders
sliderSubgroupControl: 'slider-control',
sliderDisplay: 'slider-display',
sliderContainer: 'slider-container',
compoundSliderContainer: 'compound-slider-container',
sliderSubgroup: 'slider-subgroup',
// Toggle Switch
toggleSwitch: 'toggle',
toggleSlider: 'toggle-slider',
// Form Fields
formField: 'form-field',
inputWrapper: 'input-wrapper',
formErrorMsg: 'error-msg',
compoundFormFieldContainer: 'compound-form-container',
localFileBtn: 'local-file-btn',
commonInput: 'input',
// Form Labels & Status
labelRow: 'label-row',
statusText: 'status-text',
// Color Picker Related
colorFieldWrapper: 'color-wrapper',
colorSwatch: 'color-swatch',
colorSwatchChecker: 'color-checker',
colorSwatchValue: 'color-value',
colorPickerPopup: 'color-popup',
// Previews
previewContainer: 'preview-container',
previewBubbleWrapper: 'preview-bubble-wrapper',
previewBubble: 'preview-bubble',
previewInputArea: 'preview-input',
previewBackground: 'preview-bg',
userPreview: 'user-preview',
// Submenu / Panels (Shared Layouts)
submenuRow: 'row',
submenuFieldset: 'fieldset',
submenuSeparator: 'separator',
featureGroup: 'feature-group',
// Notifications & Banners
warningBanner: 'warning-banner',
conflictText: 'conflict-text',
conflictReloadBtnId: 'conflict-reload-btn',
}),
sliderDefault: 'is-default',
invalidInput: 'is-invalid',
};
static MODAL_CLASSES = addPrefix(`${APPID}-modal-`, {
dialog: 'dialog',
box: 'box',
header: 'header',
content: 'content',
footer: 'footer',
footerMessage: 'footer-message',
buttonGroup: 'button-group',
});
static ROOT_IDS = addPrefix(`${APPID}-`, {
SETTINGS_PANEL: 'settings-panel',
THEME_MODAL: 'theme-modal',
JSON_MODAL: 'json-modal',
FIXED_NAV: 'fixed-nav-console',
JUMP_LIST: 'jump-list-container',
TOAST: 'toast-container',
COLOR_PICKER: 'color-picker-popup',
SETTINGS_BUTTON: 'settings-button',
});
static getCommonStyle() {
const key = 'common-style';
const generator = (cls) => StyleTemplates.getSharedCommonCss(cls);
return { key, rootId: null, classes: StyleDefinitions.COMMON_CLASSES, vars: {}, generator };
}
static getModalStyle() {
const key = 'modal-style';
const generator = (cls) => StyleTemplates.getSharedModalCss(cls);
return { key, rootId: null, classes: StyleDefinitions.MODAL_CLASSES, vars: {}, generator };
}
static getStaticBase() {
const key = 'static-base';
const prefix = `${APPID}-${key}-`;
const classes = addPrefix(prefix, {
maxWidthActive: 'max-width-active',
});
const vars = {
chatContentMaxWidth: CSS_VARS.CHAT_CONTENT_MAX_WIDTH,
messageMarginTop: CSS_VARS.MESSAGE_MARGIN_TOP,
};
const cssGenerator = (cls) => PlatformAdapters.StyleManager.getStaticCss(cls);
return { key, rootId: null, classes, vars, generator: cssGenerator };
}
static getDynamicRules() {
const key = 'dynamic-rules';
// Generator accepts activeVars set to filter outputs
const cssGenerator = (cls, activeVars) => {
const styleOverrides = PlatformAdapters.ThemeManager.getStyleOverrides();
return StyleTemplates.getThemeBaseCss(cls, activeVars, styleOverrides);
};
return { key, rootId: null, classes: {}, vars: {}, generator: cssGenerator };
}
static getSettingsButton() {
const key = 'settings-button';
const rootId = StyleDefinitions.ROOT_IDS.SETTINGS_BUTTON;
const prefix = `${APPID}-${key}`;
const classes = {
buttonId: rootId,
};
const generator = () => StyleTemplates.getSettingsButtonCss(rootId, classes, prefix);
return { key, rootId, classes, vars: {}, generator };
}
static getSettingsPanel() {
const key = 'settings-panel';
const rootId = StyleDefinitions.ROOT_IDS.SETTINGS_PANEL;
const prefix = `${APPID}-${key}`;
const classes = {
panel: rootId,
...addPrefix(`${prefix}-`, {
appliedThemeName: 'theme-name',
// Layout Helpers
topRow: 'top-row',
}),
};
const generator = () => StyleTemplates.getSettingsPanelCss(rootId, classes);
return { key, rootId, classes, vars: {}, generator };
}
static getJsonModal() {
const key = 'json-modal';
const rootId = StyleDefinitions.ROOT_IDS.JSON_MODAL;
const modalId = StyleDefinitions.MODAL_CLASSES.dialog;
const prefix = `${APPID}-${key}`;
const classes = {
dialogId: modalId,
...addPrefix(`${prefix}-`, {
// Layout & Components
jsonEditor: 'editor',
statusContainer: 'status-container',
// Button IDs - Managed by StyleManager
exportBtn: 'export-btn',
importBtn: 'import-btn',
cancelBtn: 'cancel-btn',
saveBtn: 'save-btn',
}),
};
const generator = () => StyleTemplates.getJsonModalCss(rootId, classes, prefix);
return { key, rootId, classes, vars: {}, generator };
}
static getThemeModal() {
const key = 'theme-modal';
const rootId = StyleDefinitions.ROOT_IDS.THEME_MODAL;
const prefix = `${APPID}-${key}-`;
const classes = addPrefix(prefix, {
dialogId: 'dialog',
// Layout Containers
headerControls: 'header-controls',
headerRow: 'header-row',
renameArea: 'rename-area',
actionArea: 'action-area',
// Content Areas
content: 'content',
generalSettings: 'general-settings',
scrollableArea: 'scrollable-area',
grid: 'grid',
// Components
separator: 'separator',
moveBtn: 'move-btn',
// Delete Confirm Group
deleteConfirmGroup: 'delete-confirm-group',
deleteConfirmLabel: 'delete-confirm-label',
// Input wrappers
renameInput: 'rename-input',
themeSelect: 'select',
// Action Buttons IDs
renameBtn: 'rename-btn',
upBtn: 'up-btn',
downBtn: 'down-btn',
newBtn: 'new-btn',
copyBtn: 'copy-btn',
deleteBtn: 'delete-btn',
renameOkBtn: 'rename-ok-btn',
renameCancelBtn: 'rename-cancel-btn',
deleteConfirmBtn: 'delete-confirm-btn',
deleteCancelBtn: 'delete-cancel-btn',
// Modal Footer Buttons IDs
saveBtn: 'save-btn',
applyBtn: 'apply-btn',
cancelBtn: 'cancel-btn',
// Container IDs
mainActionsId: 'actions-main',
renameActionsId: 'actions-rename',
});
const generator = () => StyleTemplates.getThemeModalCss(rootId, classes);
return { key, rootId, classes, vars: {}, generator };
}
static getColorPicker() {
const key = 'color-picker';
const rootId = StyleDefinitions.ROOT_IDS.COLOR_PICKER;
const prefix = `${APPID}-${key}-`;
const classes = addPrefix(prefix, {
picker: 'container',
svPlane: 'sv-plane',
svThumb: 'sv-thumb',
sliderGroup: 'slider-group',
sliderTrack: 'slider-track',
hueTrack: 'hue-track',
alphaCheckerboard: 'alpha-checkerboard',
gradientWhite: 'gradient-white',
gradientBlack: 'gradient-black',
// Helper class for the popup wrapper to handle positioning context
colorPickerPopup: 'popup',
});
const generator = () => StyleTemplates.getColorPickerCss(rootId, classes);
return { key, rootId, classes, vars: {}, generator };
}
static getToast() {
const key = 'toast';
const rootId = StyleDefinitions.ROOT_IDS.TOAST;
const prefix = `${APPID}-${key}-`;
const classes = {
container: rootId,
visible: 'is-visible',
...addPrefix(prefix, {
cancelBtn: 'cancel-btn',
}),
};
const generator = () => StyleTemplates.getToastCss(rootId, classes);
return { key, rootId, classes, vars: {}, generator };
}
static getFixedNav() {
const key = 'fixed-nav';
const rootId = StyleDefinitions.ROOT_IDS.FIXED_NAV;
const prefix = `${APPID}-${key}-`;
// 1. Define the class map (Source of Truth)
const classes = {
// IDs
consoleId: rootId,
// Helpers
isHidden: 'is-hidden',
...addPrefix(prefix, {
bulkCollapseBtnId: 'bulk-collapse-btn',
autoscrollBtnId: 'autoscroll-btn',
// Classes
console: 'console',
unpositioned: 'unpositioned',
hidden: 'hidden',
group: 'group',
separator: 'separator',
counter: 'counter',
counterCurrent: 'counter-current',
counterTotal: 'counter-total',
btn: 'btn',
btnAccent: 'btn-accent',
btnDanger: 'btn-danger',
roleBtn: 'role-btn',
jumpInput: 'jump-input',
highlightMessage: 'highlight-message',
highlightTurn: 'highlight-turn',
// Role Colors
roleTotal: 'role-total',
roleUser: 'role-user',
roleAssistant: 'role-assistant',
}),
};
// 2. CSS Generator using the class map
const generator = () => StyleTemplates.getFixedNavCss(rootId, classes);
return { key, rootId, classes, vars: {}, generator };
}
static getJumpList() {
const key = 'jump-list';
const rootId = StyleDefinitions.ROOT_IDS.JUMP_LIST;
const prefix = `${APPID}-${key}-`;
const classes = {
// IDs
containerId: rootId,
filterRegexValid: 'is-regex-valid',
modeString: 'is-string',
modeRegex: 'is-regex',
modeInvalid: 'is-regex-invalid',
current: 'is-current',
focused: 'is-focused',
userItem: 'user-item',
asstItem: 'assistant-item',
visible: 'is-visible',
expandDown: 'expand-down',
...addPrefix(prefix, {
listId: 'list',
previewId: 'preview',
// Classes
scrollbox: 'scrollbox',
filterContainer: 'filter-container',
filter: 'filter',
modeLabel: 'mode-label',
}),
};
const generator = () => StyleTemplates.getJumpListCss(rootId, classes);
return { key, rootId, classes, vars: {}, generator };
}
static getTimestamp() {
const key = 'timestamp';
const prefix = `${APPID}-${key}-`;
const classes = addPrefix(prefix, {
container: 'container',
assistant: 'assistant',
user: 'user',
text: 'text',
hidden: 'hidden',
});
const cssGenerator = (cls) => StyleTemplates.getTimestampCss(cls);
return { key, rootId: null, classes, vars: {}, generator: cssGenerator };
}
static getMessageNumber() {
const key = 'message-number';
const prefix = `${APPID}-${key}-`;
const classes = addPrefix(prefix, {
parent: 'parent',
number: 'text',
assistant: 'assistant',
user: 'user',
hidden: 'hidden',
});
const cssGenerator = (cls) => StyleTemplates.getMessageNumberCss(cls);
return { key, rootId: null, classes, vars: {}, generator: cssGenerator };
}
static getStandingImage() {
const key = 'standing-image';
const prefix = `${APPID}-${key}-`;
const classes = addPrefix(prefix, {
userImageId: 'user',
assistantImageId: 'assistant',
});
const vars = {
userImage: CSS_VARS.USER_STANDING_IMAGE,
assistantImage: CSS_VARS.ASSISTANT_STANDING_IMAGE,
userWidth: CSS_VARS.STANDING_IMG_USER_WIDTH,
assistantWidth: CSS_VARS.STANDING_IMG_ASST_WIDTH,
assistantLeft: CSS_VARS.STANDING_IMG_ASST_LEFT,
userMask: CSS_VARS.STANDING_IMG_USER_MASK,
assistantMask: CSS_VARS.STANDING_IMG_ASST_MASK,
};
const cssGenerator = (cls) => StyleTemplates.getStandingImageCss(cls);
return { key, rootId: null, classes, vars, generator: cssGenerator };
}
static getBubbleUI() {
const key = 'bubble-ui';
const prefix = `${APPID}-${key}-`;
const classes = addPrefix(prefix, {
// Collapsible
collapsibleParent: 'collapsible',
collapsibleContent: 'content',
collapsibleBtn: 'toggle-btn',
// Navigation
navContainer: 'nav-container',
navButtons: 'nav-buttons',
navGroupTop: 'nav-group-top',
navGroupBottom: 'nav-group-bottom',
navBtn: 'nav-btn',
navPrev: 'nav-prev',
navNext: 'nav-next',
navTop: 'nav-top',
// States
hidden: 'hidden',
collapsed: 'collapsed',
navParent: 'nav-parent',
imageOnlyAnchor: 'image-only-anchor',
});
const cssGenerator = (cls) => PlatformAdapters.StyleManager.getBubbleCss(cls);
return { key, rootId: null, classes, vars: {}, generator: cssGenerator };
}
static getAvatar() {
const key = 'avatar';
// Define CSS variable names centrally
const vars = {
iconSize: CSS_VARS.ICON_SIZE,
iconMargin: CSS_VARS.ICON_MARGIN,
};
const cssGenerator = () => PlatformAdapters.Avatar.getCss();
return { key, rootId: null, classes: {}, vars, generator: cssGenerator };
}
}
/**
* @class StyleManager
* @description Centralizes the creation, injection, and management of CSS style elements.
* Implements an idempotent injection strategy: it checks for an existing `<style>` element by ID
* and reuses it if found, updating only the text content. If not found, a new element is created.
* This approach prevents duplicate styles and supports efficient dynamic updates.
*/
class StyleManager {
static _handles = new Map();
/**
* Removes a style element by its ID.
* @param {string} id The ID of the style element to remove.
*/
static _removeById(id) {
document.getElementById(id)?.remove();
}
/**
* Generates IDs and Prefixes, constructs CSS using the provided generator, and injects the style.
* @param {object} def Definition object from StyleDefinitions.
* @returns {StyleHandle} The style handle.
*/
static _render(def) {
// If rootId is present, the generator expects no arguments (Scoped Component Mode)
// If rootId is null, the generator expects classes/vars (Legacy/Global Mode)
const id = def.rootId ? `${def.rootId}-style` : `${APPID}-style-${def.key}`;
const prefix = `${APPID}-${def.key}`; // Prefix is still used for internal class generation logic if needed
let cssContent;
if (def.rootId) {
// Scoped Mode: Generator handles everything including Mix-ins
cssContent = def.generator();
} else {
// Legacy/Global Mode: Pass classes and potentially activeVars (handled by dynamic updater, not here usually)
cssContent = def.generator(def.classes);
}
this._inject(id, cssContent);
return { id, prefix, classes: def.classes, vars: def.vars, rootId: def.rootId };
}
/**
* @param {string} id The ID of the style element.
* @param {string} cssContent The CSS content to inject.
*/
static _inject(id, cssContent) {
let style = document.getElementById(id);
if (!style) {
const newStyle = h('style', { id });
if (newStyle instanceof HTMLElement) {
style = newStyle;
// Safely append to head or root element to support early execution (@run-at document-start)
const target = document.head || document.documentElement;
if (target) {
target.appendChild(style);
}
}
}
if (style) {
style.textContent = cssContent;
}
}
/**
* Requests a style handle for the given definition provider.
* Implements the Singleton pattern: creates only if not exists, otherwise reuses.
* @param {() => StyleDefinition} defProvider Function that returns the style definition.
* @returns {StyleHandle} The style handle.
*/
static request(defProvider) {
if (!this._handles.has(defProvider)) {
const def = defProvider();
const handle = this._render(def);
this._handles.set(defProvider, handle);
}
return this._handles.get(defProvider);
}
/**
* Explicitly removes a style.
* This should only be used for temporary styles that must be cleaned up (e.g. debug tools).
* @param {() => StyleDefinition} defProvider Function that returns the style definition.
*/
static remove(defProvider) {
if (this._handles.has(defProvider)) {
const handle = this._handles.get(defProvider);
this._removeById(handle.id);
this._handles.delete(defProvider);
}
}
}
// =================================================================================
// 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, options) {
// Modern Path: Use OffscreenCanvas + createImageBitmap to avoid main thread blocking.
if (typeof createImageBitmap === 'function' && typeof OffscreenCanvas === 'function') {
return (async () => {
let bitmap = null;
try {
// 1. Decode image asynchronously off main thread
bitmap = await createImageBitmap(file);
let { width, height } = bitmap;
const needsResize = (options.maxWidth && width > options.maxWidth) || (options.maxHeight && height > options.maxHeight);
const isWebP = file.type === 'image/webp';
// If it's already WebP and fits dimensions, skip re-compression.
if (isWebP && !needsResize) {
const { promise, resolve, reject } = Promise.withResolvers();
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === 'string') resolve(reader.result);
else reject(new Error('Failed to read file as a data URL.'));
};
reader.onerror = () => reject(new Error('Failed to read file.'));
reader.readAsDataURL(file);
return promise;
}
// 2. Calculate dimensions (Aspect Ratio Logic)
if (needsResize) {
const ratio = width / height;
if (options.maxWidth && width > options.maxWidth) {
width = options.maxWidth;
height = width / ratio;
}
if (options.maxHeight && height > options.maxHeight) {
height = options.maxHeight;
width = height * ratio;
}
}
// 3. Draw to OffscreenCanvas
const canvas = new OffscreenCanvas(Math.round(width), Math.round(height));
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Failed to get 2D context from OffscreenCanvas.');
// High quality resizing is handled by the browser's implementation of drawImage with a bitmap
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
// 4. Compress asynchronously off main thread
const blob = await canvas.convertToBlob({
type: 'image/webp',
quality: options.quality || CONSTANTS.IMAGE_PROCESSING.QUALITY,
});
// 5. Convert Blob to Data URL
const { promise, resolve, reject } = Promise.withResolvers();
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === 'string') resolve(reader.result);
else reject(new Error('Failed to convert blob to Data URL.'));
};
reader.onerror = () => reject(new Error('FileReader error during blob conversion.'));
reader.readAsDataURL(blob);
return promise;
} catch (e) {
// Fallback to legacy path if OffscreenCanvas fails
Logger.warn('DataConverter', '', 'Modern image processing failed. Falling back to legacy method.', e);
return this._imageToDataUrlLegacy(file, options);
} finally {
// Critical: Release GPU memory associated with the bitmap
if (bitmap) {
bitmap.close();
}
}
})();
}
// Legacy Path: Use FileReader + Image + Canvas (Main Thread)
return this._imageToDataUrlLegacy(file, options);
}
/**
* @private
* Fallback implementation using FileReader + Image + Canvas (Main Thread).
* Used when OffscreenCanvas is unavailable or fails.
* @param {File} file
* @param {object} options
* @returns {Promise<string>}
*/
_imageToDataUrlLegacy(file, options) {
const { promise, resolve, reject } = Promise.withResolvers();
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 = (options.maxWidth && img.width > options.maxWidth) || (options.maxHeight && img.height > options.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 (options.maxWidth && width > options.maxWidth) {
width = options.maxWidth;
height = width / ratio;
}
if (options.maxHeight && height > options.maxHeight) {
height = options.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', options.quality || CONSTANTS.IMAGE_PROCESSING.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);
return promise;
}
}
// =================================================================================
// SECTION: Utility Functions
// Description: General helper functions used across the script.
// =================================================================================
/**
* Schedules a function to run when the browser is idle.
* Returns a cancel function to abort the scheduled task.
* In environments without `requestIdleCallback`, this runs asynchronously immediately (1ms delay) to prevent blocking,
* effectively ignoring the `timeout` constraint by satisfying it instantly.
* @param {(deadline: IdleDeadline) => void} callback The function to execute.
* @param {number} timeout The maximum time to wait for idle before forcing execution.
* @returns {() => void} A function to cancel the scheduled task.
*/
function runWhenIdle(callback, timeout) {
if ('requestIdleCallback' in window) {
const id = window.requestIdleCallback(callback, { timeout });
return () => window.cancelIdleCallback(id);
} else {
// Fallback: Execute almost immediately (1ms) to avoid blocking.
// This satisfies the "run by timeout" contract trivially since 1ms < timeout.
const id = setTimeout(() => {
// Provide a minimal IdleDeadline-like object.
// timeRemaining() returns 50ms to simulate a fresh frame.
callback({
didTimeout: false,
timeRemaining: () => 50,
});
}, 1);
return () => clearTimeout(id);
}
}
/**
* @param {Function} func
* @param {number} delay
* @param {boolean} useIdle
* @returns {((...args: unknown[]) => void) & { cancel: () => void }}
*/
function debounce(func, delay, useIdle) {
let timerId = null;
let cancelIdle = null;
const cancel = () => {
if (timerId !== null) {
clearTimeout(timerId);
timerId = null;
}
if (cancelIdle) {
cancelIdle();
cancelIdle = null;
}
};
const debounced = function (...args) {
cancel();
timerId = setTimeout(() => {
timerId = null; // Timer finished
if (useIdle) {
// Calculate idle timeout based on delay: clamp(delay * 4, 200, 2000)
// This ensures short delays don't wait too long, while long delays are capped.
const idleTimeout = Math.min(Math.max(delay * 4, 200), 2000);
// Schedule idle callback and store the cancel function
// Explicitly receive 'deadline' to match runWhenIdle signature
cancelIdle = runWhenIdle((deadline) => {
cancelIdle = null; // Idle callback finished
func.apply(this, args);
}, idleTimeout);
} else {
func.apply(this, args);
}
}, delay);
};
debounced.cancel = cancel;
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) {
try {
return structuredClone(obj);
} catch (e) {
Logger.error('CLONE FAILED', '', 'deepClone failed. Data contains non-clonable items.', e);
throw e;
}
}
/**
* 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, sourceVal] of Object.entries(source)) {
// Security: Prevent prototype pollution
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
continue;
}
// Strict check: Ignore keys that do not exist in the target (default config).
if (!Object.hasOwn(target, key)) {
continue;
}
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;
}
/**
* Removes system-internal properties (prefixed with _system) from the config object before saving.
* @param {object} data The config object to sanitize.
* @returns {object} A deep copy of the config object without system properties.
*/
function sanitizeConfigForSave(data) {
if (!data || typeof data !== 'object') return data;
const clean = deepClone(data);
delete clean[CONSTANTS.STORE_KEYS.SYSTEM_ROOT];
return clean;
}
/**
* 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.replaceAll('.', ' ').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.warn('UNSAFE URL', LOG_STYLES.YELLOW, `Blocked potentially unsafe protocol "${parsedUrl.protocol}" in attribute "${key}":`, url);
}
} catch {
el.setAttribute(key, '#');
Logger.warn('INVALID URL', LOG_STYLES.YELLOW, `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 && typeof value !== 'undefined') {
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);
if (el instanceof HTMLElement || el instanceof SVGElement) {
return el;
}
throw new Error('Created element is not a valid HTMLElement or SVGElement');
}
/**
* 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?.map((child) => createIconFromDef(child)) ?? [];
return h(def.tag, def.props, children);
}
/**
* Generates a unique ID string with a given prefix.
* Uses crypto.randomUUID() which is natively supported in secure contexts.
* @param {string} prefix - The prefix for the ID.
* @returns {string}
*/
function generateUniqueId(prefix) {
return `${APPID}-${prefix}-${crypto.randomUUID()}`;
}
/**
* 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.
* Note: This function assumes trusted input. It performs a basic strip of <script> tags
* but does not provide complete XSS protection against malicious SVG content.
* @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 cleanup: remove <script> tags.
const sanitizedSvg = svg.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '');
// Gemini's CSP blocks single quotes in data URLs, so they must be encoded.
const encodedSvg = encodeURIComponent(sanitizedSvg).replaceAll("'", '%27').replaceAll('"', '%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}.` };
}
/**
* Validates a color string using the browser's internal parser via the Option element style.
* @param {string | null} str - The color string to validate.
* @returns {boolean} True if the string is a valid CSS color.
*/
function validateColorString(str) {
if (!str || typeof str !== 'string' || str.trim() === '') return false;
const s = new Option().style;
s.color = str;
return s.color !== '';
}
/**
* 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".
* Validates that the pattern is not empty and handles flag sanitization.
* @param {string} input The string to parse.
* @returns {RegExp} The constructed RegExp object with safe flags.
* @throws {Error} If the format is invalid or the regex is invalid.
*/
function parseRegexPattern(input) {
if (typeof input !== 'string') {
throw new Error(`Invalid format. Must be a string.`);
}
// 1. Strict format check: ensures non-empty pattern and proper escaping
const match = input.match(/^\/((?:\\.|[^\\/])+)\/([a-z]*)$/i);
if (!match) {
throw new Error(`Invalid format. Must be /pattern/flags string with a non-empty pattern: ${input}`);
}
const pattern = match[1];
const rawFlags = match[2];
// 2. Validate allowed flags
if (/[^imsugy]/i.test(rawFlags)) {
throw new Error(`Invalid flags. Only 'i', 'm', 's', 'u', 'g', 'y' are allowed: ${input}`);
}
// 3. Sanitize flags: remove 'g' and 'y' to prevent stateful issues (lastIndex)
// We only support stateless checks.
const safeFlags = rawFlags.replace(/[gy]/gi, '');
try {
return new RegExp(pattern, safeFlags);
} 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.
* Delegates the actual scrolling logic to the platform-specific adapter.
* @param {HTMLElement} element The target element to scroll to.
*/
function scrollToElement(element) {
if (!element) return;
PlatformAdapters.General.scrollTo(element);
}
/**
* 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 {unknown} value The value to set.
* @returns {boolean} True if successful, false if the path was invalid or blocked.
*/
function setPropertyByPath(obj, path, value) {
if (!obj || typeof obj !== 'object') {
Logger.warn('', '', `setPropertyByPath: Target object is invalid (type: ${typeof obj}).`);
return false;
}
if (!path) return false;
const keys = path.split('.');
let current = obj;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (!key) {
Logger.warn('', '', `setPropertyByPath: Invalid empty key in path "${path}".`);
return false;
}
// Security: Prevent prototype pollution
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
Logger.warn('', '', `setPropertyByPath: Blocked forbidden key "${key}" in path "${path}".`);
return false;
}
// If the property exists, validate that it is an object (and not null) to allow nesting.
if (current[key] !== undefined && current[key] !== null) {
if (typeof current[key] !== 'object') {
Logger.warn('', '', `setPropertyByPath: Cannot set property "${keys[i + 1]}" on non-object value at "${keys.slice(0, i + 1).join('.')}" in path "${path}".`);
return false;
}
} else {
// Only create a new object if the property is null or undefined.
// This prevents overwriting existing values (like arrays) with empty objects.
current[key] = {};
}
current = current[key];
}
const lastKey = keys.at(-1);
if (!lastKey) {
Logger.warn('', '', `setPropertyByPath: Invalid empty key at end of path "${path}".`);
return false;
}
// Security: Prevent prototype pollution on the last key
if (lastKey === '__proto__' || lastKey === 'constructor' || lastKey === 'prototype') {
Logger.warn('', '', `setPropertyByPath: Blocked forbidden key "${lastKey}" in path "${path}".`);
return false;
}
current[lastKey] = value;
return true;
}
/**
* @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 }) {
const { promise, resolve, reject } = Promise.withResolvers();
let measuredData;
// Phase 1: Synchronously read all required layout properties from the DOM.
try {
measuredData = measure();
} catch (e) {
Logger.error('LAYOUT ERROR', LOG_STYLES.RED, 'Error during measure phase:', e);
reject(e);
return promise;
}
// Phase 2: Schedule the DOM mutations to run in the next animation frame.
requestAnimationFrame(() => {
try {
mutate(measuredData);
resolve();
} catch (e) {
Logger.error('LAYOUT ERROR', LOG_STYLES.RED, 'Error during mutate phase:', e);
reject(e);
}
});
return promise;
}
/**
* Executes a batch processing task on an array of items, separating Read (Measure) and Write (Mutate) operations.
* Uses requestAnimationFrame to prevent blocking the main thread.
*
* @template T, R
* @param {T[]} items - The array of items to process.
* @param {number} batchSize - The number of items to process per frame.
* @param {(item: T, index: number) => R | null | undefined} measureFn - Function to read from the DOM. Returns data for mutation, or null/undefined to skip.
* @param {(data: R) => void} mutateFn - Function to write to the DOM.
* @param {(() => void) | null} onFinish - Callback executed ONLY when all batches are successfully processed.
* @param {(() => void) | null} onAbort - Callback executed ONLY when processing is cancelled before completion.
* @returns {() => void} A cancel function to abort the processing.
*/
function runBatchUpdate(items, batchSize, measureFn, mutateFn, onFinish, onAbort) {
if (!items || items.length === 0) {
if (onFinish) onFinish();
return () => {};
}
let index = 0;
let rafId = null;
let isCancelled = false;
let isFinished = false;
const processNextBatch = () => {
if (isCancelled) return;
const endIndex = Math.min(index + batchSize, items.length);
const batchMeasurements = [];
// Phase 1: Measure (READ)
for (let i = index; i < endIndex; i++) {
const item = items[i];
try {
const measurement = measureFn(item, i);
// Strict check to allow 0, false, or empty string as valid measurements
if (measurement !== null && measurement !== undefined) {
batchMeasurements.push(measurement);
}
} catch (e) {
Logger.warn('BatchUpdate', '', 'Error during measure phase:', e);
}
}
// Phase 2: Mutate (WRITE)
for (const data of batchMeasurements) {
try {
mutateFn(data);
} catch (e) {
Logger.warn('BatchUpdate', '', 'Error during mutate phase:', e);
}
}
index = endIndex;
if (index < items.length) {
rafId = requestAnimationFrame(processNextBatch);
} else {
isFinished = true;
if (onFinish) onFinish();
}
};
rafId = requestAnimationFrame(processNextBatch);
return () => {
// If already finished, do nothing (cleanup already handled by onFinish)
if (isFinished) return;
isCancelled = true;
if (rafId) cancelAnimationFrame(rafId);
// Execute abort callback only if interrupted
if (onAbort) onAbort();
};
}
/**
* @description Ensures the settings button is correctly placed.
* @param {object} settingsButton The settings button component instance.
* @param {string} anchorSelector The CSS selector for the anchor element.
* @param {() => boolean} isExcludedPageFn A function that returns true if the current page should be excluded.
*/
function ensureSettingsButtonPlacement(settingsButton, anchorSelector, isExcludedPageFn) {
if (!settingsButton?.element) return;
withLayoutCycle({
measure: () => {
// Read phase
const anchor = document.querySelector(anchorSelector);
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 if the component was destroyed pending the animation frame
if (!settingsButton.element) return;
// Guard: Check for excluded page immediately to prevent zombie UI.
if (isExcludedPageFn()) {
if (settingsButton.element.isConnected) {
settingsButton.element.remove();
Logger.debug('UI GUARD', LOG_STYLES.CYAN, '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.warn('GHOST BUSTER', LOG_STYLES.YELLOW, 'Detected non-functional ghost button. Removing...');
existingBtn.remove();
}
// 2. Injection
if (shouldInject || isGhost) {
anchor.prepend(settingsButton.element);
Logger.debug('UI INJECTION', LOG_STYLES.CYAN, 'Settings button injected into anchor.');
}
settingsButton.element.style.display = '';
},
});
}
/**
* @class DomState
* @description A static utility to encapsulate DOM data attribute operations.
* Ensures consistency between dataset properties (camelCase) and HTML attributes (kebab-case).
*/
class DomState {
/**
* @param {HTMLElement} element
* @param {string} key
* @returns {string | undefined}
*/
static get(element, key) {
return element.dataset[key];
}
/**
* @param {HTMLElement} element
* @param {string} key
* @param {number} defaultValue
* @returns {number}
*/
static getInt(element, key, defaultValue) {
const val = parseInt(element.dataset[key], 10);
return isNaN(val) ? defaultValue : val;
}
/**
* @param {HTMLElement} element
* @param {string} key
* @param {string|number|boolean} value
*/
static set(element, key, value) {
element.dataset[key] = String(value);
}
/**
* Sets a data attribute to "true".
* @param {HTMLElement} element
* @param {string} key
*/
static mark(element, key) {
element.dataset[key] = 'true';
}
/**
Checks if a data attribute exists (using Object.hasOwn for safety).
* @param {HTMLElement} element
* @param {string} key
* @returns {boolean}
*/
static has(element, key) {
return Object.hasOwn(element.dataset, key);
}
/**
* Removes a data attribute.
* @param {HTMLElement} element
* @param {string} key
*/
static remove(element, key) {
delete element.dataset[key];
}
/**
* Converts a camelCase dataset key to its corresponding kebab-case HTML attribute name.
* Example: "myCustomKey" -> "data-my-custom-key"
* @param {string} key
* @returns {string}
*/
static toAttributeName(key) {
return 'data-' + key.replace(/([A-Z])/g, '-$1').toLowerCase();
}
/**
* Generates a CSS attribute selector for a given key and optional value.
* @param {string} key - The dataset key (camelCase).
* @param {string} [value] - The specific value to match.
* @returns {string} The CSS selector (e.g., `[data-aiuxc-id="123"]`).
*/
static getSelector(key, value) {
const attr = this.toAttributeName(key);
if (value === undefined) {
return `[${attr}]`;
}
return `[${attr}="${value}"]`;
}
}
// =================================================================================
// SECTION: Configuration Management (GM Storage)
// Description: Handles persistent storage, validation, and sanitization of application settings.
// =================================================================================
/**
* @description A centralized utility for validating and sanitizing configuration objects.
* Uses CONFIG_SCHEMA to drive validation logic generically.
*/
const ConfigProcessor = {
/**
* Generates slider properties (min, max, step, transformers) from the schema definition.
* Handles the logic for "Auto" (null) values by extending the minimum range.
* Searches both theme and platform scopes.
* @param {string} configKey - The configuration key to look up in CONFIG_SCHEMA.
* @returns {object|null} Slider properties or null if not a valid numeric field.
*/
getSliderProps(configKey) {
// Search in both scopes
const schemaItem = CONFIG_SCHEMA.theme[configKey] || CONFIG_SCHEMA.platform[configKey];
if (!schemaItem || schemaItem.type !== 'numeric' || !schemaItem.validators) {
return null;
}
const { min, max, step } = schemaItem.validators;
const isNullable = schemaItem.def?.nullable;
// If allowedValues is present, it's not a range slider (handled by select logic usually, but here for completeness)
if (schemaItem.validators.allowedValues) return null;
const uiMin = isNullable ? min - step : min;
return {
min: uiMin,
max,
step,
transformValue: (uiValue) => {
// Convert UI value to Store value
// If UI value is below the schema minimum, treat it as null (Auto)
if (uiValue < min) return null;
return uiValue;
},
toInputValue: (storeValue) => {
// Convert Store value to UI value
// If Store value is null/undefined, return the UI minimum (Auto position)
if (storeValue === null || storeValue === undefined) return uiMin;
return storeValue;
},
};
},
/**
* 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) {
/** @type {Array<{field: string, message: string}>} */
const errors = [];
// Iterate over the theme schema
for (const [configKey, schemaItem] of Object.entries(CONFIG_SCHEMA.theme)) {
// Skip metadata pattern validation for defaultSet
if (isDefaultSet && schemaItem.type === 'regexArray') continue;
const value = getPropertyByPath(themeData, configKey);
// Skip validation if value is undefined (not present in this update)
if (value === undefined) continue;
if (schemaItem.type === 'image') {
const imageType = schemaItem.def?.imageType || 'image';
const result = validateImageString(value, imageType);
if (!result.isValid) {
errors.push({ field: configKey, message: `${schemaItem.ui?.label || configKey} ${result.message}` });
}
} else if (schemaItem.type === 'regexArray') {
if (Array.isArray(value)) {
const seen = new Set();
const localErrors = [];
for (let i = 0; i < value.length; i++) {
const p = value[i];
if (seen.has(p)) {
localErrors.push(`Line ${i + 1}: Duplicate pattern found -> "${p}"`);
continue;
}
seen.add(p);
try {
parseRegexPattern(p);
} catch (e) {
localErrors.push(`Line ${i + 1}: ${e.message}`);
}
}
if (localErrors.length > 0) {
let finalMessage = '';
if (localErrors.length <= 3) {
finalMessage = localErrors.join('\n');
} else {
const displayed = localErrors.slice(0, 3);
finalMessage = displayed.join('\n') + `\n...and ${localErrors.length - 3} more error(s).`;
}
errors.push({ field: configKey, message: finalMessage });
}
}
} else if (schemaItem.type === 'color') {
// Allow null/empty for optional colors, but if set, must be valid
if (value && !validateColorString(value)) {
errors.push({ field: configKey, message: `${schemaItem.ui?.label || configKey} Invalid color format.` });
}
} else if (schemaItem.type === 'numeric' && schemaItem.validators) {
const { min, max } = schemaItem.validators;
if (typeof value === 'number') {
if (Number.isNaN(value) || value < min || value > max) {
errors.push({ field: configKey, message: `${schemaItem.ui?.label || configKey} Must be a valid number between ${min} and ${max}.` });
}
}
} else if (schemaItem.type === 'select') {
const options = schemaItem.def?.options;
// Allow null/empty for theme inheritance, but if set, must be valid option
// Only validate if options are explicitly defined as an array
if (Array.isArray(options)) {
// Extract valid values from options (handling objects or primitives)
const validValues = options.map((opt) => (isObject(opt) ? opt.value : opt));
if (value !== null && value !== undefined && value !== '' && !validValues.includes(value)) {
errors.push({ field: configKey, message: `${schemaItem.ui?.label || configKey} Invalid selection.` });
}
}
}
}
return { isValid: errors.length === 0, errors };
},
/**
* Normalizes theme data by cleaning up array fields.
* @param {object} themeData The theme data to normalize.
* @returns {object} The normalized theme data.
*/
normalize(themeData) {
const normalized = deepClone(themeData);
for (const [key, schemaItem] of Object.entries(CONFIG_SCHEMA.theme)) {
if (schemaItem.type === 'regexArray') {
const value = getPropertyByPath(normalized, key);
if (Array.isArray(value)) {
// Remove empty strings or strings with only whitespace
const cleanValue = value.filter((v) => v && v.trim() !== '');
setPropertyByPath(normalized, key, cleanValue);
}
}
}
return normalized;
},
/**
* 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 Platform Specific Options
if (config.platforms) {
Object.entries(config.platforms).forEach(([platformName, platformConfig]) => {
// Critical section check
const criticalSections = ['options', 'features', 'defaultSet'];
criticalSections.forEach((section) => {
if (!isObject(platformConfig[section])) {
const defaultSection = DEFAULT_THEME_CONFIG.platforms[platformName]?.[section];
if (defaultSection) {
platformConfig[section] = deepClone(defaultSection);
Logger.warn('Config', '', `Restored corrupted section "${section}" for ${platformName} from defaults.`);
}
}
});
// Sanitize Platform Settings using Platform Schema
for (const [key, schemaItem] of Object.entries(CONFIG_SCHEMA.platform)) {
const value = getPropertyByPath(platformConfig, key);
const defaultValue = getPropertyByPath(DEFAULT_THEME_CONFIG.platforms[platformName], key);
if (schemaItem.type === 'numeric') {
const sanitizedValue = this._sanitizeNumericProperty(value, schemaItem, defaultValue);
setPropertyByPath(platformConfig, key, sanitizedValue);
} else if (schemaItem.type === 'toggle') {
if (typeof value !== 'boolean') {
setPropertyByPath(platformConfig, key, defaultValue);
}
} else if (schemaItem.type === 'select') {
const options = schemaItem.def?.options;
// Ensure options exist and are an array before validation
if (Array.isArray(options)) {
// Extract valid values and default value
const validValues = options.map((opt) => (isObject(opt) ? opt.value : opt));
if (!validValues.includes(value)) {
// Use default value or the first option's value
let safeDefault = defaultValue;
if (safeDefault === undefined && options.length > 0) {
const firstOpt = options[0];
safeDefault = isObject(firstOpt) ? firstOpt.value : firstOpt;
}
setPropertyByPath(platformConfig, key, safeDefault);
}
}
}
}
});
}
// 2. Sanitize Themes (ThemeSets + DefaultSets)
const platformDefaultSets = [];
if (config.platforms) {
Object.values(config.platforms).forEach((pConfig) => {
if (pConfig.defaultSet) platformDefaultSets.push(pConfig.defaultSet);
});
}
const allThemes = [...(config.themeSets || []), ...platformDefaultSets];
for (const theme of allThemes) {
if (!theme) continue;
for (const [key, schemaItem] of Object.entries(CONFIG_SCHEMA.theme)) {
// Note: Theme defaults are handled by ThemeManager fallback logic, not here.
// Here we only ensure data integrity (type safety).
if (schemaItem.type === 'numeric') {
const value = getPropertyByPath(theme, key);
// For themes, if value is invalid, we reset to null (inherit from default)
const sanitizedValue = this._sanitizeNumericProperty(value, schemaItem, null);
setPropertyByPath(theme, key, sanitizedValue);
} else if (schemaItem.type === 'image') {
const value = getPropertyByPath(theme, key);
const imageType = schemaItem.def?.imageType || 'image';
const result = validateImageString(value, imageType);
if (!result.isValid) {
Logger.warn('Config', '', `Invalid image in config [${key}]: ${result.message}. Resetting to null.`);
setPropertyByPath(theme, key, null);
}
} else if (schemaItem.type === 'regexArray') {
const value = getPropertyByPath(theme, key);
if (Array.isArray(value)) {
const validPatterns = value.filter((p) => {
try {
parseRegexPattern(p);
return true;
} catch (e) {
Logger.warn('Config', '', `Invalid pattern in config [${key}]: ${e.message}. Removing.`);
return false;
}
});
setPropertyByPath(theme, key, validPatterns);
}
} else if (schemaItem.type === 'color') {
const value = getPropertyByPath(theme, key);
if (value && !validateColorString(value)) {
Logger.warn('Config', '', `Invalid color in config [${key}]: "${value}". Resetting to null.`);
setPropertyByPath(theme, key, null);
}
} else if (schemaItem.type === 'select') {
const value = getPropertyByPath(theme, key);
const options = schemaItem.def?.options;
// Allow null/empty for theme inheritance, but if set, must be valid option
// Only validate if options are explicitly defined as an array
if (Array.isArray(options)) {
const validValues = options.map((opt) => (isObject(opt) ? opt.value : opt));
if (value !== null && value !== undefined && value !== '' && !validValues.includes(value)) {
Logger.warn('Config', '', `Invalid selection in config [${key}]: "${value}". Resetting to null.`);
setPropertyByPath(theme, key, null);
}
}
}
}
}
return config;
},
/**
* @private
* Validates and sanitizes a numeric property based on the provided schema item.
* @param {string | number | null} value The value to sanitize.
* @param {object} schemaItem The schema item containing validator rules.
* @param {any} defaultValue The fallback value.
* @returns {any} The sanitized value.
*/
_sanitizeNumericProperty(value, schemaItem, defaultValue) {
const { validators, def } = schemaItem;
const isNullable = def?.nullable;
if (isNullable && value === null) {
return null;
}
if (validators?.allowedValues && Array.isArray(validators.allowedValues)) {
const isValid = validators.allowedValues.some((v) => String(v) === String(value));
if (!isValid) {
return defaultValue === null ? null : defaultValue;
}
// Return consistent type
const matched = validators.allowedValues.find((v) => String(v) === String(value));
return matched;
}
if (typeof value === 'number') {
if (Number.isNaN(value) || (validators && (value < validators.min || value > validators.max))) {
return defaultValue === null ? null : defaultValue;
}
return value;
}
// Migration Logic (String -> Number)
if (typeof value === 'string') {
const parsed = parseFloat(value);
if (!isNaN(parsed) && validators && parsed >= validators.min && parsed <= validators.max) {
return parsed;
}
}
return defaultValue === null ? null : defaultValue;
},
};
/**
* @class ConfigManager
* @description Manages the application configuration, including loading, saving, validation, and sanitization.
* Handles interaction with the underlying storage mechanism (GM.getValue/GM.setValue) and ensures configuration integrity.
*/
class ConfigManager {
/**
* @param {DataConverter} dataConverter - Service for converting data formats (e.g., images).
*/
constructor(dataConverter) {
this.ROOT_KEY = CONSTANTS.STORAGE_SETTINGS.ROOT_KEY;
this.THEME_PREFIX = CONSTANTS.STORAGE_SETTINGS.THEME_PREFIX;
this.DEFAULT_CONFIG = DEFAULT_THEME_CONFIG;
/** @type {AppConfig|null} */
this.config = null;
this.dataConverter = dataConverter;
// Cache for dirty checking
this._manifestCache = null; // Raw string from storage (includes updatedAt)
this._lastSavedManifestContent = null; // Stringified content without updatedAt
/** @type {Map<string, string>} */
this._themeCache = new Map();
/** @type {string[]} */
this.loadErrors = [];
}
/**
* Loads the configuration from storage and merges it with defaults.
* Applies validation and sanitization immediately after loading.
* @param {boolean} updateState - Whether to update the internal state (this.config). Set to false for sync detection.
* @returns {Promise<AppConfig>} The fully loaded and resolved configuration object.
*/
async load(updateState) {
// 1. Load Manifest
const manifestRaw = await GM.getValue(this.ROOT_KEY);
let loadedConfig = null;
if (updateState) {
this.loadErrors = [];
// Clear cache to prevent memory leaks from deleted themes when reloading
this._themeCache.clear();
}
if (manifestRaw) {
try {
/** @type {StorageManifest} */
const manifest = JSON.parse(manifestRaw);
// Validate manifest structure
if (!manifest || !Array.isArray(manifest.themeIndex)) {
throw new Error('Invalid manifest structure: themeIndex is missing or invalid.');
}
if (updateState) {
this._manifestCache = manifestRaw;
// Cache content without updatedAt for save comparison
const content = { ...manifest };
delete content.updatedAt;
this._lastSavedManifestContent = JSON.stringify(content);
}
// 2. Load Themes in parallel (Resilient loading)
const themePromises = manifest.themeIndex.map(async (id) => {
try {
const themeKey = id;
const themeRaw = await GM.getValue(themeKey);
if (themeRaw) {
if (updateState) {
this._themeCache.set(id, themeRaw);
}
return JSON.parse(themeRaw);
}
return null;
} catch (e) {
// Skip corrupted theme files instead of failing the entire load
Logger.warn('Config', '', `Failed to load theme ${id}:`, e);
if (updateState) {
this.loadErrors.push(`Theme ${id}: ${e.message}`);
}
return null;
}
});
const themes = (await Promise.all(themePromises)).filter((t) => t !== null);
// 3. Reconstruct AppConfig
loadedConfig = {
...manifest.config,
themeSets: themes,
};
} catch (e) {
Logger.error('LOAD FAILED', LOG_STYLES.RED, 'Failed to parse configuration. Resetting to default settings.', e);
if (updateState) {
this.loadErrors.push(`Manifest Error: ${e.message}`);
}
loadedConfig = null;
}
}
const completeConfig = deepClone(this.DEFAULT_CONFIG);
const resolvedConfig = resolveConfig(completeConfig, loadedConfig ?? {});
ConfigProcessor.process(resolvedConfig);
if (updateState) {
this.config = resolvedConfig;
}
return resolvedConfig;
}
/**
* Enforces save order: Themes -> Manifest (Commit) -> GC (Cleanup).
* @param {AppConfig} obj
* @returns {Promise<void>}
*/
async save(obj) {
// --- Sync timestamp toggle to localStorage (ChatGPT only) ---
if (PlatformAdapters.Timestamp.hasTimestampLogic()) {
try {
const isTimestampEnabled = Boolean(obj?.platforms?.[PLATFORM]?.features?.timestamp?.enabled);
localStorage.setItem(CONSTANTS.STORE_KEYS.LOCAL_TIMESTAMP_ENABLED, String(isTimestampEnabled));
} catch (e) {
Logger.warn('Config', '', 'Failed to access localStorage for timestamp toggle sync.', e);
}
}
// 1. Sanitization & Normalization
// Do NOT deepClone the entire object to avoid performance hit with large images.
const { themeSets, ...configWithoutThemes } = obj;
const validThemes = [];
const uniqueIds = new Set();
// Validate and Deduplicate Themes
for (const theme of themeSets) {
let currentTheme = theme;
let isModified = false;
// Ensure metadata exists
if (!currentTheme.metadata) {
currentTheme = { ...currentTheme, metadata: { id: '', name: 'Unnamed Theme', matchPatterns: [], urlPatterns: [] } };
isModified = true;
}
let id = currentTheme.metadata.id;
// If ID is missing or duplicate, generate a new one
if (!id || typeof id !== 'string' || uniqueIds.has(id)) {
const newId = generateUniqueId('theme');
Logger.warn('Config', '', `Fixed invalid or duplicate theme ID. New ID: ${newId}`);
if (!isModified) {
// Shallow clone specific theme to modify ID safely
currentTheme = { ...currentTheme, metadata: { ...currentTheme.metadata } };
}
currentTheme.metadata.id = newId;
id = newId;
}
uniqueIds.add(id);
validThemes.push(currentTheme);
}
// Create a safe config structure (shallow copy)
const safeConfig = {
...configWithoutThemes,
themeSets: validThemes,
};
let hasThemeChange = false;
// 2. Save Themes (Pre-commit)
// Wrapped in try-catch to handle QuotaExceededError and prevent partial writes from corrupting the manifest trigger.
try {
for (const theme of validThemes) {
const id = theme.metadata.id;
const themeString = JSON.stringify(theme);
if (themeString !== this._themeCache.get(id)) {
await GM.setValue(id, themeString);
this._themeCache.set(id, themeString);
hasThemeChange = true;
}
}
} catch (e) {
Logger.error('Config', '', 'Failed to save themes. Storage quota might be exceeded.', e);
// Propagate error to UI to notify user, do NOT proceed to save manifest.
throw e;
}
// 3. Save Manifest (Commit)
const tempManifest = {
schemaVersion: 1,
config: configWithoutThemes,
themeIndex: validThemes.map((t) => t.metadata.id),
};
const tempManifestString = JSON.stringify(tempManifest);
const hasManifestChange = tempManifestString !== this._lastSavedManifestContent;
const commitTimestamp = Date.now();
// Explicitly update timestamp and write manifest if there are ANY changes (theme content OR manifest structure).
// This ensures that even if only a color inside a theme changed (without changing ID list), the updated manifest
// will signal the SyncManager in other tabs to reload.
if (hasThemeChange || hasManifestChange) {
/** @type {StorageManifest} */
const finalManifest = {
...tempManifest,
updatedAt: commitTimestamp,
};
const finalManifestString = JSON.stringify(finalManifest);
try {
await GM.setValue(this.ROOT_KEY, finalManifestString);
this._manifestCache = finalManifestString;
// Update the cache check only after successful commit.
// This ensures that if saving fails, the cache remains stale so the next attempt detects a change.
this._lastSavedManifestContent = tempManifestString;
} catch (e) {
Logger.error('Config', '', 'Failed to save manifest.', e);
throw e;
}
}
// 4. Garbage Collection (Safe GC)
// Removes orphaned theme files that are no longer in the manifest.
// Includes optimistic locking to prevent deleting files created by other tabs during our save process.
try {
// Re-fetch manifest to check if another tab has updated it since our save
const currentManifestRaw = await GM.getValue(this.ROOT_KEY);
let isSafeToGC = false;
if (currentManifestRaw) {
const currentManifest = JSON.parse(currentManifestRaw);
// If the updatedAt matches what we just wrote (or if we didn't write, what we read last), it's safe.
// If timestamp differs, another tab wrote something, so we abort GC to be safe.
if (hasThemeChange || hasManifestChange) {
isSafeToGC = currentManifest.updatedAt === commitTimestamp;
} else {
// If we didn't save, we check against our cached manifest
const cachedManifest = this._manifestCache ? JSON.parse(this._manifestCache) : null;
isSafeToGC = cachedManifest && currentManifest.updatedAt === cachedManifest.updatedAt;
}
}
if (isSafeToGC) {
const allKeys = await GM.listValues();
const validThemeKeys = new Set(validThemes.map((t) => t.metadata.id));
for (const key of allKeys) {
if (key.startsWith(this.THEME_PREFIX) && !validThemeKeys.has(key)) {
await GM.deleteValue(key);
this._themeCache.delete(key);
Logger.log('Config', LOG_STYLES.TEAL, `GC: Deleted orphaned theme: ${key}`);
}
}
} else {
Logger.log('Config', LOG_STYLES.TEAL, 'GC: Skipped garbage collection due to potential concurrent modification by another tab.');
}
} catch (e) {
Logger.warn('Config', '', 'GC: Failed to perform garbage collection.', e);
}
// 5. Update Internal State
this.config = safeConfig;
EventBus.publish(EVENTS.CONFIG_SAVE_SUCCESS);
}
/**
* @returns {AppConfig|null}
*/
get() {
return this.config;
}
/**
* @returns {number}
*/
getIconSize() {
const size = this.config?.platforms?.[PLATFORM]?.options?.icon_size;
if (typeof size === 'number' && CONSTANTS.UI_SPECS.AVATAR.SIZE_OPTIONS.includes(size)) {
return size;
}
return CONSTANTS.UI_SPECS.AVATAR.DEFAULT_SIZE;
}
/**
* @param {AppConfig} config
* @returns {number}
*/
getConfigSize(config) {
const cleanConfig = sanitizeConfigForSave(config);
const json = JSON.stringify(cleanConfig);
return new TextEncoder().encode(json).length;
}
/**
* @param {number} size
* @returns {boolean}
*/
isSizeExceeded(size) {
return size > CONSTANTS.STORAGE_SETTINGS.CONFIG_SIZE_LIMIT_BYTES;
}
}
// =================================================================================
// SECTION: Sync Manager
// Description: Synchronizes configuration changes across open tabs/windows in real-time.
// =================================================================================
class SyncManager extends BaseManager {
constructor() {
super();
this.listenerId = null;
this.lastProcessedTime = 0;
this.debouncedProcess = null;
}
_onInit() {
// Initialize the debounced processor with a delay to throttle rapid updates
this.debouncedProcess = debounce(this._processRemoteChange.bind(this), 300, false);
this.addDisposable(this.debouncedProcess.cancel);
// Monitor the Root Key (Manifest) for changes.
// Any change to the root key (including updatedAt) signals a config update.
const listenerId = GM_addValueChangeListener(CONSTANTS.STORAGE_SETTINGS.ROOT_KEY, (name, oldValue, newValue, remote) => {
// Only process changes originating from other tabs/windows
if (remote) {
this.debouncedProcess(newValue);
}
});
this.listenerId = listenerId;
// Register cleanup: remove listener and reset state
this.addDisposable(() => {
GM_removeValueChangeListener(listenerId);
this.listenerId = null;
this.debouncedProcess = null;
});
}
_onDestroy() {
// Cleanup handled by disposables.
}
/**
* Processes the remote change string after debounce.
* Parses JSON and checks timestamps to avoid redundant updates.
* @private
* @param {string} newValue - The raw JSON string from storage.
*/
_processRemoteChange(newValue) {
if (!newValue) return;
let shouldUpdate = false;
let newTimestamp = 0;
try {
const manifest = JSON.parse(newValue);
// Validate structure and check timestamp
if (manifest && typeof manifest.updatedAt === 'number') {
newTimestamp = manifest.updatedAt;
// Only proceed if this update is newer than the last one we processed
if (newTimestamp > this.lastProcessedTime) {
shouldUpdate = true;
} else {
Logger.debug('SyncManager', LOG_STYLES.TEAL, `Skipped stale event. (Current: ${newTimestamp}, Last: ${this.lastProcessedTime})`);
}
} else {
// Fallback for legacy data or corruption: force update to be safe
shouldUpdate = true;
newTimestamp = Date.now();
}
} catch (e) {
Logger.warn('SyncManager', '', 'Failed to parse remote config. Forcing update.', e);
shouldUpdate = true;
newTimestamp = Date.now();
}
if (shouldUpdate) {
this.lastProcessedTime = newTimestamp;
Logger.log('SyncManager', LOG_STYLES.TEAL, 'Remote config change detected. Publishing event.');
EventBus.publish(EVENTS.REMOTE_CONFIG_CHANGED);
}
}
}
// =================================================================================
// 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.warn('HISTORY HOOK', LOG_STYLES.YELLOW, `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;
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;
}
/**
* @private
* @param {number} newItemSize
*/
_makeSpaceForNewItem(newItemSize) {
if (newItemSize > CONSTANTS.STORAGE_SETTINGS.CACHE_SIZE_LIMIT_BYTES) {
Logger.warn('CACHE LIMIT', LOG_STYLES.YELLOW, `Item size (${newItemSize}) exceeds cache limit (${CONSTANTS.STORAGE_SETTINGS.CACHE_SIZE_LIMIT_BYTES}). Cannot be cached.`);
return;
}
while (this.currentCacheSize + newItemSize > CONSTANTS.STORAGE_SETTINGS.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('CACHE', '', `Evicted ${oldestKey} from cache to free up space.`);
}
}
}
clearFailedUrls() {
this.failedUrls.clear();
}
/**
* Gets an image as a data URL. Returns a cached version immediately if available.
* @param {string} url
* @param {object} resizeOptions
* @param {number} [resizeOptions.width]
* @param {number} [resizeOptions.height]
* @param {AbortSignal} [resizeOptions.signal]
* @returns {Promise<string|null>}
*/
async getImageAsDataUrl(url, resizeOptions) {
if (!url || typeof url !== 'string' || !url.startsWith('http')) {
return url; // Return data URIs or other values directly
}
const width = resizeOptions.width;
const height = resizeOptions.height;
const signal = resizeOptions.signal;
const cacheKey = width ? `${url}|w=${width},h=${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) => {
let abortHandler = null;
// Helper to clean up the abort listener to prevent memory leaks
const cleanupListener = () => {
if (signal && abortHandler) {
signal.removeEventListener('abort', abortHandler);
abortHandler = null;
}
};
// If already aborted, resolve immediately
if (signal?.aborted) {
resolve(null);
return;
}
const request = GM_xmlhttpRequest({
method: 'GET',
url: url,
responseType: 'blob',
onload: async (response) => {
// Guard: If aborted during the request but before onload fired, stop processing.
if (signal?.aborted) {
cleanupListener();
resolve(null);
return;
}
if (response.status >= 200 && response.status < 300) {
try {
const dataUrl = await this.dataConverter.imageToOptimizedDataUrl(response.response, {
maxWidth: width,
maxHeight: height,
quality: 0.85,
});
// Check abort again after heavy processing (image conversion)
if (signal?.aborted) {
cleanupListener();
resolve(null);
return;
}
const size = dataUrl.length;
this._makeSpaceForNewItem(size);
this.cache.set(cacheKey, { data: dataUrl, size });
this.currentCacheSize += size;
resolve(dataUrl);
} catch (e) {
// Guard: If the error was caused or accompanied by an abort, do not mark as failed.
if (signal?.aborted) {
cleanupListener();
resolve(null);
return;
}
Logger.error('CONVERSION FAILED', LOG_STYLES.RED, `Data conversion error for URL: ${url}`, e);
this.failedUrls.add(cacheKey);
resolve(null);
}
} else {
Logger.error('FETCH FAILED', LOG_STYLES.RED, `HTTP Error: ${response.status}, URL: ${url}`);
this.failedUrls.add(cacheKey);
resolve(null);
}
cleanupListener();
},
onerror: (error) => {
if (signal?.aborted) {
cleanupListener();
resolve(null);
return;
}
Logger.error('FETCH FAILED', LOG_STYLES.RED, `GM_xmlhttpRequest error for URL: ${url}`, error);
this.failedUrls.add(cacheKey);
cleanupListener();
resolve(null);
},
ontimeout: () => {
if (signal?.aborted) {
cleanupListener();
resolve(null);
return;
}
Logger.error('FETCH FAILED', LOG_STYLES.RED, `GM_xmlhttpRequest timeout for URL: ${url}`);
this.failedUrls.add(cacheKey);
cleanupListener();
resolve(null);
},
onabort: () => {
// Handle system-initiated aborts where signal.aborted might not be true yet or relevant
cleanupListener();
resolve(null);
},
});
// Attach abort listener if signal is provided
if (signal) {
abortHandler = () => {
request.abort();
cleanupListener();
resolve(null); // Resolve with null to safely release the await in caller
};
signal.addEventListener('abort', abortHandler);
}
});
}
}
// =================================================================================
// SECTION: Message Cache Management
// Description: Centralized manager for caching and sorting message elements from the DOM.
// =================================================================================
class MessageCacheManager extends BaseManager {
/**
* @param {object} streamingState - Shared state object for streaming status.
*/
constructor(streamingState) {
super();
this.userMessages = [];
this.assistantMessages = [];
this.totalMessages = [];
this.elementMap = new Map();
this.streamingState = streamingState;
this.debouncedRebuildCache = debounce(this.rebuild.bind(this), CONSTANTS.TIMING.DEBOUNCE_DELAYS.CACHE_UPDATE, true);
this.debouncedNotify = debounce(this.notify.bind(this), CONSTANTS.TIMING.DEBOUNCE_DELAYS.CACHE_UPDATE, true);
}
_onInit() {
this._subscribe(EVENTS.CACHE_UPDATE_REQUEST, () => this.debouncedRebuildCache());
this._subscribe(EVENTS.NAVIGATION_START, () => {
this.clear();
// Force reset streaming state on navigation to prevent deadlocks
this.streamingState.isActive = false;
});
// Streaming state is managed via shared object, so we don't need to listen to START to update a flag.
// We only need to listen to END to trigger a cache rebuild.
this._subscribe(EVENTS.STREAMING_END, () => {
this.debouncedRebuildCache();
});
this._subscribe(EVENTS.RAW_MESSAGE_ADDED, (element) => {
// Determine dynamically whether to append or rebuild based on DOM position.
this.append(element);
});
this.rebuild();
this.addDisposable(this.debouncedRebuildCache.cancel);
this.addDisposable(this.debouncedNotify.cancel);
}
_onDestroy() {
this.userMessages = [];
this.assistantMessages = [];
this.totalMessages = [];
this.elementMap.clear();
}
rebuild() {
Logger.info('CACHE', LOG_STYLES.TEAL, 'Rebuilding cache...');
// Guard clause: If no conversation turns are on the page (e.g., on the homepage), clear the cache and exit.
// Even if the cache is already empty, we must call clear() (which triggers notify())
// to ensure ObserverManager receives the "0 messages" signal for navigation completion detection.
if (!document.querySelector(CONSTANTS.SELECTORS.CONVERSATION_UNIT)) {
this.clear();
return;
}
// Limit search scope to the messages root container
const rootContainer = PlatformAdapters.General.getMessagesRoot();
this.userMessages = [];
this.assistantMessages = [];
this.totalMessages = [];
// Fetch all candidates in document order and classify using the adapter
const allCandidates = rootContainer.querySelectorAll(CONSTANTS.SELECTORS.BUBBLE_FEATURE_MESSAGE_CONTAINERS);
for (let i = 0; i < allCandidates.length; i++) {
const msg = allCandidates[i];
if (!(msg instanceof HTMLElement)) continue;
const role = PlatformAdapters.General.getMessageRole(msg);
if (role === CONSTANTS.SELECTORS.FIXED_NAV_ROLE_USER) {
this.userMessages.push(msg);
this.totalMessages.push(msg);
} else if (role === CONSTANTS.SELECTORS.FIXED_NAV_ROLE_ASSISTANT) {
// Filter out empty, non-functional message containers
if (PlatformAdapters.General.filterMessage(msg)) {
this.assistantMessages.push(msg);
this.totalMessages.push(msg);
}
}
}
// 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.debouncedNotify();
}
/**
* Appends a new message element to the cache if it follows the last known message.
* If the message appears earlier in the DOM (e.g., history load), triggers a full rebuild.
* @param {HTMLElement} rawElement - The raw element detected by Sentinel.
*/
append(rawElement) {
// 1. Resolve the message container from the raw element
const messageElement = PlatformAdapters.General.findMessageElement(rawElement);
if (!messageElement || !messageElement.isConnected) return;
// 2. Check for duplicates
if (this.elementMap.has(messageElement)) return;
// 3. Filter out invalid or ghost messages
if (!PlatformAdapters.General.filterMessage(messageElement)) return;
// 4. Validate Order and Cache Integrity
// If the cache is empty, we can't determine order relative to "last". Fallback to rebuild.
if (this.totalMessages.length === 0) {
this.debouncedRebuildCache();
return;
}
const lastMessage = this.totalMessages.at(-1);
// Check if the new message is strictly AFTER the last cached message in the DOM.
// DOCUMENT_POSITION_FOLLOWING (4): The node (messageElement) follows the reference node (lastMessage).
const position = lastMessage.compareDocumentPosition(messageElement);
if (!(position & Node.DOCUMENT_POSITION_FOLLOWING)) {
// The new message is BEFORE the last message (e.g. history loaded at top).
// Our append-only assumption is violated. Force a full rebuild to sort everything correctly.
this.debouncedRebuildCache();
return;
}
// 5. Determine the role (User or Assistant)
const rawRole = PlatformAdapters.General.getMessageRole(messageElement);
let role = null;
if (rawRole === CONSTANTS.SELECTORS.FIXED_NAV_ROLE_USER) {
role = CONSTANTS.INTERNAL_ROLES.USER;
} else if (rawRole === CONSTANTS.SELECTORS.FIXED_NAV_ROLE_ASSISTANT) {
role = CONSTANTS.INTERNAL_ROLES.ASSISTANT;
}
if (!role) return;
// 6. Safe Append
if (role === CONSTANTS.INTERNAL_ROLES.USER) {
this.userMessages.push(messageElement);
} else {
this.assistantMessages.push(messageElement);
}
this.totalMessages.push(messageElement);
// 7. Update indices
const roleIndex = (role === CONSTANTS.INTERNAL_ROLES.USER ? this.userMessages : this.assistantMessages).length - 1;
const totalIndex = this.totalMessages.length - 1;
this.elementMap.set(messageElement, { role, index: roleIndex, totalIndex });
// 8. Trigger UI update
this.debouncedNotify();
}
notify() {
EventBus.publish(EVENTS.CACHE_UPDATED);
}
/**
* Finds the role and index of a given message element within the cached arrays.
* @param {HTMLElement} messageElement
* @returns {{role: 'user'|'assistant', index: number, totalIndex: number} | null}
*/
findMessageIndex(messageElement) {
return this.elementMap.get(messageElement) || null;
}
/**
* Retrieves a message element at a specific index for a given role.
* @param {'user'|'assistant'} role
* @param {number} index
* @returns {HTMLElement | null}
*/
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();
}
/**
* @returns {HTMLElement[]}
*/
getUserMessages() {
return this.userMessages;
}
/**
* @returns {HTMLElement[]}
*/
getAssistantMessages() {
return this.assistantMessages;
}
/**
* @returns {HTMLElement[]}
*/
getTotalMessages() {
return this.totalMessages;
}
}
// =================================================================================
// SECTION: Theme and Style Management
// Description: Applies theme configuration changes to the DOM via CSS variables.
// =================================================================================
/**
* 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 === undefined || o === null ? undefined : o[k]), obj);
}
class ThemeManager extends BaseManager {
/**
* @param {ConfigManager} configManager
* @param {ImageDataManager} imageDataManager
*/
constructor(configManager, imageDataManager) {
super();
this.configManager = configManager;
this.imageDataManager = imageDataManager;
this.themeStyleElem = null;
this.dynamicRulesStyleElem = null;
this.staticStyleHandle = null;
this.dynamicStyleHandle = null;
this.lastURL = null;
this.lastTitle = null;
this.lastAppliedThemeSet = null;
this.cachedTitle = null;
/** @type {ThemeSet | null} */
this.cachedThemeSet = null;
/** @type {Array<{pattern: RegExp, set: ThemeSet, type: 'url'|'title'}>} */
this.patternCache = [];
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;
this.themeAbortController = null; // Controller to manage cancellation of pending image fetches
// State for layout preview and throttling
this.cachedPreviewWidth = undefined; // undefined: no preview, null: preview default, string: preview value
this.isLayoutUpdateScheduled = false;
}
_onInit() {
this.isLayoutUpdateScheduled = false;
this._subscribe(EVENTS.NAVIGATION, () => this._onNavigation());
this._subscribe(EVENTS.TITLE_CHANGED, () => this.debouncedUpdateTheme(false));
this._subscribe(EVENTS.THEME_UPDATE, () => this.debouncedUpdateTheme(false));
this._subscribe(EVENTS.DEFERRED_LAYOUT_UPDATE, () => this.scheduleLayoutUpdate());
// Handle layout changes with throttling
this._subscribe(EVENTS.WINDOW_RESIZED, () => this.scheduleLayoutUpdate());
this._subscribe(EVENTS.SIDEBAR_LAYOUT_CHANGED, () => this.scheduleLayoutUpdate());
// Update cache and schedule layout update on width preview
this._subscribe(EVENTS.WIDTH_PREVIEW, (width) => {
this.cachedPreviewWidth = width;
this.scheduleLayoutUpdate();
});
// Rebuild cache on config update to keep references fresh
this._subscribe(EVENTS.CONFIG_UPDATED, (newConfig) => {
this._rebuildPatternCache(newConfig);
// Clear theme cache to force re-evaluation with new patterns
this.cachedThemeSet = null;
// Clear preview cache on save
this.cachedPreviewWidth = undefined;
this.debouncedUpdateTheme(false);
});
// Initialize styles and hold references
this.staticStyleHandle = StyleManager.request(StyleDefinitions.getStaticBase);
this.themeStyleElem = document.getElementById(this.staticStyleHandle.id);
this.dynamicStyleHandle = StyleManager.request(StyleDefinitions.getDynamicRules);
this.dynamicRulesStyleElem = document.getElementById(this.dynamicStyleHandle.id);
// Register cleanup resources
this.addDisposable(this.debouncedUpdateTheme.cancel);
this.addDisposable(() => this.themeAbortController?.abort());
this.addDisposable(() => this._cleanupCssVariables());
// Build initial cache
this._rebuildPatternCache(this.configManager.get());
}
_onDestroy() {
// Cleanup handled by disposables.
// Do NOT nullify style handles here, as they are required for cleanupCssVariables.
// Clear caches to release memory
this.patternCache = [];
this.lastAppliedImageValues.clear();
this.cachedThemeSet = null;
this.cachedTitle = null;
// Clear DOM references (optional, but good for GC)
this.themeStyleElem = null;
this.dynamicRulesStyleElem = null;
}
/**
* @private
* @param {AppConfig} config
*/
_rebuildPatternCache(config) {
this.patternCache = [];
if (!config || !config.themeSets) return;
const compile = (patterns, type, set) => {
if (!Array.isArray(patterns)) return;
for (const patternStr of patterns) {
try {
const regex = parseRegexPattern(patternStr);
this.patternCache.push({ pattern: regex, set, type });
} catch (e) {
Logger.warn('CACHE', '', `Invalid ${type} pattern "${patternStr}" in theme "${set.metadata?.name}": ${e.message}`);
// Continue to next pattern
}
}
};
// Order matters: Process themes in order.
// Inside each theme, check URL patterns first, then Title patterns.
for (const set of config.themeSets) {
compile(set.metadata?.urlPatterns, 'url', set);
compile(set.metadata?.matchPatterns, 'title', set);
}
}
/**
* @private
* Removes all CSS variables defined in ALL_STYLE_DEFINITIONS from the root element.
*/
_cleanupCssVariables() {
const rootStyle = document.documentElement.style;
for (const definition of ALL_STYLE_DEFINITIONS) {
if (definition.cssVar) {
rootStyle.removeProperty(definition.cssVar);
}
}
if (this.staticStyleHandle && this.staticStyleHandle.vars) {
Object.values(this.staticStyleHandle.vars).forEach((cssVar) => {
rootStyle.removeProperty(cssVar);
});
}
}
_onNavigation() {
if (!PlatformAdapters.ThemeManager.shouldDeferInitialTheme(this)) {
this.updateTheme(false);
}
}
/** @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 titleName = this.cachedTitle;
let urlPath = window.location.pathname;
// Decode URL path to match user expectations
try {
urlPath = decodeURI(urlPath);
} catch {
// Keep original if decoding fails
}
// Iterate through the pre-compiled cache
// The cache already preserves the priority order (Theme order -> URL -> Title)
const hit = this.patternCache.find((entry) => {
if (entry.type === 'title' && titleName) {
return entry.pattern.test(titleName);
}
if (entry.type === 'url') {
return entry.pattern.test(urlPath);
}
return false;
});
if (hit) {
this.cachedThemeSet = hit.set;
return hit.set;
}
// Fallback to default if no title or no match
const config = this.configManager.get();
const defaultSet = config.platforms[PLATFORM].defaultSet;
const defaultMetadata = { id: CONSTANTS.THEME_IDS.DEFAULT, name: 'Default Settings', matchPatterns: [], urlPatterns: [] };
this.cachedThemeSet = { ...defaultSet, metadata: defaultMetadata };
return this.cachedThemeSet;
}
/**
* @param {boolean} force
*/
updateTheme(force) {
Logger.debug('THEME CHECK', LOG_STYLES.CYAN, '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.debug('THEME CHECK', LOG_STYLES.CYAN, '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();
} else {
// Even if no update is needed, publish the event to signal completion.
// This is critical for clearing loading states (e.g. settings button animation)
// that were triggered by the update request.
EventBus.publish(EVENTS.THEME_APPLIED, { theme: currentThemeSet, config: config });
}
}
/**
* @param {ThemeSet} currentThemeSet
* @param {AppConfig} fullConfig
*/
async applyThemeStyles(currentThemeSet, fullConfig) {
if (this.isDestroyed) return;
// Abort previous pending requests (if any) to prevent resource waste and race conditions.
if (this.themeAbortController) {
this.themeAbortController.abort();
}
this.themeAbortController = new AbortController();
const signal = this.themeAbortController.signal;
const myRequestId = ++this.currentRequestId;
Logger.time(`ThemeManager.applyThemeStyles#${myRequestId}`);
this.lastAppliedThemeSet = currentThemeSet;
const rootStyle = document.documentElement.style;
const imageProcessingPromises = [];
// Track active variables to generate optimized CSS
const activeVars = new Set();
// 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');
// 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)) {
if (val) activeVars.add(definition.cssVar); // Keep existing as active
continue;
}
// Invalidation: Immediately remove the current value from cache.
this.lastAppliedImageValues.delete(definition.cssVar);
// Stage 1 (Sync): Immediately set to 'none' to prevent flicker of default images.
rootStyle.setProperty(definition.cssVar, 'none');
// Mark as active so the CSS rule includes the property (with 'none' value initially)
activeVars.add(definition.cssVar);
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')) {
const resizeOptions = { signal }; // Inject signal
if (isIcon) {
resizeOptions.width = currentIconSize;
resizeOptions.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 (signal aborted) or app is destroyed, discard this result.
if (this.isDestroyed || this.currentRequestId !== myRequestId || signal.aborted) 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;
// Only set if transformer didn't return null
if (finalValue !== null) {
rootStyle.setProperty(definition.cssVar, finalValue);
activeVars.add(definition.cssVar);
} else {
rootStyle.removeProperty(definition.cssVar);
}
} else {
rootStyle.removeProperty(definition.cssVar);
}
}
}
// Update the dynamic style sheet with only the active properties
if (this.dynamicRulesStyleElem) {
// StyleDefinitions.getDynamicRules().generator accepts activeVars
const newCss = StyleDefinitions.getDynamicRules().generator(null, activeVars);
if (this.dynamicRulesStyleElem.textContent !== newCss) {
this.dynamicRulesStyleElem.textContent = newCss;
}
}
// Await all image processing and ensure event is fired even if errors occur
try {
await Promise.all(imageProcessingPromises);
} catch (e) {
Logger.error('ThemeManager', '', 'Error applying theme images:', e);
} finally {
// Guard event publication
if (!this.isDestroyed && this.currentRequestId === myRequestId && !signal.aborted) {
EventBus.publish(EVENTS.THEME_APPLIED, { theme: currentThemeSet, config: fullConfig });
}
Logger.timeEnd(`ThemeManager.applyThemeStyles#${myRequestId}`);
}
}
scheduleLayoutUpdate() {
if (this.isLayoutUpdateScheduled) return;
this.isLayoutUpdateScheduled = true;
EventBus.queueUIWork(() => {
try {
if (this.isDestroyed) return;
this.applyChatContentMaxWidth();
} finally {
this.isLayoutUpdateScheduled = false;
}
});
}
applyChatContentMaxWidth() {
if (this.isDestroyed) return;
const rootStyle = document.documentElement.style;
const config = this.configManager.get();
if (!config) return;
// Prioritize cached preview width if it exists (including null for default)
// If undefined, fall back to stored configuration
const userMaxWidth = this.cachedPreviewWidth !== undefined ? this.cachedPreviewWidth : config.platforms[PLATFORM].options.chat_content_max_width;
const activeClass = this.staticStyleHandle.classes.maxWidthActive;
const maxWidthVar = this.staticStyleHandle.vars.chatContentMaxWidth;
withLayoutCycle({
measure: () => {
// --- Read Phase ---
// Read layout properties needed for the 'else' block.
return {
sidebarWidth: getSidebarWidth(),
windowWidth: window.innerWidth,
};
},
mutate: (measured) => {
// --- Write Phase ---
if (this.isDestroyed) return;
if (!userMaxWidth) {
document.body.classList.remove(activeClass);
rootStyle.removeProperty(maxWidthVar);
} else {
document.body.classList.add(activeClass);
const themeSet = this.getThemeSet();
const iconSize = config.platforms[PLATFORM].options.icon_size;
const defaultSet = config.platforms[PLATFORM].defaultSet;
// Check if standing images are active in the current theme or default.
const hasStandingImage =
getPropertyByPath(themeSet, 'user.standingImageUrl') ||
getPropertyByPath(themeSet, 'assistant.standingImageUrl') ||
getPropertyByPath(defaultSet, 'user.standingImageUrl') ||
getPropertyByPath(defaultSet, 'assistant.standingImageUrl');
let requiredMarginPerSide = iconSize + CONSTANTS.UI_SPECS.AVATAR.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.
// Get unit dynamically from schema (Platform Scope).
const unit = CONFIG_SCHEMA.platform['options.chat_content_max_width'].def.unit;
const finalMaxWidth = `min(${userMaxWidth}${unit}, ${maxAllowedWidth}${unit})`;
rootStyle.setProperty(maxWidthVar, 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
// Description: Centralizes DOM monitoring (Mutation/Resize) to react to layout changes and content updates.
// =================================================================================
/**
* @class PageObserverScope
* @description A container for managing the lifecycle of page-specific resources.
* Handles LIFO disposal of registered disposables (functions, observers, objects).
*/
class PageObserverScope {
constructor() {
this.disposables = [];
}
/**
* Registers a disposable resource.
* @param {AppDisposable} disposable
*/
add(disposable) {
if (disposable) this.disposables.push(disposable);
}
/**
* Disposes all registered resources in LIFO order.
*/
dispose() {
// LIFO order for safety (reverse iteration)
for (let i = this.disposables.length - 1; i >= 0; i--) {
const d = this.disposables[i];
try {
if (typeof d === 'function') {
d();
} else if (d && typeof d.dispose === 'function') {
d.dispose();
} else if (d && typeof d.disconnect === 'function') {
d.disconnect();
} else if (d && typeof d.abort === 'function') {
d.abort();
}
} catch (e) {
Logger.warn('Observer', '', 'Error disposing page resource:', e);
}
}
this.disposables = [];
}
}
class ObserverManager extends BaseManager {
/**
* @param {MessageCacheManager} messageCacheManager
* @param {object} streamingState - Shared state object for streaming status.
*/
constructor(messageCacheManager, streamingState) {
super();
// Initialize observers to null; they will be created in _onInit via manageFactory
this.layoutResizeObserver = null;
this.observedElements = new Map();
this.processedTurnNodes = new Set();
/** @type {Map<HTMLElement, [string, (element: Element) => void]>} */
this.sentinelTurnListeners = new Map();
this.debouncedCacheUpdate = debounce(this._publishCacheUpdate.bind(this), CONSTANTS.TIMING.DEBOUNCE_DELAYS.CACHE_UPDATE, true);
this.pageScopeCleaner = null;
this.streamingState = streamingState;
// 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;
// Bound listener for navigation-related cache updates
this.boundHandleCacheUpdateForNavigation = this._handleCacheUpdateForNavigation.bind(this);
this._cacheEventKey = createEventKey(this, EVENTS.CACHE_UPDATED);
// Tracks the actual viewport dimensions to filter out height-only body mutations during streaming
this.lastWindowMetrics = { width: 0, height: 0, clientWidth: 0 };
}
_onInit() {
// Capture initial window metrics for resize filtering
this.lastWindowMetrics = {
width: window.innerWidth,
height: window.innerHeight,
clientWidth: document.documentElement.clientWidth,
};
// Create observers using manageFactory
// This ensures they are automatically disconnected when the manager is destroyed.
this.layoutResizeObserver = this.manageFactory(CONSTANTS.RESOURCE_KEYS.LAYOUT_RESIZE_OBSERVER, () => {
return new ResizeObserver(this._handleResize.bind(this));
});
// 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());
// Update debounced visibility check with destroy guards
this.debouncedVisibilityCheck = debounce(
() => {
if (this.isDestroyed) return;
EventBus.queueUIWork(() => {
if (this.isDestroyed) return;
this.publishVisibilityRecheck();
});
},
CONSTANTS.TIMING.DEBOUNCE_DELAYS.VISIBILITY_CHECK,
true
);
// Perform initial setup.
// This ensures all managers recognize the initial load as a navigation event.
EventBus.publish(EVENTS.NAVIGATION);
}
_onDestroy() {
this.debouncedCacheUpdate.cancel();
this.debouncedVisibilityCheck.cancel();
// Clean up any lingering turn completion listeners.
for (const [selector, callback] of this.sentinelTurnListeners.values()) {
sentinel.off(selector, callback);
}
this.sentinelTurnListeners.clear();
// layoutResizeObserver is automatically disconnected by BaseManager
// Page-specific observers are automatically disposed by BaseManager via PageObserverScope
// Clear element caches to allow GC
this.observedElements.clear();
this.processedTurnNodes.clear();
this.lastWindowMetrics = null;
}
/**
* @param {object} config
* @param {string} config.triggerSelector
* @param {string} config.resizeTargetSelector
* @returns {() => void} A cleanup function.
*/
startGenericInputAreaObserver(config) {
const { triggerSelector, resizeTargetSelector } = config;
let observedInputArea = null;
const setupObserver = (inputArea) => {
if (inputArea === observedInputArea) return;
// Cleanup previous observers
if (observedInputArea) {
this.unobserveElement(observedInputArea);
}
observedInputArea = inputArea;
// Resize Observer (via ObserverManager)
// If the inputArea itself matches the target, use it. Otherwise find the target within.
const resizeTarget = inputArea.matches(resizeTargetSelector) ? inputArea : inputArea.querySelector(resizeTargetSelector);
if (resizeTarget instanceof HTMLElement) {
this.observeElement(resizeTarget, CONSTANTS.OBSERVED_ELEMENT_TYPES.INPUT_AREA);
}
// Trigger initial placement
EventBus.publish(EVENTS.UI_REPOSITION);
};
// Use Sentinel to detect when the trigger is added
sentinel.on(triggerSelector, setupObserver);
// Initial check
const initialInputArea = document.querySelector(triggerSelector);
if (initialInputArea instanceof HTMLElement) {
setupObserver(initialInputArea);
}
return () => {
sentinel.off(triggerSelector, setupObserver);
if (observedInputArea) this.unobserveElement(observedInputArea);
};
}
/**
* @param {object} config
* @param {string} config.triggerSelector
* @param {string} config.observerType
* @param {function(HTMLElement): HTMLElement|null} config.targetResolver - A function to resolve the actual panel element from the trigger element.
* @param {function(): void} [config.immediateCallback] - An optional callback executed immediately and repeatedly during the animation loop.
* @returns {() => void} A cleanup function.
*/
startGenericPanelObserver(config) {
const { triggerSelector, observerType, targetResolver, immediateCallback } = config;
let isPanelVisible = false;
let isStateUpdating = false; // Lock to prevent race conditions
let disappearanceObserver = null;
let observedPanel = null;
let animationLoopId = null;
const STABILIZATION_MS = CONSTANTS.TIMING.ANIMATIONS.LAYOUT_STABILIZATION_MS;
const MAX_DURATION_MS = 5000; // Hard limit to prevent infinite loops
let loopEndTime = 0;
let lastRectString = '';
// Helper to run updates
const triggerUpdates = () => {
if (immediateCallback) immediateCallback();
EventBus.publish(EVENTS.UI_REPOSITION);
};
// Helper to serialize rect for comparison
const getRectString = (el) => {
if (!el || !el.isConnected) return '';
const r = el.getBoundingClientRect();
return `${r.left},${r.top},${r.width},${r.height}`;
};
// Function to run the layout update loop with dynamic extension and hard limit
const startUpdateLoop = (targetPanel) => {
if (animationLoopId) cancelAnimationFrame(animationLoopId);
const startTime = Date.now();
// Initial deadline
loopEndTime = startTime + STABILIZATION_MS;
lastRectString = getRectString(targetPanel);
const loop = () => {
triggerUpdates();
const now = Date.now();
// Check for geometric changes to extend the deadline
if (targetPanel) {
const currentRectString = getRectString(targetPanel);
if (currentRectString !== lastRectString) {
// Extend deadline if geometry is changing
loopEndTime = now + STABILIZATION_MS;
lastRectString = currentRectString;
}
}
// Check continuation conditions:
// 1. Within the dynamic deadline
// 2. Within the hard limit (MAX_DURATION_MS)
if (now < loopEndTime && now - startTime < MAX_DURATION_MS) {
animationLoopId = requestAnimationFrame(loop);
} else {
// Ensure one final update after stabilization or timeout
animationLoopId = null;
triggerUpdates();
}
};
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) this.unobserveElement(observedPanel);
observedPanel = panel;
this.observeElement(observedPanel, observerType);
}
return;
}
isPanelVisible = isNowVisible;
// Trigger animation loop to handle transition, targeting the relevant panel
// If appearing, target the new panel. If disappearing, target the old observedPanel to track its exit.
const trackingTarget = panel || observedPanel;
startUpdateLoop(trackingTarget);
EventBus.publish(EVENTS.UI_REPOSITION);
if (isNowVisible && panel) {
// --- Panel just appeared ---
Logger.debug('PANEL STATE', LOG_STYLES.CYAN, 'Panel appeared:', triggerSelector);
observedPanel = panel;
this.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.debug('PANEL STATE', LOG_STYLES.CYAN, 'Panel disappeared:', triggerSelector);
disappearanceObserver?.disconnect();
disappearanceObserver = null;
if (observedPanel) {
this.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) {
this.unobserveElement(observedPanel);
}
if (animationLoopId) cancelAnimationFrame(animationLoopId);
};
}
/**
* @private
* @description Resets observers and sets up page-specific listeners synchronously to avoid race conditions.
*/
_onNavigation() {
try {
// Sync window metrics to prevent stale state after SPA navigation
this.lastWindowMetrics = {
width: window.innerWidth,
height: window.innerHeight,
clientWidth: document.documentElement.clientWidth,
};
// Reset streaming state on navigation to prevent locks
this.streamingState.isActive = false;
this.processedTurnNodes.clear();
// Clean up non-persistent observers (everything except BODY)
// Use a snapshot of keys to avoid modification during iteration
const elements = Array.from(this.observedElements.keys());
for (const element of elements) {
const type = this.observedElements.get(element);
if (type !== CONSTANTS.OBSERVED_ELEMENT_TYPES.BODY) {
this.unobserveElement(element);
}
}
// Clean up all resources from the previous page using the scope cleaner.
// This disposes the previous PageObserverScope and removes it from BaseManager.
if (this.pageScopeCleaner) {
this.pageScopeCleaner();
this.pageScopeCleaner = null;
}
this.manageResource(CONSTANTS.RESOURCE_KEYS.ZERO_MSG_TIMER, null); // Stop any pending 0-message timers
// Create a new scope for the current page
const scope = new PageObserverScope();
// Register the scope with BaseManager (auto-cleanup on destroy) and keep the cleaner
this.pageScopeCleaner = this.addDisposable(scope);
// 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.
EventBus.subscribe(EVENTS.CACHE_UPDATED, this.boundHandleCacheUpdateForNavigation, this._cacheEventKey);
// Add unsubscribe to the page scope
scope.add(() => EventBus.unsubscribe(EVENTS.CACHE_UPDATED, this._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),
startGenericPanelObserver: this.startGenericPanelObserver.bind(this),
startGenericInputAreaObserver: this.startGenericInputAreaObserver.bind(this),
});
if (typeof cleanup === 'function') {
scope.add(cleanup);
}
}
} catch (e) {
Logger.error('NAV_HANDLER_ERROR', LOG_STYLES.RED, 'Error during navigation handling:', e);
}
}
/**
* @description Evaluates a newly completed message element to determine if a streaming session has started.
* Implements self-healing logic to reset stuck streaming flags if a new turn begins unexpectedly.
* @param {HTMLElement} messageElement The message element that was just processed.
*/
handleMessageComplete(messageElement) {
const role = PlatformAdapters.General.getMessageRole(messageElement);
// Only assistant messages trigger streaming state
if (role !== CONSTANTS.SELECTORS.FIXED_NAV_ROLE_ASSISTANT) return;
const turnNode = messageElement.closest(CONSTANTS.SELECTORS.CONVERSATION_UNIT);
if (!(turnNode instanceof HTMLElement)) return;
// If the turn is NOT complete, it means streaming is in progress.
if (!PlatformAdapters.Observer.isTurnComplete(turnNode)) {
// Self-healing: If flag is already true, it means the previous stream didn't close properly.
if (this.streamingState.isActive) {
Logger.warn('Observer', '', 'New streaming started while flag was stuck. Resetting.');
this.streamingState.isActive = false;
}
this.streamingState.isActive = true;
EventBus.publish(EVENTS.STREAMING_START);
}
}
/**
* @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.
this.manageResource(CONSTANTS.RESOURCE_KEYS.ZERO_MSG_TIMER, null);
if (this.messageCacheManager && this.messageCacheManager.getTotalMessages().length > 0) {
// --- Case A: Messages Found ---
// This is the "history loaded" state. Navigation is complete.
Logger.debug('CACHE', LOG_STYLES.TEAL, 'Cache update has messages. Firing NAVIGATION_END.');
EventBus.publish(EVENTS.NAVIGATION_END);
// Unsubscribe self, as navigation is complete.
EventBus.unsubscribe(EVENTS.CACHE_UPDATED, this._cacheEventKey);
} 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', LOG_STYLES.TEAL, `Cache update has 0 messages. Starting ${CONSTANTS.TIMING.TIMEOUTS.ZERO_MESSAGE_GRACE_PERIOD}ms grace period...`);
const id = setTimeout(() => {
if (this.isDestroyed) return;
// If the timer finishes *without* being canceled by another cache update,
// we are definitively on a 0-message page. Navigation is complete.
Logger.debug('CACHE', LOG_STYLES.TEAL, '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, this._cacheEventKey);
}, CONSTANTS.TIMING.TIMEOUTS.ZERO_MESSAGE_GRACE_PERIOD);
this.manageResource(CONSTANTS.RESOURCE_KEYS.ZERO_MSG_TIMER, () => clearTimeout(id));
}
}
_handleResize(entries) {
const eventsToPublish = new Set();
for (const entry of entries) {
// Self-healing: If the element is detached from DOM, stop observing it immediately.
if (!entry.target.isConnected) {
this.unobserveElement(entry.target);
continue;
}
const type = this.observedElements.get(entry.target);
if (!type) continue;
switch (type) {
case CONSTANTS.OBSERVED_ELEMENT_TYPES.BODY: {
// Read current viewport dimensions
const currentWidth = window.innerWidth;
const currentHeight = window.innerHeight;
const currentClientWidth = document.documentElement.clientWidth;
// Check if the viewport has actually changed (resizing or scrollbar appearance)
// Ignore height-only body mutations caused by content expansion (e.g., streaming)
if (this.lastWindowMetrics.width !== currentWidth || this.lastWindowMetrics.height !== currentHeight || this.lastWindowMetrics.clientWidth !== currentClientWidth) {
this.lastWindowMetrics = {
width: currentWidth,
height: currentHeight,
clientWidth: currentClientWidth,
};
eventsToPublish.add(EVENTS.WINDOW_RESIZED);
}
break;
}
case CONSTANTS.OBSERVED_ELEMENT_TYPES.INPUT_AREA:
eventsToPublish.add(EVENTS.INPUT_AREA_RESIZED);
break;
case CONSTANTS.OBSERVED_ELEMENT_TYPES.SIDE_PANEL:
eventsToPublish.add(EVENTS.UI_REPOSITION);
break;
}
}
// Publish collected unique events once per frame
for (const event of eventsToPublish) {
EventBus.publish(event);
}
}
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);
}
/**
* @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', CONSTANTS.TIMING.PERF_MONITOR_THROTTLE);
// 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.
this.processedTurnNodes.add(turnNode);
// Handle logic if we were tracking a stream that finished instantly or externally
if (this.streamingState.isActive) {
this.streamingState.isActive = false;
EventBus.publish(EVENTS.STREAMING_END);
EventBus.publish(EVENTS.DEFERRED_LAYOUT_UPDATE);
}
} 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);
// End streaming state if active
if (this.streamingState.isActive) {
this.streamingState.isActive = false;
EventBus.publish(EVENTS.STREAMING_END);
EventBus.publish(EVENTS.DEFERRED_LAYOUT_UPDATE);
}
// 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
* @returns {boolean}
* @private
*/
_isTurnComplete(turnNode) {
return PlatformAdapters.Observer.isTurnComplete(turnNode);
}
/** @private */
_cleanupDisconnectedElements() {
// 1. Cleanup Turn Listeners (Sentinel)
for (const [turnNode, [selector, callback]] of this.sentinelTurnListeners) {
if (!turnNode.isConnected) {
sentinel.off(selector, callback);
this.sentinelTurnListeners.delete(turnNode);
}
}
// 2. Cleanup Processed Turn Nodes (Set)
for (const turnNode of this.processedTurnNodes) {
if (!turnNode.isConnected) {
this.processedTurnNodes.delete(turnNode);
}
}
// 3. Cleanup Observed Elements (ResizeObserver)
for (const element of this.observedElements.keys()) {
if (!element.isConnected) {
this.layoutResizeObserver.unobserve(element);
this.observedElements.delete(element);
}
}
}
/** @private */
_publishCacheUpdate() {
// Perform garbage collection before notifying other managers
this._cleanupDisconnectedElements();
EventBus.publish(EVENTS.CACHE_UPDATE_REQUEST);
}
}
class AvatarManager extends BaseManager {
/**
* @param {ConfigManager} configManager
* @param messageCacheManager
*/
constructor(configManager, messageCacheManager) {
super();
this.configManager = configManager;
this.messageCacheManager = messageCacheManager;
this.style = null; // Handle for styles
// A queue to hold incoming avatar injection requests.
/** @type {Set<HTMLElement>} */
this._injectionQueue = new Set();
// 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);
// 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}`)]);
this.injectAttempts = new WeakMap();
this.injectFailed = new WeakMap();
}
_onInit() {
this.injectAvatarStyle();
// Instead of processing immediately, queue the element for batch processing.
this._subscribe(EVENTS.AVATAR_INJECT, (elem) => this.queueForInjection(elem));
// Clear queue and cancel pending tasks on navigation start to prevent memory leaks and unnecessary processing.
this._subscribe(EVENTS.NAVIGATION_START, () => {
this._injectionQueue.clear();
this._debouncedProcessQueue.cancel();
this.manageResource(CONSTANTS.RESOURCE_KEYS.BATCH_TASK, null);
this.injectAttempts = new WeakMap();
this.injectFailed = new WeakMap();
});
// Ensure state is clean on navigation settlement.
this._subscribe(EVENTS.NAVIGATION, () => {
this._injectionQueue.clear();
});
// Self-Healing: Re-check all messages after navigation completes.
// This restores avatars that may have been lost during DOM recycling (e.g., bfcache restoration).
this._subscribe(EVENTS.NAVIGATION_END, () => this.restoreAvatars());
this.addDisposable(this._debouncedProcessQueue.cancel);
}
_onDestroy() {
// Cleanup handled by disposables.
// Do NOT set this.style to null here, as it may be used by pending disposables.
this._injectionQueue.clear();
this.avatarTemplate = null;
this.injectAttempts = new WeakMap();
this.injectFailed = new WeakMap();
// Do not manually remove avatar containers from the DOM.
// Let the site's framework manage the DOM lifecycle to support SPA caching.
}
/**
* @param {HTMLElement} msgElem
*/
queueForInjection(msgElem) {
// Avoid unnecessary queuing if the avatar is already injected
if (msgElem.getElementsByClassName(CONSTANTS.SELECTORS.SIDE_AVATAR_CONTAINER_CLASS).length > 0) {
return;
}
const MAX_ATTEMPTS = CONSTANTS.RETRY.AVATAR_INJECTION_LIMIT;
const attempts = this.injectAttempts.get(msgElem) || 0;
if (attempts >= MAX_ATTEMPTS) {
// Log the failure only once to avoid spamming the console.
if (!this.injectFailed.has(msgElem)) {
Logger.warn('AVATAR RETRY FAILED', LOG_STYLES.YELLOW, `Avatar injection failed after ${MAX_ATTEMPTS} attempts. Halting retries for this element:`, msgElem);
this.injectFailed.set(msgElem, true);
}
return; // Stop trying
}
this.injectAttempts.set(msgElem, attempts + 1);
this._injectionQueue.add(msgElem);
this._debouncedProcessQueue();
}
/**
* Queues all currently cached messages for avatar injection checks.
* Used to restore avatars lost during DOM recycling (SPA navigation/bfcache).
* The batch processor efficiently skips elements that already have avatars.
*/
restoreAvatars() {
const allMessages = this.messageCacheManager.getTotalMessages();
for (const msg of allMessages) {
this.queueForInjection(msg);
}
}
/** @private */
_processInjectionQueue() {
// Cancel any existing batch task immediately
this.manageResource(CONSTANTS.RESOURCE_KEYS.BATCH_TASK, null);
if (this._injectionQueue.size === 0) {
return;
}
Logger.debug('AVATAR QUEUE', LOG_STYLES.CYAN, `Processing ${this._injectionQueue.size} items.`);
const messagesToProcess = [...this._injectionQueue];
this._injectionQueue.clear();
const reservedKeys = new Set(); // For scope duplication check
const cancelFn = runBatchUpdate(
messagesToProcess,
CONSTANTS.PROCESSING.BATCH_SIZE,
// Measure
(msgElem) => {
if (!msgElem.isConnected) return null;
const role = PlatformAdapters.General.getMessageRole(msgElem);
if (!role) return null;
const measurement = PlatformAdapters.Avatar.measureAvatarTarget(msgElem);
if (measurement) {
// Check exclusion key to prevent duplicates in this entire process
if (measurement.exclusionKey) {
if (reservedKeys.has(measurement.exclusionKey)) return null;
reservedKeys.add(measurement.exclusionKey);
}
return measurement;
}
return null;
},
// Mutate
(measurement) => {
// Safety check: Ensure targets are still connected
if (measurement.targetElement && !measurement.targetElement.isConnected) return;
if (measurement.processedTarget && !measurement.processedTarget.isConnected) return;
// Create container (only if injection is needed)
let container = null;
if (measurement.shouldInject) {
const node = this.avatarTemplate.cloneNode(true);
// Verify type using instanceof instead of casting
if (node instanceof HTMLElement) {
container = node;
} else {
// If cloning failed to produce an HTMLElement, skip injection for this item
return;
}
}
PlatformAdapters.Avatar.injectAvatar(measurement, container);
// Cleanup flags using the strongly typed originalElement property
this.injectAttempts.delete(measurement.originalElement);
this.injectFailed.delete(measurement.originalElement);
},
// On Finish
() => {
this.manageResource(CONSTANTS.RESOURCE_KEYS.BATCH_TASK, null);
},
null
);
this.manageResource(CONSTANTS.RESOURCE_KEYS.BATCH_TASK, cancelFn);
}
injectAvatarStyle() {
if (this.style) return;
this.style = StyleManager.request(StyleDefinitions.getAvatar);
this.updateIconSizeCss();
}
updateIconSizeCss() {
if (!this.style) return;
const iconSize = this.configManager.getIconSize();
const vars = this.style.vars;
document.documentElement.style.setProperty(vars.iconSize, `${iconSize}px`);
document.documentElement.style.setProperty(vars.iconMargin, `${CONSTANTS.UI_SPECS.AVATAR.MARGIN}px`);
}
}
class StandingImageManager extends BaseManager {
/**
* @param {ConfigManager} configManager
* @param messageCacheManager
* @param {ThemeManager} themeManager
*/
constructor(configManager, messageCacheManager, themeManager) {
super();
this.configManager = configManager;
this.messageCacheManager = messageCacheManager;
this.themeManager = themeManager;
this.isUpdateScheduled = false;
this.isAutoScrolling = false;
this.scheduleUpdate = this.scheduleUpdate.bind(this);
this.anchorSelectors = [];
this.style = null; // Handle for styles and class names
}
_onInit() {
this.isUpdateScheduled = false;
this.injectStyles(); // Inject styles first to generate IDs
this.createContainers(); // Create containers using generated IDs
// Register cleanup for the created DOM elements
this.addDisposable(() => {
if (this.style) {
const cls = this.style.classes;
document.getElementById(cls.userImageId)?.remove();
document.getElementById(cls.assistantImageId)?.remove();
}
});
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.DEFERRED_LAYOUT_UPDATE, this.scheduleUpdate);
// Auto-scroll visibility handling
this._subscribe(EVENTS.AUTO_SCROLL_START, () => {
this.isAutoScrolling = true;
this.updateVisibility();
});
this._subscribe(EVENTS.AUTO_SCROLL_COMPLETE, () => {
this.isAutoScrolling = false;
this.updateVisibility();
});
// Safety reset on navigation start to ensure visibility is restored if scroll is interrupted
this._subscribe(EVENTS.NAVIGATION_START, () => {
this.isAutoScrolling = false;
});
// 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);
this.addDisposable(() => sentinel.off(selector, this.scheduleUpdate));
});
PlatformAdapters.StandingImage.setupEventListeners(this);
}
_onDestroy() {
// Cleanup handled by disposables.
// Do NOT set this.style to null here, as it is required for container removal.
}
scheduleUpdate() {
if (this.isDestroyed) return;
if (this.isUpdateScheduled) return;
this.isUpdateScheduled = true;
EventBus.queueUIWork(async () => {
try {
if (this.isDestroyed) return;
this.updateVisibility();
await this.recalculateStandingImagesLayout();
} finally {
this.isUpdateScheduled = false;
}
});
}
injectStyles() {
if (this.style) return;
this.style = StyleManager.request(StyleDefinitions.getStandingImage);
}
createContainers() {
if (!this.style) return;
const cls = this.style.classes;
if (document.getElementById(cls.assistantImageId)) return;
const userImg = h('div', { id: cls.userImageId });
const asstImg = h('div', { id: cls.assistantImageId });
document.body.appendChild(userImg);
document.body.appendChild(asstImg);
}
updateVisibility() {
PlatformAdapters.StandingImage.updateVisibility(this);
}
/**
* @returns {Promise<void>}
*/
async recalculateStandingImagesLayout() {
return PlatformAdapters.StandingImage.recalculateLayout(this);
}
}
// =================================================================================
// SECTION: Bubble Feature Management
// Description: Orchestrates the injection of interactive features (buttons, navigation) into message bubbles.
// =================================================================================
/**
* @constant BubbleFeatureDefs
* @description Definitions for bubble UI features, extracted from BubbleUIManager.
* Contains render logic and update behaviors for Collapsible, Navigation, etc.
*/
const BubbleFeatureDefs = {
COLLAPSIBLE: {
name: 'collapsible',
isEnabled: (config) => config.platforms[PLATFORM].features.collapsible_button.enabled,
getInfo: (msgElem) => PlatformAdapters.BubbleUI.getCollapsibleInfo(msgElem),
measure: (info, messageElement, manager) => {
if (!info) return null;
const config = manager.configManager.get();
if (config.platforms[PLATFORM].features.collapsible_button.auto_collapse_user_message.enabled) {
if (!manager.autoCollapseProcessedIds.has(messageElement)) {
const role = PlatformAdapters.General.getMessageRole(messageElement);
if (role === CONSTANTS.SELECTORS.FIXED_NAV_ROLE_USER) {
return { height: info.bubbleElement.offsetHeight };
}
}
}
return null;
},
render: (info, msgElem, manager) => {
const button = manager.featureTemplates.collapsibleButton.cloneNode(true);
if (!(button instanceof HTMLElement)) return null;
const cls = manager.styleHandle.classes;
button.onclick = (e) => {
e.stopPropagation();
info.msgWrapper.classList.toggle(cls.collapsed);
};
return button;
},
update: (element, info, isEnabled, messageElement, manager, measurement) => {
const cls = manager.styleHandle.classes;
if (isEnabled && info) {
element.classList.remove(cls.hidden);
// Apply class to both wrappers to support different platform CSS strategies
info.msgWrapper.classList.add(cls.collapsibleParent);
info.positioningParent.classList.add(cls.collapsibleParent);
info.bubbleElement.classList.add(cls.collapsibleContent);
// --- Auto Collapse Logic ---
if (measurement && measurement.height > CONSTANTS.UI_SPECS.COLLAPSIBLE.HEIGHT_THRESHOLD) {
info.msgWrapper.classList.add(cls.collapsed);
}
if (!manager.autoCollapseProcessedIds.has(messageElement)) {
manager.autoCollapseProcessedIds.set(messageElement, true);
}
} else {
element.classList.add(cls.hidden);
if (info) {
info.msgWrapper.classList.remove(cls.collapsibleParent, cls.collapsed);
info.positioningParent.classList.remove(cls.collapsibleParent);
info.bubbleElement.classList.remove(cls.collapsibleContent);
}
}
},
},
BUBBLE_NAV_TOP: {
name: 'bubbleNavTop',
group: 'bubbleNavButtons',
position: 'top',
isEnabled: (config) => config.platforms[PLATFORM].features.bubble_nav_buttons.enabled,
getInfo: (msgElem) => PlatformAdapters.BubbleUI.getBubbleNavButtonsInfo(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);
EventBus.publish(EVENTS.NAV_HIGHLIGHT_MESSAGE, targetMsg);
}
};
const prevBtn = manager.featureTemplates.navPrevButton.cloneNode(true);
const nextBtn = manager.featureTemplates.navNextButton.cloneNode(true);
if (!(prevBtn instanceof HTMLElement) || !(nextBtn instanceof HTMLElement)) return null;
prevBtn.onclick = createClickHandler(-1);
nextBtn.onclick = createClickHandler(1);
const cls = manager.styleHandle.classes;
return h(`div.${cls.navGroupTop}`, [prevBtn, nextBtn]);
},
update: (element, info, isEnabled, messageElement, manager) => {
const cls = manager.styleHandle.classes;
element.classList.toggle(cls.hidden, !isEnabled);
},
},
BUBBLE_NAV_BOTTOM: {
name: 'bubbleNavBottom',
group: 'bubbleNavButtons',
position: 'bottom',
isEnabled: (config) => config.platforms[PLATFORM].features.bubble_nav_buttons.enabled,
getInfo: (msgElem) => PlatformAdapters.BubbleUI.getBubbleNavButtonsInfo(msgElem),
render: (info, msgElem, manager) => {
const topBtn = manager.featureTemplates.navTopButton.cloneNode(true);
if (!(topBtn instanceof HTMLElement)) return null;
topBtn.onclick = (e) => {
e.stopPropagation();
scrollToElement(msgElem);
};
const cls = manager.styleHandle.classes;
return h(`div.${cls.navGroupBottom}`, [topBtn]);
},
update: (element, info, isEnabled, messageElement, manager) => {
const cls = manager.styleHandle.classes;
element.classList.toggle(cls.hidden, !isEnabled);
},
},
};
/**
* 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 extends BaseManager {
/**
* @param {ConfigManager} configManager
* @param {MessageCacheManager} messageCacheManager
*/
constructor(configManager, messageCacheManager) {
super();
this.configManager = configManager;
this.messageCacheManager = messageCacheManager;
this.navContainers = new WeakMap();
this.featureElementsCache = new WeakMap();
this.autoCollapseProcessedIds = new WeakMap();
this.styleHandle = null;
this.featureTemplates = {}; // Initialized in init
/**
* @private
* @type {Array<object>}
*/
this._features = [BubbleFeatureDefs.COLLAPSIBLE, BubbleFeatureDefs.BUBBLE_NAV_TOP, BubbleFeatureDefs.BUBBLE_NAV_BOTTOM].map((def) => ({
...def,
// Wrap update method to inject 'this' (manager) as the last argument
// This allows the external definition to access manager state (styles, config, etc.)
update: (element, info, isEnabled, messageElement, measurement) => def.update(element, info, isEnabled, messageElement, this, measurement),
}));
}
_onInit() {
this.injectStyle();
this._createTemplates();
this._subscribe(EVENTS.TURN_COMPLETE, (turnNode) => this.processTurn(turnNode));
// Clean up DOM and cancel tasks immediately on navigation start
this._subscribe(EVENTS.NAVIGATION_START, () => this._handleNavigationStart());
// Reset state on navigation complete
this._subscribe(EVENTS.NAVIGATION, () => this._onNavigation());
this._subscribe(EVENTS.CACHE_UPDATED, () => this.updateAll());
}
_onDestroy() {
this.navContainers = new WeakMap();
this.featureElementsCache = new WeakMap();
this.autoCollapseProcessedIds = new WeakMap();
// Do NOT set this.styleHandle to null here.
this.featureTemplates = {};
// Do not manually remove injected DOM elements or classes.
// Let the site's framework manage the DOM lifecycle to support SPA caching.
}
updateAll() {
const allMessages = this.messageCacheManager.getTotalMessages();
this._processUpdateQueue(allMessages, CONSTANTS.RESOURCE_KEYS.BATCH_TASK, () => {
this._updateNavButtonStates();
});
}
/**
* @param {HTMLElement} turnNode
*/
processTurn(turnNode) {
const nodes = turnNode.querySelectorAll(CONSTANTS.SELECTORS.BUBBLE_FEATURE_MESSAGE_CONTAINERS);
/** @type {HTMLElement[]} */
const allMessageElements = [];
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if (node instanceof HTMLElement) {
allMessageElements.push(node);
}
}
this._processUpdateQueue(allMessageElements, CONSTANTS.RESOURCE_KEYS.BATCH_TASK_TURN);
}
/**
* @private
* @param {HTMLElement[]} messages
* @param {string} resourceKey
* @param {() => void} [onComplete]
*/
_processUpdateQueue(messages, resourceKey, onComplete) {
// Cancel any existing batch task for this specific key
this.manageResource(resourceKey, null);
const cancelFn = runBatchUpdate(
messages,
CONSTANTS.PROCESSING.BATCH_SIZE,
// Measure
(msg) => {
if (msg.isConnected) {
return this._measureElement(msg);
}
return null;
},
// Mutate
(m) => {
if (m.messageElement.isConnected) {
this._mutateElement(m);
}
},
// On Finish
() => {
this.manageResource(resourceKey, null);
if (onComplete) onComplete();
},
null
);
this.manageResource(resourceKey, cancelFn);
}
/**
* @private
* @param {HTMLElement} messageElement
*/
_measureElement(messageElement) {
const config = this.configManager.get();
if (!config) return null;
// 1. Feature Info Gathering
const featureTasks = this._features.map((feature) => {
const isEnabled = feature.isEnabled(config);
// getInfo is assumed to be a READ operation
const info = isEnabled ? feature.getInfo(messageElement) : null;
// Execute feature-specific measurement if defined
const measurement = isEnabled && typeof feature.measure === 'function' ? feature.measure(info, messageElement, this) : null;
return {
feature,
cacheKey: feature.name,
isEnabled,
info,
measurement,
};
});
// 2. Navigation Anchor Check
const needsNavContainer = featureTasks.some((t) => t.isEnabled && t.info && t.feature.group === 'bubbleNavButtons');
let navPositioningParent = null;
if (needsNavContainer) {
navPositioningParent = PlatformAdapters.BubbleUI.getNavPositioningParent(messageElement);
}
return {
messageElement,
featureTasks,
navPositioningParent,
};
}
/**
* @private
* @param {object} measurement
*/
_mutateElement(measurement) {
const { messageElement, featureTasks, navPositioningParent } = measurement;
const cls = this.styleHandle.classes;
// 1. Cleanup Anchor Class (Self-correction)
if (messageElement.classList.contains(cls.imageOnlyAnchor)) {
messageElement.classList.remove(cls.imageOnlyAnchor);
}
// 2. Prepare Nav Container
let bubbleNavContainer = null;
if (navPositioningParent) {
if (this.navContainers.has(messageElement)) {
bubbleNavContainer = this.navContainers.get(messageElement);
// Lazy check: If cached container is disconnected, discard it
if (bubbleNavContainer && !bubbleNavContainer.isConnected) {
bubbleNavContainer = null;
this.navContainers.delete(messageElement);
}
}
if (!bubbleNavContainer) {
// Check DOM in case it exists but wasn't cached (e.g., after reload)
let container = messageElement.querySelector(`.${cls.navContainer}`);
if (!container) {
// Create and Append
navPositioningParent.style.position = 'relative';
navPositioningParent.classList.add(cls.navParent);
container = h(`div.${cls.navContainer}`, [h(`div.${cls.navButtons}`)]);
if (container instanceof HTMLElement) {
navPositioningParent.appendChild(container);
this.navContainers.set(messageElement, container);
}
} else {
this.navContainers.set(messageElement, container);
}
bubbleNavContainer = container;
}
}
// 3. Apply Features
let msgFeaturesMap = this.featureElementsCache.get(messageElement);
if (!msgFeaturesMap) {
msgFeaturesMap = new Map();
this.featureElementsCache.set(messageElement, msgFeaturesMap);
}
for (const task of featureTasks) {
const { feature, cacheKey, isEnabled, info, measurement: featureMeasurement } = task;
if (isEnabled && info) {
let featureElement = msgFeaturesMap.get(cacheKey);
// Lazy check: If cached feature element is disconnected, discard it
if (featureElement && !featureElement.isConnected) {
featureElement = undefined;
}
if (!featureElement) {
// Render (Create DOM)
featureElement = feature.render(info, messageElement, this);
if (featureElement) {
msgFeaturesMap.set(cacheKey, featureElement);
let targetContainer = null;
let cleanupSelector = null;
if (feature.group === 'bubbleNavButtons') {
if (bubbleNavContainer) {
targetContainer = bubbleNavContainer.querySelector(`.${cls.navButtons}`);
if (featureElement.classList.contains(cls.navGroupTop)) {
cleanupSelector = `.${cls.navGroupTop}`;
} else {
cleanupSelector = `.${cls.navGroupBottom}`;
}
}
} else {
targetContainer = info.positioningParent;
if (featureElement.classList.length > 0) {
cleanupSelector = `.${featureElement.classList[0]}`;
}
}
if (targetContainer && cleanupSelector) {
const existing = targetContainer.querySelector(cleanupSelector);
if (existing) existing.remove();
if (feature.position === 'top') {
targetContainer.prepend(featureElement);
} else {
targetContainer.appendChild(featureElement);
}
}
}
}
if (featureElement) {
feature.update(featureElement, info, true, messageElement, featureMeasurement);
}
} else {
const featureElement = msgFeaturesMap.get(cacheKey);
if (featureElement) {
feature.update(featureElement, info, false, messageElement, featureMeasurement);
}
}
}
}
/** @private */
_handleNavigationStart() {
// Cancel pending batch processing to prevent memory leaks or errors
this.manageResource(CONSTANTS.RESOURCE_KEYS.BATCH_TASK, null);
this.manageResource(CONSTANTS.RESOURCE_KEYS.BATCH_TASK_TURN, null);
this.manageResource(CONSTANTS.RESOURCE_KEYS.BUTTON_STATE_TASK, null);
// Reset state for the new page
this.navContainers = new WeakMap();
this.featureElementsCache = new WeakMap();
this.autoCollapseProcessedIds = new WeakMap();
// Do not manually remove injected DOM elements or classes.
// Let the site's framework manage the DOM lifecycle to support SPA caching.
}
/** @private */
_onNavigation() {
// Cancel pending batch processing as a fail-safe (in case NAVIGATION_START was missed)
this.manageResource(CONSTANTS.RESOURCE_KEYS.BATCH_TASK, null);
this.manageResource(CONSTANTS.RESOURCE_KEYS.BATCH_TASK_TURN, null);
this.manageResource(CONSTANTS.RESOURCE_KEYS.BUTTON_STATE_TASK, null);
// Ensure caches are cleared if not already done
this.navContainers = new WeakMap();
this.featureElementsCache = new WeakMap();
this.autoCollapseProcessedIds = new WeakMap();
}
injectStyle() {
this.styleHandle = StyleManager.request(StyleDefinitions.getBubbleUI);
}
/** @private */
_createTemplates() {
const cls = this.styleHandle.classes;
const prevClass = `${cls.navBtn} ${cls.navPrev}`;
const nextClass = `${cls.navBtn} ${cls.navNext}`;
const topClass = `${cls.navBtn} ${cls.navTop}`;
this.featureTemplates = {
collapsibleButton: h(`button.${cls.collapsibleBtn}`, { type: 'button', title: 'Toggle message' }, [this._createIcon('collapse')]),
navPrevButton: h('button', { className: prevClass, type: 'button', title: 'Scroll to previous message', dataset: { [CONSTANTS.DATA_KEYS.ORIGINAL_TITLE]: 'Scroll to previous message' } }, [this._createIcon('prev')]),
navNextButton: h('button', { className: nextClass, type: 'button', title: 'Scroll to next message', dataset: { [CONSTANTS.DATA_KEYS.ORIGINAL_TITLE]: 'Scroll to next message' } }, [this._createIcon('next')]),
navTopButton: h('button', { className: topClass, type: 'button', title: 'Scroll to top of this message' }, [this._createIcon('top')]),
};
}
/** @private */
_createIcon(type) {
const iconMap = {
collapse: 'arrowUp',
prev: 'arrowUp',
next: 'arrowDown',
top: 'scrollToTop',
};
const iconKey = iconMap[type];
if (!iconKey) {
return null;
}
const element = createIconFromDef(StyleDefinitions.ICONS[iconKey]);
if (element instanceof SVGElement) {
return element;
}
return null;
}
/** @private */
_updateNavButtonStates() {
// Cancel any pending button state updates
this.manageResource(CONSTANTS.RESOURCE_KEYS.BUTTON_STATE_TASK, null);
const disabledHint = '(No message to scroll to)';
const cls = this.styleHandle.classes;
const allMessages = this.messageCacheManager.getTotalMessages();
const cancelFn = runBatchUpdate(
allMessages,
CONSTANTS.PROCESSING.BATCH_SIZE,
(message) => {
if (!message.isConnected) return null;
const container = this.navContainers.get(message);
if (!container || !container.isConnected) return null;
// Calculate state based on cache role index logic
// We need to know if this is the first or last message of its role.
const roleInfo = this.messageCacheManager.findMessageIndex(message);
if (!roleInfo) return null;
const roleMessages = roleInfo.role === CONSTANTS.INTERNAL_ROLES.USER ? this.messageCacheManager.getUserMessages() : this.messageCacheManager.getAssistantMessages();
const isFirst = roleInfo.index === 0;
const isLast = roleInfo.index === roleMessages.length - 1;
return { container, isFirst, isLast };
},
({ container, isFirst, isLast }) => {
const prevBtn = container.querySelector(`.${cls.navPrev}`);
if (prevBtn) {
prevBtn.disabled = isFirst;
const originalTitle = DomState.get(prevBtn, CONSTANTS.DATA_KEYS.ORIGINAL_TITLE);
prevBtn.title = isFirst ? `${originalTitle} ${disabledHint}` : originalTitle;
}
const nextBtn = container.querySelector(`.${cls.navNext}`);
if (nextBtn) {
nextBtn.disabled = isLast;
const originalTitle = DomState.get(nextBtn, CONSTANTS.DATA_KEYS.ORIGINAL_TITLE);
nextBtn.title = isLast ? `${originalTitle} ${disabledHint}` : originalTitle;
}
},
() => this.manageResource(CONSTANTS.RESOURCE_KEYS.BUTTON_STATE_TASK, null),
null
);
this.manageResource(CONSTANTS.RESOURCE_KEYS.BUTTON_STATE_TASK, cancelFn);
}
}
// =================================================================================
// SECTION: Fixed Navigation Console
// Description: Manages the fixed navigation UI docked to the input area.
// =================================================================================
class FixedNavigationManager extends BaseManager {
/**
* @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) {
super();
this.messageCacheManager = messageCacheManager;
this.configManager = configManager;
this.autoScrollManager = autoScrollManager; // May be null
this.messageLifecycleManager = messageLifecycleManager;
this.options = options; // Keep reference for re-initialization
// Handle to the styles and classes
this.styleHandle = null;
// Centralized state management
this.state = {
currentIndices: {
[CONSTANTS.NAV_ROLES.USER]: -1,
[CONSTANTS.NAV_ROLES.ASSISTANT]: -1,
[CONSTANTS.NAV_ROLES.TOTAL]: -1,
},
highlightedMessage: null,
isInitialSelectionDone: !!options.isReEnabling,
jumpListComponent: null,
lastFilterValue: '',
previousTotalMessages: messageCacheManager.getTotalMessages().length,
isAutoScrolling: false,
activeRole: CONSTANTS.NAV_ROLES.TOTAL,
inputMode: CONSTANTS.INPUT_MODES.NORMAL, // 'normal', 'shift'
stickyMode: null, // null | 'shift'
interactionActive: false, // true if hovered or focused
};
this.lastShortcutTime = 0;
// Cache for UI elements to avoid repeated querySelector calls
this.uiCache = null;
this.isRepositionScheduled = false;
this.scheduleReposition = this.scheduleReposition.bind(this);
this.handleBodyClick = this.handleBodyClick.bind(this);
this._handleKeyDown = this._handleKeyDown.bind(this);
this._handleDocumentKeyChange = this._handleDocumentKeyChange.bind(this);
this._handleInteractionStateChange = this._handleInteractionStateChange.bind(this);
this._handleRoleContextMenu = this._handleRoleContextMenu.bind(this);
this._handleModeContextMenu = this._handleModeContextMenu.bind(this);
this._handleWindowBlur = this._handleWindowBlur.bind(this);
}
/**
* @returns {Promise<void>}
*/
async _onInit() {
// Re-initialize state if it was destroyed (null)
if (!this.state) {
this.state = {
currentIndices: {
[CONSTANTS.NAV_ROLES.USER]: -1,
[CONSTANTS.NAV_ROLES.ASSISTANT]: -1,
[CONSTANTS.NAV_ROLES.TOTAL]: -1,
},
highlightedMessage: null,
isInitialSelectionDone: !!this.options.isReEnabling,
jumpListComponent: null,
lastFilterValue: '',
previousTotalMessages: this.messageCacheManager.getTotalMessages().length,
isAutoScrolling: false,
activeRole: CONSTANTS.NAV_ROLES.TOTAL,
inputMode: CONSTANTS.INPUT_MODES.NORMAL,
stickyMode: null,
interactionActive: false,
};
}
// --- Performance Optimization: Search Cache ---
/** @type {Map<HTMLElement, {displayText: string, lowerText: string, roleClass: string}>} */
this.searchCache = new Map();
/** @type {HTMLElement[]} */
this.indexingQueue = [];
this.indexingTask = null;
this.styleHandle = this.injectStyle();
// Pre-inject JumpList styles to avoid overhead on toggle
this.jumpListStyleHandle = StyleManager.request(StyleDefinitions.getJumpList);
this.createContainers();
this._subscribe(EVENTS.CACHE_UPDATED, this._handleCacheUpdate.bind(this));
// Reset state immediately on navigation start to hide UI and cleanup components
this._subscribe(EVENTS.NAVIGATION_START, this.resetState.bind(this));
this._subscribe(EVENTS.NAVIGATION, this.resetState.bind(this));
this._subscribe(EVENTS.INTEGRITY_SCAN_MESSAGES_FOUND, this._handleIntegrityScanMessagesFound.bind(this));
this._subscribe(EVENTS.NAV_HIGHLIGHT_MESSAGE, this.setHighlightAndIndices.bind(this));
this._subscribe(EVENTS.TURN_COMPLETE, this._handleTurnComplete.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.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();
}
/**
* @returns {void}
*/
_onDestroy() {
this.isRepositionScheduled = false;
// Perform cleanup that requires state before clearing it
if (this.state) {
// jumpListComponent is managed by BaseManager and will be destroyed automatically.
if (this.state.highlightedMessage && this.styleHandle) {
this.state.highlightedMessage.classList.remove(this.styleHandle.classes.highlightMessage);
}
}
this.navConsole?.remove();
// UI cleanup is handled by disposables registered in _onInit and createContainers
this.uiCache = null;
this.navConsole = null;
this.state = null;
this.styleHandle = null;
this.jumpListStyleHandle = null;
}
/**
* @private
* @param {HTMLElement} turnNode
*/
_handleTurnComplete(turnNode) {
// Retrieve all message elements contained within the turn
// Accurately identify them using CONSTANTS.SELECTORS.BUBBLE_FEATURE_MESSAGE_CONTAINERS
const messages = turnNode.querySelectorAll(CONSTANTS.SELECTORS.BUBBLE_FEATURE_MESSAGE_CONTAINERS);
messages.forEach((msg) => {
if (msg instanceof HTMLElement && this.searchCache.has(msg)) {
this.searchCache.delete(msg);
}
});
// After clearing the cache, call the sync method to prompt queuing for re-indexing during the next idle time
// (Debounce or throttle is not needed as it runs on idle)
this._syncSearchCache();
}
updateUI() {
this._renderUI();
}
hideJumpList() {
this._hideJumpList();
}
selectLastMessage() {
const totalMessages = this.messageCacheManager.getTotalMessages();
if (totalMessages.length > 0) {
const lastMessage = totalMessages.at(-1);
this.navigateToMessage(lastMessage);
}
}
navigateToMessage(element) {
if (!element) return;
// Manual navigation overrides the initial auto-scroll logic.
this.state.isInitialSelectionDone = true;
this.setHighlightAndIndices(element);
this._scrollToMessage(element);
}
resetState() {
// Guard against execution after destruction
if (this.isDestroyed || !this.state) return;
// Force cleanup of any active input session to prevent UI corruption
this._forceCleanupJumpInput();
// Hide immediately to prevent flickering of old state
if (this.navConsole) {
this.navConsole.classList.add(this.styleHandle.classes.hidden);
// Mark as unpositioned to ensure it stays transparent until the next layout calculation finishes
this.navConsole.classList.add(this.styleHandle.classes.unpositioned);
}
if (this.state.highlightedMessage) {
this.state.highlightedMessage.classList.remove(this.styleHandle.classes.highlightMessage);
}
// Ensure the jump list component is properly destroyed via manager
this.manageResource(CONSTANTS.RESOURCE_KEYS.JUMP_LIST, null);
// Clear performance caches to release memory
if (this.searchCache) this.searchCache.clear();
this.indexingQueue = [];
// Pending idle tasks are cleared implicitly as they check the queue length
this.state = {
currentIndices: {
[CONSTANTS.NAV_ROLES.USER]: -1,
[CONSTANTS.NAV_ROLES.ASSISTANT]: -1,
[CONSTANTS.NAV_ROLES.TOTAL]: -1,
},
highlightedMessage: null,
isInitialSelectionDone: false,
jumpListComponent: null,
lastFilterValue: '',
previousTotalMessages: 0,
isAutoScrolling: false,
activeRole: CONSTANTS.NAV_ROLES.TOTAL,
inputMode: CONSTANTS.INPUT_MODES.NORMAL,
stickyMode: null,
interactionActive: false,
};
// Reset filter text
this.lastFilterValue = '';
// Reset the bulk collapse button to its default state
const collapseBtn = this.uiCache?.buttons?.fold;
if (collapseBtn instanceof HTMLElement) {
DomState.set(collapseBtn, CONSTANTS.DATA_KEYS.STATE, CONSTANTS.UI_STATES.EXPANDED);
}
// Do not call _renderUI() here.
// Visibility and correct values will be restored when CACHE_UPDATED fires on the new page.
}
/** @private */
_syncSearchCache() {
const totalMessages = this.messageCacheManager.getTotalMessages();
// 1. Cleanup: Remove messages that are no longer in the DOM
for (const msg of this.searchCache.keys()) {
if (!this.messageCacheManager.findMessageIndex(msg)) {
this.searchCache.delete(msg);
}
}
// 2. Queueing: Find messages not yet cached
this.indexingQueue = [];
for (const msg of totalMessages) {
if (!this.searchCache.has(msg)) {
this.indexingQueue.push(msg);
}
}
// 3. Schedule Idle Indexing
if (this.indexingQueue.length > 0) {
this._runIdleIndexing();
}
}
/** @private */
_runIdleIndexing() {
if (this.indexingQueue.length === 0) return;
// Use runWhenIdle utility
this.idleIndexingCancelFn = runWhenIdle((deadline) => {
this.idleIndexingCancelFn = null;
if (this.isDestroyed || !this.jumpListStyleHandle) return;
const cls = this.jumpListStyleHandle.classes;
while (this.indexingQueue.length > 0 && deadline.timeRemaining() > 1) {
const msg = this.indexingQueue.shift();
// Double check if still valid and not cached
if (msg && msg.isConnected && !this.searchCache.has(msg)) {
const role = PlatformAdapters.General.getMessageRole(msg);
const roleClass = role === CONSTANTS.SELECTORS.FIXED_NAV_ROLE_USER ? cls.userItem : role === CONSTANTS.SELECTORS.FIXED_NAV_ROLE_ASSISTANT ? cls.asstItem : null;
const rawText = PlatformAdapters.General.getJumpListDisplayText(msg);
const displayText = (rawText || '').replace(/\s+/g, ' ').trim();
this.searchCache.set(msg, {
displayText: displayText,
lowerText: displayText.toLowerCase(),
roleClass: roleClass,
});
}
}
// If items remain, schedule next batch
if (this.indexingQueue.length > 0) {
this._runIdleIndexing();
}
}, CONSTANTS.TIMING.POLLING.IDLE_INDEXING_MS);
}
/**
* @param {HTMLElement} targetMsg
* @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 = {
[CONSTANTS.NAV_ROLES.TOTAL]: currentTotalIndex,
[CONSTANTS.NAV_ROLES.USER]: -1,
[CONSTANTS.NAV_ROLES.ASSISTANT]: -1,
};
// Determine indices based on the message role
if (cachedInfo.role === CONSTANTS.INTERNAL_ROLES.USER) {
newIndices[CONSTANTS.NAV_ROLES.USER] = currentRoleIndex;
newIndices[CONSTANTS.NAV_ROLES.ASSISTANT] = this.findNearestIndex(targetMsg, CONSTANTS.INTERNAL_ROLES.ASSISTANT);
} else {
newIndices[CONSTANTS.NAV_ROLES.ASSISTANT] = currentRoleIndex;
newIndices[CONSTANTS.NAV_ROLES.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.
* @param {HTMLElement} currentMsg
* @param {string} targetRole
* @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
}
_handleCacheUpdate() {
// Note: MessageCacheManager suppresses cache updates during streaming,
// so we don't need to explicitly check for streaming state here.
// Sync search cache immediately on update
this._syncSearchCache();
// 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;
let indicesUpdated = false;
// Check if new messages were added (e.g., from layout scan) and if we were at the end.
if (newTotal > oldTotal && this.state.currentIndices[CONSTANTS.NAV_ROLES.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) {
if (!this.messageCacheManager.findMessageIndex(this.state.highlightedMessage)) {
Logger.log('NAVIGATION', '', '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[CONSTANTS.NAV_ROLES.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]);
indicesUpdated = true;
} else {
// Cache is empty, reset state.
this.resetState();
return;
}
} else {
// Message still exists. Force update indices as its position might have changed
// (e.g. previous messages were deleted).
this.setHighlightAndIndices(this.state.highlightedMessage);
indicesUpdated = true;
}
}
// 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;
indicesUpdated = true;
} else if (!this.state.highlightedMessage && totalMessages.length > 0) {
// If initial selection is already done (e.g. re-enabling via settings),
// default to the last message instead of the first, but do not scroll.
if (this.state.isInitialSelectionDone) {
this.setHighlightAndIndices(totalMessages.at(-1));
} else {
this.setHighlightAndIndices(totalMessages[0]);
}
indicesUpdated = true;
}
PlatformAdapters.FixedNav.handleInfiniteScroll(this, this.state.highlightedMessage, this.state.previousTotalMessages);
this.state.previousTotalMessages = totalMessages.length;
if (!indicesUpdated) {
this._renderUI();
}
}
_handleIntegrityScanMessagesFound() {
this.selectLastMessage();
}
createContainers() {
// Check if element exists using the rootId from the style handle
const rootId = this.styleHandle.rootId;
const existingConsole = document.getElementById(rootId);
if (existingConsole) {
// Remove existing console to ensure clean initialization
existingConsole.remove();
}
// Use rootId for the ID attribute. The class 'unpositioned' is still needed for initial state.
const cls = this.styleHandle.classes;
const navConsole = h(`div#${rootId}.${cls.unpositioned}`);
if (!(navConsole instanceof HTMLElement)) return;
this.navConsole = navConsole;
document.body.appendChild(this.navConsole);
// Register cleanup for the container immediately after creation
this.addDisposable(() => {
this.navConsole?.remove();
this.navConsole = null;
});
this.renderInitialUI();
this.attachEventListeners();
}
injectStyle() {
if (this.styleHandle) return this.styleHandle;
return StyleManager.request(StyleDefinitions.getFixedNav);
}
renderInitialUI() {
if (!this.navConsole) return;
const cls = this.styleHandle.classes;
// Buttons configuration
const btnBaseClass = cls.btn;
// Left Slot Buttons
const btnPrev = h(
`button.${btnBaseClass}.${cls.btnAccent}`,
{
title: 'Previous message',
},
[createIconFromDef(StyleDefinitions.ICONS.arrowUp)]
);
const btnFirst = h(`button.${btnBaseClass}.${cls.btnDanger}`, { title: 'First message', style: { display: 'none' } }, [createIconFromDef(StyleDefinitions.ICONS.scrollToFirst)]);
// Platform specific buttons (Scan) - Shift mode for Left Slot
const platformButtons = PlatformAdapters.FixedNav.getPlatformSpecificButtons(this, this.styleHandle);
platformButtons.forEach((btn) => {
if (btn instanceof HTMLElement) {
btn.style.display = 'none';
}
});
// Right Slot Buttons
const btnNext = h(
`button.${btnBaseClass}.${cls.btnAccent}`,
{
title: 'Next message',
},
[createIconFromDef(StyleDefinitions.ICONS.arrowDown)]
);
const btnLast = h(`button.${btnBaseClass}.${cls.btnDanger}`, { title: 'Last message', style: { display: 'none' } }, [createIconFromDef(StyleDefinitions.ICONS.scrollToLast)]);
const btnFold = h(
`button#${cls.bulkCollapseBtnId}.${btnBaseClass}`,
{
style: { display: 'none' },
dataset: { [CONSTANTS.DATA_KEYS.STATE]: CONSTANTS.UI_STATES.EXPANDED },
},
[createIconFromDef(StyleDefinitions.ICONS.bulkCollapse), createIconFromDef(StyleDefinitions.ICONS.bulkExpand)]
);
// Center Info
// Role Button: Icon-based button for switching roles
const roleBtn = h(
`button.${cls.roleBtn}`,
{
title: 'Click: Open Jump List\nRight-Click: Switch Role',
oncontextmenu: this._handleRoleContextMenu,
},
[] // Icon will be set in _renderUI
);
const counter = h(
`span.${cls.counter}`,
{
title: 'Click to enter message number to jump\n[Right-Click] Cycle modes (Normal/Shift)',
oncontextmenu: this._handleModeContextMenu,
},
[h(`span.${cls.counterCurrent}`, '--'), ' / ', h(`span.${cls.counterTotal}`, '--')]
);
// Layout Construction
const leftSlot = h(`div.${cls.group}`, [btnFirst, ...platformButtons, btnPrev]);
const centerSlot = h(`div.${cls.group}`, [roleBtn, counter]);
const rightSlot = h(`div.${cls.group}`, [btnNext, btnLast, btnFold]);
this.navConsole.replaceChildren(leftSlot, centerSlot, rightSlot);
// Cache UI references
this.uiCache = {
buttons: {
prev: btnPrev,
first: btnFirst,
platformButtons: platformButtons,
next: btnNext,
last: btnLast,
fold: btnFold,
role: roleBtn,
},
info: {
counter: counter,
current: counter.querySelector(`.${cls.counterCurrent}`),
total: counter.querySelector(`.${cls.counterTotal}`),
},
};
}
attachEventListeners() {
document.body.addEventListener('click', this.handleBodyClick, true);
this.addDisposable(() => document.body.removeEventListener('click', this.handleBodyClick, true));
document.addEventListener('keydown', this._handleKeyDown, true);
this.addDisposable(() => document.removeEventListener('keydown', this._handleKeyDown, true));
document.addEventListener('keydown', this._handleDocumentKeyChange, true);
this.addDisposable(() => document.removeEventListener('keydown', this._handleDocumentKeyChange, true));
document.addEventListener('keyup', this._handleDocumentKeyChange, true);
this.addDisposable(() => document.removeEventListener('keyup', this._handleDocumentKeyChange, true));
window.addEventListener('blur', this._handleWindowBlur);
this.addDisposable(() => window.removeEventListener('blur', this._handleWindowBlur));
if (this.navConsole) {
this.navConsole.addEventListener('mouseenter', this._handleInteractionStateChange);
this.navConsole.addEventListener('mouseleave', this._handleInteractionStateChange);
this.navConsole.addEventListener('focusin', this._handleInteractionStateChange);
this.navConsole.addEventListener('focusout', this._handleInteractionStateChange);
this.addDisposable(() => {
if (this.navConsole) {
this.navConsole.removeEventListener('mouseenter', this._handleInteractionStateChange);
this.navConsole.removeEventListener('mouseleave', this._handleInteractionStateChange);
this.navConsole.removeEventListener('focusin', this._handleInteractionStateChange);
this.navConsole.removeEventListener('focusout', this._handleInteractionStateChange);
}
});
}
}
scheduleReposition() {
if (this.isRepositionScheduled) return;
this.isRepositionScheduled = true;
EventBus.queueUIWork(() => {
try {
if (this.isDestroyed) return;
this.repositionContainers();
} finally {
this.isRepositionScheduled = false;
}
});
}
/**
* @returns {void}
*/
repositionContainers() {
if (!this.navConsole) return;
const config = this.configManager.get();
// Default to 'input_top' if not specified
const positionSetting = config?.platforms?.[PLATFORM]?.features?.fixed_nav_console?.position || CONSTANTS.CONSOLE_POSITIONS.INPUT_TOP;
// Resolve anchor container for header mode
let headerContainer = null;
if (positionSetting === CONSTANTS.CONSOLE_POSITIONS.HEADER) {
headerContainer = PlatformAdapters.FixedNav.getNavAnchorContainer();
}
// Use withLayoutCycle to prevent layout thrashing
withLayoutCycle({
measure: () => {
// --- Read Phase ---
// 1. Determine Target Mode
let targetMode = positionSetting;
// Fallback: Check availability and container existence
if (targetMode === CONSTANTS.CONSOLE_POSITIONS.HEADER) {
const adapter = PlatformAdapters.FixedNav;
const isAvailable = adapter.isHeaderPositionAvailable();
if (!headerContainer || !isAvailable) {
targetMode = CONSTANTS.CONSOLE_POSITIONS.INPUT_TOP;
}
}
// 2. Prepare Data
const result = {
mode: targetMode,
isAlreadyEmbedded: targetMode === CONSTANTS.CONSOLE_POSITIONS.HEADER ? this.navConsole.parentElement === headerContainer : false,
isAlreadyBodyChild: targetMode !== CONSTANTS.CONSOLE_POSITIONS.HEADER ? this.navConsole.parentElement === document.body : false,
headerContainer,
style: {},
};
// 3. Measure ONLY if needed
if (targetMode === CONSTANTS.CONSOLE_POSITIONS.INPUT_TOP) {
const inputForm = document.querySelector(CONSTANTS.SELECTORS.FIXED_NAV_INPUT_AREA_TARGET);
const consoleRect = this.navConsole.getBoundingClientRect();
const consoleWidth = consoleRect.width;
const windowHeight = window.innerHeight;
const margin = CONSTANTS.UI_SPECS.PANEL_MARGIN;
if (inputForm) {
const formRect = inputForm.getBoundingClientRect();
const bottomPosition = windowHeight - formRect.top + margin;
const formCenter = formRect.left + formRect.width / 2;
result.style.left = `${formCenter - consoleWidth / 2}px`;
result.style.bottom = `${bottomPosition}px`;
} else {
// Fallback: Position at bottom center if input form is not found
result.style.left = `${window.innerWidth / 2 - consoleWidth / 2}px`;
result.style.bottom = `${margin}px`;
}
}
return result;
},
mutate: (measured) => {
// --- Write Phase ---
if (this.isDestroyed) return;
if (!measured) return;
if (measured.mode === CONSTANTS.CONSOLE_POSITIONS.HEADER) {
// Apply Embedded Mode
if (!measured.isAlreadyEmbedded && measured.headerContainer) {
measured.headerContainer.appendChild(this.navConsole);
}
this.navConsole.classList.add('is-embedded');
// Reset fixed positioning styles
this.navConsole.style.left = '';
this.navConsole.style.right = '';
this.navConsole.style.bottom = '';
this.navConsole.style.position = '';
} else {
// Apply Fixed Mode
if (!measured.isAlreadyBodyChild) {
document.body.appendChild(this.navConsole);
}
this.navConsole.classList.remove('is-embedded');
this.navConsole.style.position = 'fixed';
this.navConsole.style.left = measured.style.left;
this.navConsole.style.right = ''; // Ensure right is cleared
this.navConsole.style.bottom = measured.style.bottom;
}
// Reveal the console
this.navConsole.classList.remove(this.styleHandle.classes.unpositioned);
},
});
}
_renderUI() {
if (!this.navConsole || !this.uiCache) return;
const cls = this.styleHandle.classes;
const { currentIndices, highlightedMessage, activeRole, inputMode, stickyMode } = this.state;
// Determine effective mode: Sticky mode overrides keyboard input mode if set
const effectiveMode = stickyMode || inputMode;
// Determine visibility
const totalMessages = this.messageCacheManager.getTotalMessages();
const isNewChat = isNewChatPage();
const hasCachedMessages = totalMessages.length > 0;
const hasDomMessages = !!document.querySelector(CONSTANTS.SELECTORS.MESSAGE_ROOT_NODE);
// Capture previous hidden state to trigger repositioning on appearance
const wasHidden = this.navConsole.classList.contains(cls.hidden);
// Hide if auto-scrolling (scanning), explicitly a new chat page, or no messages found.
if (this.state.isAutoScrolling || isNewChat || (!hasCachedMessages && !hasDomMessages)) {
this.navConsole.classList.add(cls.hidden);
// Mark as unpositioned to ensure it stays transparent until the next layout calculation finishes
this.navConsole.classList.add(cls.unpositioned);
} else {
this.navConsole.classList.remove(cls.hidden);
// If previously hidden or currently unpositioned, ensure it's marked as unpositioned (transparent)
// and schedule a reposition. It will become visible only after repositionContainers completes.
if (wasHidden || this.navConsole.classList.contains(cls.unpositioned)) {
this.navConsole.classList.add(cls.unpositioned);
this.scheduleReposition();
}
}
// Update Counters & Label
const roleMap = {
[CONSTANTS.NAV_ROLES.TOTAL]: { displayName: 'Total', icon: 'list', colorClass: cls.roleTotal, messages: totalMessages, index: currentIndices[CONSTANTS.NAV_ROLES.TOTAL] },
[CONSTANTS.NAV_ROLES.ASSISTANT]: { displayName: 'Assistant', icon: 'chatLeft', colorClass: cls.roleAssistant, messages: this.messageCacheManager.getAssistantMessages(), index: currentIndices[CONSTANTS.NAV_ROLES.ASSISTANT] },
[CONSTANTS.NAV_ROLES.USER]: { displayName: 'User', icon: 'chatRight', colorClass: cls.roleUser, messages: this.messageCacheManager.getUserMessages(), index: currentIndices[CONSTANTS.NAV_ROLES.USER] },
};
const currentData = roleMap[activeRole];
// Update Icon
const roleBtn = this.uiCache.buttons.role;
if (roleBtn instanceof HTMLElement) {
// Only update DOM if icon type changed
const currentIconType = DomState.get(roleBtn, CONSTANTS.DATA_KEYS.ICON_TYPE);
if (currentIconType !== currentData.icon) {
const iconDef = StyleDefinitions.ICONS[currentData.icon];
if (iconDef) {
roleBtn.replaceChildren(createIconFromDef(iconDef));
} else {
roleBtn.replaceChildren();
}
DomState.set(roleBtn, CONSTANTS.DATA_KEYS.ICON_TYPE, currentData.icon);
}
// Update Colors
// Check class list before modifying to avoid style recalc
if (!roleBtn.classList.contains(currentData.colorClass)) {
roleBtn.classList.remove(cls.roleTotal, cls.roleUser, cls.roleAssistant);
this.uiCache.info.counter.classList.remove(cls.roleTotal, cls.roleUser, cls.roleAssistant);
roleBtn.classList.add(currentData.colorClass);
this.uiCache.info.counter.classList.add(currentData.colorClass);
}
// Update Tooltip
const newTitle = `Current Role: ${currentData.displayName}\n\nClick: Open Jump List\nRight-Click: Switch Role`;
if (roleBtn.title !== newTitle) {
roleBtn.title = newTitle;
}
}
const currentText = String(currentData.index > -1 ? currentData.index + 1 : '--');
if (this.uiCache.info.current.textContent !== currentText) {
this.uiCache.info.current.textContent = currentText;
}
const totalText = String(currentData.messages.length ? currentData.messages.length : '--');
if (this.uiCache.info.total.textContent !== totalText) {
this.uiCache.info.total.textContent = totalText;
}
// Access elements from cache and Update Highlight
document.querySelectorAll(`.${cls.highlightMessage}, .${cls.highlightTurn}`).forEach((el) => {
el.classList.remove(cls.highlightMessage);
el.classList.remove(cls.highlightTurn);
});
if (highlightedMessage) {
highlightedMessage.classList.add(cls.highlightMessage);
PlatformAdapters.FixedNav.applyAdditionalHighlight(highlightedMessage, this.styleHandle);
}
if (this.state.jumpListComponent) {
this.state.jumpListComponent.updateHighlightedMessage(highlightedMessage);
}
// Update Button Visibility based on Input Mode (Using Effective Mode)
const btns = this.uiCache.buttons;
const isShift = effectiveMode === CONSTANTS.INPUT_MODES.SHIFT;
const isNormal = !isShift;
// Helper to toggle display style without triggering reflow if unchanged
const toggleDisplay = (el, show) => {
if (!el) return;
const newVal = show ? '' : 'none';
if (el.style.display !== newVal) el.style.display = newVal;
};
// Prev/Next are always visible
toggleDisplay(btns.prev, true);
toggleDisplay(btns.next, true);
// First/Last visible in Normal mode
toggleDisplay(btns.first, isNormal);
toggleDisplay(btns.last, isNormal);
// Platform Buttons (Scan) visible in Shift mode (Replaces First)
const platformButtons = btns.platformButtons || [];
platformButtons.forEach((btn) => {
if (btn instanceof HTMLElement) {
toggleDisplay(btn, isShift);
// Update state if visible and manager exists
if (this.autoScrollManager && isShift && btn instanceof HTMLButtonElement) {
PlatformAdapters.FixedNav.updatePlatformSpecificButtonState(btn, this.state.isAutoScrolling, this.autoScrollManager);
}
}
});
// Fold button visible in Shift mode (Replaces Last)
toggleDisplay(btns.fold, isShift);
// Update Tooltips and State
const roleName = activeRole === CONSTANTS.NAV_ROLES.TOTAL ? 'message' : activeRole === CONSTANTS.NAV_ROLES.ASSISTANT ? 'assistant message' : 'user message';
// Determine Shift action text dynamically
let shiftActionText = '';
if (PLATFORM === PLATFORM_DEFS.GEMINI.NAME) {
shiftActionText = '\n[Shift] Load history';
} else if (PLATFORM === PLATFORM_DEFS.CHATGPT.NAME) {
if (isFirefox()) {
shiftActionText = '\n[Shift] Scan layout';
} else {
shiftActionText = '';
}
}
const updateTooltip = (el, text) => {
if (el instanceof HTMLElement && el.title !== text) {
el.title = text;
}
};
updateTooltip(btns.first, `First ${roleName}${shiftActionText}`);
updateTooltip(btns.prev, `Previous ${roleName}`);
updateTooltip(btns.next, `Next ${roleName}`);
updateTooltip(btns.last, `Last ${roleName}\n[Shift] Fold`);
// Update bulk collapse button visibility and state
this._updateBulkCollapseButtonTooltip(btns.fold);
}
_updateBulkCollapseButtonTooltip(button) {
if (!button) return;
const currentState = DomState.get(button, CONSTANTS.DATA_KEYS.STATE);
// Set the tooltip to describe the action that WILL be taken on click.
const tooltipText = currentState === CONSTANTS.UI_STATES.EXPANDED ? 'Collapse all messages' : 'Expand all messages';
// Only update DOM if changed
if (button.title !== tooltipText) {
button.title = tooltipText;
}
}
/**
* @private
* @param {string} role ('user', 'asst', 'total').
* @param {string} direction ('prev', 'next', 'first', 'last').
*/
_navigateTo(role, direction) {
const { [CONSTANTS.NAV_ROLES.USER]: currentUserIndex, [CONSTANTS.NAV_ROLES.ASSISTANT]: currentAsstIndex, [CONSTANTS.NAV_ROLES.TOTAL]: currentTotalIndex } = this.state.currentIndices;
const roleMap = {
[CONSTANTS.NAV_ROLES.USER]: { messages: this.messageCacheManager.getUserMessages(), currentIndex: currentUserIndex },
[CONSTANTS.NAV_ROLES.ASSISTANT]: { messages: this.messageCacheManager.getAssistantMessages(), currentIndex: currentAsstIndex },
[CONSTANTS.NAV_ROLES.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]);
}
}
_scrollToMessage(element) {
if (!element) return;
const targetToScroll = element;
scrollToElement(targetToScroll);
}
_toggleAllMessages() {
const button = this.navConsole.querySelector(`#${this.styleHandle.classes.bulkCollapseBtnId}`);
if (!(button instanceof HTMLElement)) return;
// Retrieve the dynamic class names
const bubbleCls = StyleDefinitions.getBubbleUI().classes;
if (!bubbleCls) return;
const currentState = DomState.get(button, CONSTANTS.DATA_KEYS.STATE);
const nextState = currentState === CONSTANTS.UI_STATES.EXPANDED ? CONSTANTS.UI_STATES.COLLAPSED : CONSTANTS.UI_STATES.EXPANDED;
DomState.set(button, CONSTANTS.DATA_KEYS.STATE, nextState);
this._updateBulkCollapseButtonTooltip(button);
// Use the correct dynamic class names to find elements and toggle state
const messages = document.querySelectorAll(`.${bubbleCls.collapsibleParent}`);
const shouldCollapse = nextState === CONSTANTS.UI_STATES.COLLAPSED;
const highlightedMessage = this.state.highlightedMessage;
messages.forEach((msg) => {
msg.classList.toggle(bubbleCls.collapsed, shouldCollapse);
});
if (highlightedMessage) {
requestAnimationFrame(() => {
if (this.isDestroyed) return;
document.body.offsetHeight; // Forcing reflow
requestAnimationFrame(() => {
if (this.isDestroyed) return;
this._scrollToMessage(highlightedMessage);
});
});
}
}
/**
* 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;
}
const cls = this.styleHandle.classes;
// 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(`.${cls.btn}`);
if (navButton instanceof HTMLElement && this.navConsole?.contains(navButton)) {
this.handleButtonClick(navButton);
return;
}
// In the new UI, counters and labels are singleton elements representing the active role.
// We no longer filter by role attribute.
const counter = target.closest(`.${cls.counter}`);
if (counter instanceof HTMLElement && this.navConsole?.contains(counter)) {
this.handleCounterClick(e, counter);
return;
}
// Handle Role Button Click
const roleBtn = target.closest(`.${cls.roleBtn}`);
if (roleBtn instanceof HTMLElement && this.navConsole?.contains(roleBtn)) {
this._toggleJumpList(roleBtn);
return;
}
const messageElement = target.closest(CONSTANTS.SELECTORS.FIXED_NAV_MESSAGE_CONTAINERS);
if (messageElement instanceof HTMLElement && !target.closest(`a, button, input, #${cls.consoleId}`)) {
this.setHighlightAndIndices(messageElement);
}
}
/**
* Handles clicks on the main navigation buttons (prev, next, etc.).
* @param {HTMLElement} buttonElement
* @returns {void}
*/
handleButtonClick(buttonElement) {
const btns = this.uiCache.buttons;
// Handle platform-specific buttons dynamically
if (btns.platformButtons && btns.platformButtons.includes(buttonElement)) {
return; // Let the button's own event listener handle it
}
switch (buttonElement) {
case btns.fold:
this._toggleAllMessages();
break;
case btns.prev:
this._navigateTo(this.state.activeRole, 'prev');
break;
case btns.next:
this._navigateTo(this.state.activeRole, 'next');
break;
case btns.first:
this._navigateTo(this.state.activeRole, 'first');
break;
case btns.last:
this._navigateTo(this.state.activeRole, 'last');
break;
default:
Logger.warn('NAV ERROR', LOG_STYLES.YELLOW, 'Unknown button clicked:', buttonElement);
break;
}
}
/**
* 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
* @returns {void}
*/
handleCounterClick(e, counterSpan) {
this._startJumpInputSession(counterSpan);
}
/** @private */
_forceCleanupJumpInput() {
if (!this.navConsole || !this.styleHandle) return;
const cls = this.styleHandle.classes;
// 1. Remove any lingering input fields
const inputs = this.navConsole.querySelectorAll(`input.${cls.jumpInput}`);
inputs.forEach((input) => input.remove());
// 2. Restore any hidden counters
const hiddenCounters = this.navConsole.querySelectorAll(`.${cls.counter}.${cls.isHidden}`);
hiddenCounters.forEach((counter) => {
counter.classList.remove(cls.isHidden);
});
}
/**
* @private
* @param {HTMLElement} counterSpan
*/
_startJumpInputSession(counterSpan) {
const cls = this.styleHandle.classes;
// Use activeRole instead of DOM attribute
const role = this.state.activeRole;
const input = h(`input.${cls.jumpInput}`, { type: 'text' });
if (!(input instanceof HTMLInputElement)) return;
counterSpan.classList.add(cls.isHidden);
counterSpan.parentNode.insertBefore(input, counterSpan.nextSibling);
input.focus();
let isEditing = true;
const endEdit = (shouldJump) => {
if (!isEditing) return;
isEditing = false;
if (shouldJump) {
const valStr = input.value.trim();
// Validate integer input only (reject decimals)
if (/^-?\d+$/.test(valStr)) {
const num = parseInt(valStr, 10);
const roleMap = {
[CONSTANTS.NAV_ROLES.USER]: this.messageCacheManager.getUserMessages(),
[CONSTANTS.NAV_ROLES.ASSISTANT]: this.messageCacheManager.getAssistantMessages(),
[CONSTANTS.NAV_ROLES.TOTAL]: this.messageCacheManager.getTotalMessages(),
};
const targetArray = roleMap[role];
if (targetArray && targetArray.length > 0) {
// Clamp value between 1 and total length
const clampedNum = Math.max(1, Math.min(num, targetArray.length));
const index = clampedNum - 1;
this.navigateToMessage(targetArray[index]);
}
}
}
// Check if the element is still connected to the DOM before removing.
if (input.isConnected) {
input.remove();
}
counterSpan.classList.remove(cls.isHidden);
};
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);
}
}
});
}
_cancelJumpInput() {
if (!this.navConsole) return;
const cls = this.styleHandle.classes;
// Instead of manually removing, trigger the standard blur event.
// This invokes the existing endEdit logic which handles state cleanup and DOM removal safely.
const input = this.navConsole.querySelector(`input.${cls.jumpInput}`);
if (input instanceof HTMLElement) {
input.blur();
}
// Fallback: Ensure counters are visible if the blur didn't clean them up for some reason
// (e.g. input was already detached but counters remained hidden)
const hiddenCounters = this.navConsole.querySelectorAll(`.${cls.counter}.${cls.isHidden}`);
hiddenCounters.forEach((counter) => {
counter.classList.remove(cls.isHidden);
});
}
_handleKeyDown(e) {
// Guard: Disable shortcuts if the console is not visible (e.g. New Chat page)
// This prevents "ghost" inputs from being created in the background.
if (this.navConsole && this.navConsole.classList.contains(this.styleHandle.classes.hidden)) {
return;
}
const config = this.configManager.get();
const shortcutsEnabled = config?.platforms?.[PLATFORM]?.features?.fixed_nav_console?.keyboard_shortcuts?.enabled;
if (shortcutsEnabled && e.altKey && !e.ctrlKey && !e.metaKey) {
// Throttle check
const now = Date.now();
if (now - this.lastShortcutTime < CONSTANTS.TIMING.KEYBOARD_THROTTLE) {
e.preventDefault();
return;
}
// Determine active role
const role = this.state.activeRole;
const roleBtn = this.uiCache?.buttons?.role;
let actionTaken = false;
switch (e.key) {
case 'ArrowUp':
e.preventDefault();
this._navigateTo(role, e.shiftKey ? 'first' : 'prev');
actionTaken = true;
break;
case 'ArrowDown':
e.preventDefault();
this._navigateTo(role, e.shiftKey ? 'last' : 'next');
actionTaken = true;
break;
case 'j':
case 'J':
e.preventDefault();
if (roleBtn instanceof HTMLElement) {
this._toggleJumpList(roleBtn);
}
actionTaken = true;
break;
case 'n':
case 'N': {
e.preventDefault();
const cls = this.styleHandle.classes;
const existingInput = this.navConsole?.querySelector(`.${cls.jumpInput}`);
if (existingInput instanceof HTMLInputElement) {
existingInput.focus();
existingInput.select();
} else {
const counter = this.uiCache?.info?.counter;
if (counter instanceof HTMLElement) {
this._startJumpInputSession(counter);
}
}
actionTaken = true;
break;
}
}
if (actionTaken) {
this.lastShortcutTime = now;
return;
}
}
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();
}
}
}
_handleDocumentKeyChange(e) {
if (e.repeat) return;
// Guard: Do not change input mode if the Jump List is open.
// This prevents the list from closing unexpectedly when typing uppercase letters (Shift+Char).
if (this.state.jumpListComponent) return;
if (e.key !== 'Shift') return;
// Only update input mode if the user is interacting with the console
if (!this.state.interactionActive) return;
let newMode = CONSTANTS.INPUT_MODES.NORMAL;
if (e.shiftKey) {
newMode = CONSTANTS.INPUT_MODES.SHIFT;
}
if (this.state.inputMode !== newMode) {
// Close jump list immediately on mode change to prevent inconsistencies
this._hideJumpList();
this.state.inputMode = newMode;
this._renderUI();
}
}
_handleInteractionStateChange(e) {
const cls = this.styleHandle.classes;
// Capture event properties needed asynchronously
const isMouseEnter = e.type === 'mouseenter';
const shiftKey = e.shiftKey;
// Delay check to handle focus transition gaps
// Use requestAnimationFrame instead of setTimeout to prevent forced reflow violations during rapid events
requestAnimationFrame(() => {
if (!this.navConsole) return;
// Check if the user is currently editing the jump input
// We do this inside rAF to ensure focus transitions (focusout -> focusin) have settled.
const isEditing = document.activeElement && document.activeElement.classList.contains(cls.jumpInput);
// Force cancel active input ONLY if we are not currently editing it.
if (!isEditing) {
this._cancelJumpInput();
}
// Update input mode immediately on entry if modifier keys are pressed
if (isMouseEnter) {
if (shiftKey) this.state.inputMode = CONSTANTS.INPUT_MODES.SHIFT;
else this.state.inputMode = CONSTANTS.INPUT_MODES.NORMAL;
}
const isHovered = this.navConsole.matches(':hover');
const isFocused = this.navConsole.contains(document.activeElement);
const isActive = isHovered || isFocused;
let shouldRender = false;
if (this.state.interactionActive !== isActive) {
this.state.interactionActive = isActive;
shouldRender = true;
}
// Reset mode to normal when interaction ends to prevent stuck states
if (!isActive && this.state.inputMode !== CONSTANTS.INPUT_MODES.NORMAL) {
this.state.inputMode = CONSTANTS.INPUT_MODES.NORMAL;
shouldRender = true;
}
// If entered with modifiers, force render to show correct mode
if (isActive && isMouseEnter) {
shouldRender = true;
}
if (shouldRender) {
this._renderUI();
}
});
}
_handleRoleContextMenu(e) {
e.preventDefault();
e.stopPropagation();
// Close jump list immediately to prevent content mismatch
this._hideJumpList();
const roles = [CONSTANTS.NAV_ROLES.TOTAL, CONSTANTS.NAV_ROLES.ASSISTANT, CONSTANTS.NAV_ROLES.USER];
const currentIndex = roles.indexOf(this.state.activeRole);
const nextIndex = (currentIndex + 1) % roles.length;
this.state.activeRole = roles[nextIndex];
this._renderUI();
}
_handleModeContextMenu(e) {
e.preventDefault();
e.stopPropagation();
const modes = [null, CONSTANTS.INPUT_MODES.SHIFT];
const currentIndex = modes.indexOf(this.state.stickyMode);
const nextIndex = (currentIndex + 1) % modes.length;
this.state.stickyMode = modes[nextIndex];
this._renderUI();
}
_handleWindowBlur() {
// Reset input mode to normal when the window loses focus (e.g. Alt+Tab).
// This prevents modifier keys from getting stuck in the active state.
if (this.state.inputMode !== CONSTANTS.INPUT_MODES.NORMAL) {
this.state.inputMode = CONSTANTS.INPUT_MODES.NORMAL;
this._renderUI();
}
}
async _toggleJumpList(labelElement) {
// Use activeRole instead of DOM attribute
const role = this.state.activeRole;
if (this.state.jumpListComponent?.role === role) {
this._hideJumpList();
return;
}
this._hideJumpList();
const roleMap = {
[CONSTANTS.NAV_ROLES.USER]: this.messageCacheManager.getUserMessages(),
[CONSTANTS.NAV_ROLES.ASSISTANT]: this.messageCacheManager.getAssistantMessages(),
[CONSTANTS.NAV_ROLES.TOTAL]: this.messageCacheManager.getTotalMessages(),
};
const messages = roleMap[role];
if (!messages || messages.length === 0) return;
// Force refresh the last message in the cache to ensure latest text content.
// This is critical for streaming messages where content changes but element identity remains.
const lastMessage = messages.at(-1);
if (lastMessage && this.searchCache.has(lastMessage)) {
this.searchCache.delete(lastMessage);
}
const jumpList = new JumpListComponent(
role,
messages,
this.state.highlightedMessage,
{
onSelect: (message) => this._handleJumpListSelect(message),
},
this.jumpListStyleHandle,
// Fallback to empty string as initialFilterValue is mandatory
this.state.lastFilterValue || '',
this.searchCache // Pass the shared cache
);
// Register as managed resource to ensure safe destruction
this.manageResource(CONSTANTS.RESOURCE_KEYS.JUMP_LIST, jumpList);
await jumpList.init();
// Keep reference in state for logic
this.state.jumpListComponent = jumpList;
this.state.jumpListComponent.show(labelElement);
}
_hideJumpList() {
if (!this.state.jumpListComponent) return;
this.state.lastFilterValue = this.state.jumpListComponent.getFilterValue();
// Dispose resource (calls destroy internally) and clear reference
this.manageResource(CONSTANTS.RESOURCE_KEYS.JUMP_LIST, null);
this.state.jumpListComponent = null;
}
_handleJumpListSelect(messageElement) {
this.navigateToMessage(messageElement);
this._hideJumpList();
}
}
// =================================================================================
// 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 extends BaseManager {
static CONFIG = {
INTEGRITY_SCAN_DELAY_MS: 1000,
IDLE_TIMEOUT_MS: 1000,
};
/**
* @param {MessageCacheManager} messageCacheManager
*/
constructor(messageCacheManager) {
super();
this.messageCacheManager = messageCacheManager;
this.isScanPending = false;
/** @type {WeakMap<HTMLElement, boolean>} */
this.processedElements = new WeakMap();
/** @type {WeakMap<HTMLElement, boolean>} */
this.completeFiredElements = new WeakMap();
}
_onInit() {
this._subscribe(EVENTS.RAW_MESSAGE_ADDED, (elem) => {
this.processRawMessage(elem);
// If an integrity scan is pending, extend the wait time (debounce)
// to avoid scanning while messages are actively loading.
if (this.isScanPending) {
this.scheduleIntegrityScan();
}
});
this._subscribe(EVENTS.NAVIGATION_START, () => {
this._cleanupProcessedFlags();
});
this._subscribe(EVENTS.NAVIGATION_END, () => {
// Force a scan to pick up any cached DOM elements that were restored by the SPA router
this.scanForUnprocessedMessages();
PlatformAdapters.General.onNavigationEnd?.(this);
});
}
_onDestroy() {
// Cleanup handled by disposables.
}
/**
* @description Re-initializes WeakMaps to safely garbage collect old DOM references.
* Used during navigation to ensure cached DOM elements are re-processed.
*/
_cleanupProcessedFlags() {
this.processedElements = new WeakMap();
this.completeFiredElements = new WeakMap();
}
/**
* @description Schedules an integrity scan to run after DOM activity settles.
* The scan runs only once after the specified silence duration.
*/
scheduleIntegrityScan() {
this.isScanPending = true;
// Cancel any existing timer to reset the debounce window
this.manageResource(CONSTANTS.RESOURCE_KEYS.INTEGRITY_SCAN, null);
const timerId = setTimeout(() => {
if (this.isDestroyed) return;
this.isScanPending = false;
const cancelIdleFn = runWhenIdle(() => {
this.manageResource(CONSTANTS.RESOURCE_KEYS.INTEGRITY_SCAN, null);
if (this.isDestroyed) return;
const newItemsFound = this.scanForUnprocessedMessages();
if (newItemsFound > 0) {
Logger.log('IntegrityScan', '', `Scan complete. Found ${newItemsFound} unprocessed items.`);
EventBus.publish(EVENTS.INTEGRITY_SCAN_MESSAGES_FOUND);
} else {
Logger.debug('IntegrityScan', '', 'Scan complete. No missing items found.');
}
}, MessageLifecycleManager.CONFIG.IDLE_TIMEOUT_MS);
this.manageResource(CONSTANTS.RESOURCE_KEYS.INTEGRITY_SCAN, cancelIdleFn);
}, MessageLifecycleManager.CONFIG.INTEGRITY_SCAN_DELAY_MS);
// Register the timer resource for automatic cleanup
this.manageResource(CONSTANTS.RESOURCE_KEYS.INTEGRITY_SCAN, () => clearTimeout(timerId));
}
/**
* @description Performs a one-time scan for any unprocessed messages.
* @returns {number} The number of new items found and processed.
*/
scanForUnprocessedMessages() {
return PlatformAdapters.General.performInitialScan?.(this) || 0;
}
processRawMessage(contentElement) {
// Check for in-memory flag immediately to avoid redundant queuing
if (this.processedElements.has(contentElement)) {
return;
}
this.processedElements.set(contentElement, 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 = PlatformAdapters.General.getMessageId(messageIdHolder);
// Always publish TIMESTAMP_ADDED when a message is detected.
// The TimestampManager will handle buffering (during navigation) or immediate application.
if (messageId) {
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 in-memory flag to ensure this only fires once per message container,
// even if it has multiple content parts detected (e.g. text and images).
if (!this.completeFiredElements.has(messageElement)) {
this.completeFiredElements.set(messageElement, 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 extends BaseManager {
/**
* @param {ConfigManager} configManager
* @param {MessageCacheManager} messageCacheManager
*/
constructor(configManager, messageCacheManager) {
super();
this.configManager = configManager;
this.messageCacheManager = messageCacheManager;
/** @type {Map<string, Date>} */
this.pendingTimestamps = new Map(); // Buffer for timestamps during navigation
/** @type {WeakMap<HTMLElement, HTMLElement>} */
this.timestampDomCache = new WeakMap();
this.styleHandle = null;
this.timestampContainerTemplate = null;
this.timestampSpanTemplate = null;
this.isEnabled = false; // Add state tracking
this.isNavigating = true; // Track navigation state (Initialize as true to buffer initial page load)
/** @type {(() => void) | null} */
this._cacheUpdateUnsub = null;
}
_onInit() {
this.injectStyle();
// Register automatic cleanup of UI and listeners when destroyed
this.addDisposable(() => this.disable());
this._subscribe(EVENTS.NAVIGATION_START, () => this._handleNavigationStart());
this._subscribe(EVENTS.NAVIGATION_END, () => this._handleNavigationEnd());
// 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));
}
_onDestroy() {
// disable() is called via disposable.
this.pendingTimestamps.clear();
}
/** @private */
_handleNavigationStart() {
this.isNavigating = true;
this.pendingTimestamps.clear();
// Cancel pending batch tasks immediately
this.manageResource(CONSTANTS.RESOURCE_KEYS.BATCH_TASK, null);
this.manageResource(CONSTANTS.RESOURCE_KEYS.BATCH_TASK_SINGLE, null);
// Explicitly reset the WeakMap to drop references to old DOM elements
this.timestampDomCache = new WeakMap();
}
/** @private */
_handleNavigationEnd() {
this.isNavigating = false;
// Fallback strategy:
// Apply pending timestamps only if API didn't provide data for them.
// This handles "New Chat" scenarios or API failures/delays.
if (this.pendingTimestamps.size > 0) {
Logger.debug('TIMESTAMPS', LOG_STYLES.TEAL, `Processing ${this.pendingTimestamps.size} pending timestamps.`);
let addedCount = 0;
this.pendingTimestamps.forEach((timestamp, messageId) => {
// Only apply if NOT already present (i.e., not loaded from API)
if (!PlatformAdapters.Timestamp.getTimestamp(messageId)) {
PlatformAdapters.Timestamp.addTimestamp(messageId, timestamp);
addedCount++;
}
});
this.pendingTimestamps.clear();
if (addedCount > 0 && this.isEnabled) {
Logger.debug('TIMESTAMPS', LOG_STYLES.TEAL, `Applied ${addedCount} fallback timestamps.`);
this.updateAllTimestamps();
}
}
}
/**
* Subscribes to events and performs an initial load and render.
* Called when the feature is enabled.
*/
enable() {
Logger.debug('TIMESTAMPS', LOG_STYLES.TEAL, 'Enabling...');
this.isEnabled = true;
// Subscribe to cache updates (e.g., deletions) using the new return-based pattern
if (!this._cacheUpdateUnsub) {
this._cacheUpdateUnsub = 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.
*/
disable() {
Logger.debug('TIMESTAMPS', LOG_STYLES.TEAL, 'Disabling...');
this.isEnabled = false;
this.manageResource(CONSTANTS.RESOURCE_KEYS.BATCH_TASK, null);
this.manageResource(CONSTANTS.RESOURCE_KEYS.BATCH_TASK_SINGLE, null);
// Unsubscribe from events that trigger DOM updates
if (this._cacheUpdateUnsub) {
this._cacheUpdateUnsub();
this._cacheUpdateUnsub = null;
}
// Hide all timestamps instead of physically removing them
this.updateAllTimestamps();
}
/**
* @private
* @param {object} detail
* @param {Map<string, Date>} detail.timestamps
*/
_loadHistoricalTimestamps({ timestamps }) {
// Data is already stored in Adapter by the time this event fires.
if (timestamps && timestamps.size > 0) {
Logger.debug('TIMESTAMPS', LOG_STYLES.TEAL, `Notified of ${timestamps.size} historical timestamps.`);
}
// If enabled, trigger a DOM update now that historical data is loaded
if (this.isEnabled) {
this.updateAllTimestamps();
}
}
/**
* @private
* @param {object} detail
* @param {string} detail.messageId
* @param {Date} detail.timestamp
*/
_handleTimestampAdded({ messageId, timestamp }) {
if (!messageId || !timestamp) return;
// If navigating and NOT a new chat page, buffer the timestamp.
// "New Chat" pages don't trigger history load, so we can apply immediately.
const isNewChat = PlatformAdapters.General.isNewChatPage();
if (this.isNavigating && !isNewChat) {
this.pendingTimestamps.set(messageId, timestamp);
return;
}
// Normal processing (Real-time or New Chat)
if (!PlatformAdapters.Timestamp.getTimestamp(messageId)) {
Logger.debug('TIMESTAMPS', LOG_STYLES.TEAL, `Added real-time timestamp for ${messageId}.`);
PlatformAdapters.Timestamp.addTimestamp(messageId, timestamp);
// If enabled, trigger a DOM update for the new real-time timestamp
if (this.isEnabled) {
this._updateSingleTimestamp(messageId);
}
}
}
/**
* @private
* Updates a single timestamp item. Uses BATCH_TASK_SINGLE to avoid conflicts with full updates.
*/
_updateSingleTimestamp(messageId) {
const selector = `[${CONSTANTS.ATTRIBUTES.MESSAGE_ID}="${messageId}"]`;
const anchor = document.querySelector(selector);
if (!anchor) return;
const messageElement = PlatformAdapters.General.findMessageElement(anchor);
if (!messageElement) return;
// Cancel any pending single update to prioritize the latest one if spamming occurs
this.manageResource(CONSTANTS.RESOURCE_KEYS.BATCH_TASK_SINGLE, null);
const item = {
messageElement,
existingContainer: this.timestampDomCache.get(messageElement),
anchor,
roleClass: null,
timestampText: this._formatTimestamp(PlatformAdapters.Timestamp.getTimestamp(messageId)),
};
const cls = this.styleHandle.classes;
const cancel = runBatchUpdate(
[item],
1,
(m) => {
// Measure
if (!m.messageElement.isConnected) return null;
// If not in cache, check DOM to prevent duplicates from race conditions
if (!m.existingContainer) {
m.existingContainer = m.anchor.querySelector(`.${cls.container}`);
if (m.existingContainer) {
this.timestampDomCache.set(m.messageElement, m.existingContainer);
}
}
if (!m.existingContainer) {
const role = PlatformAdapters.General.getMessageRole(m.messageElement);
if (role) {
m.roleClass = role === CONSTANTS.SELECTORS.FIXED_NAV_ROLE_USER ? cls.user : cls.assistant;
}
}
return m;
},
(m) => {
// Mutate
let container = m.existingContainer;
if (!container && m.anchor && m.anchor.isConnected && m.roleClass) {
const node = this.timestampContainerTemplate.cloneNode(true);
const span = this.timestampSpanTemplate.cloneNode(true);
if (node instanceof HTMLElement && span instanceof Element) {
container = node;
container.classList.add(m.roleClass);
container.appendChild(span);
m.anchor.prepend(container);
this.timestampDomCache.set(m.messageElement, container);
}
}
if (container && container.isConnected) {
const span = container.lastElementChild;
if (span) span.textContent = m.timestampText;
container.classList.toggle(cls.hidden, !m.timestampText);
}
},
() => this.manageResource(CONSTANTS.RESOURCE_KEYS.BATCH_TASK_SINGLE, null),
null
);
this.manageResource(CONSTANTS.RESOURCE_KEYS.BATCH_TASK_SINGLE, cancel);
}
/**
* @param {string} messageId
* @returns {Date | undefined}
*/
getTimestamp(messageId) {
return PlatformAdapters.Timestamp.getTimestamp(messageId);
}
injectStyle() {
if (this.styleHandle) return;
this.styleHandle = StyleManager.request(StyleDefinitions.getTimestamp);
const cls = this.styleHandle.classes;
this.timestampContainerTemplate = h(`div.${cls.container}`);
this.timestampSpanTemplate = h(`span.${cls.text}`);
}
updateAllTimestamps() {
// Cancel any existing batch task immediately
this.manageResource(CONSTANTS.RESOURCE_KEYS.BATCH_TASK, null);
const config = this.configManager.get();
if (!config) return;
const allMessages = this.messageCacheManager.getTotalMessages();
const isTimestampEnabled = config.platforms[PLATFORM].features.timestamp.enabled;
// Run a batch operation with Measure/Mutate separation to create/update all
const cls = this.styleHandle.classes;
const cancelFn = runBatchUpdate(
allMessages,
CONSTANTS.PROCESSING.BATCH_SIZE,
// Measure
(messageElement) => {
if (!messageElement.isConnected) return null;
let existingContainer = this.timestampDomCache.get(messageElement);
let anchor = null;
let roleClass = null;
let timestampText = '';
// Calculate Anchor & Role only if needed (not cached yet)
if (!existingContainer) {
const messageIdHolder = messageElement.closest(CONSTANTS.SELECTORS.MESSAGE_ID_HOLDER);
if (messageIdHolder) {
anchor = messageIdHolder;
// Check DOM for existing container (Idempotency)
existingContainer = anchor.querySelector(`.${cls.container}`);
if (existingContainer) {
this.timestampDomCache.set(messageElement, existingContainer);
} else if (isTimestampEnabled) {
// Only calculate role if we need to create it
const role = PlatformAdapters.General.getMessageRole(messageElement);
if (role) {
roleClass = role === CONSTANTS.SELECTORS.FIXED_NAV_ROLE_USER ? cls.user : cls.assistant;
}
}
}
} else {
// If we have cached container, ensure we can find the ID holder for text update
anchor = existingContainer.parentElement;
}
// Calculate Text (always needed to update content/visibility)
if (isTimestampEnabled) {
// Use the anchor found above, or find it now if we have a container but need ID
const holder = anchor || messageElement.closest(CONSTANTS.SELECTORS.MESSAGE_ID_HOLDER);
if (holder) {
const messageId = PlatformAdapters.General.getMessageId(holder);
const timestamp = messageId ? this.getTimestamp(messageId) : undefined;
timestampText = this._formatTimestamp(timestamp);
}
}
return {
messageElement,
existingContainer,
anchor,
roleClass,
timestampText,
};
},
// Mutate
(m) => {
let container = m.existingContainer;
// Create if missing and valid AND feature is enabled
if (!container && m.anchor && m.anchor.isConnected && m.roleClass && isTimestampEnabled) {
const node = this.timestampContainerTemplate.cloneNode(true);
const span = this.timestampSpanTemplate.cloneNode(true);
if (node instanceof HTMLElement && span instanceof Element) {
container = node;
container.classList.add(m.roleClass);
container.appendChild(span);
m.anchor.prepend(container);
this.timestampDomCache.set(m.messageElement, container);
}
}
// Update
if (container && container.isConnected) {
const span = container.lastElementChild; // Assuming span is appended last
if (span && isTimestampEnabled) {
span.textContent = m.timestampText;
}
container.classList.toggle(cls.hidden, !isTimestampEnabled || !m.timestampText);
}
},
// On Finish
() => {
this.manageResource(CONSTANTS.RESOURCE_KEYS.BATCH_TASK, null);
},
null
);
this.manageResource(CONSTANTS.RESOURCE_KEYS.BATCH_TASK, cancelFn);
}
/**
* @param {Date} date
* @returns {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}`;
}
}
class MessageNumberManager extends BaseManager {
/**
* @param {ConfigManager} configManager
* @param {MessageCacheManager} messageCacheManager
*/
constructor(configManager, messageCacheManager) {
super();
this.configManager = configManager;
this.messageCacheManager = messageCacheManager;
this.numberSpanCache = new WeakMap();
this.styleHandle = null;
this.numberSpanTemplate = null;
}
_onInit() {
this.injectStyle();
// Use :cacheUpdated for batch updates (re-numbering, visibility toggles after config changes).
this._subscribe(EVENTS.CACHE_UPDATED, () => this.updateAllMessageNumbers());
// Clean up immediately on navigation start
this._subscribe(EVENTS.NAVIGATION_START, () => this._handleNavigationStart());
}
_onDestroy() {
// No DOM removal here. Managed by site framework.
}
/** @private */
/** @private */
_handleNavigationStart() {
this.manageResource(CONSTANTS.RESOURCE_KEYS.BATCH_TASK, null);
// Explicitly reset the WeakMap to drop references to old DOM elements
this.numberSpanCache = new WeakMap();
}
injectStyle() {
if (this.styleHandle) return;
this.styleHandle = StyleManager.request(StyleDefinitions.getMessageNumber);
const cls = this.styleHandle.classes;
this.numberSpanTemplate = h(`span.${cls.number}`);
}
updateAllMessageNumbers() {
// Cancel any existing batch task immediately
this.manageResource(CONSTANTS.RESOURCE_KEYS.BATCH_TASK, null);
const config = this.configManager.get();
if (!config) return;
const allMessages = this.messageCacheManager.getTotalMessages();
const isNavConsoleEnabled = config.platforms[PLATFORM].features.fixed_nav_console.enabled;
const cls = this.styleHandle.classes;
const cancelFn = runBatchUpdate(
allMessages,
CONSTANTS.PROCESSING.BATCH_SIZE,
// Measure
(message, i) => {
// Skip if message is no longer in DOM
if (!message.isConnected) return null;
let existingSpan = this.numberSpanCache.get(message);
let anchor = null;
let role = null;
// Only query DOM if span doesn't exist yet
if (!existingSpan) {
anchor = PlatformAdapters.BubbleUI.getNavPositioningParent(message);
if (anchor) {
// Check DOM for existing span (Idempotency)
existingSpan = anchor.querySelector(`.${cls.number}`);
if (existingSpan) {
this.numberSpanCache.set(message, existingSpan);
} else if (isNavConsoleEnabled) {
// Only calculate role if we need to create it
role = PlatformAdapters.General.getMessageRole(message);
}
}
}
return {
message,
displayIndex: i + 1,
existingSpan,
anchor,
role,
};
},
// Mutate
(m) => {
let span = m.existingSpan;
// Create new span if needed AND feature is enabled
if (!span && m.anchor && m.anchor.isConnected && m.role && isNavConsoleEnabled) {
m.anchor.classList.add(cls.parent);
const roleClass = m.role === CONSTANTS.SELECTORS.FIXED_NAV_ROLE_USER ? cls.user : cls.assistant;
const node = this.numberSpanTemplate.cloneNode(true);
if (node instanceof HTMLElement) {
span = node;
span.classList.add(roleClass);
m.anchor.appendChild(span);
this.numberSpanCache.set(m.message, span);
}
}
// Update content and visibility
if (span && span.isConnected) {
if (isNavConsoleEnabled) {
span.textContent = `#${m.displayIndex}`;
}
span.classList.toggle(cls.hidden, !isNavConsoleEnabled);
}
},
// On Finish
() => {
this.manageResource(CONSTANTS.RESOURCE_KEYS.BATCH_TASK, null);
},
null
);
this.manageResource(CONSTANTS.RESOURCE_KEYS.BATCH_TASK, cancelFn);
}
}
// =================================================================================
// SECTION: UI Elements - Components and Manager
// Description: Provides reusable UI components, a reactive form engine, and manages high-level UI orchestration.
// =================================================================================
/**
* @abstract
* @class UIComponentBase
* @extends BaseManager
* @description Base class for a UI component with lifecycle management.
*/
class UIComponentBase extends BaseManager {
constructor(callbacks) {
super();
this.callbacks = callbacks;
this.element = null;
this.storeSubscriptions = [];
}
/**
* @abstract
* Renders the component's DOM structure. Must be implemented by subclasses.
* @returns {HTMLElement|void}
*/
render() {
throw new Error('Component must implement render method.');
}
/**
* Adds a store subscription handle (unsubscribe function) to be managed by the component.
* @param {() => void} unsub - The unsubscribe function returned by store.subscribe.
*/
addStoreSubscription(unsub) {
if (typeof unsub === 'function') {
this.storeSubscriptions.push(unsub);
}
}
/**
* Executes all registered store subscription handles and clears the list.
* Can be called manually (e.g. on modal close) or automatically on destroy.
*/
clearStoreSubscriptions() {
this.storeSubscriptions.forEach((unsub) => unsub());
this.storeSubscriptions = [];
}
/**
* Lifecycle hook for cleanup.
* Removes the component's element from the DOM.
* @protected
* @override
*/
_onDestroy() {
this.clearStoreSubscriptions();
this.element?.remove();
this.element = null;
}
}
/**
* @class ReactiveStore
* @description A simple reactive store implementing the Pub/Sub pattern.
* It allows setting and getting values using dot-notation paths and notifies listeners on changes.
*
* [NOTIFICATION BEHAVIOR]
* - Notifications are triggered strictly for the exact path being modified.
* - Changes do NOT bubble up to parent paths.
* - Subscribers are responsible for determining if a change affects them (e.g., using startsWith checks).
*/
class ReactiveStore {
constructor(initialState) {
this.state = deepClone(initialState);
this.listeners = new Set();
}
/**
* Retrieves a value from the store by path.
* @param {string} path - The dot-notation path to the property.
* @returns {any} The value at the specified path.
*/
get(path) {
return getPropertyByPath(this.state, path);
}
/**
* Sets a value in the store at the specified path.
* Notifies listeners if the value has effectively changed.
* @param {string} path - The dot-notation path to the property.
* @param {any} value - The new value to set.
*/
set(path, value) {
// Validate path to prevent errors in split logic or setPropertyByPath
if (!path || typeof path !== 'string') {
Logger.warn('Store', '', `ReactiveStore.set: Invalid path "${path}"`);
return;
}
const currentValue = this.get(path);
// Use Object.is for strict equality check (handles NaN, -0/+0 correctly)
if (Object.is(currentValue, value)) return;
// Only notify if the update was successful
if (setPropertyByPath(this.state, path, value)) {
// Notify only the specific path that changed.
this.notify(path);
}
}
/**
* Returns a deep copy of the current full state object.
* @returns {object}
*/
getData() {
return deepClone(this.state);
}
/**
* Returns a direct reference to the current state object.
* WARNING: The returned object MUST be treated as Read-Only. Do not mutate directly.
* Use this for performance-critical reads where deep cloning is too expensive.
* @returns {Readonly<object>}
*/
getStateRef() {
return this.state;
}
/**
* Replaces the entire state with a new object and notifies listeners.
* Triggers notifications for all top-level keys in both old and new states.
* @param {object} newState - The new full state object. Must be a valid object (null is ignored).
*/
replaceState(newState) {
if (!newState || typeof newState !== 'object') {
Logger.warn('Store', '', 'ReactiveStore.replaceState: Invalid state object.');
return;
}
const oldState = this.state;
this.state = deepClone(newState);
// Notify for the union of keys in old and new states to ensure deleted keys are handled
const keys = new Set([...Object.keys(oldState || {}), ...Object.keys(this.state || {})]);
keys.forEach((key) => {
this.notify(key);
});
}
/**
* Subscribes to store updates.
* WARNING: The state object passed to the callback is a direct reference to the store's internal state.
* Do not mutate the state object directly within the callback. Use store.set() for updates.
* @param {function(object, string): void} callback - The function to call on update. Receives (state, changedPath).
* @returns {function(): void} A function to unsubscribe.
*/
subscribe(callback) {
if (typeof callback !== 'function') {
Logger.warn('Store', '', 'ReactiveStore.subscribe: Callback must be a function.');
return () => {};
}
this.listeners.add(callback);
return () => this.listeners.delete(callback);
}
/**
* @private
* Notifies all subscribers of a change.
* @param {string} changedPath - The path that was updated.
*/
notify(changedPath) {
for (const listener of this.listeners) {
try {
listener(this.state, changedPath);
} catch (e) {
Logger.error('Store', '', 'ReactiveStore listener failed:', e);
}
}
}
}
/**
* @class UIBuilder
* @description A lightweight, procedural UI builder that handles DOM generation,
* two-way data binding with ReactiveStore, and lifecycle management.
*/
class UIBuilder {
/**
* @param {ReactiveStore} store
* @param {object} context
* @param {(fn: () => void) => void} disposer
*/
constructor(store, context, disposer) {
this.store = store;
this.context = context;
this.styles = context.styles || {};
this.disposer = disposer;
}
/**
* Creates a DOM element wrapper using the global h() function.
* @param {string} tag
* @param {object} props
* @param {Array<Node|string> | Node | string} children
* @returns {HTMLElement | SVGElement}
*/
create(tag, props, children) {
return h(tag, props, children);
}
/**
* Observes store paths and triggers callback on change.
* Automatically registers cleanup.
* @param {string|string[]} paths - Config key(s) to observe.
* @param {function(unknown): void} callback - Called with full store state on change.
*/
observe(paths, callback) {
const pathList = Array.isArray(paths) ? paths : [paths];
// Initial call
callback(this.store.getStateRef());
const unsub = this.store.subscribe((state, changedPath) => {
// Check if changedPath matches or is parent/child of any observed path
const isMatch = pathList.some((p) => p === changedPath || changedPath.startsWith(p + '.') || p.startsWith(changedPath + '.'));
if (isMatch) {
callback(state);
}
});
this.disposer(unsub);
}
/**
* @private
* Sets up dynamic visibility and disabled state.
* @param {HTMLElement} element
* @param {object} options
*/
_setupDynamicState(element, options) {
if (options.visibleIf || options.disabledIf) {
const deps = options.dependencies || [];
// If specific dependencies aren't listed but we have a key, watch the key too (unlikely for visibility but safe)
if (options.key && !deps.includes(options.key)) deps.push(options.key);
if (deps.length > 0) {
this.observe(deps, (state) => {
if (options.visibleIf) {
element.style.display = options.visibleIf(state) ? '' : 'none';
}
if (options.disabledIf) {
const isDisabled = options.disabledIf(state);
const targets = element.matches('input, select, textarea, button') ? [element] : element.querySelectorAll('input, select, textarea, button');
targets.forEach((t) => {
if (t instanceof HTMLInputElement || t instanceof HTMLSelectElement || t instanceof HTMLTextAreaElement || t instanceof HTMLButtonElement) {
t.disabled = isDisabled;
}
});
element.classList.toggle('is-disabled', isDisabled);
element.style.opacity = isDisabled ? '0.5' : '';
element.style.pointerEvents = isDisabled ? 'none' : '';
}
});
}
}
}
/**
* @private
* Binds an input element to the store key.
* @param {HTMLElement} element - The container element.
* @param {HTMLInputElement|HTMLSelectElement|HTMLTextAreaElement} input - The input element.
* @param {string} key - Store key.
* @param {object} options - Transform options.
*/
_bindInput(element, input, key, options) {
// 1. Store -> UI Update
this.observe(key, (state) => {
// Clear error on external update
this._updateErrorState(element, key, state);
const rawValue = getPropertyByPath(state, key);
// Allow manual override for complex components (like ColorPicker)
if (options.onStoreUpdate) {
options.onStoreUpdate(input, rawValue);
return;
}
const uiValue = options.toInputValue ? options.toInputValue(rawValue) : rawValue;
if (input instanceof HTMLInputElement && input.type === 'checkbox') {
input.checked = !!uiValue;
} else if (input.value !== String(uiValue ?? '')) {
// Avoid resetting cursor position if value is effectively same
input.value = String(uiValue ?? '');
}
// Hook for label update (sliders)
// Pass rawValue to allow formatting based on the actual store value (e.g. null -> "Auto")
if (options.onUIUpdate) options.onUIUpdate(rawValue, uiValue);
});
// 2. UI -> Store Update
let eventType = 'input';
if (input instanceof HTMLInputElement && input.type === 'checkbox') {
eventType = 'change';
} else if (input instanceof HTMLSelectElement) {
eventType = 'change';
}
const handler = (e) => {
let value;
if (input instanceof HTMLInputElement && input.type === 'checkbox') {
value = input.checked;
} else {
value = input.value;
}
if (input instanceof HTMLInputElement && (input.type === 'number' || input.type === 'range')) {
value = value === '' ? null : parseFloat(String(value));
} else if (typeof value === 'string' && value === '') {
value = null; // Normalize empty string to null
}
if (options.transformValue) {
value = options.transformValue(value);
}
this.store.set(key, value);
// Optimistic error clearing
this._clearError(key);
};
input.addEventListener(eventType, handler);
this.disposer(() => input.removeEventListener(eventType, handler));
}
/**
* @private
* Manages error display based on store state.
*/
_updateErrorState(element, key, state) {
const errorPath = `${CONSTANTS.STORE_KEYS.ERRORS_PATH}.${key}`;
const error = getPropertyByPath(state, errorPath); // Expecting { message: string } or null
const errorMsg = element.querySelector(`.${this.styles.formErrorMsg}`);
const input = element.querySelector('input, select, textarea');
if (errorMsg && error) {
errorMsg.textContent = error.message || error;
if (input) input.classList.add(this.styles.invalidInput);
} else if (errorMsg) {
errorMsg.textContent = '';
if (input) input.classList.remove(this.styles.invalidInput);
}
}
_clearError(key) {
// Helper to clear error in store
this.store.set(`${CONSTANTS.STORE_KEYS.ERRORS_PATH}.${key}`, null);
}
// --- Components ---
text(key, label, options) {
const cls = this.styles;
const input = this.create('input', { type: 'text' }, []);
const errorSpan = this.create('div', { className: cls.formErrorMsg }, []);
const children = [input];
// File selection button for image fields
if (options.fieldType === 'image' || options.fieldType === 'icon') {
const btn = this.create(
'button',
{
className: cls.localFileBtn,
type: 'button',
title: 'Select local file',
onclick: () => {
if (this.context.fileHandler) {
this.context.fileHandler(key, {
onStart: () => {
if (errorSpan instanceof HTMLElement) {
errorSpan.textContent = 'Processing...';
errorSpan.style.color = this.context.siteStyles?.PALETTE?.accent_text;
}
},
onSuccess: () => {
if (errorSpan instanceof HTMLElement) {
errorSpan.textContent = '';
errorSpan.style.color = '';
}
if (input instanceof HTMLElement) input.classList.remove(cls.invalidInput);
},
onError: (msg) => {
if (errorSpan instanceof HTMLElement) {
errorSpan.textContent = msg;
errorSpan.style.color = this.context.siteStyles?.PALETTE?.error_text;
}
},
});
}
},
},
[createIconFromDef(StyleDefinitions.ICONS.folder)]
);
children.push(btn);
}
const container = this.create('div', { className: cls.formField }, [
this.create('div', { className: cls.labelRow }, [this.create('label', { title: options.tooltip }, label)]),
this.create('div', { className: cls.inputWrapper }, children),
errorSpan,
]);
if (container instanceof HTMLElement && input instanceof HTMLInputElement) {
this._bindInput(container, input, key, options);
this._setupDynamicState(container, options);
// Allow manual error update observing
this.observe(`${CONSTANTS.STORE_KEYS.ERRORS_PATH}.${key}`, (state) => this._updateErrorState(container, key, state));
}
return container;
}
textarea(key, label, options) {
const cls = this.styles;
const input = this.create('textarea', { rows: options.rows || 3 }, []);
const errorSpan = this.create('div', { className: cls.formErrorMsg }, []);
const container = this.create('div', { className: cls.formField }, [this.create('label', { title: options.tooltip }, label), input, errorSpan]);
if (container instanceof HTMLElement && input instanceof HTMLTextAreaElement) {
this._bindInput(container, input, key, options);
this._setupDynamicState(container, options);
this.observe(`${CONSTANTS.STORE_KEYS.ERRORS_PATH}.${key}`, (state) => this._updateErrorState(container, key, state));
}
return container;
}
toggle(key, label, options) {
const cls = this.styles;
const input = this.create('input', { type: 'checkbox' }, []);
const container = this.create('div', { className: cls.submenuRow }, [
this.create('label', { title: options.title }, label), // Label on left
this.create('label', { className: cls.toggleSwitch, title: options.title }, [
// Switch on right
input,
this.create('span', { className: cls.toggleSlider }, []),
]),
]);
if (container instanceof HTMLElement && input instanceof HTMLInputElement) {
this._bindInput(container, input, key, options);
this._setupDynamicState(container, options);
}
return container;
}
range(key, label, min, max, options) {
const cls = this.styles;
const step = options.step || 1;
const input = this.create('input', { type: 'range', min, max, step }, []);
const display = this.create('span', { className: cls.sliderDisplay }, []);
const controlGroup = this.create('div', { className: cls.sliderSubgroupControl }, [input, display]);
const containerClass = options.containerClass ? cls[options.containerClass] : cls.sliderContainer;
const container = this.create('div', { className: containerClass }, [this.create('label', { title: options.tooltip }, label), controlGroup]);
const extendedOptions = {
...options,
// Use rawValue (val) for display logic to handle 'Auto' (null) correctly
onUIUpdate: (val, uiVal) => {
if (options.valueLabelFormatter) {
display.textContent = options.valueLabelFormatter(val);
} else {
display.textContent = val === null || val === undefined ? 'Auto' : String(val);
}
// Handle 'default' style (grey out text if auto)
if (controlGroup instanceof HTMLElement) {
const isDefault = val === null || val === undefined;
controlGroup.classList.toggle('is-default', isDefault);
}
},
};
if (container instanceof HTMLElement && input instanceof HTMLInputElement) {
this._bindInput(container, input, key, extendedOptions);
this._setupDynamicState(container, options);
}
return container;
}
select(key, label, options) {
const cls = this.styles;
const selectOptions = (options.options || []).map((opt) => {
let value, text;
// Support both primitive values and { value, label } objects
if (opt && typeof opt === 'object' && 'value' in opt && 'label' in opt) {
value = opt.value;
text = opt.label;
} else {
value = opt;
text = opt === '' ? '(not set)' : opt;
}
return this.create('option', { value: value }, text);
});
const input = this.create('select', {}, selectOptions);
let container;
if (options.showLabel) {
container = this.create('div', { className: cls.formField }, [this.create('label', { title: options.tooltip }, label), input]);
} else {
// Bare select (often used in rows)
container = input;
}
if (container instanceof HTMLElement && input instanceof HTMLSelectElement) {
this._bindInput(container, input, key, options);
this._setupDynamicState(container, options);
}
return container;
}
color(key, label, options) {
const cls = this.styles;
const input = this.create('input', { type: 'text', autocomplete: 'off' }, []);
const swatchValue = this.create('span', { className: cls.colorSwatchValue }, []);
const swatch = this.create('button', { className: cls.colorSwatch, type: 'button', title: 'Open color picker' }, [this.create('span', { className: cls.colorSwatchChecker }, []), swatchValue]);
// Picker logic encapsulation
let activePicker = null;
const closePicker = () => {
if (activePicker) {
activePicker.destroy();
activePicker.popup.remove();
activePicker = null;
}
};
// Ensure picker is closed when the parent component is destroyed or re-rendered
this.disposer(() => closePicker());
if (swatch instanceof HTMLElement) {
swatch.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
if (!(input instanceof HTMLInputElement)) return;
if (activePicker) {
closePicker();
return;
}
// Create popup
const popupRoot = this.create('div', {}, []);
// Apply the pickerRootId from context to ensure styles are scoped correctly
const popupWrapper = this.create(
'div',
{
id: this.context.pickerRootId,
className: cls.colorPickerPopup,
style: { position: 'absolute' },
},
[popupRoot]
);
const dialog = swatch.closest('dialog') || document.body;
if (popupWrapper instanceof HTMLElement) {
dialog.appendChild(popupWrapper);
}
if (popupRoot instanceof HTMLElement) {
const picker = new CustomColorPicker(popupRoot, {
initialColor: input.value || 'rgb(128 64 64 / 1)',
classes: cls,
});
picker.render();
// Positioning logic (simplified from existing)
const rect = swatch.getBoundingClientRect();
const containerRect = dialog.getBoundingClientRect();
if (popupWrapper instanceof HTMLElement) {
popupWrapper.style.top = `${rect.bottom - containerRect.top + 4}px`;
popupWrapper.style.left = `${rect.left - containerRect.left}px`;
}
// Events
const onColorChange = (ev) => {
if (ev instanceof CustomEvent) {
const color = ev.detail.color;
input.value = color;
if (swatchValue instanceof HTMLElement) {
swatchValue.style.backgroundColor = color;
}
input.dispatchEvent(new Event('input')); // Trigger store update
}
};
popupRoot.addEventListener('color-change', onColorChange);
// Auto close
const outsideClick = (ev) => {
if (popupWrapper instanceof HTMLElement) {
if (ev.target instanceof Node && !popupWrapper.contains(ev.target) && ev.target !== swatch && !swatch.contains(ev.target)) closePicker();
}
};
setTimeout(() => document.addEventListener('click', outsideClick, true), 0);
// ESC key close
const onKeyDown = (ev) => {
if (ev.key === 'Escape') {
ev.preventDefault();
ev.stopPropagation();
closePicker();
}
};
document.addEventListener('keydown', onKeyDown, true);
activePicker = {
destroy: () => {
picker.destroy();
document.removeEventListener('click', outsideClick, true);
document.removeEventListener('keydown', onKeyDown, true);
},
popup: popupWrapper,
};
}
};
}
const container = this.create('div', { className: cls.formField }, [
this.create('div', { className: cls.labelRow }, [
this.create('label', { title: options.tooltip }, label),
this.create('span', { className: cls.statusText }, []), // Error container placeholder
]),
this.create('div', { className: cls.colorFieldWrapper }, [input, swatch]),
]);
const extendedOptions = {
...options,
onStoreUpdate: (el, val) => {
if (input instanceof HTMLInputElement) input.value = val || '';
if (swatchValue instanceof HTMLElement) swatchValue.style.backgroundColor = val || 'transparent';
},
};
if (container instanceof HTMLElement && input instanceof HTMLInputElement) {
this._bindInput(container, input, key, extendedOptions);
this._setupDynamicState(container, options);
// Error handling for color field (using statusText)
this.observe(`${CONSTANTS.STORE_KEYS.ERRORS_PATH}.${key}`, (state) => {
const error = getPropertyByPath(state, `${CONSTANTS.STORE_KEYS.ERRORS_PATH}.${key}`);
const statusText = container.querySelector(`.${cls.statusText}`);
if (statusText instanceof HTMLElement) {
statusText.textContent = error ? error.message || error : '';
statusText.style.color = error ? this.context.siteStyles?.PALETTE?.error_text || 'red' : '';
}
if (error) input.classList.add(cls.invalidInput);
else input.classList.remove(cls.invalidInput);
});
}
return container;
}
button(id, text, onClick, options) {
const cls = this.styles;
const className = options.className ? `${cls.modalButton} ${options.className}` : cls.modalButton;
const btn = this.create(
'button',
{
id,
className,
type: 'button',
title: options.title || '',
onclick: (e) => {
e.preventDefault();
if (typeof onClick === 'function') {
onClick(e);
}
},
style: { width: options.fullWidth ? '100%' : 'auto' },
},
text
);
return btn;
}
// --- Layouts ---
group(label, children, options) {
const cls = this.styles;
const container = this.create('fieldset', { className: cls.submenuFieldset }, [this.create('legend', {}, label), ...children]);
if (container instanceof HTMLElement) {
this._setupDynamicState(container, options);
}
return container;
}
row(children, options) {
const cls = this.styles;
let className = cls.submenuRow;
if (options.className && cls[options.className]) {
className += ` ${cls[options.className]}`;
} else if (options.className) {
className += ` ${options.className}`;
}
const container = this.create('div', { className }, children);
if (container instanceof HTMLElement) {
this._setupDynamicState(container, options);
}
return container;
}
separator(options) {
const container = this.create('div', { className: this.styles.submenuSeparator }, []);
if (container instanceof HTMLElement) {
this._setupDynamicState(container, options);
}
return container;
}
label(text, options) {
const container = this.create('label', { title: options.title }, text);
if (container instanceof HTMLElement) {
this._setupDynamicState(container, options);
}
return container;
}
container(children, options) {
let className = '';
if (options.className && this.styles[options.className]) {
className = this.styles[options.className];
}
const container = this.create('div', { className }, children);
if (container instanceof HTMLElement) {
this._setupDynamicState(container, options);
}
return container;
}
}
/**
* @class ThemePreviewController
* @description Manages the live preview updates within the Theme Modal.
* It decouples DOM manipulation from the Modal component logic by reacting to Store changes.
*/
class ThemePreviewController {
/**
* @param {HTMLElement} rootElement - The root element of the modal to search for preview nodes.
* @param {ReactiveStore} store - The store instance to subscribe to.
* @param {object} defaultSet - The default theme configuration for fallback values.
*/
constructor(rootElement, store, defaultSet) {
if (!defaultSet) {
throw new Error('[ThemePreviewController] defaultSet is required.');
}
this.store = store;
this.defaultSet = defaultSet;
this.isEditingDefault = false;
/**
* @type {{
* user: Element | null,
* assistant: Element | null,
* inputArea: Element | null,
* window: Element | null
* }}
*/
this.dom = {
user: rootElement.querySelector(DomState.getSelector(CONSTANTS.DATA_KEYS.PREVIEW_FOR, 'user')),
assistant: rootElement.querySelector(DomState.getSelector(CONSTANTS.DATA_KEYS.PREVIEW_FOR, 'assistant')),
inputArea: rootElement.querySelector(DomState.getSelector(CONSTANTS.DATA_KEYS.PREVIEW_FOR, 'inputArea')),
window: rootElement.querySelector(DomState.getSelector(CONSTANTS.DATA_KEYS.PREVIEW_FOR, 'window')),
};
// Bind method to preserve 'this' context
this.onStoreUpdate = this.onStoreUpdate.bind(this);
this.unsubscribe = this.store.subscribe(this.onStoreUpdate);
}
destroy() {
if (this.unsubscribe) {
this.unsubscribe();
}
this.unsubscribe = null;
this.store = null;
this.defaultSet = null;
this.onStoreUpdate = null;
this.dom = {
user: null,
assistant: null,
inputArea: null,
window: null,
};
}
/**
* Updates the internal reference to the default set.
* Used to ensure fallback values are current after a "Save" or "Apply" action.
* @param {object} newDefaultSet - The updated default theme configuration.
*/
setDefaultSet(newDefaultSet) {
if (!newDefaultSet) {
throw new Error('[ThemePreviewController] newDefaultSet is required.');
}
this.defaultSet = newDefaultSet;
}
/**
* Sets the flag indicating whether the current theme being edited is the default set.
* When true, fallback logic is disabled to correctly preview "unset" (null) values.
* @param {boolean} isEditingDefault
*/
setIsEditingDefault(isEditingDefault) {
this.isEditingDefault = isEditingDefault;
// Force re-render of all preview sections to apply the new mode immediately.
// This ensures that switching between "Default" (no fallback) and "Custom" (with fallback)
// updates the preview appearance even if the underlying data hasn't changed.
if (this.store) {
const state = this.store.getStateRef();
this.onStoreUpdate(state, 'user');
this.onStoreUpdate(state, 'assistant');
this.onStoreUpdate(state, 'window');
this.onStoreUpdate(state, 'inputArea');
}
}
/**
* Handles updates from the store and dispatches to specific render methods.
* Checks for both exact key matches (e.g., 'user') and nested paths (e.g., 'user.textColor').
* @param {object} state - The full state object.
* @param {string} changedPath - The path of the property that changed.
*/
onStoreUpdate(state, changedPath) {
if (!this.store) return;
if (!changedPath) return;
if (changedPath === 'user' || changedPath.startsWith('user.')) {
this.renderActorPreview('user', state.user);
} else if (changedPath === 'assistant' || changedPath.startsWith('assistant.')) {
this.renderActorPreview('assistant', state.assistant);
} else if (changedPath === 'window' || changedPath.startsWith('window.')) {
this.renderWindowPreview(state.window);
} else if (changedPath === 'inputArea' || changedPath.startsWith('inputArea.')) {
this.renderInputAreaPreview(state.inputArea);
}
}
/**
* Resolves the effective value for a theme property based on editing mode.
* @private
* @param {object} config - The current config object.
* @param {object} defaultConfig - The default config object.
* @param {string} key - The property key.
* @returns {any} The resolved value.
*/
_resolveValue(config, defaultConfig, key) {
const val = config[key];
if (this.isEditingDefault) return val;
return val ?? defaultConfig[key];
}
/**
* Applies mapped styles to an element using resolved values.
* @private
* @param {HTMLElement} element - The target element.
* @param {object} config - The current config object.
* @param {object} defaultConfig - The default config object.
* @param {string} schemaPrefix - The prefix for schema lookup (e.g. 'user', 'window').
* @param {Record<string, string>} mapping - Map of CSS property to config key.
*/
_applyStyles(element, config, defaultConfig, schemaPrefix, mapping) {
const style = element.style;
for (const [cssProp, configKey] of Object.entries(mapping)) {
const val = this._resolveValue(config, defaultConfig, configKey);
if (val !== null && val !== undefined) {
if (typeof val === 'number') {
// Numeric values MUST have a unit defined in the schema.
const schemaKey = `${schemaPrefix}.${configKey}`;
const unit = CONFIG_SCHEMA.theme[schemaKey]?.def?.unit;
style[cssProp] = `${val}${unit}`;
} else {
// String values (colors, fonts, images) are applied as-is.
style[cssProp] = String(val);
}
} else {
style[cssProp] = '';
}
}
}
/**
* Updates the preview style for a specific actor (user/assistant).
* Falls back to defaultSet values if properties are null/undefined, unless editing the default set itself.
* @param {'user'|'assistant'} actor
* @param {object} config
*/
renderActorPreview(actor, config) {
const element = this.dom[actor];
if (!(element instanceof HTMLElement)) return;
const currentConfig = config ?? {};
const defaultConfig = this.defaultSet[actor] ?? {};
// Apply standard styles
this._applyStyles(element, currentConfig, defaultConfig, actor, {
color: 'textColor',
backgroundColor: 'bubbleBackgroundColor',
fontFamily: 'font',
borderRadius: 'bubbleBorderRadius',
padding: 'bubblePadding',
});
// If updating user settings, also sync the font to the input area preview
if (actor === 'user' && this.dom.inputArea instanceof HTMLElement) {
this._applyStyles(this.dom.inputArea, currentConfig, defaultConfig, actor, {
fontFamily: 'font',
});
}
// Max Width (Special handling for Auto fallback)
// If the resolved value is null (Auto), fallback to hardcoded defaults for preview purposes
// to mimic the behavior of previous versions (user: 50%, assistant: 90%).
const resolvedWidth = this._resolveValue(currentConfig, defaultConfig, 'bubbleMaxWidth');
const maxWidthDefault = actor === 'user' ? CONSTANTS.UI_SPECS.PREVIEW_BUBBLE_MAX_WIDTH.USER : CONSTANTS.UI_SPECS.PREVIEW_BUBBLE_MAX_WIDTH.ASSISTANT;
// Get unit from schema (Theme Scope)
const unit = CONFIG_SCHEMA.theme[`${actor}.bubbleMaxWidth`].def.unit;
const maxWidth = resolvedWidth !== null ? `${resolvedWidth}${unit}` : maxWidthDefault;
element.style.width = maxWidth;
element.style.maxWidth = maxWidth;
}
/**
* Updates the preview style for the input area.
* Falls back to defaultSet values if properties are null/undefined, unless editing the default set itself.
* @param {object} config
*/
renderInputAreaPreview(config) {
const element = this.dom.inputArea;
if (!(element instanceof HTMLElement)) return;
const currentConfig = config ?? {};
const defaultConfig = this.defaultSet.inputArea ?? {};
this._applyStyles(element, currentConfig, defaultConfig, 'inputArea', {
color: 'textColor',
backgroundColor: 'backgroundColor',
});
}
/**
* Updates the preview style for the window background.
* Falls back to defaultSet values if properties are null/undefined, unless editing the default set itself.
* @param {object} config
*/
renderWindowPreview(config) {
const element = this.dom.window;
if (!(element instanceof HTMLElement)) return;
const currentConfig = config ?? {};
const defaultConfig = this.defaultSet.window ?? {};
// Only background-color is previewed for window
this._applyStyles(element, currentConfig, defaultConfig, 'window', {
backgroundColor: 'backgroundColor',
});
}
}
/**
* @class CustomColorPicker
* @description A reusable color picker UI component.
* It renders the UI using the CSS classes provided in the options, relying on external style injection.
* Uses static methods for color conversion and the external `validateColorString` helper for validation.
*/
class CustomColorPicker {
static DIMENSIONS = {
WIDTH: 280,
HEIGHT: 350,
PADDING: 32,
MARGIN: 4,
};
/**
* @param {Element} rootElement The DOM element to render the picker into.
* @param {object} options
* @param {string} options.initialColor The initial color to display.
* @param {Record<string, string>} options.classes The CSS class map provided by StyleManager.
*/
constructor(rootElement, options) {
this.rootElement = rootElement;
this.options = options;
this.state = { h: 0, s: 100, v: 100, a: 1 };
this.dom = {};
this.isUpdating = false;
this._pendingEmit = false; // Flag to track if the next update should emit an event
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) {
if (a < 1) {
return `rgb(${r} ${g} ${b} / ${a.toFixed(2).replace(/\.?0+$/, '') || 0})`;
}
return `rgb(${r} ${g} ${b})`;
}
/**
* Resolves a CSS color string to an RGBA object using the DOM.
* This method is only called when the UI is active and the DOM is available.
* @param {string} str - The color string.
* @returns {{r: number, g: number, b: number, a: number} | null} RGBA object or null if invalid.
*/
static resolveColor(str) {
if (!str || typeof str !== 'string' || str.trim() === '') return null;
const s = str.trim().toLowerCase();
// --- Fast Path: Keyword ---
if (s === 'transparent') {
return { r: 0, g: 0, b: 0, a: 0 };
}
// --- Fast Path: HEX ---
if (/^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/.test(s)) {
let hex = s.substring(1);
if (hex.length === 3 || hex.length === 4) {
hex = Array.from(hex)
.map((c) => c + c)
.join('');
}
if (hex.length === 6 || hex.length === 8) {
return {
r: parseInt(hex.substring(0, 2), 16),
g: parseInt(hex.substring(2, 4), 16),
b: parseInt(hex.substring(4, 6), 16),
a: hex.length === 8 ? parseInt(hex.substring(6, 8), 16) / 255 : 1,
};
}
}
// --- Fallback: DOM Parsing ---
// Before heavy DOM manipulation, check validity using the lightweight global validator.
// This prevents creating an Option element for inputs that are already handled by fast paths.
if (!validateColorString(str)) return null;
// Then use DOM to parse complex components (e.g. 'red' -> 255, 0, 0, hsl, color-mix)
const temp = document.createElement('div');
temp.style.color = 'initial';
temp.style.color = str;
if (temp.style.color === '' || temp.style.color === 'initial') {
return null;
}
temp.style.display = 'none';
document.body.appendChild(temp);
const computedColor = window.getComputedStyle(temp).color;
document.body.removeChild(temp);
// Supports both legacy comma-separated (rgb(255, 0, 0)) and modern space/slash-separated (rgb(255 0 0 / 1)) formats
const rgbaMatch = computedColor.match(/rgba?\(\s*(\d+)(?:\s*,\s*|\s+)(\d+)(?:\s*,\s*|\s+)(\d+)(?:(?:\s*,\s*|\s*\/\s*)([0-9.]+))?\s*\)/);
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() {
// Get references to created DOM elements from _createDom
Object.assign(this.dom, this._createDom());
this._attachEventListeners();
// Initial render should not emit events
this.setColor(this.options.initialColor, true);
}
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 = {};
// Shared styles are managed by StyleManager and should NOT be removed here.
}
setColor(rgbString, silent) {
// Use the DOM-based resolver since we need RGB components for HSV conversion
const parsed = CustomColorPicker.resolveColor(rgbString);
if (parsed) {
const { r, g, b, a } = parsed;
const { h, s, v } = CustomColorPicker.rgbToHsv(r, g, b);
this.state = { h, s, v, a };
// If silent is true, do NOT emit event (pass false to _requestUpdate)
this._requestUpdate(!silent);
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);
}
_createDom() {
const cls = this.options.classes;
// References to key elements will be captured during creation.
let svPlane, svThumb, hueSlider, alphaSlider, alphaTrack;
const colorPicker = h(`div.${cls.picker}`, { 'aria-label': 'Color picker' }, [
(svPlane = h(
`div.${cls.svPlane}`,
{
role: 'slider',
tabIndex: 0,
'aria-label': 'Saturation and Value',
},
[h(`div.${cls.gradientWhite}`), h(`div.${cls.gradientBlack}`), (svThumb = h(`div.${cls.svThumb}`))]
)),
h(`div.${cls.sliderGroup}`, [
h(`div.${cls.sliderTrack}.${cls.hueTrack}`),
(hueSlider = h('input', {
type: 'range',
min: '0',
max: '360',
step: '1',
'aria-label': 'Hue',
})),
]),
h(`div.${cls.sliderGroup}`, [
h(`div.${cls.alphaCheckerboard}`),
(alphaTrack = h(`div.${cls.sliderTrack}`)),
(alphaSlider = h('input', {
type: 'range',
min: '0',
max: '1',
step: '0.01',
'aria-label': 'Alpha',
})),
]),
]);
this.rootElement.replaceChildren(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);
// User interaction should always emit events
this._requestUpdate(true);
}
_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;
}
}
// Keyboard interaction should emit events
this._requestUpdate(true);
});
hueSlider.addEventListener('input', () => {
this.state.h = parseInt(hueSlider.value, 10);
// Slider interaction should emit events
this._requestUpdate(true);
});
alphaSlider.addEventListener('input', () => {
this.state.a = parseFloat(alphaSlider.value);
// Slider interaction should emit events
this._requestUpdate(true);
});
}
_requestUpdate(emitEvent) {
// Accumulate the emit flag. If any update in this frame requests an emit, it should happen.
this._pendingEmit = this._pendingEmit || emitEvent;
if (this.isUpdating) return;
this.isUpdating = true;
requestAnimationFrame(() => {
try {
this._updateUIDisplay();
if (this._pendingEmit) {
this._dispatchChangeEvent();
this._pendingEmit = false;
}
} finally {
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,
})
);
}
}
}
/**
* @callback ModalButtonOnClick
* @param {CustomModal} modalInstance
* @param {MouseEvent} event
* @returns {void}
*/
class CustomModal {
static DEFAULTS = {
WIDTH: '500px',
};
/**
* @param {object} options
* @param {string} options.title
* @param {string} options.width
* @param {string | null} options.id
* @param {number | string | undefined} options.zIndex
* @param {boolean} options.closeOnBackdropClick
* @param {Array<{text: string, id: string, className?: string, title?: string, onClick: ModalButtonOnClick}>} options.buttons
* @param {function(Event): void | null} options.onCancel
* @param {function(): void | null} options.onDestroy
*/
constructor(options) {
this.options = options;
// Style loading is handled by the parent component (ThemeModal/JsonModal) via Mix-in.
// CustomModal directly uses the static class definitions.
this.element = null;
this.dom = {}; // To hold references to internal elements like header, content, footer
this._createModalElement();
}
_createModalElement() {
const cls = StyleDefinitions.MODAL_CLASSES;
const commonBtnClass = StyleDefinitions.COMMON_CLASSES.modalButton;
// 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) => {
// Combine common button class with any custom classes provided
const fullClassName = [commonBtnClass, btnDef.className].filter(Boolean).join(' ');
return h(
'button',
{
id: btnDef.id,
className: fullClassName,
onclick: (e) => btnDef.onClick(this, e),
title: btnDef.title || '',
},
btnDef.text
);
});
const buttonGroup = h(`div.${cls.buttonGroup}`, buttons);
// Create the entire modal structure using h().
const dialogElement = h(
`dialog.${cls.dialog}`, // Common dialog class
{
id: this.options.id, // Specific ID passed from options (Required for CSS scoping)
[`data-${APPID}-scope`]: '',
},
(modalBox = h(`div.${cls.box}`, { style: { width: this.options.width } }, [
(header = h(`div.${cls.header}`, this.options.title)),
(content = h(`div.${cls.content}`)),
(footer = h(`div.${cls.footer}`, [(footerMessage = h(`div.${cls.footerMessage}`)), buttonGroup])),
]))
);
if (!(dialogElement instanceof HTMLDialogElement)) {
Logger.error('UI', '', 'Failed to create modal dialog element.');
return;
}
this.element = dialogElement;
// The 'close' event is the single source of truth for when the dialog has been dismissed.
this.element.addEventListener('close', () => this.destroy());
// Listen for the 'cancel' event (fired on ESC) to allow intercepting the close action.
this.element.addEventListener('cancel', (e) => {
if (typeof this.options.onCancel === 'function') {
this.options.onCancel(e);
}
});
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) {
if (this.element instanceof HTMLDialogElement && typeof this.element.showModal === 'function') {
if (this.element.open) return;
if (this.options.zIndex !== undefined) {
this.element.style.zIndex = String(this.options.zIndex);
}
this.element.showModal();
// Positioning logic
if (anchorElement && typeof anchorElement.getBoundingClientRect === 'function') {
// ANCHORED POSITIONING
const modalBox = this.dom.modalBox;
const btnRect = anchorElement.getBoundingClientRect();
const margin = CONSTANTS.UI_SPECS.MODAL_MARGIN;
const anchorOffset = CONSTANTS.UI_SPECS.ANCHOR_OFFSET;
const modalWidth = modalBox.offsetWidth || parseInt(this.options.width, 10);
const modalHeight = modalBox.offsetHeight;
let left = btnRect.left;
const top = btnRect.bottom + anchorOffset;
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 - anchorOffset;
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();
}
}
/**
* @param {Node} element
*/
setContent(element) {
this.dom.content.replaceChildren(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
* @param {function(): void} callbacks.onClick
* @param {object} options
* @param {string} options.id
* @param {string} options.title
*/
constructor(callbacks, options) {
super(callbacks);
this.options = options;
this.styleHandle = null;
this.id = null;
}
/**
* Renders the settings button element in memory.
* @returns {HTMLElement} The created button element.
*/
render() {
// Inject styles and get the managed ID
this.styleHandle = StyleManager.request(StyleDefinitions.getSettingsButton);
// Use the rootId defined in the style definition for the element ID
this.id = this.styleHandle.rootId;
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 = StyleDefinitions.ICONS.settings;
if (iconDef) {
const svgElement = createIconFromDef(iconDef);
if (svgElement instanceof Node) {
this.element.appendChild(svgElement);
}
}
return this.element;
}
_onDestroy() {
this.styleHandle = null;
super._onDestroy();
}
/**
* Toggles the loading animation state of the button.
* @param {boolean} isLoading
*/
setLoading(isLoading) {
if (this.element) {
this.element.classList.toggle('is-loading', isLoading);
}
}
}
/**
* Manages the settings panel/submenu using ReactiveStore for state management.
*/
class SettingsPanelComponent extends UIComponentBase {
/**
* @param {object} callbacks
* @param {(config: AppConfig) => Promise<void>} [callbacks.onSave]
* @param {() => Promise<AppConfig>} [callbacks.getCurrentConfig]
* @param {() => object} [callbacks.getCurrentWarning]
* @param {() => ThemeSet} [callbacks.getCurrentThemeSet]
* @param {() => void} [callbacks.onShowJsonModal]
* @param {function(string=): void} [callbacks.onShowThemeModal]
* @param {() => HTMLElement|null} [callbacks.getAnchorElement]
* @param {(config: AppConfig) => {size: number, isExceeded: boolean}} [callbacks.checkSize]
* @param {() => void} [callbacks.onShow]
*/
constructor(callbacks) {
super(callbacks);
this.activeThemeSet = null;
this.subscriptions = [];
this.store = null;
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);
this.isUpdatingFromExternal = false; // Flag to prevent save loops
/** @type {Array<() => void>} */
this._uiSubscriptions = []; // Transient subscriptions for the current render cycle
}
_onInit() {
StyleManager.request(StyleDefinitions.getCommonStyle);
this.style = StyleManager.request(StyleDefinitions.getSettingsPanel);
this._subscribe(EVENTS.CONFIG_UPDATED, async (newConfig) => {
// If the panel is open, refresh the store.
// Guard against save loops triggered by external updates.
if (this.isOpen() && this.store) {
this.isUpdatingFromExternal = true;
try {
// Preserve current system state to keep warning banners visible
const currentSystem = this.store.get(CONSTANTS.STORE_KEYS.SYSTEM_ROOT);
const newState = { ...newConfig, [CONSTANTS.STORE_KEYS.SYSTEM_ROOT]: currentSystem };
this.store.replaceState(newState);
} finally {
this.isUpdatingFromExternal = false;
}
}
});
// Subscribe to warning updates to update the store dynamically
this._subscribe(EVENTS.CONFIG_WARNING_UPDATE, (payload) => {
if (this.store) {
this.store.set(CONSTANTS.STORE_KEYS.WARNING_PATH, payload);
}
});
this.addDisposable(this.debouncedSave.cancel);
// Ensure listeners are removed and panel is hidden on destroy
this.addDisposable(() => this.hide());
}
_onDestroy() {
this.store = null;
// Clean up transient UI subscriptions
this._uiSubscriptions.forEach((unsub) => unsub());
this._uiSubscriptions = [];
super._onDestroy();
}
render() {
if (this.style && document.getElementById(this.style.rootId)) {
document.getElementById(this.style.rootId).remove();
}
this.element = this._createPanelContainer();
document.body.appendChild(this.element);
// Note: Content is rendered in show() because it depends on the current config
return this.element;
}
toggle() {
const shouldShow = this.element.style.display === 'none';
if (shouldShow) {
this.show();
} else {
this.hide();
}
}
isOpen() {
return this.element && this.element.style.display !== 'none';
}
hide() {
if (this.element) {
this.element.style.display = 'none';
}
document.removeEventListener('click', this._handleDocumentClick, true);
document.removeEventListener('keydown', this._handleDocumentKeydown, true);
}
/**
* Updates the displayed theme name, refreshes the store, and shows the panel.
* @returns {Promise<void>}
*/
async show() {
// Initialize or refresh store with the latest config
const currentConfig = await this.callbacks.getCurrentConfig();
if (this.isDestroyed) return;
const currentWarning = this.callbacks.getCurrentWarning();
const { SYSTEM_ROOT, SYSTEM_WARNING, SYSTEM_SIZE_EXCEEDED } = CONSTANTS.STORE_KEYS;
// Check size state
const sizeInfo = this.callbacks.checkSize(currentConfig);
// If size is exceeded, override warning message for the panel context
// unless a system warning is already active.
let warningState = currentWarning;
if (sizeInfo.isExceeded) {
warningState = {
show: true,
message: 'Configuration size limit exceeded.\nSome settings are disabled. Please reduce size via JSON or Themes.',
};
}
// Merge system state into the config for the store
const storeState = {
...currentConfig,
[SYSTEM_ROOT]: {
[SYSTEM_WARNING]: warningState,
[SYSTEM_SIZE_EXCEEDED]: sizeInfo.isExceeded,
},
};
if (this.store) {
this.isUpdatingFromExternal = true;
try {
this.store.replaceState(storeState);
} finally {
this.isUpdatingFromExternal = false;
}
} else {
this.store = new ReactiveStore(storeState);
// Subscribe store changes to save logic
const unsub = this.store.subscribe((state, path) => {
// Block save if updating from external source
if (this.isUpdatingFromExternal) return;
// Block save if the change is within the system internal state
if (path && String(path).startsWith(CONSTANTS.STORE_KEYS.SYSTEM_ROOT)) {
return;
}
this.debouncedSave();
});
// Register the unsubscribe callback for cleanup
this.addStoreSubscription(unsub);
}
// Re-render content to ensure schema freshness (and update disabled states)
this._renderContent();
// Update applied theme name display (manual DOM update as it's not in store)
if (this.callbacks.getCurrentThemeSet) {
this.activeThemeSet = this.callbacks.getCurrentThemeSet();
const themeName = this.activeThemeSet.metadata?.name || 'Default Settings';
const themeNameEl = this.element.querySelector(`#${this.style.prefix}-theme-name`);
if (themeNameEl) {
themeNameEl.textContent = themeName;
}
}
const anchor = this.callbacks.getAnchorElement();
if (anchor) {
const anchorRect = anchor.getBoundingClientRect();
const margin = CONSTANTS.UI_SPECS.PANEL_MARGIN;
const offset = CONSTANTS.UI_SPECS.ANCHOR_OFFSET;
let top = anchorRect.bottom + offset;
let left = anchorRect.left;
this.element.style.display = 'block';
const panelWidth = this.element.offsetWidth;
const panelHeight = this.element.offsetHeight;
if (left + panelWidth > window.innerWidth - margin) {
left = window.innerWidth - panelWidth - margin;
}
if (top + panelHeight > window.innerHeight - margin) {
top = window.innerHeight - panelHeight - margin;
}
this.element.style.left = `${Math.max(margin, left)}px`;
this.element.style.top = `${Math.max(margin, top)}px`;
} else {
// Fallback if no anchor
this.element.style.display = 'block';
}
document.addEventListener('click', this._handleDocumentClick, true);
document.addEventListener('keydown', this._handleDocumentKeydown, true);
// Notify callbacks
this.callbacks.onShow?.();
}
_createPanelContainer() {
// Use the rootId provided by the style definition for scoping
return h(`div#${this.style.rootId}`, {
style: { display: 'none' },
role: 'menu',
[`data-${APPID}-scope`]: '',
});
}
_renderContent() {
// 1. Clean up previous render's resources
this._uiSubscriptions.forEach((unsub) => unsub());
this._uiSubscriptions = [];
// Merge classes directly from StyleDefinitions for common styles
const cls = { ...StyleDefinitions.COMMON_CLASSES, ...this.style.classes };
const prefix = this.style.prefix;
const context = { styles: cls, siteStyles: SITE_STYLES };
// UIBuilder instance
const uiDisposer = (unsub) => {
if (typeof unsub === 'function') {
this._uiSubscriptions.push(unsub);
}
};
const ui = new UIBuilder(this.store, context, uiDisposer);
// --- 1. System Warning Banner ---
const warningBanner = this._createSystemWarning(ui);
// --- 2. Applied Theme Section ---
const themeSection = ui.group('Applied Theme', [ui.button(`${prefix}-theme-name`, 'Loading...', null, { fullWidth: true, title: 'Click to edit this theme' })], {});
// --- 3. Submenu Section ---
const submenuSection = ui.row(
[
ui.group('Themes', [ui.button(`${prefix}-edit-themes-btn`, 'Edit Themes...', null, { fullWidth: true, title: 'Open the theme editor to create and modify themes.' })], {}),
ui.group('JSON', [ui.button(`${prefix}-json-btn`, 'JSON...', null, { fullWidth: true, title: 'Opens the advanced settings modal to directly edit, import, or export the entire configuration in JSON format.' })], {}),
],
{ className: 'topRow' }
);
// --- 4. Options Section ---
// Schema References
const iconSizeSchema = CONFIG_SCHEMA.platform['options.icon_size'];
const chatWidthSchema = CONFIG_SCHEMA.platform['options.chat_content_max_width'];
const avatarSpaceSchema = CONFIG_SCHEMA.platform['options.respect_avatar_space'];
// Resolve paths using ConfigPathResolver
const widthKey = ConfigPathResolver.resolve('platform', 'options.chat_content_max_width');
const iconSizeKey = ConfigPathResolver.resolve('platform', 'options.icon_size');
const avatarSpaceKey = ConfigPathResolver.resolve('platform', 'options.respect_avatar_space');
const widthProps = ConfigProcessor.getSliderProps('options.chat_content_max_width');
const sizeCheck = {
dependencies: [CONSTANTS.STORE_KEYS.SIZE_EXCEEDED_PATH],
disabledIf: (data) => getPropertyByPath(data, CONSTANTS.STORE_KEYS.SIZE_EXCEEDED_PATH),
};
const optionsSection = ui.group(
'Options',
[
ui.range(iconSizeKey, iconSizeSchema.ui.label, 0, CONSTANTS.UI_SPECS.AVATAR.SIZE_OPTIONS.length - 1, {
step: 1,
tooltip: iconSizeSchema.ui.tooltip,
transformValue: (index) => CONSTANTS.UI_SPECS.AVATAR.SIZE_OPTIONS[index] ?? CONSTANTS.UI_SPECS.AVATAR.DEFAULT_SIZE,
toInputValue: (pixelVal) => {
const idx = CONSTANTS.UI_SPECS.AVATAR.SIZE_OPTIONS.indexOf(pixelVal);
return idx !== -1 ? idx : 0;
},
valueLabelFormatter: (val) => {
const unit = iconSizeSchema.def?.unit;
return `${val}${unit}`;
},
}),
ui.range(widthKey, chatWidthSchema.ui.label, widthProps.min, widthProps.max, {
step: widthProps.step,
tooltip: chatWidthSchema.ui.tooltip,
transformValue: widthProps.transformValue,
toInputValue: widthProps.toInputValue,
valueLabelFormatter: (val) => {
if (!val) return 'Auto';
const unit = chatWidthSchema.def?.unit;
return `${val}${unit}`;
},
}),
ui.separator({ className: 'submenuSeparator' }),
ui.row(
[
ui.label(avatarSpaceSchema.ui.label, {
title: avatarSpaceSchema.ui.title,
}),
ui.toggle(avatarSpaceKey, '', {
title: avatarSpaceSchema.ui.title,
}),
],
{}
),
],
sizeCheck
);
// --- 5. Features Section ---
const featureRows = [];
// Helper to render schema-driven toggles
const renderSchemaToggle = (schemaKey) => {
const schema = CONFIG_SCHEMA.platform[schemaKey];
if (!schema) return document.createDocumentFragment();
const fullKey = ConfigPathResolver.resolve('platform', schemaKey);
// Resolve dependencies
const dependencies = schema.ui.dependencies ? schema.ui.dependencies.map((d) => ConfigPathResolver.resolve('platform', d)) : [];
return ui.row(
[
ui.label(schema.ui.label, { title: schema.ui.title }),
ui.toggle(fullKey, '', {
title: schema.ui.title,
// Use Resolver to extract platform config for disabledIf check
disabledIf: schema.ui.disabledIf ? (data) => schema.ui.disabledIf(ConfigPathResolver.getPlatformConfig(data)) : undefined,
dependencies: dependencies,
}),
],
{ className: 'featureGroup' }
);
};
// Platform Specific Features
const platformFeatures = PlatformAdapters.SettingsPanel.getPlatformSpecificFeatureToggles();
platformFeatures.forEach((feat) => {
featureRows.push(renderSchemaToggle(feat.configKey));
});
// Common Features
featureRows.push(renderSchemaToggle('features.collapsible_button.enabled'));
featureRows.push(renderSchemaToggle('features.bubble_nav_buttons.enabled'));
// Keyboard shortcuts
featureRows.push(renderSchemaToggle('features.fixed_nav_console.keyboard_shortcuts.enabled'));
featureRows.push(renderSchemaToggle('features.fixed_nav_console.enabled'));
// Console Position (Select)
const consolePosKeyRaw = 'features.fixed_nav_console.position';
const consolePosSchema = CONFIG_SCHEMA.platform[consolePosKeyRaw];
const consolePosKey = ConfigPathResolver.resolve('platform', consolePosKeyRaw);
const consolePosDeps = [`features.fixed_nav_console.enabled`].map((d) => ConfigPathResolver.resolve('platform', d));
featureRows.push(
ui.row(
[
ui.label(consolePosSchema.ui.label, { title: consolePosSchema.ui.title }),
ui.select(consolePosKey, '', {
options: consolePosSchema.def.options,
title: consolePosSchema.ui.title,
dependencies: consolePosDeps,
// Explicit check using Resolver
disabledIf: (data) => !getPropertyByPath(ConfigPathResolver.getPlatformConfig(data), 'features.fixed_nav_console.enabled'),
}),
],
{ className: 'featureGroup' }
)
);
const featuresSection = ui.group('Features', featureRows, sizeCheck);
// Assemble
this.element.replaceChildren(warningBanner, themeSection, submenuSection, optionsSection, featuresSection);
this._setupStaticListeners();
this._setupObservers(ui);
}
_createSystemWarning(ui) {
const warningKey = CONSTANTS.STORE_KEYS.WARNING_MSG_PATH;
const showKey = CONSTANTS.STORE_KEYS.WARNING_SHOW_PATH;
const cls = StyleDefinitions.COMMON_CLASSES;
const container = ui.create('div', { className: cls.warningBanner }, []);
// Bind content and visibility manually via observe
ui.observe([warningKey, showKey], (state) => {
const message = getPropertyByPath(state, warningKey);
const show = getPropertyByPath(state, showKey);
container.textContent = message || '';
container.style.display = show ? '' : 'none';
});
return container;
}
_setupObservers(ui) {
const p = `platforms.${PLATFORM}`;
const widthKey = `${p}.options.chat_content_max_width`;
// Watch for chat width changes to trigger preview
// Use UIBuilder's observe to auto-manage lifecycle
ui.observe(widthKey, (state) => {
const val = getPropertyByPath(state, widthKey);
// Trigger preview event
EventBus.publish(EVENTS.WIDTH_PREVIEW, val);
});
}
_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();
}
}
/**
* @private
* Collects data from Store and calls the onSave callback.
*/
async _handleDebouncedSave() {
if (!this.store) return;
const storeData = this.store.getStateRef();
const newConfig = sanitizeConfigForSave(storeData);
try {
await this.callbacks.onSave?.(newConfig);
} catch (e) {
Logger.error('UI', '', 'SettingsPanel save failed:', e);
}
}
_setupStaticListeners() {
const prefix = this.style.prefix;
// Bind actions for self-static buttons (Theme, JSON, etc.)
// Note: Use optional chaining or check existence as re-render might change IDs
const bindClick = (id, handler) => {
const btn = this.element.querySelector(`#${id}`);
if (btn) btn.onclick = handler;
};
bindClick(`${prefix}-theme-name`, () => {
if (this.activeThemeSet) {
let themeKey = this.activeThemeSet.metadata?.id;
if (themeKey === 'default') {
themeKey = CONSTANTS.THEME_IDS.DEFAULT;
}
this.callbacks.onShowThemeModal?.(themeKey || CONSTANTS.THEME_IDS.DEFAULT);
this.hide();
}
});
bindClick(`${prefix}-json-btn`, () => {
this.callbacks.onShowJsonModal?.();
this.hide();
});
bindClick(`${prefix}-edit-themes-btn`, () => {
this.callbacks.onShowThemeModal?.();
this.hide();
});
}
}
/**
* Manages the JSON editing modal by using the CustomModal component.
*/
class JsonModalComponent extends UIComponentBase {
static DEFAULTS = {
WIDTH: 'min(440px, 95vw)',
};
static RESOURCE_KEYS = {
DEBOUNCE_CALC: 'debounceCalc',
LISTENERS: 'listeners',
MODAL: 'modal',
FILE_READER: 'fileReader',
};
constructor(callbacks) {
super(callbacks);
this.modal = null;
this.styleHandle = null;
this.store = null;
this.isOpening = false;
// Debounced calculator is initialized in _onInit
}
_onInit() {
// Initialize the debounced size calculator as a managed resource
this.debouncedCalcSize = debounce(this._calculateAndSetSize.bind(this), CONSTANTS.TIMING.DEBOUNCE_DELAYS.SIZE_CALCULATION, true);
// Debounce the size calculation to avoid heavy operations on every keystroke
this.manageResource(JsonModalComponent.RESOURCE_KEYS.DEBOUNCE_CALC, this.debouncedCalcSize.cancel);
}
render() {
// No-op
}
async open(anchorElement) {
if (this.modal || this.isOpening) return;
this.isOpening = true;
try {
// Inject styles
StyleManager.request(StyleDefinitions.getCommonStyle);
StyleManager.request(StyleDefinitions.getModalStyle);
this.styleHandle = StyleManager.request(StyleDefinitions.getJsonModal);
// Common styles are now mixed in, so we use static references
const btnClass = StyleDefinitions.COMMON_CLASSES.modalButton;
const primaryBtnClass = StyleDefinitions.COMMON_CLASSES.primaryBtn;
const pushRightBtnClass = StyleDefinitions.COMMON_CLASSES.pushRightBtn;
const cls = this.styleHandle.classes;
// Initialize Data
const currentConfig = await this.callbacks.getCurrentConfig();
// Guard: Stop if component was destroyed during await
if (this.isDestroyed) return;
const initialJson = JSON.stringify(currentConfig, null, 2);
const currentWarning = this.callbacks.getCurrentWarning();
const { SYSTEM_ROOT, SYSTEM_WARNING } = CONSTANTS.STORE_KEYS;
this.store = new ReactiveStore({
jsonString: initialJson,
status: { text: '', color: '' },
sizeInfo: { text: 'Checking...', color: '' },
isProcessing: false,
[SYSTEM_ROOT]: { [SYSTEM_WARNING]: currentWarning },
});
// Prepare Listener Keys
const warningListenerKey = createEventKey(this, EVENTS.CONFIG_WARNING_UPDATE);
const configUpdateListenerKey = createEventKey(this, EVENTS.CONFIG_UPDATED);
// Register cleanup for listeners
const cleanupListeners = () => {
this.clearStoreSubscriptions();
EventBus.unsubscribe(EVENTS.CONFIG_WARNING_UPDATE, warningListenerKey);
EventBus.unsubscribe(EVENTS.CONFIG_UPDATED, configUpdateListenerKey);
this._cleanupListeners();
};
this.manageResource(JsonModalComponent.RESOURCE_KEYS.LISTENERS, cleanupListeners);
// Subscribe to jsonString changes to update size info
const sizeUnsub = this.store.subscribe((state, path) => {
if (path === 'jsonString') {
this.debouncedCalcSize(state.jsonString);
}
// Update Button states based on size info and processing status
if (path === 'sizeInfo' || path === 'jsonString' || path === 'isProcessing') {
const isProcessing = state.isProcessing;
const isExceeded = state.sizeInfo && state.sizeInfo.color === SITE_STYLES.PALETTE.danger_text;
const saveBtn = this.modal?.element?.querySelector(`#${cls.saveBtn}`);
const exportBtn = this.modal?.element?.querySelector(`#${cls.exportBtn}`);
const importBtn = this.modal?.element?.querySelector(`#${cls.importBtn}`);
const cancelBtn = this.modal?.element?.querySelector(`#${cls.cancelBtn}`);
// Disable other buttons during processing
if (exportBtn instanceof HTMLButtonElement) exportBtn.disabled = isProcessing;
if (importBtn instanceof HTMLButtonElement) importBtn.disabled = isProcessing;
if (cancelBtn instanceof HTMLButtonElement) cancelBtn.disabled = isProcessing;
if (saveBtn instanceof HTMLButtonElement) {
// Save is disabled if processing OR size exceeded
const shouldDisable = isProcessing || isExceeded;
saveBtn.disabled = shouldDisable;
if (isProcessing) {
saveBtn.style.opacity = '0.5';
saveBtn.style.cursor = 'wait';
} else if (isExceeded) {
saveBtn.title = 'Cannot save: Configuration size limit exceeded.';
saveBtn.style.opacity = '0.5';
saveBtn.style.cursor = 'not-allowed';
// Show warning status
this.store.set('status', {
text: 'Size Limit Exceeded: Save disabled.',
color: SITE_STYLES.PALETTE.danger_text,
});
} else {
saveBtn.title = 'Apply changes and close.';
saveBtn.style.opacity = '';
saveBtn.style.cursor = '';
// Clear warning status if it was set by this check
const currentStatus = this.store.get('status');
if (currentStatus && currentStatus.text === 'Size Limit Exceeded: Save disabled.') {
this.store.set('status', { text: '', color: '' });
}
}
}
// Update global cursor
if (this.modal?.element) {
this.modal.element.style.cursor = isProcessing ? 'wait' : '';
}
}
});
// Register the unsubscribe callback for cleanup
this.addStoreSubscription(sizeUnsub);
// Subscribe to warning updates
EventBus.subscribe(
EVENTS.CONFIG_WARNING_UPDATE,
(payload) => {
if (this.store) {
this.store.set(CONSTANTS.STORE_KEYS.WARNING_PATH, payload);
}
},
warningListenerKey
);
// Subscribe to remote configuration updates to support "Reload UI" functionality
EventBus.subscribe(
EVENTS.CONFIG_UPDATED,
async (newConfig) => {
const newJson = JSON.stringify(newConfig, null, 2);
if (this.store) {
this.store.set('jsonString', newJson);
// Reset status
this.store.set('status', {
text: 'Refreshed from storage.',
color: SITE_STYLES.PALETTE.accent_text,
});
}
// Clear conflict notification if present
if (this.modal && this.modal.dom.footerMessage) {
this.modal.dom.footerMessage.textContent = '';
this.modal.dom.footerMessage.classList.remove(StyleDefinitions.COMMON_CLASSES.conflictText);
}
},
configUpdateListenerKey
);
this.modal = new CustomModal({
title: `${APPNAME} Settings`,
width: JsonModalComponent.DEFAULTS.WIDTH, // Responsive width
id: this.styleHandle.rootId, // Use the rootId for scoping
zIndex: SITE_STYLES.Z_INDICES.JSON_MODAL,
closeOnBackdropClick: false,
buttons: [
{ text: 'Export', id: cls.exportBtn, className: btnClass, title: 'Export current settings to a JSON file.', onClick: () => this._handleExport() },
{ text: 'Import', id: cls.importBtn, className: btnClass, title: 'Click to replace settings.\nHold [Ctrl] to append themes (keep existing).', onClick: (modal, e) => this._handleImport(e.ctrlKey) },
{ text: 'Cancel', id: cls.cancelBtn, className: `${btnClass} ${pushRightBtnClass}`, title: 'Close without saving.', onClick: () => this.close() },
{ text: 'Save', id: cls.saveBtn, className: `${btnClass} ${primaryBtnClass}`, title: 'Apply changes and close.', onClick: () => this._handleSave() },
],
onCancel: null,
onDestroy: () => {
// When the modal is closed (by user or code), ensure all temporary resources are disposed via the manager.
this.manageResource(JsonModalComponent.RESOURCE_KEYS.MODAL, null);
this.manageResource(JsonModalComponent.RESOURCE_KEYS.LISTENERS, null);
this.manageResource(JsonModalComponent.RESOURCE_KEYS.FILE_READER, null);
this.store = null;
this.modal = null;
},
});
// Register Modal as a managed resource
this.manageResource(JsonModalComponent.RESOURCE_KEYS.MODAL, this.modal);
this._setupKeyboardListeners(cls, primaryBtnClass);
const contentContainer = this.modal.getContentContainer();
// --- UI Rendering using UIBuilder ---
const context = {
styles: { ...StyleDefinitions.COMMON_CLASSES, ...this.styleHandle.classes },
pickerRootId: StyleDefinitions.ROOT_IDS.COLOR_PICKER,
};
const ui = new UIBuilder(this.store, context, this.addStoreSubscription.bind(this));
contentContainer.style.padding = '8px';
const content = this._renderJsonContent(ui);
contentContainer.appendChild(content);
// Initial size calculation
this._calculateAndSetSize(initialJson);
this.callbacks.onModalOpen?.();
this.modal.show(anchorElement);
// Focus handling
requestAnimationFrame(() => {
const textarea = contentContainer.querySelector('textarea');
if (textarea) {
textarea.focus();
textarea.scrollTop = 0;
textarea.selectionStart = 0;
textarea.selectionEnd = 0;
}
});
} finally {
this.isOpening = false;
}
}
_renderJsonContent(ui) {
const cls = ui.context.styles;
const container = document.createDocumentFragment();
// 1. JSON Editor
const textarea = ui.create(
'textarea',
{
className: cls.jsonEditor,
spellcheck: false,
},
[]
);
// Manual binding for code editor
ui.observe('jsonString', (state) => {
// Prevent cursor jumping if user is typing
if (document.activeElement === textarea) return;
textarea.value = state.jsonString || '';
});
textarea.addEventListener('input', (e) => {
// Update store (which triggers size calculation)
ui.store.set('jsonString', e.target.value);
});
const editorField = ui.container([textarea], { className: cls.formField });
container.appendChild(editorField);
// 2. Status Bar
const statusMsg = ui.create('div', { className: 'status-msg-display' }, []);
const sizeInfo = ui.create('div', { className: 'size-info-display' }, []);
ui.observe(['status', 'sizeInfo'], (state) => {
const status = state.status || {};
const info = state.sizeInfo || {};
// Update Status Message
if (statusMsg.textContent !== (status.text || '')) {
statusMsg.textContent = status.text || '';
statusMsg.style.color = status.color || '';
}
// Update Size Info
if (sizeInfo.textContent !== (info.text || '')) {
sizeInfo.textContent = info.text || '';
sizeInfo.style.color = info.color || '';
sizeInfo.style.fontWeight = info.bold ? 'bold' : 'normal';
}
});
const statusRow = ui.container([statusMsg, sizeInfo], { className: cls.statusContainer });
container.appendChild(statusRow);
return container;
}
close() {
this.modal?.close();
}
_onDestroy() {
// BaseManager handles disposal of MODAL, LISTENERS, FILE_READER and DEBOUNCE_CALC via managed resources.
this.styleHandle = null;
this.store = null;
this.modal = null;
super._onDestroy();
}
_setupKeyboardListeners(cls, primaryBtnClass) {
let isCtrlPressed = false;
let isHovered = false;
let isFocused = false;
// Capture the button element immediately as it exists in the modal
const importBtn = this.modal?.element?.querySelector(`#${cls.importBtn}`);
if (!importBtn) return;
const updateButtonState = () => {
// Only switch to Append mode if Ctrl is pressed AND the button is being interacted with
const shouldAppend = isCtrlPressed && (isHovered || isFocused);
const currentText = importBtn.textContent;
const targetText = shouldAppend ? 'Append' : 'Import';
if (currentText !== targetText) {
importBtn.textContent = targetText;
importBtn.classList.toggle(primaryBtnClass, shouldAppend);
}
};
// Global key listener for Ctrl state
this._keyListener = (e) => {
if (e.key === 'Control') {
isCtrlPressed = e.type === 'keydown';
updateButtonState();
}
};
document.addEventListener('keydown', this._keyListener);
document.addEventListener('keyup', this._keyListener);
// Window focus listener to reset state (safety)
this._focusListener = () => {
isCtrlPressed = false;
updateButtonState();
};
window.addEventListener('focus', this._focusListener);
// Button-specific interaction listeners
// Note: These do not need explicit removal in _cleanupListeners because
// the button element itself is destroyed when the modal is closed.
importBtn.addEventListener('mouseenter', () => {
isHovered = true;
updateButtonState();
});
importBtn.addEventListener('mouseleave', () => {
isHovered = false;
updateButtonState();
});
importBtn.addEventListener('focus', () => {
isFocused = true;
updateButtonState();
});
importBtn.addEventListener('blur', () => {
isFocused = false;
updateButtonState();
});
}
_cleanupListeners() {
if (this._keyListener) {
document.removeEventListener('keydown', this._keyListener);
document.removeEventListener('keyup', this._keyListener);
this._keyListener = null;
}
if (this._focusListener) {
window.removeEventListener('focus', this._focusListener);
this._focusListener = null;
}
}
_calculateAndSetSize(text) {
if (!this.store) return;
let sizeInBytes = 0;
let isRaw = false;
try {
const obj = JSON.parse(text);
const minified = JSON.stringify(obj);
sizeInBytes = new TextEncoder().encode(minified).length;
} catch {
sizeInBytes = new TextEncoder().encode(text).length;
isRaw = true;
}
const sizeStr = this._formatBytes(sizeInBytes);
const recommendedStr = this._formatBytes(CONSTANTS.STORAGE_SETTINGS.CONFIG_SIZE_RECOMMENDED_LIMIT_BYTES);
const limitStr = this._formatBytes(CONSTANTS.STORAGE_SETTINGS.CONFIG_SIZE_LIMIT_BYTES);
const displayStr = `${isRaw ? '(Raw) ' : ''}${sizeStr} / ${recommendedStr} (Max: ${limitStr})`;
let color = '';
if (sizeInBytes >= CONSTANTS.STORAGE_SETTINGS.CONFIG_SIZE_LIMIT_BYTES) {
color = SITE_STYLES.PALETTE.danger_text;
} else if (sizeInBytes >= CONSTANTS.STORAGE_SETTINGS.CONFIG_SIZE_RECOMMENDED_LIMIT_BYTES) {
color = '#ff9800'; // Warning color
}
this.store.set('sizeInfo', {
text: displayStr,
color: color,
bold: !!color,
});
}
_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];
}
async _handleSave() {
if (!this.store) return;
this.store.set('isProcessing', true);
const jsonString = this.store.get('jsonString');
// Reset status
this.store.set('status', { text: '', color: '' });
try {
const obj = JSON.parse(jsonString);
await this.callbacks.onSave(obj);
this.close();
} catch (e) {
this.store.set('status', {
text: e.message,
color: SITE_STYLES.PALETTE.danger_text,
});
} finally {
if (this.store) {
this.store.set('isProcessing', false);
}
}
}
async _handleExport() {
if (!this.store) return;
this.store.set('status', { text: '', color: '' });
try {
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();
setTimeout(() => URL.revokeObjectURL(url), CONSTANTS.TIMING.TIMEOUTS.BLOB_URL_REVOKE_DELAY);
this.store.set('status', {
text: 'Export successful.',
color: SITE_STYLES.PALETTE.accent_text,
});
} catch (e) {
this.store.set('status', {
text: `Export failed: ${e.message}`,
color: SITE_STYLES.PALETTE.danger_text,
});
}
}
_handleImport(isAppend) {
if (!this.store) 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];
target.value = '';
if (!file) return;
const reader = new FileReader();
// Register FileReader as a managed resource to allow auto-abort on destroy/re-run
this.manageResource(JsonModalComponent.RESOURCE_KEYS.FILE_READER, reader);
reader.onload = async (e) => {
// Guard: Check if destroyed during file read
if (this.isDestroyed || !this.store) return;
this.store.set('status', { text: 'Processing...', color: '' });
this.store.set('isProcessing', true);
requestAnimationFrame(async () => {
// Guard: Check if destroyed during animation frame
if (this.isDestroyed || !this.store) return;
try {
const result = e.target?.result;
if (typeof result !== 'string') throw new Error('Invalid file');
const importedConfig = JSON.parse(result);
if (!importedConfig || typeof importedConfig !== 'object' || Array.isArray(importedConfig)) {
throw new Error('Invalid JSON structure');
}
let baseConfig;
if (isAppend) {
try {
// Append Mode: Use current editor content as base
const currentEditorJson = this.store.get('jsonString');
baseConfig = JSON.parse(currentEditorJson);
} catch {
throw new Error('Cannot append: Editor contains invalid JSON. Please fix it before appending.');
}
} else {
// Replace Mode: Use default config as base (reset)
baseConfig = DEFAULT_THEME_CONFIG;
}
const mergedConfig = deepClone(baseConfig);
if (!isAppend) mergedConfig.themeSets = [];
// --- Theme Import Logic ---
if (Array.isArray(importedConfig.themeSets)) {
if (isAppend) {
// Append Mode: Ignore Import IDs, Assign New IDs, Append All
importedConfig.themeSets.forEach((importedTheme) => {
if (!importedTheme || typeof importedTheme !== 'object') return;
if (!importedTheme.metadata) importedTheme.metadata = {};
// Always generate a new unique ID to avoid collision with existing themes
importedTheme.metadata.id = generateUniqueId('theme');
mergedConfig.themeSets.push(importedTheme);
});
} else {
// Replace Mode: Preserve Import IDs (dedupe within file only)
const processedIds = new Set();
importedConfig.themeSets.forEach((importedTheme) => {
if (!importedTheme || typeof importedTheme !== 'object') return;
if (!importedTheme.metadata) importedTheme.metadata = {};
if (!importedTheme.metadata.id) importedTheme.metadata.id = generateUniqueId('theme');
const id = importedTheme.metadata.id;
// If ID is duplicated within the import file itself, regenerate it
if (processedIds.has(id)) {
const newId = generateUniqueId('theme');
importedTheme.metadata.id = newId;
mergedConfig.themeSets.push(importedTheme);
processedIds.add(newId);
} else {
mergedConfig.themeSets.push(importedTheme);
processedIds.add(id);
}
});
}
}
['developer', 'platforms'].forEach((key) => {
if (importedConfig[key] !== undefined && isObject(importedConfig[key])) {
if (!mergedConfig[key]) mergedConfig[key] = {};
resolveConfig(mergedConfig[key], importedConfig[key]);
}
});
const finalConfig = deepClone(DEFAULT_THEME_CONFIG);
resolveConfig(finalConfig, mergedConfig);
ConfigProcessor.process(finalConfig);
const jsonString = JSON.stringify(finalConfig, null, 2);
// Update Store (which updates UI)
this.store.set('jsonString', jsonString);
const statusText = isAppend ? 'Import appended to current view. Repeat to add more, or click "Save" to apply.' : 'Import successful. Click "Save" to apply.';
this.store.set('status', {
text: statusText,
color: SITE_STYLES.PALETTE.accent_text,
});
} catch (err) {
this.store.set('status', {
text: `Import failed: ${err.message}`,
color: SITE_STYLES.PALETTE.danger_text,
});
} finally {
if (this.store) {
this.store.set('isProcessing', false);
}
// Clean up the file reader resource
this.manageResource(JsonModalComponent.RESOURCE_KEYS.FILE_READER, null);
}
});
};
this.store.set('status', { text: 'Reading file...', color: '' });
reader.readAsText(file);
},
});
if (fileInput instanceof HTMLElement) {
fileInput.click();
}
}
}
/**
* @class ThemeService
* @description Encapsulates business logic for theme operations (CRUD).
* Operates on AppConfig objects and returns updated states.
*/
class ThemeService {
/**
* Creates a new theme.
* @param {AppConfig} config The current configuration.
* @returns {{ config: AppConfig, newThemeId: string }}
*/
static create(config) {
const newConfig = deepClone(config);
const existingNames = new Set(newConfig.themeSets.map((t) => t.metadata.name?.trim().toLowerCase()));
const newName = proposeUniqueName('New Theme', existingNames);
const newId = generateUniqueId('theme');
// Use defaultSet as a template to ensure the correct structure (keys).
const defaultSet = newConfig.platforms[PLATFORM].defaultSet;
const emptyTheme = deepClone(defaultSet);
// Recursively set all configuration values to null to signify "inherit from default".
const nullify = (obj) => {
for (const [key, value] of Object.entries(obj)) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
nullify(value);
} else {
obj[key] = null;
}
}
};
nullify(emptyTheme);
/** @type {ThemeSet} */
const newTheme = {
...emptyTheme,
metadata: {
id: newId,
name: newName,
matchPatterns: [],
urlPatterns: [],
},
};
newConfig.themeSets.push(newTheme);
return { config: newConfig, newThemeId: newId };
}
/**
* Copies an existing theme.
* @param {AppConfig} config
* @param {string} sourceId
* @returns {{ config: AppConfig, newThemeId: string } | null}
*/
static copy(config, sourceId) {
const newConfig = deepClone(config);
const isDefault = sourceId === CONSTANTS.THEME_IDS.DEFAULT;
let sourceThemeContent;
let baseName;
let sourceMatchPatterns = [];
let sourceUrlPatterns = [];
if (isDefault) {
sourceThemeContent = newConfig.platforms[PLATFORM].defaultSet;
baseName = 'Default';
} else {
const found = newConfig.themeSets.find((t) => t.metadata.id === sourceId);
if (!found) return null;
sourceThemeContent = found;
baseName = found.metadata.name || 'Theme';
sourceMatchPatterns = found.metadata.matchPatterns || [];
sourceUrlPatterns = found.metadata.urlPatterns || [];
}
const existingNames = new Set(newConfig.themeSets.map((t) => t.metadata.name?.trim().toLowerCase()));
const newName = proposeUniqueName(baseName, existingNames);
const newId = generateUniqueId('theme');
// Construct new theme ensuring ThemeSet type compliance
/** @type {ThemeSet} */
const newTheme = {
...deepClone(sourceThemeContent),
metadata: {
id: newId,
name: newName,
matchPatterns: [...sourceMatchPatterns],
urlPatterns: [...sourceUrlPatterns],
},
};
let insertIndex = 0;
if (!isDefault) {
const currentIndex = newConfig.themeSets.findIndex((t) => t.metadata.id === sourceId);
if (currentIndex !== -1) {
insertIndex = currentIndex + 1;
}
}
newConfig.themeSets.splice(insertIndex, 0, newTheme);
return { config: newConfig, newThemeId: newId };
}
/**
* Deletes a theme.
* @param {AppConfig} config
* @param {string} themeId
* @returns {{ config: AppConfig, nextActiveId: string }}
*/
static delete(config, themeId) {
const newConfig = deepClone(config);
let nextActiveId = CONSTANTS.THEME_IDS.DEFAULT;
const currentIndex = newConfig.themeSets.findIndex((t) => t.metadata.id === themeId);
const currentLength = newConfig.themeSets.length;
if (currentLength > 1) {
if (currentIndex === currentLength - 1) {
nextActiveId = newConfig.themeSets[currentIndex - 1].metadata.id;
} else {
nextActiveId = newConfig.themeSets[currentIndex + 1].metadata.id;
}
}
newConfig.themeSets = newConfig.themeSets.filter((t) => t.metadata.id !== themeId);
return { config: newConfig, nextActiveId };
}
/**
* Moves a theme in the list.
* @param {AppConfig} config
* @param {string} themeId
* @param {number} direction -1 or 1
* @returns {AppConfig | null} Returns null if move is invalid
*/
static move(config, themeId, direction) {
if (themeId === CONSTANTS.THEME_IDS.DEFAULT) return null;
const newConfig = deepClone(config);
const currentIndex = newConfig.themeSets.findIndex((t) => t.metadata.id === themeId);
if (currentIndex === -1) return null;
const newIndex = currentIndex + direction;
if (newIndex < 0 || newIndex >= newConfig.themeSets.length) return null;
const item = newConfig.themeSets.splice(currentIndex, 1)[0];
newConfig.themeSets.splice(newIndex, 0, item);
return newConfig;
}
/**
* Renames a theme.
* @param {AppConfig} config
* @param {string} themeId
* @param {string} newName
* @returns {AppConfig}
* @throws {Error} If validation fails
*/
static rename(config, themeId, newName) {
const trimmedName = newName.trim();
if (!trimmedName) {
throw new Error('Theme name cannot be empty.');
}
const newConfig = deepClone(config);
const isNameTaken = newConfig.themeSets.some((t) => t.metadata.id !== themeId && t.metadata.name?.toLowerCase() === trimmedName.toLowerCase());
if (isNameTaken) {
throw new Error(`Name "${trimmedName}" is already in use.`);
}
const theme = newConfig.themeSets.find((t) => t.metadata.id === themeId);
if (theme) {
theme.metadata.name = trimmedName;
}
return newConfig;
}
}
/**
* Manages the Theme Settings modal by leveraging the CustomModal component.
*/
class ThemeModalComponent extends UIComponentBase {
static UI_MODES = {
NORMAL: 'NORMAL',
RENAMING: 'RENAMING_THEME',
DELETING: 'CONFIRM_DELETE',
};
static DEFAULTS = {
WIDTH: 'min(880px, 95vw)',
};
static RESOURCE_KEYS = {
MODAL: 'modal',
PREVIEW_CTRL: 'previewCtrl',
LISTENERS: 'listeners',
};
constructor(callbacks) {
super(callbacks);
this.modal = null;
this.dataConverter = callbacks.dataConverter;
this.checkSize = callbacks.checkSize;
this.store = null;
this.previewController = null;
this.style = null; // Style handle will be stored here
this.isOpening = false;
// Centralized state management
this.state = {
activeThemeKey: null,
uiMode: ThemeModalComponent.UI_MODES.NORMAL, // 'NORMAL', 'RENAMING_THEME', 'CONFIRM_DELETE'
pendingDeletionKey: null,
config: null, // Holds the working copy of the config
isSizeExceeded: false,
isSaving: false,
};
}
render() {
// No-op: DOM generation is delegated to CustomModal in open().
}
/**
* Opens the theme modal for the specified theme or default set.
* @param {string} [selectThemeKey] - The ID of the theme to select initially.
*/
async open(selectThemeKey) {
if (this.modal || this.isOpening) return;
this.isOpening = true;
try {
// 1. Request all necessary styles upfront
StyleManager.request(StyleDefinitions.getCommonStyle);
StyleManager.request(StyleDefinitions.getModalStyle);
this.style = StyleManager.request(StyleDefinitions.getThemeModal);
this.pickerStyle = StyleManager.request(StyleDefinitions.getColorPicker);
const initialConfig = await this.callbacks.getCurrentConfig();
// Guard: Stop if component was destroyed during await
if (this.isDestroyed) return;
if (!initialConfig) return;
const { isExceeded } = this.checkSize(initialConfig);
// Initialize state for the new session
this.state = {
activeThemeKey: selectThemeKey || CONSTANTS.THEME_IDS.DEFAULT,
uiMode: ThemeModalComponent.UI_MODES.NORMAL,
pendingDeletionKey: null,
config: deepClone(initialConfig), // Create a deep copy for editing
isSizeExceeded: isExceeded,
isSaving: false,
};
const primaryBtnClass = StyleDefinitions.COMMON_CLASSES.primaryBtn;
const cls = this.style.classes;
// Prepare Listener Keys
const warningListenerKey = createEventKey(this, EVENTS.CONFIG_WARNING_UPDATE);
const configUpdateListenerKey = createEventKey(this, EVENTS.CONFIG_UPDATED);
// Register cleanup for listeners
const cleanupListeners = () => {
this.clearStoreSubscriptions();
EventBus.unsubscribe(EVENTS.CONFIG_WARNING_UPDATE, warningListenerKey);
EventBus.unsubscribe(EVENTS.CONFIG_UPDATED, configUpdateListenerKey);
};
this.manageResource(ThemeModalComponent.RESOURCE_KEYS.LISTENERS, cleanupListeners);
// Subscribe to warning updates
EventBus.subscribe(
EVENTS.CONFIG_WARNING_UPDATE,
(payload) => {
if (this.store) {
this.store.set(CONSTANTS.STORE_KEYS.WARNING_PATH, payload);
}
},
warningListenerKey
);
// Subscribe to remote configuration updates to support "Reload UI" functionality
EventBus.subscribe(
EVENTS.CONFIG_UPDATED,
async (newConfig) => {
// Guard: Skip UI refresh if we are currently saving changes ourselves.
// This prevents resetting the scroll position and focus processing during "Apply".
if (this.state.isSaving) return;
// Update internal state with new config
this.state.config = deepClone(newConfig);
const sizeInfo = this.checkSize(newConfig);
this.state.isSizeExceeded = sizeInfo.isExceeded;
// Re-initialize form with current active key (or fallback if deleted)
const themeExists = this.state.activeThemeKey === CONSTANTS.THEME_IDS.DEFAULT || this.state.config.themeSets.some((t) => t.metadata.id === this.state.activeThemeKey);
if (!themeExists) {
this.state.activeThemeKey = CONSTANTS.THEME_IDS.DEFAULT;
}
// Update preview controller with new default set
if (this.previewController) {
this.previewController.setDefaultSet(newConfig.platforms[PLATFORM].defaultSet);
}
// Refresh form and UI
await this._initFormWithTheme(this.state.activeThemeKey);
this._renderUI();
// Clear conflict notification if present
if (this.modal && this.modal.dom.footerMessage) {
this.modal.dom.footerMessage.textContent = '';
this.modal.dom.footerMessage.classList.remove(StyleDefinitions.COMMON_CLASSES.conflictText);
}
},
configUpdateListenerKey
);
this.modal = new CustomModal({
title: `${APPNAME} - Theme settings`,
width: ThemeModalComponent.DEFAULTS.WIDTH,
id: this.style.rootId, // Use rootId from style definition
zIndex: CONSTANTS.Z_INDICES.THEME_MODAL,
closeOnBackdropClick: false,
buttons: [
{ text: 'Cancel', id: cls.cancelBtn, className: ``, title: 'Discard changes and close the modal.', onClick: () => this.close() },
{ text: 'Apply', id: cls.applyBtn, className: ``, title: 'Save changes and keep the modal open.', onClick: () => this._handleThemeAction(false) },
{ text: 'Save', id: cls.saveBtn, className: primaryBtnClass, title: 'Save changes and close the modal.', onClick: () => this._handleThemeAction(true) },
],
onCancel: (e) => {
// If not in normal mode, cancel the close event and revert the UI mode.
if (this.state.uiMode !== ThemeModalComponent.UI_MODES.NORMAL) {
e.preventDefault();
this._handleActionCancel();
}
},
onDestroy: () => {
// When the modal is closed (by user or code), ensure all temporary resources are disposed via the manager.
this.manageResource(ThemeModalComponent.RESOURCE_KEYS.MODAL, null);
this.manageResource(ThemeModalComponent.RESOURCE_KEYS.PREVIEW_CTRL, null);
this.manageResource(ThemeModalComponent.RESOURCE_KEYS.LISTENERS, null);
this.store = null;
this.modal = null;
this.previewController = null;
// Release memory: Clear the temporary state containing the config copy
this.state = {
activeThemeKey: null,
uiMode: ThemeModalComponent.UI_MODES.NORMAL,
pendingDeletionKey: null,
config: null,
isSizeExceeded: false,
isSaving: false,
};
},
});
// Register Modal as a managed resource
this.manageResource(ThemeModalComponent.RESOURCE_KEYS.MODAL, this.modal);
const headerControls = this._createHeaderControls();
const mainContent = this._createMainContent();
// Override base modal styles for specific layout needs
Object.assign(this.modal.dom.header.style, {
borderBottom: `1px solid ${SITE_STYLES.PALETTE.border}`,
paddingBottom: CONSTANTS.UI_SPECS.THEME_MODAL_HEADER_PADDING,
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
gap: '12px',
});
Object.assign(this.modal.dom.footer.style, {
borderTop: `1px solid ${SITE_STYLES.PALETTE.border}`,
paddingTop: CONSTANTS.UI_SPECS.THEME_MODAL_FOOTER_PADDING,
});
this.modal.dom.header.appendChild(headerControls);
this.modal.setContent(mainContent);
this._setupEventListeners();
// Initialize Store and Form Engine with current theme
await this._initFormWithTheme(this.state.activeThemeKey);
// Connect Controller to DOM
this.previewController = new ThemePreviewController(this.modal.element, this.store, initialConfig.platforms[PLATFORM].defaultSet);
this.manageResource(ThemeModalComponent.RESOURCE_KEYS.PREVIEW_CTRL, this.previewController);
// Sync initial mode.
this.previewController.setIsEditingDefault(this.state.activeThemeKey === CONSTANTS.THEME_IDS.DEFAULT);
this.callbacks.onModalOpen?.();
this._renderUI(); // Update header controls
this.modal.show(null);
requestAnimationFrame(() => {
if (this.isDestroyed || !this.modal || !this.modal.element) return;
const scrollableArea = this.modal.element.querySelector(`.${this.style.classes.scrollableArea}`);
if (scrollableArea) scrollableArea.scrollTop = 0;
});
} finally {
this.isOpening = false;
}
}
_onDestroy() {
// BaseManager handles disposal of managed resources.
// Only non-managed references need explicit clearing here.
this.style = null;
this.pickerStyle = null;
super._onDestroy();
}
close() {
this.modal?.close();
}
_getCurrentThemeData(config, key) {
if (key === CONSTANTS.THEME_IDS.DEFAULT) {
return config.platforms[PLATFORM].defaultSet;
}
const found = config.themeSets.find((t) => t.metadata.id === key);
return found || {};
}
async _initFormWithTheme(themeKey) {
// Guard: Check if destroyed before proceeding (especially if called after async op)
if (this.isDestroyed || !this.state.config) return;
// Clear old subscriptions before re-initializing the form to prevent memory leaks
this.clearStoreSubscriptions();
const initialTheme = this._getCurrentThemeData(this.state.config, themeKey);
const currentWarning = this.callbacks.getCurrentWarning();
const { SYSTEM_ROOT, SYSTEM_WARNING } = CONSTANTS.STORE_KEYS;
// Merge system state into the theme data
const storeState = {
...initialTheme,
[SYSTEM_ROOT]: { [SYSTEM_WARNING]: currentWarning },
};
if (!this.store) {
this.store = new ReactiveStore(storeState);
} else {
this.store.replaceState(storeState);
}
const contentArea = this.modal.element.querySelector(`.${this.style.classes.content}`);
if (contentArea) {
const context = {
styles: {
...StyleDefinitions.COMMON_CLASSES,
...this.style.classes,
...this.pickerStyle.classes,
},
siteStyles: SITE_STYLES,
fileHandler: this._handleLocalFileSelect.bind(this),
pickerRootId: StyleDefinitions.ROOT_IDS.COLOR_PICKER,
};
const ui = new UIBuilder(this.store, context, this.addStoreSubscription.bind(this));
// Render the entire form structure
const formContent = this._renderThemeForm(ui, themeKey);
contentArea.replaceChildren(formContent);
// Add error monitoring to auto-clear footer message
this.addStoreSubscription(
this.store.subscribe((state, path) => {
// Check if the update is related to errors
if (path && path.startsWith(CONSTANTS.STORE_KEYS.ERRORS_PATH)) {
const errors = getPropertyByPath(state, CONSTANTS.STORE_KEYS.ERRORS_PATH) || {};
// Check if any error still exists
const hasError = Object.values(errors).some((val) => val && val.message);
const footerMessage = this.modal?.dom?.footerMessage;
// Only clear if it matches the validation error message
if (footerMessage && !hasError && footerMessage.textContent === 'Please correct the errors above.') {
footerMessage.textContent = '';
}
}
})
);
// Re-initialize preview controller to bind to new DOM elements
// First, dispose the old one
this.manageResource(ThemeModalComponent.RESOURCE_KEYS.PREVIEW_CTRL, null);
// Create new one with the current store and default set
this.previewController = new ThemePreviewController(this.modal.element, this.store, this.state.config.platforms[PLATFORM].defaultSet);
// Sync mode
this.previewController.setIsEditingDefault(themeKey === CONSTANTS.THEME_IDS.DEFAULT);
this.manageResource(ThemeModalComponent.RESOURCE_KEYS.PREVIEW_CTRL, this.previewController);
}
}
/**
* Retrieves the schema definition securely with logging and fallback.
* @param {string} key - The config key (e.g. 'window.backgroundSize').
* @returns {object} The schema definition or a fallback object.
* @private
*/
_getSchemaDef(key) {
const def = CONFIG_SCHEMA.theme[key];
if (!def) {
Logger.warn('ThemeModal', '', `Schema definition not found for ${key}`);
return { ui: { label: 'Unknown', tooltip: '' }, def: { options: [], unit: '' } };
}
return def;
}
/**
* @private
* Renders the theme editor form using UIBuilder.
*/
_renderThemeForm(ui, themeKey) {
const isNotDefault = themeKey !== CONSTANTS.THEME_IDS.DEFAULT;
const fragments = [];
// 1. General Settings (Patterns) - Only for custom themes
if (isNotDefault) {
const matchSchema = this._getSchemaDef('metadata.matchPatterns');
const urlSchema = this._getSchemaDef('metadata.urlPatterns');
const patternsGroup = ui.container(
[
ui.textarea('metadata.matchPatterns', matchSchema.ui.label, {
tooltip: matchSchema.ui.tooltip,
rows: 3,
transformValue: (val) => (val ? val.split('\n') : []),
toInputValue: (val) => (Array.isArray(val) ? val.join('\n') : val || ''),
}),
ui.textarea('metadata.urlPatterns', urlSchema.ui.label, {
tooltip: urlSchema.ui.tooltip,
rows: 3,
transformValue: (val) => (val ? val.split('\n') : []),
toInputValue: (val) => (Array.isArray(val) ? val.join('\n') : val || ''),
}),
],
{ className: 'compoundFormFieldContainer' }
); // Grid layout for patterns
fragments.push(ui.container([patternsGroup], { className: 'generalSettings' }));
fragments.push(ui.separator({ className: 'separator' }));
}
// 2. Actor & Window Settings Grid
const gridContent = ui.container([this._renderActorGroup(ui, 'assistant', 'Assistant'), this._renderActorGroup(ui, 'user', 'User'), this._renderWindowGroup(ui), this._renderInputGroup(ui)], { className: 'grid' });
fragments.push(gridContent);
return ui.container(fragments, { className: 'scrollableArea' });
}
_renderActorGroup(ui, actor, title) {
const prefix = actor;
// Helper to retrieve schema definition locally
const getSchema = (prop) => this._getSchemaDef(`${actor}.${prop}`);
const paddingProps = ConfigProcessor.getSliderProps(`${actor}.bubblePadding`);
const radiusProps = ConfigProcessor.getSliderProps(`${actor}.bubbleBorderRadius`);
const widthProps = ConfigProcessor.getSliderProps(`${actor}.bubbleMaxWidth`);
// Retrieve schemas
const nameS = getSchema('name');
const iconS = getSchema('icon');
const imgS = getSchema('standingImageUrl');
const bgS = getSchema('bubbleBackgroundColor');
const textS = getSchema('textColor');
const fontS = getSchema('font');
const padS = getSchema('bubblePadding');
const radS = getSchema('bubbleBorderRadius');
const maxWS = getSchema('bubbleMaxWidth');
return ui.group(
title,
[
ui.text(`${prefix}.name`, nameS.ui.label, { tooltip: nameS.ui.tooltip }),
ui.text(`${prefix}.icon`, iconS.ui.label, { tooltip: iconS.ui.tooltip, fieldType: iconS.def?.imageType || 'icon' }),
ui.text(`${prefix}.standingImageUrl`, imgS.ui.label, { tooltip: imgS.ui.tooltip, fieldType: imgS.def?.imageType || 'image' }),
ui.group(
'Bubble Settings',
[
ui.color(`${prefix}.bubbleBackgroundColor`, bgS.ui.label, { tooltip: bgS.ui.tooltip }),
ui.color(`${prefix}.textColor`, textS.ui.label, { tooltip: textS.ui.tooltip }),
ui.text(`${prefix}.font`, fontS.ui.label, { tooltip: fontS.ui.tooltip }),
// Compound container for Padding & Radius
ui.container(
[
ui.range(`${prefix}.bubblePadding`, padS.ui.label, paddingProps.min, paddingProps.max, {
step: paddingProps.step,
tooltip: padS.ui.tooltip,
containerClass: 'sliderSubgroup',
transformValue: paddingProps.transformValue,
toInputValue: paddingProps.toInputValue,
valueLabelFormatter: (val) => (val === null ? 'Auto' : `${val}${padS.def.unit}`),
}),
ui.range(`${prefix}.bubbleBorderRadius`, radS.ui.label, radiusProps.min, radiusProps.max, {
step: radiusProps.step,
tooltip: radS.ui.tooltip,
containerClass: 'sliderSubgroup',
transformValue: radiusProps.transformValue,
toInputValue: radiusProps.toInputValue,
valueLabelFormatter: (val) => (val === null ? 'Auto' : `${val}${radS.def.unit}`),
}),
],
{ className: 'compoundSliderContainer' }
),
ui.range(`${prefix}.bubbleMaxWidth`, maxWS.ui.label, widthProps.min, widthProps.max, {
step: widthProps.step,
tooltip: maxWS.ui.tooltip,
containerClass: 'sliderContainer',
transformValue: widthProps.transformValue,
toInputValue: widthProps.toInputValue,
valueLabelFormatter: (val) => (val === null ? 'Auto' : `${val}${maxWS.def.unit}`),
}),
ui.separator({ className: 'separator' }),
this._renderPreview(ui, actor),
],
{}
),
],
{}
);
}
_renderWindowGroup(ui) {
const bgKey = 'window.backgroundColor';
const bgSchema = this._getSchemaDef(bgKey);
const imgKey = 'window.backgroundImageUrl';
const imgSchema = this._getSchemaDef(imgKey);
const sizeKey = 'window.backgroundSize';
const sizeSchema = this._getSchemaDef(sizeKey);
const posKey = 'window.backgroundPosition';
const posSchema = this._getSchemaDef(posKey);
const repeatKey = 'window.backgroundRepeat';
const repeatSchema = this._getSchemaDef(repeatKey);
return ui.group(
'Background',
[
ui.color(bgKey, bgSchema.ui.label, { tooltip: bgSchema.ui.tooltip }),
ui.text(imgKey, imgSchema.ui.label, { tooltip: imgSchema.ui.tooltip, fieldType: imgSchema.def?.imageType }),
ui.container(
[
ui.select(sizeKey, sizeSchema.ui.label, {
options: sizeSchema.def.options,
tooltip: sizeSchema.ui.tooltip,
showLabel: true,
}),
ui.select(posKey, posSchema.ui.label, {
options: posSchema.def.options,
tooltip: posSchema.ui.tooltip,
showLabel: true,
}),
],
{ className: 'compoundFormFieldContainer' }
),
ui.container(
[
ui.select(repeatKey, repeatSchema.ui.label, {
options: repeatSchema.def.options,
tooltip: repeatSchema.ui.tooltip,
showLabel: true,
}),
this._renderPreviewBackground(ui),
],
{ className: 'compoundFormFieldContainer' }
),
],
{}
);
}
_renderInputGroup(ui) {
const bgKey = 'inputArea.backgroundColor';
const bgSchema = this._getSchemaDef(bgKey);
const textKey = 'inputArea.textColor';
const textSchema = this._getSchemaDef(textKey);
return ui.group(
'Input area',
[ui.color(bgKey, bgSchema.ui.label, { tooltip: bgSchema.ui.tooltip }), ui.color(textKey, textSchema.ui.label, { tooltip: textSchema.ui.tooltip }), ui.separator({ className: 'separator' }), this._renderPreviewInput(ui)],
{}
);
}
_renderPreview(ui, actor) {
const cls = ui.context.styles;
const wrapperClass = actor === 'user' ? `${cls.previewBubbleWrapper} ${cls.userPreview}` : cls.previewBubbleWrapper;
return ui.create('div', { className: cls.previewContainer }, [
ui.create('label', {}, 'Preview:'),
ui.create('div', { className: wrapperClass }, [ui.create('div', { className: cls.previewBubble, dataset: { [CONSTANTS.DATA_KEYS.PREVIEW_FOR]: actor } }, [ui.create('span', {}, 'Sample Text')])]),
]);
}
_renderPreviewInput(ui) {
const cls = ui.context.styles;
return ui.create('div', { className: cls.previewContainer }, [
ui.create('label', {}, 'Preview:'),
ui.create('div', { className: cls.previewBubbleWrapper }, [ui.create('div', { className: cls.previewInputArea, dataset: { [CONSTANTS.DATA_KEYS.PREVIEW_FOR]: 'inputArea' } }, [ui.create('span', {}, 'Sample input text')])]),
]);
}
_renderPreviewBackground(ui) {
const cls = ui.context.styles;
return ui.create('div', { className: cls.formField }, [
ui.create('label', {}, 'BG Preview:'),
ui.create('div', { className: cls.previewBubbleWrapper, style: { padding: '0', minHeight: '0' } }, [ui.create('div', { className: cls.previewBackground, dataset: { [CONSTANTS.DATA_KEYS.PREVIEW_FOR]: 'window' } }, [])]),
]);
}
_setupEventListeners() {
if (!this.modal) return;
const modalElement = this.modal.element;
const cls = this.style.classes;
modalElement.addEventListener('click', (e) => {
const target = e.target.closest('button');
if (!target) return;
const actionMap = {
[`${cls.newBtn}`]: () => this._handleThemeNew(),
[`${cls.copyBtn}`]: () => this._handleThemeCopy(),
[`${cls.deleteBtn}`]: () => this._handleDeleteClick(),
[`${cls.deleteConfirmBtn}`]: () => this._handleThemeDeleteConfirm(),
[`${cls.deleteCancelBtn}`]: () => this._handleActionCancel(),
[`${cls.upBtn}`]: () => this._handleThemeMove(-1),
[`${cls.downBtn}`]: () => this._handleThemeMove(1),
[`${cls.renameBtn}`]: () => this._handleRenameClick(),
[`${cls.renameOkBtn}`]: () => this._handleRenameConfirm(),
[`${cls.renameCancelBtn}`]: () => this._handleActionCancel(),
};
const action = actionMap[target.id];
if (action) action();
});
modalElement.addEventListener('change', (e) => {
const target = e.target;
if (!(target instanceof HTMLElement)) return;
if (target.matches(`#${cls.themeSelect}`) && target instanceof HTMLSelectElement) {
this.state.activeThemeKey = target.value;
this._initFormWithTheme(this.state.activeThemeKey);
this._renderUI();
}
});
modalElement.addEventListener('mouseover', (e) => {
const target = e.target;
if (!(target instanceof HTMLElement)) return;
if (target.matches('input[type="text"], textarea') && (target.offsetWidth < target.scrollWidth || target.offsetHeight < target.scrollHeight)) {
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
target.title = target.value;
}
}
});
modalElement.addEventListener('mouseout', (e) => {
const target = e.target;
if (!(target instanceof HTMLElement)) return;
if (target.matches('input[type="text"], textarea')) target.title = '';
});
modalElement.addEventListener('keydown', (e) => {
const target = e.target;
if (!(target instanceof HTMLElement)) return;
if (target.matches(`#${cls.renameInput}`)) {
if (e.key === 'Enter') {
e.preventDefault();
this._handleRenameConfirm();
}
}
});
}
async _handleLocalFileSelect(targetKey, callbacks) {
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;
try {
if (callbacks && callbacks.onStart) {
callbacks.onStart();
}
const options = this._getImageOptions(targetKey, this.state.config);
const dataUrl = await this.dataConverter.imageToOptimizedDataUrl(file, options);
// Guard: Check if destroyed or closed after async op
if (this.isDestroyed || !this.store) return;
if (this.store) {
this.store.set(targetKey, dataUrl);
}
if (callbacks && callbacks.onSuccess) {
callbacks.onSuccess();
}
} catch (error) {
// Check abort condition before logging
if (this.isDestroyed || !this.store) return;
Logger.error('IMAGE PROC FAILED', LOG_STYLES.RED, 'Image processing failed:', error);
if (callbacks && callbacks.onError) {
callbacks.onError(`Error: ${error.message}`);
}
}
};
if (fileInput instanceof HTMLElement) {
fileInput.click();
}
}
_getImageOptions(targetKey, config) {
const quality = CONSTANTS.IMAGE_PROCESSING.QUALITY;
// targetKey is the full config path string
if (targetKey.includes('backgroundImageUrl')) {
return { maxWidth: CONSTANTS.IMAGE_PROCESSING.MAX_WIDTH_BG, quality };
}
if (targetKey.includes('standingImageUrl')) {
return { maxHeight: CONSTANTS.IMAGE_PROCESSING.MAX_HEIGHT_STANDING, quality };
}
if (targetKey.includes('icon')) {
const iconSize = config?.platforms?.[PLATFORM]?.options?.icon_size ?? CONSTANTS.UI_SPECS.AVATAR.DEFAULT_SIZE;
return { maxWidth: iconSize, maxHeight: iconSize, quality };
}
return { quality };
}
_renderUI() {
if (!this.modal) return;
const { uiMode, activeThemeKey, config, isSizeExceeded, isSaving } = this.state;
const cls = this.style.classes;
const isDefault = activeThemeKey === CONSTANTS.THEME_IDS.DEFAULT;
const isRenaming = uiMode === ThemeModalComponent.UI_MODES.RENAMING;
const isDeleting = uiMode === ThemeModalComponent.UI_MODES.DELETING;
const headerRow = this.modal.element.querySelector(`.${cls.headerRow}`);
// --- UI Element References ---
const label = headerRow.querySelector('label');
const select = headerRow.querySelector('select');
const renameInput = headerRow.querySelector('input[type="text"]');
const mainActions = headerRow.querySelector(`#${cls.mainActionsId}`);
const renameActions = headerRow.querySelector(`#${cls.renameActionsId}`);
const deleteConfirmGroup = headerRow.querySelector(`#${cls.deleteConfirmGroup}`);
// --- Toggle visibility based on mode ---
select.style.display = isRenaming ? 'none' : 'block';
renameInput.style.display = isRenaming ? 'block' : 'none';
mainActions.style.visibility = uiMode === ThemeModalComponent.UI_MODES.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;
const options = [h('option', { value: CONSTANTS.THEME_IDS.DEFAULT }, 'Default Settings')];
config.themeSets.forEach((theme, index) => {
const rawName = (theme.metadata?.name || '').trim() || `Theme ${index + 1}`;
// Display index to help user identify position (UI only, does not affect data)
const displayName = `${index + 1}. ${rawName}`;
options.push(h('option', { value: theme.metadata.id }, displayName));
});
select.replaceChildren(...options);
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 !== ThemeModalComponent.UI_MODES.NORMAL || isSaving;
const index = config.themeSets.findIndex((t) => t.metadata.id === activeThemeKey);
// Block theme selection during actions
select.disabled = isActionInProgress;
if (label) label.style.opacity = isActionInProgress ? '0.5' : '';
// Block structural changes if size is exceeded
headerRow.querySelector(`#${cls.upBtn}`).disabled = isActionInProgress || isDefault || index <= 0 || isSizeExceeded;
headerRow.querySelector(`#${cls.downBtn}`).disabled = isActionInProgress || isDefault || index >= config.themeSets.length - 1 || isSizeExceeded;
headerRow.querySelector(`#${cls.deleteBtn}`).disabled = isActionInProgress || isDefault; // Delete always allowed
headerRow.querySelector(`#${cls.newBtn}`).disabled = isActionInProgress || isSizeExceeded;
headerRow.querySelector(`#${cls.copyBtn}`).disabled = isActionInProgress || isSizeExceeded;
headerRow.querySelector(`#${cls.renameBtn}`).disabled = isActionInProgress || isDefault || isSizeExceeded;
// --- Disable content areas and footer buttons during actions ---
// Access DOM via engine elements if needed, or query global class
const scrollArea = this.modal.element.querySelector(`.${cls.scrollableArea}`);
if (scrollArea) scrollArea.classList.toggle('is-disabled', isActionInProgress);
this.modal.element.querySelector(`#${cls.applyBtn}`).disabled = isActionInProgress;
this.modal.element.querySelector(`#${cls.saveBtn}`).disabled = isActionInProgress;
this.modal.element.querySelector(`#${cls.cancelBtn}`).disabled = isActionInProgress;
// --- Show warning in footer if exceeded ---
const footerMessage = this.modal.dom.footerMessage;
if (footerMessage) {
if (isSizeExceeded) {
footerMessage.textContent = 'Configuration size limit exceeded. Please reduce size via JSON or Delete.';
footerMessage.style.color = SITE_STYLES.PALETTE.error_text;
} else if (!footerMessage.classList.contains(StyleDefinitions.COMMON_CLASSES.conflictText)) {
// Clear only if it's not a conflict message
footerMessage.textContent = '';
}
}
}
_createHeaderControls() {
const cls = this.style.classes;
const commonBtn = StyleDefinitions.COMMON_CLASSES.modalButton;
const commonInput = StyleDefinitions.COMMON_CLASSES.commonInput;
const dangerBtn = StyleDefinitions.COMMON_CLASSES.dangerBtn;
const moveBtnClass = `${commonBtn} ${cls.moveBtn}`;
const deleteConfirmBtnClass = `${commonBtn} ${dangerBtn}`;
return h(`div.${cls.headerControls}`, [
h(`div.${cls.headerRow}`, [
h('label', { htmlFor: cls.themeSelect }, 'Theme:'),
h(`div.${cls.renameArea}`, [h(`select#${cls.themeSelect}.${commonInput}`), h('input', { type: 'text', id: cls.renameInput, className: commonInput, style: { display: 'none' } })]),
h(`div.${cls.actionArea}`, [
h(`div#${cls.mainActionsId}`, [
h(`button#${cls.renameBtn}.${commonBtn}`, 'Rename'),
h('button', { id: cls.upBtn, className: moveBtnClass }, [createIconFromDef(StyleDefinitions.ICONS.arrowUp)]),
h('button', { id: cls.downBtn, className: moveBtnClass }, [createIconFromDef(StyleDefinitions.ICONS.arrowDown)]),
h(`button#${cls.newBtn}.${commonBtn}`, 'New'),
h(`button#${cls.copyBtn}.${commonBtn}`, 'Copy'),
h(`button#${cls.deleteBtn}.${commonBtn}`, 'Delete'),
]),
h(`div#${cls.renameActionsId}`, { style: { display: 'none' } }, [h(`button#${cls.renameOkBtn}.${commonBtn}`, 'OK'), h(`button#${cls.renameCancelBtn}.${commonBtn}`, 'Cancel')]),
h('div', { id: cls.deleteConfirmGroup, className: cls.deleteConfirmGroup, style: { display: 'none' } }, [
h(`span.${cls.deleteConfirmLabel}`, 'Are you sure?'),
h('button', { id: cls.deleteConfirmBtn, className: deleteConfirmBtnClass }, 'Confirm Delete'),
h(`button#${cls.deleteCancelBtn}.${commonBtn}`, 'Cancel'),
]),
]),
]),
]);
}
_createMainContent() {
// Container for UIBuilder output
return h(`div.${this.style.classes.content}`);
}
async _saveConfigAndHandleFeedback(newConfig) {
// Guard: Check if destroyed before proceeding (modal might be closed)
if (!this.modal || this.isDestroyed) return false;
const footerMessage = this.modal.dom.footerMessage;
if (footerMessage) footerMessage.textContent = '';
try {
await this.callbacks.onSave(newConfig);
// Guard: Check if destroyed during await
if (!this.modal || this.isDestroyed) return false;
this.state.config = deepClone(newConfig);
// Re-calculate size state after successful save (e.g. deletion might remove exceeded state)
const sizeInfo = this.checkSize(newConfig);
this.state.isSizeExceeded = sizeInfo.isExceeded;
// Update the preview controller with the latest default set.
if (this.previewController) {
this.previewController.setDefaultSet(newConfig.platforms[PLATFORM].defaultSet);
}
return true;
} catch (e) {
// Check if still valid before updating UI
if (this.modal && footerMessage) {
footerMessage.textContent = e.message;
footerMessage.style.color = SITE_STYLES.PALETTE.error_text;
}
return false;
}
}
async _handleThemeAction(shouldClose) {
const footerMessage = this.modal?.dom?.footerMessage;
// Guard: Do not clear the footer message if it is a conflict warning (waiting for reload)
if (footerMessage && !footerMessage.classList.contains(StyleDefinitions.COMMON_CLASSES.conflictText)) {
footerMessage.textContent = '';
}
// Get current form data from Store and sanitize it
const storeData = this.store.getStateRef();
let themeData = sanitizeConfigForSave(storeData);
// Normalize data (remove empty patterns) before validation and saving
themeData = ConfigProcessor.normalize(themeData);
const validationResult = ConfigProcessor.validate(themeData, this.state.activeThemeKey === CONSTANTS.THEME_IDS.DEFAULT);
if (!validationResult.isValid) {
// Map validation errors to store to trigger UI updates
validationResult.errors.forEach((err) => {
this.store.set(`${CONSTANTS.STORE_KEYS.ERRORS_PATH}.${err.field}`, { message: err.message });
});
// Also show a summary in the footer
if (footerMessage) {
footerMessage.textContent = 'Please correct the errors above.';
footerMessage.style.color = SITE_STYLES.PALETTE.error_text;
}
return;
}
// Lock UI
this.state.isSaving = true;
this._renderUI();
const newConfig = deepClone(this.state.config);
if (this.state.activeThemeKey === CONSTANTS.THEME_IDS.DEFAULT) {
// Update defaultSet
resolveConfig(newConfig.platforms[PLATFORM].defaultSet, themeData);
delete newConfig.platforms[PLATFORM].defaultSet.metadata;
} else {
const index = newConfig.themeSets.findIndex((t) => t.metadata.id === this.state.activeThemeKey);
if (index !== -1) {
// Update specific theme, preserving metadata not in form (id, name)
const existingMetadata = newConfig.themeSets[index].metadata;
themeData.metadata = {
...existingMetadata,
matchPatterns: themeData.metadata.matchPatterns,
urlPatterns: themeData.metadata.urlPatterns,
};
newConfig.themeSets[index] = themeData;
}
}
const success = await this._saveConfigAndHandleFeedback(newConfig);
if (success && shouldClose) {
this.close();
} else {
// Unlock UI (if still open)
this.state.isSaving = false;
this._renderUI();
}
}
async _handleThemeNew() {
const { config } = this.state;
const { config: newConfig, newThemeId } = ThemeService.create(config);
// Check size before proceeding
const { isExceeded } = this.checkSize(newConfig);
if (isExceeded) {
const footerMessage = this.modal?.dom?.footerMessage;
if (footerMessage) {
footerMessage.textContent = 'Cannot create new theme: Configuration size limit exceeded.';
footerMessage.style.color = SITE_STYLES.PALETTE.error_text;
}
return;
}
const success = await this._saveConfigAndHandleFeedback(newConfig);
if (success) {
this.state.activeThemeKey = newThemeId;
this.state.uiMode = ThemeModalComponent.UI_MODES.RENAMING;
await this._initFormWithTheme(this.state.activeThemeKey);
this._renderUI();
const input = this.modal.element.querySelector(`#${this.style.classes.renameInput}`);
if (input) {
input.focus();
input.select();
}
}
}
async _handleThemeCopy() {
const { config, activeThemeKey } = this.state;
const result = ThemeService.copy(config, activeThemeKey);
if (!result) return;
const { config: newConfig, newThemeId } = result;
// Check size before proceeding
const { isExceeded } = this.checkSize(newConfig);
if (isExceeded) {
const footerMessage = this.modal?.dom?.footerMessage;
if (footerMessage) {
footerMessage.textContent = 'Cannot copy theme: Configuration size limit exceeded.';
footerMessage.style.color = SITE_STYLES.PALETTE.error_text;
}
return;
}
const success = await this._saveConfigAndHandleFeedback(newConfig);
if (success) {
this.state.activeThemeKey = newThemeId;
await this._initFormWithTheme(this.state.activeThemeKey);
this._renderUI();
}
}
async _handleThemeMove(direction) {
const { config, activeThemeKey } = this.state;
const newConfig = ThemeService.move(config, activeThemeKey, direction);
if (newConfig) {
const success = await this._saveConfigAndHandleFeedback(newConfig);
if (success) {
this._renderUI();
}
}
}
_handleRenameClick() {
this.state.uiMode = ThemeModalComponent.UI_MODES.RENAMING;
this._renderUI();
const input = this.modal.element.querySelector(`#${this.style.classes.renameInput}`);
if (input) {
input.focus();
input.select();
}
}
async _handleRenameConfirm() {
const { config, activeThemeKey } = this.state;
const footerMessage = this.modal?.dom?.footerMessage;
if (footerMessage) footerMessage.textContent = '';
const input = this.modal.element.querySelector(`#${this.style.classes.renameInput}`);
const newName = input.value;
try {
const newConfig = ThemeService.rename(config, activeThemeKey, newName);
const success = await this._saveConfigAndHandleFeedback(newConfig);
if (success) {
this.state.uiMode = ThemeModalComponent.UI_MODES.NORMAL;
this._renderUI();
}
} catch (e) {
if (footerMessage) {
footerMessage.textContent = e.message;
footerMessage.style.color = SITE_STYLES.PALETTE.error_text;
}
}
}
_handleDeleteClick() {
this.state.uiMode = ThemeModalComponent.UI_MODES.DELETING;
this.state.pendingDeletionKey = this.state.activeThemeKey;
this._renderUI();
}
async _handleThemeDeleteConfirm() {
const { config, pendingDeletionKey } = this.state;
if (pendingDeletionKey === CONSTANTS.THEME_IDS.DEFAULT || !pendingDeletionKey) {
this._handleActionCancel();
return;
}
const { config: newConfig, nextActiveId } = ThemeService.delete(config, pendingDeletionKey);
const success = await this._saveConfigAndHandleFeedback(newConfig);
if (success) {
this.state.activeThemeKey = nextActiveId;
this.state.pendingDeletionKey = null;
this.state.uiMode = ThemeModalComponent.UI_MODES.NORMAL;
await this._initFormWithTheme(nextActiveId);
this._renderUI();
}
}
_handleActionCancel() {
this.state.uiMode = ThemeModalComponent.UI_MODES.NORMAL;
this.state.pendingDeletionKey = null;
this._renderUI();
}
}
class JumpListComponent extends UIComponentBase {
static DIMENSIONS = {
ITEM_HEIGHT: 34,
WIDTH: 360,
MAX_VISIBLE_ITEMS: 20,
INITIAL_BATCH_SIZE: 30, // Number of items to render initially (20 visible + 10 buffer)
SCROLL_BUFFER: 5, // Number of extra items to render above/below the visible area
};
static RESOURCE_KEYS = {
RESIZE_OBSERVER: 'resizeObserver',
};
constructor(role, messages, highlightedMessage, callbacks, styleHandle, initialFilterValue, searchCache) {
super(callbacks);
this.role = role;
this.messages = messages;
this.styleHandle = styleHandle;
// Use the shared cache to build the searchable list efficiently.
// If a message is not yet in the cache, has empty text (streaming),
// OR if it is the latest message (volatile), fetch data from DOM and update the cache.
const cls = this.styleHandle.classes;
this.searchableMessages = this.messages.map((msg, originalIndex) => {
let cachedData = searchCache.get(msg);
const isLatest = originalIndex === this.messages.length - 1;
// 1. Force refresh for the latest message to ensure real-time consistency during streaming.
// 2. Fallback generation for uncached items or items with empty text (previously cached during streaming).
if (isLatest || !cachedData || !cachedData.displayText) {
const role = PlatformAdapters.General.getMessageRole(msg);
const roleClass = role === CONSTANTS.SELECTORS.FIXED_NAV_ROLE_USER ? cls.userItem : role === CONSTANTS.SELECTORS.FIXED_NAV_ROLE_ASSISTANT ? cls.asstItem : null;
const rawText = PlatformAdapters.General.getJumpListDisplayText(msg);
const displayText = (rawText || '').replace(/\s+/g, ' ').trim();
// Only update/set cache if data is missing or has changed (for latest message)
if (!cachedData || cachedData.displayText !== displayText) {
cachedData = {
displayText: displayText,
lowerText: displayText.toLowerCase(),
roleClass: roleClass,
};
// Update shared cache
searchCache.set(msg, cachedData);
}
}
return {
element: msg,
originalIndex: originalIndex,
displayText: cachedData.displayText,
lowerText: cachedData.lowerText,
roleClass: cachedData.roleClass,
};
});
// --- Component state ---
this.state = {
highlightedMessage: highlightedMessage,
initialFilterValue: initialFilterValue,
filteredMessages: [],
scrollTop: 0,
focusedIndex: -1,
isRendering: false,
// Cache ONLY container dimensions for scroll calculations.
// Position (top/left) is NOT cached to avoid sync issues.
containerHeight: 0,
};
// --- DOM Management & Virtualization ---
// Map<index, HTMLElement> - Tracks currently rendered elements to avoid DOM queries
this.renderedItems = new Map();
// Array<HTMLElement> - Pool of recycled list items to reduce GC pressure
this.itemPool = [];
// --- Render scheduling ---
this.renderRafId = null;
this.isRenderScheduled = false;
this.idleIndexingCancelFn = null;
// Pending state for preview update aggregation
this.pendingPreviewIndex = -1;
this.isPreviewUpdateScheduled = false;
// --- Interaction throttling ---
// Timer to re-enable pointer events after scrolling
this.scrollEndTimer = null;
// --- Virtual scroll properties ---
this.itemHeight = JumpListComponent.DIMENSIONS.ITEM_HEIGHT; // 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.isPreviewVisible = false; // Internal flag to track preview visibility state
this.previewTimer = null; // Timer for debounce management
this.filterTimer = null; // Timer for filter input debounce
this.lastPreviewText = null; // Cache to avoid unnecessary reflows in preview
this.lastPreviewIndex = -1; // Cache to track position changes
// --- Resize Observer ---
// Monitors size changes without polling or forced reflows
// Managed by BaseManager for automatic cleanup
this.resizeObserver = this.manageFactory(JumpListComponent.RESOURCE_KEYS.RESIZE_OBSERVER, () => {
return new ResizeObserver((entries) => {
let needsUpdate = false;
for (const entry of entries) {
if (entry.target === this.scrollBox) {
// Only trigger render if metrics actually changed
if (this.state.containerHeight !== this.scrollBox.clientHeight) {
this.state.containerHeight = this.scrollBox.clientHeight;
needsUpdate = true;
}
}
}
if (needsUpdate) {
this._requestRender();
}
});
});
// Bind handlers
this._handleClick = this._handleClick.bind(this);
this._handleKeyDown = this._handleKeyDown.bind(this);
this._handleFilter = this._handleFilter.bind(this);
this._handleFilterKeyDown = this._handleFilterKeyDown.bind(this);
this._handleScroll = this._handleScroll.bind(this);
this._performPreviewUpdate = this._performPreviewUpdate.bind(this);
// Static handlers for recycled items to avoid closure creation
this._handleItemMouseEnter = this._handleItemMouseEnter.bind(this);
this._handleItemMouseLeave = this._handleItemMouseLeave.bind(this);
}
render() {
const cls = this.styleHandle.classes;
const rootId = this.styleHandle.rootId;
// 1. The inner list (ul) acts as a "sizer" or "spacer".
this.listElement = h(`ul#${cls.listId}`, {
style: { position: 'relative', overflow: 'hidden', height: '0px' },
});
// 2. The scrollBox (div) is the "viewport".
this.scrollBox = h(`div.${cls.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);
// Use passive listener for smooth scrolling
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',
// Update tooltip to reflect flag restrictions
title: 'Filter by plain text or a regular expression.\n' + 'Enter text for a simple search.\n' + 'Use /regex/flags format for advanced filtering.\n' + 'Allowed flags: i, m, s, u\n' + "(Note: 'g' and 'y' flags are forbidden)",
className: cls.filter,
value: this.state.initialFilterValue,
oninput: this._handleFilter,
onkeydown: this._handleFilterKeyDown,
onclick: (e) => e.stopPropagation(),
});
const modeLabel = h('span', { className: cls.modeLabel });
const inputContainer = h(`div.${cls.filterContainer}`, [filterInput, modeLabel]);
// 4. The main element (div) handles the overall layout using flexbox.
// Use rootId for the container ID to match scoped CSS.
this.element = h(`div#${rootId}`, {
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();
// Start observing layout changes
this.resizeObserver.observe(this.scrollBox);
return this.element;
}
show(anchorElement) {
if (!this.element) this.render();
// 1. Measure (Read Phase)
const anchorRect = anchorElement.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
// 2. Mutate (Write Phase)
document.body.appendChild(this.element);
const cls = this.styleHandle.classes;
// Manually trigger the filter immediately to populate the list without delay
this._executeFilter();
const verticalMargin = 8;
// Increase horizontal margin to avoid overlapping with browser scrollbar
const horizontalMargin = 24;
const width = JumpListComponent.DIMENSIONS.WIDTH;
const isTopHalf = anchorRect.top < viewportHeight / 2;
// Strict List Height Calculation:
// Apply max-height directly to the scrollBox to enforce exactly 20 items.
// This decouples the list size from the filter input size (which varies by OS/Browser).
const listMaxHeight = JumpListComponent.DIMENSIONS.MAX_VISIBLE_ITEMS * this.itemHeight;
this.scrollBox.style.maxHeight = `${listMaxHeight}px`;
// Horizontal Positioning (Keep on screen)
let left = anchorRect.left;
if (left + width > viewportWidth - horizontalMargin) {
left = viewportWidth - width - horizontalMargin;
}
// Ensure strictly non-negative left position
left = Math.max(horizontalMargin, left);
this.element.style.left = `${left}px`;
this.element.style.width = `${width}px`;
if (isTopHalf) {
// Expand Down (Header Mode / Top Half)
this.element.classList.add(cls.expandDown);
this.element.style.top = `${anchorRect.bottom + 4}px`;
this.element.style.bottom = 'auto';
// Viewport Constraint for the Outer Container
const availableHeight = viewportHeight - anchorRect.bottom - verticalMargin * 2;
this.element.style.maxHeight = `${Math.max(100, availableHeight)}px`;
} else {
// Expand Up (Footer Mode / Bottom Half)
this.element.classList.remove(cls.expandDown);
this.element.style.top = 'auto';
this.element.style.bottom = `${viewportHeight - anchorRect.top + 4}px`;
// Viewport Constraint for the Outer Container
// Reserve 10% space at the top to avoid covering headers entirely
const topLimit = viewportHeight * 0.1;
const availableHeight = anchorRect.top - topLimit - verticalMargin;
this.element.style.maxHeight = `${Math.max(100, availableHeight)}px`;
}
// Force reflow to ensure start position is applied before transition
this.element.getBoundingClientRect();
this.element.classList.add(cls.visible);
const filterInput = this.element.querySelector(`.${cls.filter}`);
if (filterInput instanceof HTMLInputElement) {
filterInput.focus();
filterInput.select();
}
}
_onInit() {
this.isRenderScheduled = false;
this.isPreviewUpdateScheduled = false;
}
_onDestroy() {
// ResizeObserver is automatically disconnected by BaseManager
this.resizeObserver = null;
if (this.idleIndexingCancelFn) {
this.idleIndexingCancelFn();
this.idleIndexingCancelFn = null;
}
this._cancelScheduledPreview();
this._cancelScheduledFilter();
this._hidePreview();
if (this.scrollEndTimer) clearTimeout(this.scrollEndTimer);
if (this.renderRafId) cancelAnimationFrame(this.renderRafId);
this.previewTooltip?.remove();
this.previewTooltip = null;
// Break circular references from DOM elements to this instance via bound event handlers
const cleanupItem = (item) => {
item.onmouseenter = null;
item.onmouseleave = null;
};
this.renderedItems.forEach((item) => {
cleanupItem(item);
item.remove();
});
this.renderedItems.clear();
this.itemPool.forEach(cleanupItem);
this.itemPool = [];
// Explicitly release large data structures
this.messages = null;
this.searchableMessages = null;
this.state = null; // Important: Clear state containing filteredMessages references
this.listElement = null;
this.scrollBox = null;
super._onDestroy();
}
/**
* @private
* Parses the search input into a structured object containing validation status, mode, and components.
* Centralizes parsing logic to ensure consistency between filtering and highlighting.
* @param {string} searchTerm
* @returns {{ isValid: boolean, mode: 'RegExp'|'Text'|'Invalid', source: string, flags: string }}
*/
_parseSearchInput(searchTerm) {
/** @type {'RegExp'|'Text'|'Invalid'} */
let mode = 'Text';
let isValid = true;
let source = searchTerm;
let flags = '';
// Robust RegExp Parsing using lastIndexOf to handle slashes in pattern
if (searchTerm.startsWith('/')) {
const lastSlashIndex = searchTerm.lastIndexOf('/');
if (lastSlashIndex > 0) {
const pattern = searchTerm.substring(1, lastSlashIndex);
const flagsRaw = searchTerm.substring(lastSlashIndex + 1);
// Check for invalid flags
// Only allow 'i', 'm', 's', 'u'.
// 'g' and 'y' are forbidden as they interfere with the internal highlighting logic.
// Any other characters are also invalid.
if (/[^imsu]/.test(flagsRaw)) {
mode = 'Invalid';
isValid = false;
} else if (pattern.length > 0) {
try {
// Test if it's a valid regex (checks for syntax errors and duplicate flags)
new RegExp(pattern, flagsRaw);
mode = 'RegExp';
isValid = true;
source = pattern;
flags = flagsRaw;
} catch {
mode = 'Invalid';
isValid = false;
}
} else {
// Empty pattern `//` -> treat as invalid
mode = 'Invalid';
isValid = false;
}
}
}
return { isValid, mode, source, flags };
}
_executeFilter() {
if (!this.element) return;
const cls = this.styleHandle.classes;
// Always query the input element from the DOM to ensure we get the latest value
const inputElement = this.element.querySelector(`.${cls.filter}`);
if (!(inputElement instanceof HTMLInputElement)) return;
const searchTerm = inputElement.value;
// Update state
this.state.filteredMessages = this._filterMessages(searchTerm, inputElement);
this.state.focusedIndex = -1;
this.state.scrollTop = 0;
if (this.scrollBox) this.scrollBox.scrollTop = 0;
// Trigger re-render
// Note: _renderUI handles DOM recycling automatically, so we don't clear renderedItems or listElement here.
this._requestRender();
this._hidePreview();
}
_scheduleFilter() {
this._cancelScheduledFilter();
this.filterTimer = setTimeout(() => {
this._executeFilter();
this.filterTimer = null;
}, CONSTANTS.TIMING.DEBOUNCE_DELAYS.FILTER_INPUT_DEBOUNCE);
}
_cancelScheduledFilter() {
if (this.filterTimer) {
clearTimeout(this.filterTimer);
this.filterTimer = null;
}
}
_flushPendingFilter() {
// If a filter update is pending, execute it immediately
if (this.filterTimer) {
this._cancelScheduledFilter();
this._executeFilter();
}
}
/**
* Disables mouse interactions (pointer-events) on the list during scrolling.
* This prevents expensive 'mouseenter' events and preview updates when items are moving rapidly.
*/
_disableMouseInteractions() {
if (!this.listElement) return;
// Set pointer-events: none to prevent hover/click while scrolling
if (this.listElement.style.pointerEvents !== 'none') {
this.listElement.style.pointerEvents = 'none';
}
// Debounce re-enabling
if (this.scrollEndTimer) {
clearTimeout(this.scrollEndTimer);
}
this.scrollEndTimer = setTimeout(() => {
if (this.listElement) {
this.listElement.style.pointerEvents = 'auto';
}
this.scrollEndTimer = null;
}, 150); // 150ms buffer after last scroll event
}
/**
* Schedules a preview update (show or hide) with a delay.
* Using a timer for both actions allows the user to move the mouse from the list item
* into the preview tooltip without it disappearing immediately.
* @param {number} index - The index of the item to preview, or -1 to hide.
* @param {number} delay - Delay in ms.
*/
_schedulePreview(index, delay) {
this._cancelScheduledPreview();
this.previewTimer = setTimeout(() => {
this._showPreview(index);
this.previewTimer = null;
}, delay);
}
_cancelScheduledPreview() {
if (this.previewTimer) {
clearTimeout(this.previewTimer);
this.previewTimer = null;
}
}
updateHighlightedMessage(newMessage) {
this.state.highlightedMessage = newMessage;
// Re-render visible items to update the '.is-current' class
this._requestRender();
}
/**
* 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;
const cls = this.styleHandle.classes;
this.previewTooltip = h(`div#${cls.previewId}`);
// 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());
});
// Cancel any pending hide/change actions when the mouse enters the tooltip
this.previewTooltip.addEventListener('mouseenter', () => this._cancelScheduledPreview());
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) {
this.pendingPreviewIndex = index;
if (!this.isPreviewUpdateScheduled) {
this.isPreviewUpdateScheduled = true;
requestAnimationFrame(this._performPreviewUpdate);
}
}
_performPreviewUpdate() {
this.isPreviewUpdateScheduled = false;
const index = this.pendingPreviewIndex;
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 cls = this.styleHandle.classes;
// Use cached text
const fullText = searchableMessage.displayText;
// --- 1. Prepare Filter Logic (Regex or String) ---
const filterInput = this.element.querySelector(`.${cls.filter}`);
const searchTerm = filterInput instanceof HTMLInputElement ? filterInput.value : '';
// Use common parsing logic
const parsed = this._parseSearchInput(searchTerm);
let highlightRegex = null;
if (searchTerm.trim()) {
if (parsed.mode === 'RegExp' && parsed.isValid) {
try {
// Safe regex creation for highlighting (force 'g' for multiple matches)
highlightRegex = new RegExp(parsed.source, parsed.flags + 'g');
} catch {
highlightRegex = null;
}
} else {
// Fallback to plain string search for highlighting (Text or Invalid mode)
// Important: Use escapeRegExp on the raw searchTerm to prevent crashes on special chars
highlightRegex = new RegExp(escapeRegExp(searchTerm), 'gi');
}
}
// Cache Key Update: Must include the actual displayed text (full text in this case)
// Cache Key: includes text, filter term, and validity to handle highlighting changes
const cacheKey = `${fullText}|${searchTerm}|${parsed.isValid}|${parsed.mode}`;
// Skip updating if content AND position (index) haven't changed
if (this.lastPreviewText === cacheKey && this.lastPreviewIndex === index && this.isPreviewVisible) {
return;
}
this.lastPreviewText = cacheKey;
this.lastPreviewIndex = index;
// --- 2. Build DOM ---
const contentFragment = document.createDocumentFragment();
contentFragment.appendChild(document.createTextNode(`${searchableMessage.originalIndex + 1}: `));
// Robust Highlight Logic with exec loop
if (highlightRegex) {
let lastIndex = 0;
let match;
const MAX_MATCHES = 100; // Circuit breaker to prevent freezing on many matches
let matchCount = 0;
// Ensure regex is not stateful from previous runs
highlightRegex.lastIndex = 0;
while ((match = highlightRegex.exec(fullText)) !== null) {
if (matchCount++ > MAX_MATCHES) break;
// Append text before match
if (match.index > lastIndex) {
contentFragment.appendChild(document.createTextNode(fullText.substring(lastIndex, match.index)));
}
// Append matched text
contentFragment.appendChild(h('strong', match[0]));
lastIndex = highlightRegex.lastIndex;
// Prevent infinite loop on zero-length matches (e.g. /^/)
if (match.index === highlightRegex.lastIndex) {
highlightRegex.lastIndex++;
}
}
// Append remaining text
if (lastIndex < fullText.length) {
contentFragment.appendChild(document.createTextNode(fullText.substring(lastIndex)));
}
} else {
contentFragment.appendChild(document.createTextNode(fullText));
}
// Phase 1: Write (Update Content)
this.previewTooltip.replaceChildren(contentFragment);
// Phase 2: Read & Position (Synchronous in this frame)
if (!this.previewTooltip) return;
// --- Position Calculation using Metrics (Read DOM for position, use cached size if possible) ---
const margin = 12;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const scrollBoxRect = this.scrollBox.getBoundingClientRect();
const itemRelativeTop = index * this.itemHeight - this.state.scrollTop;
const itemAbsoluteTop = scrollBoxRect.top + itemRelativeTop;
// Initial position candidate (Right of list)
let left = scrollBoxRect.right + margin;
let top = itemAbsoluteTop;
// Measure tooltip only once.
const tooltipRect = this.previewTooltip.getBoundingClientRect();
// Collision detection
if (left + tooltipRect.width > viewportWidth - margin) {
// Flip to Left of list
left = scrollBoxRect.left - tooltipRect.width - margin;
}
if (top + tooltipRect.height > viewportHeight - margin) {
// Shift up
top = viewportHeight - 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(cls.visible);
this.isPreviewVisible = true;
}
_hidePreview() {
// Update pending state to invalid so scheduled rAF will perform hide
this.pendingPreviewIndex = -1;
// If we are already visible, we can hide immediately or wait for rAF.
// But to be responsive for direct calls (like from filter), handle immediate DOM update too.
if (this.previewTooltip) {
this.previewTooltip.classList.remove(this.styleHandle.classes.visible);
this.isPreviewVisible = false;
this.lastPreviewText = null;
}
}
_revertToFocusedPreview() {
if (this.state.focusedIndex > -1) {
this._showPreview(this.state.focusedIndex);
} else {
this._hidePreview();
}
}
_handleItemMouseEnter(e) {
const target = e.currentTarget;
if (!target) return;
// Retrieve index from dataset, avoiding closure capture
const index = parseInt(target.dataset[CONSTANTS.DATA_KEYS.FILTERED_INDEX], 10);
if (isNaN(index)) return;
// Use a short delay for mouse to avoid heavy rendering during rapid movement
// If preview is already visible (user is exploring the list), switch instantly.
const delay = this.isPreviewVisible ? 0 : CONSTANTS.TIMING.DEBOUNCE_DELAYS.JUMP_LIST_PREVIEW_HOVER;
this._schedulePreview(index, delay);
}
_handleItemMouseLeave() {
// Revert to the focused item (or hide) after a delay
this._schedulePreview(this.state.focusedIndex, CONSTANTS.TIMING.DEBOUNCE_DELAYS.JUMP_LIST_PREVIEW_RESET);
}
_createListItem() {
// Only create the DOM structure and bind static listeners once
const item = h('li', {
style: {
position: 'absolute',
left: '0',
height: `${this.itemHeight}px`,
width: '100%',
boxSizing: 'border-box',
display: 'flex',
alignItems: 'center',
},
onmouseenter: this._handleItemMouseEnter,
onmouseleave: this._handleItemMouseLeave,
});
return item;
}
_configureItem(item, searchableMessage, index) {
const messageElement = searchableMessage.element;
const originalIndex = searchableMessage.originalIndex;
const cls = this.styleHandle.classes;
// Use pre-calculated values
const displayText = `${originalIndex + 1}: ${searchableMessage.displayText}`;
// Reset dynamic classes but keep base 'li' styling implicitly via tag
// Explicitly remove ONLY the dynamic classes we manage
item.classList.remove(cls.current, cls.focused, cls.userItem, cls.asstItem);
// Update Data & Styles
item.dataset[CONSTANTS.DATA_KEYS.MESSAGE_INDEX] = originalIndex;
item.dataset[CONSTANTS.DATA_KEYS.FILTERED_INDEX] = index;
item.style.top = `${index * this.itemHeight}px`;
// Update text content only if changed
if (item.textContent !== displayText) {
item.textContent = displayText;
}
// Apply current state
if (this.state.highlightedMessage === messageElement) {
item.classList.add(cls.current);
}
if (this.state.focusedIndex === index) {
item.classList.add(cls.focused);
}
if (searchableMessage.roleClass) {
item.classList.add(searchableMessage.roleClass);
}
}
_filterMessages(searchTerm, inputElement) {
const cls = this.styleHandle.classes;
const modeLabel = this.element.querySelector(`.${cls.modeLabel}`);
const parsed = this._parseSearchInput(searchTerm);
// Update UI only if changed to avoid reflows
if (inputElement.classList.contains(cls.filterRegexValid) !== parsed.isValid) {
inputElement.classList.toggle(cls.filterRegexValid, parsed.isValid);
}
const modeClassMap = {
RegExp: `${cls.modeLabel} ${cls.modeRegex}`,
Invalid: `${cls.modeLabel} ${cls.modeInvalid}`,
Text: `${cls.modeLabel} ${cls.modeString}`,
};
const modeClass = modeClassMap[parsed.mode];
if (modeLabel.className !== modeClass) {
modeLabel.className = modeClass;
modeLabel.textContent = parsed.mode;
}
// Prepare RegExp object if valid
let regex = null;
if (parsed.mode === 'RegExp' && parsed.isValid) {
try {
regex = new RegExp(parsed.source, parsed.flags);
} catch {
/* ignore, handled by parse */
}
}
const lowerCaseSearchTerm = searchTerm.toLowerCase();
return this.searchableMessages.filter((msg) => {
if (regex) {
// For regex, test against the original, case-preserved text.
return regex.test(msg.displayText);
} else if (parsed.mode === 'Text') {
// For plain text, perform a case-insensitive search using the original input logic.
// Note: searchTerm is used here, not parsed.source, though they are same in Text mode.
return lowerCaseSearchTerm === '' || msg.lowerText.includes(lowerCaseSearchTerm);
} else {
// Invalid regex -> Treat as literal search of the full input string (Fallback)
// This matches the original behavior where invalid regex was often just filtered by the raw string.
return lowerCaseSearchTerm === '' || msg.lowerText.includes(lowerCaseSearchTerm);
}
});
}
_requestRender() {
if (this.isRenderScheduled) return;
this.isRenderScheduled = true;
this.renderRafId = requestAnimationFrame(() => {
this.isRenderScheduled = false;
this.renderRafId = null;
this._renderUI();
});
}
_getVisibleRange() {
const total = this.state.filteredMessages.length;
if (!total) return { startIndex: 0, endIndex: -1 };
// Use cached metrics for height, but state.scrollTop is accurate
const scrollTop = Math.max(0, this.state.scrollTop || 0);
const containerHeight = Math.max(0, this.state.containerHeight || 0);
const itemHeight = Math.max(1, this.itemHeight || JumpListComponent.DIMENSIONS.ITEM_HEIGHT);
const buffer = JumpListComponent.DIMENSIONS.SCROLL_BUFFER;
let startIndex = Math.floor(scrollTop / itemHeight) - buffer;
startIndex = Math.max(0, Math.min(total - 1, startIndex));
let endIndex;
if (containerHeight <= 0) {
// Initial render, container height not yet known. Render a default batch.
const initialBatchSize = JumpListComponent.DIMENSIONS.INITIAL_BATCH_SIZE;
// Fix off-by-one: endIndex should be valid index bound
endIndex = startIndex + initialBatchSize - 1;
} else {
// Inclusive index
endIndex = Math.floor((scrollTop + containerHeight) / itemHeight) + buffer;
}
endIndex = Math.max(startIndex, Math.min(total - 1, endIndex));
return { startIndex, endIndex };
}
_renderUI() {
if (!this.listElement || !this.scrollBox) return;
// Step 1: Update the virtual height immediately to ensure correct layout calculations.
const totalHeight = this.state.filteredMessages.length * this.itemHeight;
const heightStyle = `${totalHeight}px`;
if (this.listElement.style.height !== heightStyle) {
this.listElement.style.height = heightStyle;
}
// 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);
}
const fragment = document.createDocumentFragment();
// First, remove any elements that are no longer in the visible range and recycle them.
for (const [index, element] of this.renderedItems.entries()) {
if (!visibleIndices.has(index)) {
element.remove();
this.renderedItems.delete(index);
this.itemPool.push(element);
}
}
// Then, add or update elements that should be visible.
for (let i = startIndex; i <= endIndex; i++) {
const message = this.state.filteredMessages[i];
let item = this.renderedItems.get(i);
if (item) {
// Item exists, just update its state
this._configureItem(item, message, i);
} else {
// Item missing, recycle or create
if (this.itemPool.length > 0) {
item = this.itemPool.pop();
} else {
item = this._createListItem();
}
this._configureItem(item, message, i);
this.renderedItems.set(i, item);
fragment.appendChild(item);
}
}
// Append all new items at once.
if (fragment.childNodes.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;
// Use cached state for read to avoid layout thrashing
const viewTop = this.state.scrollTop;
// Use cached height or fallback to DOM read only if necessary (0 means not yet measured)
const viewHeight = this.state.containerHeight || this.scrollBox.clientHeight;
const viewBottom = viewTop + viewHeight;
let newScrollTop = viewTop;
if (itemTop < viewTop) {
newScrollTop = itemTop;
} else if (itemBottom > viewBottom) {
newScrollTop = itemBottom - viewHeight;
}
if (newScrollTop !== viewTop) {
this.scrollBox.scrollTop = newScrollTop;
// Update state after potential scroll change immediately
this.state.scrollTop = newScrollTop;
}
}
this._requestRender();
}
_handleScroll(event) {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
// Disable mouse interactions to prevent hover spam during scroll
this._disableMouseInteractions();
// Explicitly hide preview immediately on scroll for better UX
this._hidePreview();
this.state.scrollTop = target.scrollTop;
this._requestRender();
}
_handleFilter(event) {
// Cancel any pending preview immediately when typing
this._cancelScheduledPreview();
// Schedule the filter update (debounce)
this._scheduleFilter();
}
_handleFilterKeyDown(event) {
// Guard: Component might be destroyed by a global capture listener (e.g. Alt+J) before this local handler runs.
if (!this.state) return;
// Only flush pending filters for navigation keys to ensure the list is up-to-date.
// For regular typing, let the debounce timer handle it to prevent input lag.
if (['ArrowDown', 'ArrowUp', 'Enter', 'Tab'].includes(event.key)) {
this._flushPendingFilter();
}
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 });
this._schedulePreview(this.state.focusedIndex, CONSTANTS.TIMING.DEBOUNCE_DELAYS.JUMP_LIST_PREVIEW_KEY_NAV);
}
break;
case 'ArrowUp':
event.preventDefault();
this.state.focusedIndex = this.state.filteredMessages.length - 1;
this._updateFocus(true);
this.scrollBox.focus({ preventScroll: true });
this._schedulePreview(this.state.focusedIndex, CONSTANTS.TIMING.DEBOUNCE_DELAYS.JUMP_LIST_PREVIEW_KEY_NAV);
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 });
this._schedulePreview(this.state.focusedIndex, CONSTANTS.TIMING.DEBOUNCE_DELAYS.JUMP_LIST_PREVIEW_KEY_NAV);
}
}
/** @param {KeyboardEvent} event */
_handleKeyDown(event) {
// Ensure the list is up-to-date before handling navigation keys
this._flushPendingFilter();
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.state.containerHeight / this.itemHeight);
newFocusedIndex = Math.min(totalItems - 1, newFocusedIndex + itemsPerPage);
break;
}
case 'PageUp': {
event.preventDefault();
if (newFocusedIndex === -1) newFocusedIndex = 0;
const itemsPerPage = Math.floor(this.state.containerHeight / 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(`.${this.styleHandle.classes.filter}`);
if (filterInput instanceof HTMLInputElement) {
filterInput.focus();
filterInput.select();
}
this.state.focusedIndex = -1;
this._updateFocus(false);
// Explicitly hide preview as focus is lost
this._cancelScheduledPreview();
this._hidePreview();
return; // Don't update focus on tab
}
default:
return;
}
if (newFocusedIndex !== this.state.focusedIndex) {
// Disable mouse interactions during keyboard navigation too
this._disableMouseInteractions();
// Immediately hide the current preview when moving focus.
// It will reappear only after the debounce delay in _schedulePreview expires.
this._hidePreview();
this.state.focusedIndex = newFocusedIndex;
this._updateFocus(true);
// Schedule preview with a delay to prevent forced reflow during rapid navigation
this._schedulePreview(this.state.focusedIndex, CONSTANTS.TIMING.DEBOUNCE_DELAYS.JUMP_LIST_PREVIEW_KEY_NAV);
}
}
getFilterValue() {
const cls = this.styleHandle.classes;
const filterInput = this.element?.querySelector(`.${cls.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 = DomState.getInt(listItem, CONSTANTS.DATA_KEYS.MESSAGE_INDEX, NaN);
if (!isNaN(originalIndex) && this.messages[originalIndex]) {
this.callbacks.onSelect?.(this.messages[originalIndex]);
}
}
}
/**
* @class SettingsWidgetController
* @description Manages the persistent settings widget (button and panel).
* Handles lifecycle, positioning, and interaction of the main menu.
*/
class SettingsWidgetController extends BaseManager {
/**
* @param {object} callbacks
* @param {() => void} callbacks.onShowJsonModal
* @param {function(string=): void} callbacks.onShowThemeModal
* @param {(config: AppConfig) => Promise<void>} callbacks.onSave
* @param {() => Promise<AppConfig>} callbacks.getCurrentConfig
* @param {() => object} callbacks.getCurrentWarning
* @param {() => ThemeSet} callbacks.getCurrentThemeSet
* @param {(config: AppConfig) => {size: number, isExceeded: boolean}} callbacks.checkSize
*/
constructor(callbacks) {
super();
this.callbacks = callbacks;
this.settingsButton = null;
this.settingsPanel = null;
this.isRepositionScheduled = false;
// Loading state flags
this.isPageLoading = true;
this.isThemeLoading = false;
this.isAutoScrolling = false;
// Bind methods
this.scheduleButtonPlacement = this.scheduleButtonPlacement.bind(this);
}
async _onInit() {
this.isRepositionScheduled = false;
// Initialize components
this.settingsButton = this.manageFactory(
CONSTANTS.RESOURCE_KEYS.SETTINGS_BUTTON,
() =>
new CustomSettingsButton(
{
onClick: () => this.settingsPanel.toggle(),
},
{
id: `${APPID}-settings-button`,
title: `Settings (${APPNAME})`,
}
)
);
this.settingsPanel = this.manageFactory(
CONSTANTS.RESOURCE_KEYS.SETTINGS_PANEL,
() =>
new SettingsPanelComponent({
onSave: this.callbacks.onSave,
getCurrentConfig: this.callbacks.getCurrentConfig,
getCurrentWarning: this.callbacks.getCurrentWarning,
getCurrentThemeSet: this.callbacks.getCurrentThemeSet,
onShowJsonModal: this.callbacks.onShowJsonModal,
onShowThemeModal: this.callbacks.onShowThemeModal,
getAnchorElement: () => this.getAnchorElement(),
checkSize: this.callbacks.checkSize,
onShow: () => {}, // Handled internally
})
);
// Render and initialize components
// Note: UI components don't use 'init' in the same way, but 'render'
if (this.settingsButton) {
await this.settingsButton.init();
this.settingsButton.render();
this.ensureButtonPlacement(); // Initial placement
this._updateButtonLoadingState(); // Apply initial state
}
// Panels act as managers too
if (this.settingsPanel) {
await this.settingsPanel.init();
if (this.isDestroyed) return;
this.settingsPanel.render();
}
// Subscribe to layout events for the button
this._subscribe(EVENTS.UI_REPOSITION, this.scheduleButtonPlacement);
this._subscribe(EVENTS.DEFERRED_LAYOUT_UPDATE, this.scheduleButtonPlacement);
this._subscribe(EVENTS.NAVIGATION_END, () => {
this.scheduleButtonPlacement();
// Defer the loading state update to the next UI cycle.
EventBus.queueUIWork(() => {
if (this.isDestroyed) return;
this.isPageLoading = false;
this._updateButtonLoadingState();
});
});
// Subscribe to events for loading state control
this._subscribe(EVENTS.NAVIGATION_START, () => {
// Only wait for message loading on active chat pages.
// For non-chat pages (New Chat, Lists), skip the wait to prevent infinite loading.
if (PlatformAdapters.General.isChatPage()) {
this.isPageLoading = true;
} else {
this.isPageLoading = false;
}
this._updateButtonLoadingState();
});
this._subscribe(EVENTS.THEME_UPDATE, () => {
this.isThemeLoading = true;
this._updateButtonLoadingState();
});
this._subscribe(EVENTS.THEME_APPLIED, () => {
this.isThemeLoading = false;
this._updateButtonLoadingState();
});
this._subscribe(EVENTS.AUTO_SCROLL_START, () => {
this.isAutoScrolling = true;
this._updateButtonLoadingState();
});
this._subscribe(EVENTS.AUTO_SCROLL_COMPLETE, () => {
this.isAutoScrolling = false;
this._updateButtonLoadingState();
});
this._subscribe(EVENTS.CONFIG_WARNING_UPDATE, (data) => {
// If an error occurs (warning shown), disable loading animation to prevent infinite spinning
if (data && data.show) {
this.isPageLoading = false;
this.isThemeLoading = false;
this._updateButtonLoadingState();
}
});
}
_onDestroy() {
this.settingsButton = null;
this.settingsPanel = null;
}
/**
* Updates the loading state of the settings button based on internal flags.
* @private
*/
_updateButtonLoadingState() {
if (!this.settingsButton) return;
const effectivePageLoading = PlatformAdapters.General.isChatPage() && this.isPageLoading;
const isLoading = effectivePageLoading || this.isThemeLoading || this.isAutoScrolling;
this.settingsButton.setLoading(isLoading);
}
/**
* Returns the DOM element of the settings button.
* Used as an anchor for positioning modals.
* @returns {HTMLElement | null}
*/
getAnchorElement() {
return this.settingsButton?.element || null;
}
scheduleButtonPlacement() {
if (this.isRepositionScheduled) return;
this.isRepositionScheduled = true;
EventBus.queueUIWork(() => {
try {
if (this.isDestroyed) return;
this.ensureButtonPlacement();
} finally {
this.isRepositionScheduled = false;
}
});
}
ensureButtonPlacement() {
if (!this.settingsButton?.element) return;
PlatformAdapters.UIManager.ensureButtonPlacement(this.settingsButton);
}
}
/**
* @class ModalCoordinator
* @description Manages the lifecycle and coordination of transient modal UI components (JSON editor, Theme editor).
*/
class ModalCoordinator extends BaseManager {
/**
* @param {object} callbacks
* @param {(config: AppConfig) => Promise<void>} callbacks.onSave
* @param {() => Promise<AppConfig>} callbacks.getCurrentConfig
* @param {() => object} callbacks.getCurrentWarning
* @param {DataConverter} callbacks.dataConverter
* @param {() => HTMLElement|null} callbacks.getAnchorElement
* @param {(config: AppConfig) => {size: number, isExceeded: boolean}} callbacks.checkSize
*/
constructor(callbacks) {
super();
this.callbacks = callbacks;
this.jsonModal = null;
this.themeModal = null;
}
async _onInit() {
// Initialize transient components
// They are instantiated here but only render DOM when open() is called
this.jsonModal = this.manageFactory(
CONSTANTS.RESOURCE_KEYS.JSON_MODAL,
() =>
new JsonModalComponent({
onSave: this.callbacks.onSave,
getCurrentConfig: this.callbacks.getCurrentConfig,
getCurrentWarning: this.callbacks.getCurrentWarning,
onModalOpen: () => {},
})
);
this.themeModal = this.manageFactory(
CONSTANTS.RESOURCE_KEYS.THEME_MODAL,
() =>
new ThemeModalComponent({
onSave: this.callbacks.onSave,
getCurrentConfig: this.callbacks.getCurrentConfig,
getCurrentWarning: this.callbacks.getCurrentWarning,
dataConverter: this.callbacks.dataConverter,
checkSize: this.callbacks.checkSize,
onModalOpen: () => {},
})
);
// Initialize components to ensure their lifecycle hooks (_onInit) are executed.
// This is critical for initializing resources like debounced functions.
if (this.jsonModal) await this.jsonModal.init();
if (this.themeModal) await this.themeModal.init();
}
_onDestroy() {
this.jsonModal = null;
this.themeModal = null;
}
openJsonModal(anchorElement) {
this.jsonModal?.open(anchorElement);
}
openThemeModal(themeKey) {
this.themeModal?.open(themeKey);
}
/**
* Returns the currently active modal component instance, if any.
* @returns {JsonModalComponent | ThemeModalComponent | null}
*/
getActiveModal() {
if (this.jsonModal?.modal?.element?.open) {
return this.jsonModal;
}
if (this.themeModal?.modal?.element?.open) {
return this.themeModal;
}
return null;
}
/**
* Shows a conflict notification on the active modal.
* @param {JsonModalComponent | ThemeModalComponent} modalComponent
* @param {() => void} reloadCallback
*/
showConflictNotification(modalComponent, reloadCallback) {
if (!modalComponent?.modal) return;
this.clearConflictNotification(modalComponent);
const conflictTextClass = StyleDefinitions.COMMON_CLASSES.conflictText;
const modalBtnClass = StyleDefinitions.COMMON_CLASSES.modalButton;
const conflictReloadBtnId = StyleDefinitions.COMMON_CLASSES.conflictReloadBtnId;
const styles = SITE_STYLES;
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: conflictReloadBtnId,
className: modalBtnClass,
textContent: 'Reload UI',
title: 'Discard local changes and load the settings from the other tab.',
style: {
borderColor: styles.PALETTE.error_text || 'red',
marginLeft: '12px',
},
onclick: (e) => {
e.preventDefault();
reloadCallback();
},
});
messageArea.classList.add(conflictTextClass);
messageArea.style.color = styles.PALETTE.error_text || 'red';
messageArea.replaceChildren(messageText, reloadBtn);
}
}
/**
* Clears the conflict notification from a modal.
* @param {JsonModalComponent | ThemeModalComponent} modalComponent
*/
clearConflictNotification(modalComponent) {
if (!modalComponent?.modal) return;
const messageArea = modalComponent.modal.dom.footerMessage;
if (messageArea) {
messageArea.textContent = '';
messageArea.classList.remove(StyleDefinitions.COMMON_CLASSES.conflictText);
}
}
}
class UIManager extends BaseManager {
/**
* @param {(config: AppConfig) => Promise<void>} onSaveCallback
* @param {() => Promise<AppConfig>} getCurrentConfigCallback
* @param {DataConverter} dataConverter
* @param {() => ThemeSet} getCurrentThemeSetCallback
* @param {(config: AppConfig) => {size: number, isExceeded: boolean}} checkSizeCallback
*/
constructor(onSaveCallback, getCurrentConfigCallback, dataConverter, getCurrentThemeSetCallback, checkSizeCallback) {
super();
// Global UI State (Source of Truth)
this.isWarningActive = false;
this.warningMessage = '';
this.commonCallbacks = {
onSave: onSaveCallback,
getCurrentConfig: getCurrentConfigCallback,
getCurrentWarning: () => ({ show: this.isWarningActive, message: this.warningMessage }),
dataConverter: dataConverter,
checkSize: checkSizeCallback,
};
this.getCurrentThemeSetCallback = getCurrentThemeSetCallback;
// Individual references for internal use
this.widgetController = null;
this.modalCoordinator = null;
}
async _onInit() {
// Initialize Sub-Controllers
this.widgetController = this.manageFactory(
CONSTANTS.RESOURCE_KEYS.WIDGET_CONTROLLER,
() =>
new SettingsWidgetController({
...this.commonCallbacks,
getCurrentThemeSet: this.getCurrentThemeSetCallback,
// Wiring: Widget -> Modal Actions
onShowJsonModal: () => {
const anchor = this.widgetController.getAnchorElement();
this.modalCoordinator.openJsonModal(anchor);
},
onShowThemeModal: (themeKey) => {
this.modalCoordinator.openThemeModal(themeKey);
},
})
);
this.modalCoordinator = this.manageFactory(
CONSTANTS.RESOURCE_KEYS.MODAL_COORDINATOR,
() =>
new ModalCoordinator({
...this.commonCallbacks,
// Wiring: Modal -> Widget Anchor Access
getAnchorElement: () => this.widgetController.getAnchorElement(),
})
);
// Initialize sub-controllers
if (this.widgetController) {
await this.widgetController.init();
if (this.isDestroyed) return;
}
if (this.modalCoordinator) {
await this.modalCoordinator.init();
if (this.isDestroyed) return;
}
// Subscribe to global UI state events
this._subscribe(EVENTS.CONFIG_WARNING_UPDATE, ({ show, message }) => {
this.isWarningActive = show;
this.warningMessage = message;
});
}
_onDestroy() {
this.widgetController = null;
this.modalCoordinator = null;
}
// --- Delegate Methods for AppController compatibility ---
/**
* Returns the currently active modal component instance.
* Delegated to ModalCoordinator.
*/
getActiveModal() {
return this.modalCoordinator.getActiveModal();
}
/**
* Shows a conflict notification on the active modal.
* Delegated to ModalCoordinator.
* @param {JsonModalComponent | ThemeModalComponent} modalComponent
* @param {() => void} reloadCallback
*/
showConflictNotification(modalComponent, reloadCallback) {
this.modalCoordinator.showConflictNotification(modalComponent, reloadCallback);
}
/**
* Clears the conflict notification from a modal.
* Delegated to ModalCoordinator.
* @param {JsonModalComponent | ThemeModalComponent} modalComponent
*/
clearConflictNotification(modalComponent) {
this.modalCoordinator.clearConflictNotification(modalComponent);
}
}
// =================================================================================
// 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 {Window & { __global_sentinel_instances__?: Record<string, Sentinel> }} */
const globalScope = window;
globalScope.__global_sentinel_instances__ ??= {};
if (globalScope.__global_sentinel_instances__[prefix]) {
return globalScope.__global_sentinel_instances__[prefix];
}
this.prefix = prefix;
this.isDestroyed = false;
this.isSuspended = false;
this._initObserver = null;
// 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._boundHandleAnimationStart = this._handleAnimationStart.bind(this);
this._injectStyleElement();
document.addEventListener('animationstart', this._boundHandleAnimationStart, true);
globalScope.__global_sentinel_instances__[prefix] = this;
}
destroy() {
if (this.isDestroyed) return;
this.isDestroyed = true;
document.removeEventListener('animationstart', this._boundHandleAnimationStart, true);
if (this._initObserver) {
this._initObserver.disconnect();
this._initObserver = null;
}
if (this.styleElement) {
this.styleElement.remove();
this.styleElement = null;
}
this.sheet = null;
this.listeners.clear();
this.rules.clear();
this.pendingRules = [];
/** @type {Window & { __global_sentinel_instances__?: Record<string, Sentinel> }} */
const globalScope = window;
if (globalScope.__global_sentinel_instances__) {
delete globalScope.__global_sentinel_instances__[this.prefix];
}
}
_injectStyleElement() {
// Ensure the style element is injected only once per project prefix.
this.styleElement = document.getElementById(this.styleId);
if (this.styleElement instanceof HTMLStyleElement) {
this.styleElement.disabled = this.isSuspended;
/** @type {HTMLStyleElement} */
const styleNode = this.styleElement;
const pollExisting = () => {
if (this.isDestroyed) return;
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;
}
if (this.styleElement instanceof HTMLStyleElement) {
this.styleElement.disabled = this.isSuspended;
}
// 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.isDestroyed) return;
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 {
this._initObserver = new MutationObserver(() => {
if (this.isDestroyed) return;
const retryTarget = document.head || document.documentElement;
if (retryTarget) {
this._initObserver.disconnect();
this._initObserver = null;
retryTarget.appendChild(this.styleElement);
initSheet();
}
});
this._initObserver.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.styleElement.disabled = this.isSuspended;
this.sheet = this.styleElement.sheet;
try {
while (this.sheet.cssRules.length > 0) {
this.sheet.deleteRule(0);
}
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 clear or restore base rules:', e);
}
this.pendingRules = [];
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) {
if (this.isDestroyed) return;
// 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) => {
try {
cb(target);
} catch (e) {
Logger.error('SENTINEL', LOG_STYLES.RED, `Listener error for selector "${selector}":`, e);
}
});
}
}
}
/**
* @param {string} selector
* @param {(element: Element) => void} callback
*/
on(selector, callback) {
if (this.isDestroyed) return;
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) {
if (this.isDestroyed) return;
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.isDestroyed) return;
this.isSuspended = true;
if (this.styleElement instanceof HTMLStyleElement) {
this.styleElement.disabled = true;
}
Logger.debug('SENTINEL', LOG_STYLES.CYAN, 'Suspended.');
}
resume() {
if (this.isDestroyed) return;
this.isSuspended = false;
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 extends BaseManager {
constructor() {
super();
this.toastElement = null;
this.activeTimers = new Set();
this.activeRafs = new Set();
}
_onInit() {
this.styleHandle = StyleManager.request(StyleDefinitions.getToast);
const message = PlatformAdapters.Toast.getAutoScrollMessage();
this._subscribe(EVENTS.AUTO_SCROLL_START, () => this.show(message, true));
this._subscribe(EVENTS.AUTO_SCROLL_COMPLETE, () => this.hide());
// Bind position updates to layout changes
const updatePosition = () => {
if (this.toastElement && this.toastElement.classList.contains(this.styleHandle.classes.visible)) {
this._updatePosition();
}
};
this._subscribe(EVENTS.WINDOW_RESIZED, updatePosition);
this._subscribe(EVENTS.SIDEBAR_LAYOUT_CHANGED, updatePosition);
this._subscribe(EVENTS.INPUT_AREA_RESIZED, updatePosition);
}
_onDestroy() {
// Cancel all pending timers and animation frames
this.activeTimers.forEach((id) => clearTimeout(id));
this.activeTimers.clear();
this.activeRafs.forEach((id) => cancelAnimationFrame(id));
this.activeRafs.clear();
// Immediately remove the element from DOM to prevent visual artifacts
if (this.toastElement) {
this.toastElement.remove();
this.toastElement = null;
}
// Also remove any fading-out elements that might still be in the DOM
// Target by ID since we switched to ID-based scoping
if (this.styleHandle) {
const rootId = this.styleHandle.rootId;
const el = document.getElementById(rootId);
if (el) el.remove();
}
}
_renderToast(message, showCancelButton) {
const cls = this.styleHandle.classes;
// Use rootId for the container ID to match scoped CSS.
const rootId = this.styleHandle.rootId;
const children = [h('span', message)];
if (showCancelButton) {
const cancelButton = h(
'button',
{
className: cls.cancelBtn,
title: 'Stop action',
onclick: () => EventBus.publish(EVENTS.AUTO_SCROLL_CANCEL_REQUEST),
},
'Cancel'
);
children.push(cancelButton);
}
// Use ID selector for the root element, not class
return h(`div#${rootId}`, children);
}
/**
* @private
* Updates the horizontal position of the toast to align with the input area.
*/
_updatePosition() {
EventBus.queueUIWork(() => {
if (!this.toastElement) return;
// Adapter method might not exist on Base adapter if not defined, so check safely or assume platform implementation
const centerX = PlatformAdapters.Toast.getToastPositionX ? PlatformAdapters.Toast.getToastPositionX() : null;
if (typeof centerX === 'number') {
this.toastElement.style.left = `${centerX}px`;
} else {
this.toastElement.style.left = ''; // Reset to CSS default (50%)
}
});
}
show(message, showCancelButton) {
const cls = this.styleHandle.classes;
// Remove existing toast if any
if (this.toastElement) {
this.hide();
}
this.toastElement = this._renderToast(message, showCancelButton);
document.body.appendChild(this.toastElement);
// Initial positioning
this._updatePosition();
// Use double requestAnimationFrame to ensure the element is rendered and the browser registers the initial state before adding the class.
// This guarantees the CSS transition will fire.
this._requestAnimationFrame(() => {
this._requestAnimationFrame(() => {
this.toastElement?.classList.add(cls.visible);
});
});
}
hide() {
if (!this.toastElement) return;
const cls = this.styleHandle.classes;
const el = this.toastElement;
el.classList.remove(cls.visible);
// Remove from DOM after transition ends using managed timer
this._setTimeout(() => {
el.remove();
}, CONSTANTS.TIMING.ANIMATIONS.TOAST_LEAVE_DURATION);
this.toastElement = null;
}
/**
* @private
* Wrapper for setTimeout that ensures cleanup on destroy.
* @param {Function} callback
* @param {number} delay
*/
_setTimeout(callback, delay) {
const id = setTimeout(() => {
this.activeTimers.delete(id);
callback();
}, delay);
this.activeTimers.add(id);
}
/**
* @private
* Wrapper for requestAnimationFrame that ensures cleanup on destroy.
* @param {Function} callback
*/
_requestAnimationFrame(callback) {
const id = requestAnimationFrame(() => {
this.activeRafs.delete(id);
callback();
});
this.activeRafs.add(id);
}
}
// =================================================================================
// SECTION: Auto Scroll Manager (Base)
// Description: Base class for platform-specific AutoScrollManagers.
// =================================================================================
/**
* @class BaseAutoScrollManager
* @extends BaseManager
*/
class BaseAutoScrollManager extends BaseManager {
/**
* @param {ConfigManager} configManager
* @param {MessageCacheManager} messageCacheManager
*/
constructor(configManager, messageCacheManager) {
super();
this.configManager = configManager;
this.messageCacheManager = messageCacheManager;
this.isEnabled = false;
this.isScrolling = false;
}
_onInit() {
this.isEnabled = Boolean(this.configManager.get()?.platforms?.[PLATFORM]?.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(false));
this._subscribe(EVENTS.CACHE_UPDATED, () => this._onCacheUpdated());
this._subscribe(EVENTS.NAVIGATION, () => this._onNavigation());
this.addDisposable(() => this.stop(false));
}
_onDestroy() {
// Cleanup handled by disposables.
}
enable() {
this.isEnabled = true;
}
disable() {
this.isEnabled = false;
this.stop(false);
}
/**
* @abstract
*/
start() {
throw new Error('start must be implemented by subclasses.');
}
/**
* @abstract
* @param {boolean} isNavigation
*/
stop(isNavigation) {
throw new Error('stop must be implemented by subclasses.');
}
/**
* @abstract
* @protected
*/
_onCacheUpdated() {
throw new Error('_onCacheUpdated must be implemented by subclasses.');
}
/**
* @abstract
* @protected
*/
_onNavigation() {
throw new Error('_onNavigation must be implemented by subclasses.');
}
}
// =================================================================================
// SECTION: App Controller (Main Controller)
// Description: The central controller that initializes all managers,
// handles dependency injection, and orchestrates the application lifecycle.
// =================================================================================
/**
* @class AppController
* @property {ConfigManager} configManager
* @property {ImageDataManager} imageDataManager
* @property {UIManager} uiManager
* @property {ObserverManager} observerManager
* @property {MessageCacheManager} messageCacheManager
* @property {AvatarManager} avatarManager
* @property {StandingImageManager} standingImageManager
* @property {ThemeManager} themeManager
* @property {BubbleUIManager} bubbleUIManager
* @property {MessageLifecycleManager} messageLifecycleManager
* @property {IFixedNavigationManager | null} fixedNavManager
* @property {MessageNumberManager} messageNumberManager
* @property {SyncManager} syncManager
* @property {IAutoScrollManager | null} autoScrollManager
* @property {unknown} toastManager
*/
class AppController extends BaseManager {
/**
* @param {DataConverter} dataConverter
* @param {ImageDataManager} imageDataManager
*/
constructor(dataConverter, imageDataManager) {
super();
this.dataConverter = dataConverter;
this.imageDataManager = imageDataManager;
this.configManager = new ConfigManager(this.dataConverter);
// Individual references for internal use
this.uiManager = null;
this.observerManager = null;
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;
/** @type {(() => void) | null} */
this.sentinelCleanup = null;
this.isNavigating = true;
/** @type {BaseManager[]} */
this.managers = [];
}
async _onInit() {
// Reset failed URLs state on initialization to allow retrying previously failed images in this new session
this.imageDataManager.clearFailedUrls();
await this.configManager.load(true);
if (this.isDestroyed) return;
// Check for load errors and display warning
if (this.configManager.loadErrors.length > 0) {
const message = `Warning: Some settings failed to load.\n${this.configManager.loadErrors.join('\n')}\nSaving now may result in data loss. Please check console logs.`;
EventBus.publish(EVENTS.CONFIG_WARNING_UPDATE, { show: true, message });
}
// 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}'.`);
const config = this.configManager.get();
config.themeSets = this._ensureUniqueThemeIds(config.themeSets);
// --- Sync and Self-Heal localStorage Timestamp state ---
if (PlatformAdapters.Timestamp.hasTimestampLogic()) {
const isTimestampEnabled = Boolean(config?.platforms?.[PLATFORM]?.features?.timestamp?.enabled);
try {
localStorage.setItem(CONSTANTS.STORE_KEYS.LOCAL_TIMESTAMP_ENABLED, String(isTimestampEnabled));
} catch (e) {
Logger.warn('APP', '', 'Failed to self-heal localStorage for timestamp toggle sync.', e);
}
// Force the correct interception state based on the loaded config to prevent rogue background processes
if (isTimestampEnabled) {
// Safe initialization check: prevent late-binding that could corrupt site fetch polyfills.
if (!PlatformAdapters.Timestamp.isInitialized) {
Logger.warn('TIMESTAMP', LOG_STYLES.YELLOW, 'Timestamp is enabled but fetch was not wrapped at document-start (e.g., due to cleared local storage). Interception will start on the next reload.');
} else {
PlatformAdapters.Timestamp.init();
}
} else {
PlatformAdapters.Timestamp.cleanup();
}
}
// Shared state for streaming status
// Store as instance property to access within AppController (e.g. for Heartbeat guard)
this.streamingState = { isActive: false };
// --- Manager Instantiation ---
// Create managers that other managers depend on
this.themeManager = this.manageFactory(CONSTANTS.RESOURCE_KEYS.THEME_MANAGER, () => new ThemeManager(this.configManager, this.imageDataManager));
this.messageCacheManager = this.manageFactory(CONSTANTS.RESOURCE_KEYS.MESSAGE_CACHE_MANAGER, () => new MessageCacheManager(this.streamingState));
this.syncManager = this.manageFactory(CONSTANTS.RESOURCE_KEYS.SYNC_MANAGER, () => new SyncManager());
// Create the rest of the managers, injecting their dependencies
this.observerManager = this.manageFactory(CONSTANTS.RESOURCE_KEYS.OBSERVER_MANAGER, () => new ObserverManager(this.messageCacheManager, this.streamingState));
this.uiManager = this.manageFactory(
CONSTANTS.RESOURCE_KEYS.UI_MANAGER,
() =>
new UIManager(
(newConfig) => this.handleSave(newConfig),
() => Promise.resolve(this.configManager.get()),
this.dataConverter,
() => this.themeManager.getThemeSet(), // Pass the callback directly
(config) => ({
size: this.configManager.getConfigSize(config),
isExceeded: this.configManager.isSizeExceeded(this.configManager.getConfigSize(config)),
})
)
);
this.avatarManager = this.manageFactory(CONSTANTS.RESOURCE_KEYS.AVATAR_MANAGER, () => new AvatarManager(this.configManager, this.messageCacheManager));
this.standingImageManager = this.manageFactory(CONSTANTS.RESOURCE_KEYS.STANDING_IMAGE_MANAGER, () => new StandingImageManager(this.configManager, this.messageCacheManager, this.themeManager));
this.bubbleUIManager = this.manageFactory(CONSTANTS.RESOURCE_KEYS.BUBBLE_UI_MANAGER, () => new BubbleUIManager(this.configManager, this.messageCacheManager));
this.messageLifecycleManager = this.manageFactory(CONSTANTS.RESOURCE_KEYS.MESSAGE_LIFECYCLE_MANAGER, () => new MessageLifecycleManager(this.messageCacheManager));
this.toastManager = this.manageFactory(CONSTANTS.RESOURCE_KEYS.TOAST_MANAGER, () => new ToastManager());
// Initialize platform-specific managers, which depend on core managers (like messageLifecycleManager)
if (!this.isDestroyed) {
PlatformAdapters.AppController.initializePlatformManagers(this);
}
if (PlatformAdapters.Timestamp.hasTimestampLogic()) {
this.timestampManager = this.manageFactory(CONSTANTS.RESOURCE_KEYS.TIMESTAMP_MANAGER, () => new TimestampManager(this.configManager, this.messageCacheManager));
}
if (config.platforms[PLATFORM].features.fixed_nav_console.enabled) {
this.fixedNavManager = this.manageFactory(
CONSTANTS.RESOURCE_KEYS.FIXED_NAV_MANAGER,
() =>
new FixedNavigationManager(
{
messageCacheManager: this.messageCacheManager,
configManager: this.configManager,
autoScrollManager: this.autoScrollManager,
messageLifecycleManager: this.messageLifecycleManager,
},
{ isReEnabling: false }
)
);
}
this.messageNumberManager = this.manageFactory(CONSTANTS.RESOURCE_KEYS.MESSAGE_NUMBER_MANAGER, () => new MessageNumberManager(this.configManager, this.messageCacheManager));
// --- Manager Registration ---
// The order determines the initialization order.
// Dependencies should be initialized before dependents.
this.managers = [
this.themeManager,
this.messageCacheManager,
this.avatarManager,
this.standingImageManager,
this.bubbleUIManager,
this.messageLifecycleManager,
this.timestampManager,
this.uiManager,
this.messageNumberManager,
this.autoScrollManager,
this.toastManager,
this.syncManager,
this.fixedNavManager,
this.observerManager,
].filter(Boolean); // Remove nulls
// --- Batch Initialization ---
for (const manager of this.managers) {
if (manager.init && typeof manager.init === 'function') {
// Handle async init if necessary, though most are sync
await manager.init();
if (this.isDestroyed) return;
}
}
// --- Post-Init Logic ---
// Manually enable timestamp manager if config says so
if (this.timestampManager && config.platforms[PLATFORM].features.timestamp.enabled) {
this.timestampManager.enable();
}
// Subscribe to app-wide events
this._subscribe(EVENTS.NAVIGATION_START, () => (this.isNavigating = true));
this._subscribe(EVENTS.NAVIGATION_END, () => (this.isNavigating = false));
this._subscribe(EVENTS.NAVIGATION, () => {
sentinel.resume(); // Ensure Sentinel is active after navigation (fixes potential suspend state from AutoScroll)
PerfMonitor.reset();
});
this._subscribe(EVENTS.CONFIG_SIZE_EXCEEDED, ({ message }) => {
EventBus.publish(EVENTS.CONFIG_WARNING_UPDATE, { show: true, message });
});
this._subscribe(EVENTS.CONFIG_SAVE_SUCCESS, () => {
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);
}
// Check if this message indicates the start of a stream
this.observerManager.handleMessageComplete(messageElement);
});
this._subscribe(EVENTS.SUSPEND_OBSERVERS, () => sentinel.suspend());
this._subscribe(EVENTS.RESUME_OBSERVERS, () => sentinel.resume());
// Correctly subscribe with no arguments
this._subscribe(EVENTS.REMOTE_CONFIG_CHANGED, () => this._handleRemoteConfigChange());
// Setup Message Processor using Platform Adapter
// This connects the low-level Sentinel detection to the high-level EventBus.
this.sentinelCleanup = PlatformAdapters.General.initializeSentinel((el) => this.handleRawMessage(el));
this.addDisposable(this.sentinelCleanup);
// --- Self-Healing Logic for Tab Suspension ---
this.lastHiddenTime = 0;
const visibilityHandler = () => {
if (document.hidden) {
this.lastHiddenTime = Date.now();
} else {
this._handleVisibilityRestored();
}
};
document.addEventListener('visibilitychange', visibilityHandler);
this.addDisposable(() => document.removeEventListener('visibilitychange', visibilityHandler));
// --- Heartbeat Monitoring ---
this._startHeartbeat();
}
_onDestroy() {
// Ensure global Sentinel is resumed if it was suspended
sentinel.resume();
this.managers = [];
this.sentinelCleanup = null;
// Release references to all managers to allow GC
this.uiManager = null;
this.observerManager = null;
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;
Logger.log('APP', '', 'AppController destroyed.');
}
/**
* @private
* Handles logic when the tab becomes visible after being hidden.
* Checks for potential state corruption due to tab discarding or heavy throttling.
*/
_handleVisibilityRestored() {
if (this.isNavigating) return;
const now = Date.now();
const timeHidden = now - this.lastHiddenTime;
// Retrieve threshold from constants
const SUSPEND_LIMIT_MS = CONSTANTS.TIMING.THRESHOLDS.SUSPEND_LIMIT_MS;
// Diagnostic Checks
let needsRepair = false;
let reason = '';
// Check 1: Time-based (Throttling/Suspend check)
if (this.lastHiddenTime > 0 && timeHidden > SUSPEND_LIMIT_MS) {
needsRepair = true;
reason = 'Long suspension';
}
// Check 2: State-based (DOM Integrity check)
// Check if the last cached message is still connected to the DOM.
// If not, the DOM was likely wiped/replaced by the framework (e.g. React hydration/render).
if (!needsRepair && this.messageCacheManager) {
const totalMessages = this.messageCacheManager.getTotalMessages();
if (totalMessages.length > 0) {
const lastMsg = totalMessages.at(-1);
if (!lastMsg.isConnected) {
needsRepair = true;
reason = 'Disconnected DOM elements';
}
}
}
if (needsRepair) {
Logger.warn('SELF HEAL', LOG_STYLES.ORANGE, `State inconsistency detected (${reason}). Scheduling repair...`);
// Use runWhenIdle to avoid freezing the UI immediately upon return
const cancelIdleFn = runWhenIdle(() => {
this.manageResource(CONSTANTS.RESOURCE_KEYS.SELF_HEAL_TASK, null);
if (this.isDestroyed || this.isNavigating) return;
this._performSelfHealing();
}, CONSTANTS.TIMING.TIMEOUTS.SELF_HEAL_IDLE_TIMEOUT_MS);
this.manageResource(CONSTANTS.RESOURCE_KEYS.SELF_HEAL_TASK, cancelIdleFn);
}
}
/**
* @private
* Re-synchronizes internal state with the actual DOM.
*/
_performSelfHealing() {
Logger.info('SELF HEAL', LOG_STYLES.TEAL, 'Executing self-healing procedures...');
// 1. Ensure Sentinel is active (in case it was suspended)
sentinel.resume();
// 2. Rebuild Cache (Removes disconnected nodes)
if (this.messageCacheManager) {
this.messageCacheManager.rebuild();
}
// 3. Scan for missed messages (Integrity Scan)
if (this.messageLifecycleManager) {
this.messageLifecycleManager.scanForUnprocessedMessages();
}
// 4. Restore Avatars (Re-injects if missing)
if (this.avatarManager) {
this.avatarManager.restoreAvatars();
}
// 5. Force UI Layout Recalculation
EventBus.publish(EVENTS.UI_REPOSITION);
Logger.info('SELF HEAL', LOG_STYLES.TEAL, 'Self-healing complete.');
}
/**
* @private
* Starts the heartbeat monitoring for DOM integrity.
*/
_startHeartbeat() {
const interval = CONSTANTS.TIMING.POLLING.HEARTBEAT_INTERVAL_MS;
const timer = setInterval(() => this._checkHeartbeat(), interval);
this.manageResource(CONSTANTS.RESOURCE_KEYS.HEARTBEAT_TIMER, () => clearInterval(timer));
}
/**
* @private
* Checks if the cached DOM elements are still valid.
*/
_checkHeartbeat() {
// Guard: Stop check if destroyed, navigating, tab hidden, or STREAMING.
if (this.isDestroyed || this.isNavigating || document.hidden || this.streamingState?.isActive) return;
if (this.messageCacheManager) {
const totalMessages = this.messageCacheManager.getTotalMessages();
if (totalMessages.length > 0) {
// Check the last message as a proxy for the entire list stability
const lastMsg = totalMessages.at(-1);
if (!lastMsg.isConnected) {
Logger.debug('HEARTBEAT', LOG_STYLES.ORANGE, 'Disconnected element detected. Scheduling self-healing.');
// Use runWhenIdle to defer repair during high load (scrolling/resizing)
const cancelIdleFn = runWhenIdle(() => {
this.manageResource(CONSTANTS.RESOURCE_KEYS.SELF_HEAL_TASK, null);
if (this.isDestroyed || this.isNavigating) return;
this._performSelfHealing();
}, CONSTANTS.TIMING.TIMEOUTS.SELF_HEAL_IDLE_TIMEOUT_MS);
this.manageResource(CONSTANTS.RESOURCE_KEYS.SELF_HEAL_TASK, cancelIdleFn);
}
}
}
}
/**
* Handles raw message elements detected by Sentinel.
* @param {HTMLElement} contentElement
*/
handleRawMessage(contentElement) {
if (!this.isInitialized) return;
EventBus.publish(EVENTS.RAW_MESSAGE_ADDED, contentElement);
}
async _handleRemoteConfigChange() {
const activeModal = this.uiManager.getActiveModal?.();
if (activeModal) {
// Modal open: Show conflict notification.
// The user can choose to reload, which will trigger _performFullReload.
Logger.warn('SYNC', LOG_STYLES.YELLOW, 'Remote change detected while modal is open. Showing conflict.');
this.uiManager.showConflictNotification(activeModal, () => this._performFullReload());
} else {
// No modal: Silent update.
Logger.info('SYNC', LOG_STYLES.TEAL, 'Remote change detected. Reloading...');
await this._performFullReload();
}
}
/**
* Reloads the configuration from storage and applies changes.
* Used for silent updates and manual reloads from conflict dialogs.
*/
async _performFullReload() {
try {
// 1. Snapshot old config to detect changes
const oldConfig = deepClone(this.configManager.get());
const oldTimestampEnabled = oldConfig.platforms[PLATFORM].features.timestamp.enabled;
// 2. Load latest from storage (updates ConfigManager state)
const newConfig = await this.configManager.load(true);
if (this.isDestroyed) return;
// --- Sync localStorage Timestamp state for remote updates ---
if (PlatformAdapters.Timestamp.hasTimestampLogic()) {
try {
const isTimestampEnabled = Boolean(newConfig?.platforms?.[PLATFORM]?.features?.timestamp?.enabled);
localStorage.setItem(CONSTANTS.STORE_KEYS.LOCAL_TIMESTAMP_ENABLED, String(isTimestampEnabled));
} catch (e) {
Logger.warn('SYNC', '', 'Failed to sync localStorage for timestamp toggle during remote update.', e);
}
}
// 3. Detect changes
const { themeChanged } = this._detectConfigChanges(oldConfig, newConfig);
// 4. Apply Updates
await this._applyUiUpdates(newConfig, themeChanged, oldTimestampEnabled, true);
// Reset warning state as we have successfully reloaded a valid config
EventBus.publish(EVENTS.CONFIG_WARNING_UPDATE, { show: false, message: '' });
Logger.info('SYNC', LOG_STYLES.TEAL, 'Configuration reloaded from storage.');
} catch (e) {
Logger.error('RELOAD FAILED', LOG_STYLES.RED, 'Failed to reload configuration:', e);
}
}
/**
* Prepares a config object for saving by merging with defaults and sanitizing.
* @param {object} partialConfig
* @returns {AppConfig}
*/
_prepareConfig(partialConfig) {
// Create a complete config object by merging the incoming data with defaults.
let completeConfig = resolveConfig(deepClone(DEFAULT_THEME_CONFIG), partialConfig);
// 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;
}
/**
* Compares two config objects to determine what aspects have changed.
* @param {AppConfig} oldConfig
* @param {AppConfig} newConfig
* @returns {{ themeChanged: boolean }}
*/
_detectConfigChanges(oldConfig, newConfig) {
let themeChanged = JSON.stringify(oldConfig.themeSets) !== JSON.stringify(newConfig.themeSets) || JSON.stringify(oldConfig.platforms[PLATFORM].defaultSet) !== JSON.stringify(newConfig.platforms[PLATFORM].defaultSet);
// If the icon size has changed, we must treat it as a theme content change
const oldIconSize = oldConfig.platforms[PLATFORM].options.icon_size;
const newIconSize = newConfig.platforms[PLATFORM].options.icon_size;
if (oldIconSize !== newIconSize) {
themeChanged = true;
}
return { themeChanged };
}
async _applyUiUpdates(completeConfig, themeChanged, oldTimestampEnabled, isRemote) {
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.platforms[PLATFORM].features.timestamp.enabled;
if (newTimestampEnabled && !oldTimestampEnabled) {
this.timestampManager.enable();
// Fetch interception MUST start at document-start to be reliable.
// Late binding wraps site polyfills, causing data corruption or null responses.
// Therefore, we must reload the page.
if (!isRemote) {
Logger.log('TIMESTAMP', LOG_STYLES.TEAL, 'Timestamp enabled. Reloading to safely apply API interception...');
window.location.reload();
return; // Prevent further UI updates as the page is reloading
} else {
// For remote updates, do NOT reload (prevents data loss in active tab) and do NOT call init() (prevents crash from late-binding).
// Interception will naturally start on the next navigation/reload.
Logger.log('TIMESTAMP', LOG_STYLES.TEAL, 'Remote timestamp enable detected. Interception will start on next reload.');
}
} else if (!newTimestampEnabled && oldTimestampEnabled) {
this.timestampManager.disable();
PlatformAdapters.Timestamp.cleanup();
} 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.platforms[PLATFORM].features.fixed_nav_console.enabled;
if (navConsoleEnabled && !this.fixedNavManager) {
this.fixedNavManager = this.manageFactory(
CONSTANTS.RESOURCE_KEYS.FIXED_NAV_MANAGER,
() =>
new FixedNavigationManager(
{
messageCacheManager: this.messageCacheManager,
configManager: this.configManager,
autoScrollManager: this.autoScrollManager,
messageLifecycleManager: this.messageLifecycleManager,
},
{ isReEnabling: true }
)
);
// Register the new manager to the lifecycle list
if (this.fixedNavManager) {
this.managers.push(this.fixedNavManager);
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();
// Remove from the lifecycle list
this.managers = this.managers.filter((m) => m !== this.fixedNavManager);
this.fixedNavManager = null;
} else if (this.fixedNavManager) {
// If the manager already exists, tell it to re-render with the new config.
this.fixedNavManager.updateUI();
this.fixedNavManager.scheduleReposition();
}
PlatformAdapters.AppController.applyPlatformSpecificUiUpdates(this, completeConfig);
}
/** @param {AppConfig} newConfig */
async handleSave(newConfig) {
try {
const oldConfig = this.configManager.get();
const oldTimestampEnabled = oldConfig.platforms[PLATFORM].features.timestamp.enabled;
const completeConfig = this._prepareConfig(newConfig);
await this.configManager.save(completeConfig);
if (this.isDestroyed) return;
// Check changes between the *original* config and the *newly saved* config
const { themeChanged } = this._detectConfigChanges(oldConfig, completeConfig);
// Apply the new logger level immediately and provide feedback only if changed.
const currentLogLevel = Logger.level;
const newLogLevel = completeConfig.developer.logger_level;
if (currentLogLevel !== newLogLevel) {
Logger.setLevel(newLogLevel);
// Use console.warn to ensure the message is visible regardless of the new level.
console.warn(LOG_PREFIX, `Logger level is '${Logger.level}'.`);
}
const activeModal = this.uiManager.getActiveModal?.();
if (activeModal) {
this.uiManager.clearConflictNotification(activeModal);
}
await this._applyUiUpdates(completeConfig, themeChanged, oldTimestampEnabled, false);
} catch (e) {
Logger.error('SAVE FAILED', LOG_STYLES.RED, 'Configuration save failed:', e.message);
EventBus.publish(EVENTS.CONFIG_SIZE_EXCEEDED, { message: `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('theme');
}
seenIds.add(theme.metadata.id);
return theme;
});
}
}
/**
* @class LifecycleManager
* @extends BaseManager
* @description Manages the application lifecycle, handling URL changes and DOM readiness to initialize or destroy the AppController.
*/
class LifecycleManager extends BaseManager {
constructor() {
super();
this.appController = null;
// Persist data conversion and caching services across app lifecycles (navigations)
this.dataConverter = new DataConverter();
this.imageDataManager = new ImageDataManager(this.dataConverter);
}
async _onInit() {
// Initialize NavigationMonitor as a managed resource
this.manageFactory(CONSTANTS.RESOURCE_KEYS.NAVIGATION_MONITOR, () => {
const monitor = new NavigationMonitor();
monitor.init();
return monitor;
});
// Check for exclusion immediately when navigation starts to stop processes early
this._subscribe(EVENTS.NAVIGATION_START, () => this._handleNavigationStart());
// Check for launch eligibility after navigation settles
this._subscribe(EVENTS.NAVIGATION, () => this._handleNavigation());
// Initial check
this._handleNavigation();
}
_onDestroy() {
this.appController = null;
}
_handleNavigationStart() {
if (PlatformAdapters.General.isExcludedPage()) {
Logger.log('EXCLUDED URL', LOG_STYLES.YELLOW, 'Excluded URL detected. Suspending script functions.');
this._shutdown();
}
}
_handleNavigation() {
if (PlatformAdapters.General.isExcludedPage()) {
// Ensure shutdown if not caught by start event (redundant safety)
this._shutdown();
} else {
this._tryLaunch();
}
}
_tryLaunch() {
// If already running, do nothing
if (this.appController) return;
const anchorSelector = CONSTANTS.SELECTORS.INPUT_TEXT_FIELD_TARGET;
const anchor = document.querySelector(anchorSelector);
if (anchor) {
this._launchApp();
} else {
// Define the listener logic
const listener = () => {
// Double check exclusion in case URL changed while waiting
if (!PlatformAdapters.General.isExcludedPage()) {
this._launchApp();
}
};
// Start listening
sentinel.on(anchorSelector, listener);
Logger.log('LIFECYCLE', LOG_STYLES.YELLOW, 'Waiting for anchor element...');
// Register the cleanup function
this.manageResource(CONSTANTS.RESOURCE_KEYS.ANCHOR_LISTENER, () => {
sentinel.off(anchorSelector, listener);
});
}
}
async _launchApp() {
// Cleanup anchor listener as we are launching
this.manageResource(CONSTANTS.RESOURCE_KEYS.ANCHOR_LISTENER, null);
if (!this.appController) {
Logger.log('LIFECYCLE', LOG_STYLES.YELLOW, 'Launching AppController...');
// Initialize AppController and register it as a resource
// Inject the persistent data/image managers
this.appController = this.manageFactory(CONSTANTS.RESOURCE_KEYS.APP_CONTROLLER, () => new AppController(this.dataConverter, this.imageDataManager));
if (this.appController) {
await this.appController.init();
}
}
}
_shutdown() {
// Cleanup anchor listener
this.manageResource(CONSTANTS.RESOURCE_KEYS.ANCHOR_LISTENER, null);
if (this.appController) {
Logger.log('LIFECYCLE', LOG_STYLES.YELLOW, 'Shutting down AppController (Excluded Page).');
// Dispose the resource (calls destroy())
this.manageResource(CONSTANTS.RESOURCE_KEYS.APP_CONTROLLER, null);
// Explicitly clear reference to prevent double-launch issues
this.appController = null;
}
}
}
// =================================================================================
// SECTION: Entry Point
// =================================================================================
// Exit if already executed
if (ExecutionGuard.hasExecuted()) return;
// Set executed flag if not executed yet
ExecutionGuard.setExecuted();
// Singleton instance for observing DOM node insertions.
const sentinel = new Sentinel(OWNERID);
// Initialize network interception immediately to safely wrap fetch before site scripts load.
// It starts in an active state to ensure early API calls are not missed during initial load or SPA navigation.
if (PlatformAdapters.Timestamp.isTimestampEnabledSync(DEFAULT_THEME_CONFIG.platforms[PLATFORM])) {
PlatformAdapters.Timestamp.init();
}
// Initialize lifecycle management to handle app startup and navigation
const lifecycleManager = new LifecycleManager();
lifecycleManager.init();
})();