Fully customize the chat UI of [ChatGPT/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.5.1
// @license MIT
// @description Fully customize the chat UI of [ChatGPT/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 data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' height='24px' viewBox='0 -960 960 960' width='24px' fill='%235985E1'%3E%3Cpath d='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'/%3E%3C/svg%3E
// @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 *
// @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()}]`;
// --- Temporal API Polyfill ---
// Lightweight fallback for outdated browsers that do not support the Temporal API yet.
const globalObj = typeof globalThis !== 'undefined' ? globalThis : window;
if (typeof globalObj.Temporal === 'undefined') {
/** @type {any} */ (globalObj).Temporal = {
Now: {
instant: () => ({
epochMilliseconds: Date.now(),
}),
timeZoneId: () => {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch {
return 'UTC';
}
},
},
Instant: {
fromEpochMilliseconds: (ms) => ({
toZonedDateTimeISO: () => {
const d = new Date(ms);
return {
year: d.getFullYear(),
month: d.getMonth() + 1,
day: d.getDate(),
hour: d.getHours(),
minute: d.getMinutes(),
second: d.getSeconds(),
};
},
}),
},
};
}
// =================================================================================
// 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,
SCROLL_CORRECTION_MAX_MS: 300,
SCROLL_CORRECTION_STABLE_FRAMES: 3,
PROGRESSIVE_SCROLL_LIMIT: 20,
SHARED_CHAT_FETCH_LIMIT: 2,
},
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,
PROGRESSIVE_SCROLL_TIMEOUT: 5000,
},
THRESHOLDS: {
SUSPEND_LIMIT_MS: 5 * 60 * 1000, // 5 minutes threshold for heavy throttling/suspension
FETCH_ERROR_LOG_THROTTLE_MS: 60 * 1000, // 60 seconds throttle for fetch error logs
},
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',
},
OBSERVER_STRATEGIES: {
SENTINEL: 'sentinel',
MUTATION: 'mutation',
},
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',
},
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',
API_MESSAGE_MANAGER: 'apiMessageManager',
TIMESTAMP_MANAGER: 'timestampManager',
FIXED_NAV_MANAGER: 'fixedNavManager',
MESSAGE_NUMBER_MANAGER: 'messageNumberManager',
AUTO_SCROLL_MANAGER: 'autoScrollManager',
// Observer Resources
LAYOUT_RESIZE_OBSERVER: 'layoutResizeObserver',
INTEGRITY_SCAN: 'integrityScan',
PAGE_SCOPE: 'pageScope',
// Task Resources
BATCH_TASK: 'batchTask',
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',
},
NAV_PURPOSE: {
LIFECYCLE: 'lifecycle',
},
};
/**
* Common constant for default SVG icons to prevent redundant generation of large strings during the initialization of each platform's settings.
*/
const DEFAULT_ICONS = {
ASSISTANT:
'<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>',
USER: '<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>',
};
/**
* 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: DEFAULT_ICONS.ASSISTANT,
textColor: null,
font: null,
bubbleBackgroundColor: null,
bubblePadding: 8,
bubbleBorderRadius: 10,
bubbleMaxWidth: null,
standingImageUrl: null,
},
user: {
name: 'You',
icon: DEFAULT_ICONS.USER,
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',
API_MESSAGES_LOADED: 'API_MESSAGES_LOADED',
API_MESSAGE_ADDED: 'API_MESSAGE_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: relative !important;
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;
}
/* Reset pseudo-elements injected by host site */
${root}::before,
${root}::after {
content: none !important;
display: none !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 execute.
* @returns {boolean} True if the page is a target page, default is false.
*/
isTargetPage() {
const allowedRoutes = CONSTANTS.URL_PATTERNS.ALLOWED_ROUTES;
const pathname = window.location.pathname;
return allowedRoutes.some((pattern) => pattern.test(pathname));
}
/**
* 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} contentElement The content element.
* @returns {HTMLElement | null} The parent message container.
*/
findMessageElement(contentElement) {
return contentElement.closest(CONSTANTS.SELECTORS.BUBBLE_FEATURE_MESSAGE_CONTAINERS);
}
/**
* 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;
}
/**
* Extracts an array of MessageNode objects representing the current chat history.
* @returns {MessageNode[]}
*/
extractMessageNodes() {
/** @type {MessageNode[]} */
const nodes = [];
const rootContainer = this.getMessagesRoot();
if (!rootContainer) return nodes;
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) {
const node = this.createMessageNode(msg);
if (node) {
nodes.push(node);
}
}
}
return nodes;
}
/**
* Creates a standardized MessageNode from a raw message DOM element.
* @param {HTMLElement} element The message element.
* @returns {MessageNode | null} The created node, or null if invalid.
*/
createMessageNode(element) {
return null;
}
/**
* 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.
* @param {HTMLElement} element The target element.
*/
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);
}
/**
* Checks if a new message node can be safely appended to the cache based on ordering rules.
* @param {MessageNode} newNode The new message node to append.
* @param {MessageNode} lastNode The currently last message node in the cache.
* @returns {boolean} True if the order is valid (append), false if invalid (force rebuild).
*/
isAppendOrderValid(newNode, lastNode) {
if (!newNode.element || !lastNode.element) {
return false;
}
const position = lastNode.element.compareDocumentPosition(newNode.element);
return !!(position & Node.DOCUMENT_POSITION_FOLLOWING);
}
}
/**
* @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() {
// 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 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.
*/
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) {
return {
shouldInject: false,
targetElement: null,
processedTarget: msgElem,
exclusionKey: msgElem,
originalElement: msgElem,
};
}
return {
shouldInject: true,
targetElement: msgElem,
processedTarget: msgElem,
exclusionKey: msgElem,
originalElement: msgElem,
};
}
/**
* 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>}
*/
async recalculateLayout(instance) {
const v = instance.style.vars;
const rootStyle = document.documentElement.style;
if (this.shouldHideStandingImages()) {
rootStyle.setProperty(v.assistantWidth, '0px');
rootStyle.setProperty(v.userWidth, '0px');
return;
}
await withLayoutCycle({
measure: () => {
// --- Read Phase ---
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
const bounds = this.measureLayoutBounds(instance, windowWidth, windowHeight);
if (!bounds) return null;
const cls = instance.style.classes;
const assistantImg = document.getElementById(cls.assistantImageId);
const userImg = document.getElementById(cls.userImageId);
return {
...bounds,
windowHeight,
assistantImgHeight: assistantImg ? assistantImg.offsetHeight : 0,
userImgHeight: userImg ? userImg.offsetHeight : 0,
};
},
mutate: (measured) => {
// --- Write Phase ---
if (instance.isDestroyed || !measured) {
if (!measured) {
rootStyle.setProperty(v.assistantWidth, '0px');
rootStyle.setProperty(v.userWidth, '0px');
}
return;
}
const { appLeft, appRight, messageLeft, messageRight, windowHeight, assistantImgHeight, userImgHeight } = measured;
// Set Assistant base position (Platform specific)
rootStyle.setProperty(v.assistantLeft, `${appLeft}px`);
// Calculate available widths
const assistantAvailableWidth = messageLeft - appLeft;
const userAvailableWidth = appRight - messageRight;
// Delegate common calculation
StandingImageLayout.apply(instance, {
assistantAvailableWidth,
userAvailableWidth,
assistantImgHeight,
userImgHeight,
windowHeight,
});
},
});
}
/**
* Determines if standing images should be hidden based on current UI state.
* @returns {boolean}
*/
shouldHideStandingImages() {
return false;
}
/**
* Measures the boundaries of the application and message areas.
* @param {StandingImageManager} instance
* @param {number} windowWidth
* @param {number} windowHeight
* @returns {{appLeft: number, appRight: number, messageLeft: number, messageRight: number} | null}
*/
measureLayoutBounds(instance, windowWidth, windowHeight) {
return null;
}
/**
* Updates the visibility of standing images.
* @param {StandingImageManager} instance The manager instance.
*/
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;
// 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 && !isFilePanelActive && !instance.isAutoScrolling ? '1' : '0';
});
}
/**
* Sets up platform-specific event listeners.
* @param {StandingImageManager} instance The manager instance.
*/
setupEventListeners(instance) {
// Registers a listener for the CACHE_UPDATED event to trigger layout recalculation.
// This ensures that standing images maintain correct positioning and visibility
// when the internal message cache is rebuilt or updated (e.g., after DOM updates or navigation).
instance.registerPlatformListener(EVENTS.CACHE_UPDATED, instance.scheduleUpdate);
}
}
/**
* @class BaseObserverAdapter
* @description Manages DOM observers.
*/
class BaseObserverAdapter {
/**
* @protected
* @description Starts a stateful observer for the input area to detect resizing and DOM reconstruction (button removal).
* @param {ObserverDependencies} dependencies The required methods from ObserverManager.
* @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,
});
}
/**
* Gets the configuration for observing turn completion based on platform specifics.
* @returns {{ strategy: string, selector?: string, observerOptions?: MutationObserverInit }}
* @throws {Error} Must be implemented by subclasses.
*/
getTurnCompletionConfig() {
throw new Error('getTurnCompletionConfig must be implemented by the platform adapter.');
}
/**
* 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.
* @returns {boolean} True if header positioning is allowed.
*/
isHeaderPositionAvailable() {
// 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 selector = this.getScrollContainerForHeaderCheck();
const container = selector ? document.querySelector(selector) : null;
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;
}
/**
* Gets the CSS selector for the container used to measure width for header positioning.
* @returns {string | null}
*/
getScrollContainerForHeaderCheck() {
return null;
}
/**
* 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 {MessageNode | null} highlightedMessage The currently highlighted message node.
* @param {number} previousTotalMessages Previous count of messages.
*/
handleInfiniteScroll(manager, highlightedMessage, previousTotalMessages) {
// No-op by default
}
/**
* Applies additional highlighting to the message or turn.
* @param {MessageNode} messageNode The message node.
* @param {StyleHandle} styleHandle The style handle.
*/
applyAdditionalHighlight(messageNode, 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) {
const cls = styleHandle.classes;
const isSupported = this.isAutoScrollSupported();
const tooltip = this.getAutoScrollTooltip(false);
const autoscrollBtn = h(
`button#${cls.autoscrollBtnId}.${cls.btn}`,
{
title: tooltip,
disabled: !isSupported,
style: isSupported ? {} : { opacity: '0.5', cursor: 'not-allowed' },
dataset: { [CONSTANTS.DATA_KEYS.ORIGINAL_TITLE]: tooltip },
onclick: (e) => {
e.preventDefault();
if (isSupported) {
EventBus.publish(EVENTS.AUTO_SCROLL_REQUEST);
}
},
},
[createIconFromDef(StyleDefinitions.ICONS.scrollToTop)]
);
return [autoscrollBtn];
}
/**
* 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) {
const isSupported = this.isAutoScrollSupported();
if (!isSupported) {
btn.disabled = true;
btn.title = this.getAutoScrollTooltip(false);
btn.style.opacity = '0.5';
btn.style.cursor = 'not-allowed';
return;
}
btn.disabled = isAutoScrolling;
if (isAutoScrolling) {
btn.title = this.getAutoScrollTooltip(true);
} else {
btn.title = DomState.get(btn, CONSTANTS.DATA_KEYS.ORIGINAL_TITLE);
}
}
/**
* Returns whether the auto-scroll feature is supported on this platform.
* @returns {boolean}
*/
isAutoScrollSupported() {
return false;
}
/**
* Gets the tooltip text for the auto-scroll button.
* @param {boolean} isAutoScrolling
* @returns {string}
*/
getAutoScrollTooltip(isAutoScrolling) {
return '';
}
/**
* Gets the tooltip text for the shift action on the fixed nav console.
* @returns {string} The shift action text.
*/
getShiftActionText() {
return '';
}
/**
* Optional hook to handle platform-specific scroll logic.
* @param {MessageNode} messageNode The target message node.
* @param {FixedNavigationManager} manager The manager instance.
* @returns {boolean} True if the scroll was handled by the adapter, false to use the default common logic.
*/
handleScrollToMessage(messageNode, manager) {
return false;
}
}
/**
* @class BaseApiMessageAdapter
* @description Manages API message fetching and processing.
*/
class BaseApiMessageAdapter {
constructor() {
/** @type {Map<string, MessageNode>} */
this.cache = new Map();
this.MAX_CACHE_SIZE = 10000;
this.isInitialized = false;
this.streamingState = null; // Reference to shared streaming state
}
/**
* Initializes API interception logic.
*/
init() {
// No-op by default
}
/**
* Cleans up API interception logic.
* Does NOT clear the data cache to allow persistence across navigation.
*/
cleanup() {
// No-op by default
}
/**
* Adds message data to the persistent cache with LRU logic.
* @param {string} id
* @param {MessageNode} node
*/
addMessageData(id, node) {
if (this.cache.size >= this.MAX_CACHE_SIZE) {
const oldestKey = this.cache.keys().next().value;
this.cache.delete(oldestKey);
}
this.cache.set(id, node);
}
/**
* Retrieves message data from the persistent cache.
* @param {string} id
* @returns {MessageNode | undefined}
*/
getMessageData(id) {
return this.cache.get(id);
}
/**
* Retrieves all message data from the persistent cache.
* @returns {MessageNode[]}
*/
getAllMessageData() {
return Array.from(this.cache.values());
}
}
/**
* @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.
*/
ensureButtonPlacement(settingsButton) {
ensureSettingsButtonPlacement(settingsButton, CONSTANTS.SELECTORS.INSERTION_ANCHOR, PlatformAdapters.General.isTargetPage);
}
}
// =================================================================================
// 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,
CAPABILITIES: {
TIMESTAMP: true,
API_MESSAGE: true,
},
PATHS: {
CHAT: '/c/',
SHARE: '/share/',
},
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',
TURN_ID: 'data-turn-id',
TURN_ID_VIRTUAL: 'data-turn-id-container',
},
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: ':is(div[data-scroll-root], div:has(> main#main):not(div[data-scroll-root] *))',
MESSAGE_WRAPPER_FINDER: '.w-full',
// Root container for message search optimization
MESSAGES_ROOT: 'main',
// --- Message containers ---
// Target persistent turn containers (Active section or Virtualized placeholder div)
CONVERSATION_UNIT: ':is(section[data-turn-id], article[data-turn-id], div[data-turn-id-container])',
MESSAGE_ID_HOLDER: '[data-message-id], [id^="image-"]',
MESSAGE_ROOT_NODE: ':is(section[data-testid^="conversation-turn-"], article[data-testid^="conversation-turn-"], [data-turn-id-container])',
// --- 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)',
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 ---
THREAD_BOTTOM_CONTAINER: ':is(#thread-bottom-container, div[class*="threadFooterContentFade"])',
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: 'button[class*="absolute"]:has(svg > use[href*="sprites-core"])',
// --- Site Specific Selectors ---
DISCLAIMER: '[data-testid="thread-disclaimer"]',
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',
// --- New Chat Intro Container ---
NEW_CHAT_INTRO_CONTAINER: 'main .composer-parent > .relative > .flex.h-full.items-center.justify-center',
},
URL_PATTERNS: {
ALLOWED_ROUTES: [/^\/$/, /^\/c\//, /^\/g\//, /^\/share\//],
CONVERSATION_ENDPOINTS: ['/backend-api/conversation', '/backend-api/share'],
},
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 {
constructor() {
super();
/** @type {{ path: string | null, title: string | null }} */
this._sidebarTitleCache = { path: null, title: null };
/** @type {Map<string, Map<string, string[]>>} */
this.chatTurnCaches = new Map();
/** @type {symbol | null} */
this._activeScrollId = null;
}
/** @override */
isCanvasModeActive() {
return !!document.querySelector(CONSTANTS.SELECTORS.CANVAS_CONTAINER);
}
/** @override */
isNewChatPage() {
const path = window.location.pathname;
// Main new chat page or GPT/Project top page (no conversation ID)
if (!(path === '/' || (path.startsWith('/g/') && !path.includes('/c/')))) {
return false;
}
// Check if there are active temporary chat messages in the DOM
return !document.querySelector(CONSTANTS.SELECTORS.MESSAGE_ROOT_NODE);
}
/** @override */
isChatPage() {
// Any URL containing CHAT or SHARE constants is a conversation page
const path = window.location.pathname;
return path.includes(CONSTANTS.PATHS.CHAT) || path.includes(CONSTANTS.PATHS.SHARE);
}
/** @override */
getMessagesRoot() {
const root = document.querySelector(CONSTANTS.SELECTORS.MESSAGES_ROOT);
return root instanceof HTMLElement ? root : document.body;
}
/** @override */
getMessageId(element) {
const msgId = element.getAttribute(CONSTANTS.ATTRIBUTES.MESSAGE_ID);
if (msgId) return msgId;
// Fallback for DALL-E images which use id="image-<uuid>"
const idAttr = element.getAttribute('id');
if (idAttr && idAttr.startsWith('image-')) {
return idAttr.substring(6); // Remove 'image-' prefix
}
return null;
}
/** @override */
getMessageRole(messageElement) {
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() {
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);
let text = '';
// 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) text = contentEl.textContent.trim();
} else {
const contentEl = messageElement.querySelector(CONSTANTS.SELECTORS.ASSISTANT_TEXT_CONTENT);
if (contentEl) text = contentEl.textContent.trim();
}
// 2. Check for an image within the message container.
const hasImage = !!(messageElement.querySelector(CONSTANTS.SELECTORS.RAW_USER_IMAGE_BUBBLE) || messageElement.querySelector(CONSTANTS.SELECTORS.RAW_ASSISTANT_IMAGE_BUBBLE));
// 3. Combine and return.
return hasImage ? `(image) ${text}`.trim() : text;
}
/** @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, it is a ghost artifact. Filter it out.
if (!hasText && !hasImage) {
return false;
}
}
return true; // Keep all other messages
}
/** @override */
extractMessageNodes() {
// --- ChatGPT Architecture Note ---
// 1. Turn Container Persistence (DOM):
// Turn containers (section or virtualized div) are always present in the DOM.
// We map these containers to their IDs to maintain a reliable scroll target even when bubbles are unmounted.
// 2. Active Path (API as Source of Truth):
// We derive the definitive message order by traversing the API tree (parentMap) from the leaf to the root.
// This ensures a complete and correctly ordered list regardless of DOM virtualization.
// 3. Persistence-aware Merging:
// We reconstruct the list by following the Active Path, using live DOM elements where available,
// and falling back to API cache for unmounted messages while maintaining a link to the persistent turn container.
/** @type {MessageNode[]} */
const nodes = [];
// Get current chat ID from URL
const match = window.location.pathname.match(/\/(?:c|share)\/([a-zA-Z0-9-]+)/i);
const currentChatId = match ? match[1] : null;
const apiAdapter = PlatformAdapters.ApiMessage;
const hasApi = apiAdapter.isInitialized;
const rootContainer = this.getMessagesRoot();
if (!rootContainer) return nodes;
// 1. Map all turn containers present in DOM (including virtualized ones)
const turnMap = new Map(); // Map<turnId, HTMLElement>
const turnContainers = rootContainer.querySelectorAll(CONSTANTS.SELECTORS.CONVERSATION_UNIT);
for (let i = 0; i < turnContainers.length; i++) {
const tc = turnContainers[i];
if (tc instanceof HTMLElement) {
const id = tc.getAttribute(CONSTANTS.ATTRIBUTES.TURN_ID) || tc.getAttribute(CONSTANTS.ATTRIBUTES.TURN_ID_VIRTUAL);
if (id) turnMap.set(id, tc);
}
}
// 2. Gather all mounted message elements and link to their turn containers
const mountedElements = new Map(); // Map<msgId, { element: HTMLElement, turnElement: HTMLElement }>
const activeIdsFromDom = [];
let sequentialTimestamp = Math.floor(Temporal.Now.instant().epochMilliseconds / 1000);
const messageElements = rootContainer.querySelectorAll(CONSTANTS.SELECTORS.BUBBLE_FEATURE_MESSAGE_CONTAINERS);
for (let i = 0; i < messageElements.length; i++) {
const msgEl = messageElements[i];
if (msgEl instanceof HTMLElement && this.filterMessage(msgEl)) {
let holder = msgEl.closest(CONSTANTS.SELECTORS.MESSAGE_ID_HOLDER);
if (!holder) holder = msgEl.querySelector(CONSTANTS.SELECTORS.MESSAGE_ID_HOLDER);
const msgId = holder ? this.getMessageId(holder) : null;
if (msgId) {
const turnElement = msgEl.closest(CONSTANTS.SELECTORS.CONVERSATION_UNIT);
if (turnElement instanceof HTMLElement) {
mountedElements.set(msgId, { element: msgEl, turnElement });
activeIdsFromDom.push(msgId);
}
}
}
}
// Synchronize DOM state with API tree
if (hasApi && typeof apiAdapter.updateActiveBranch === 'function') {
apiAdapter.updateActiveBranch(currentChatId, activeIdsFromDom);
}
// 3. Build Active Path from API tree
let activePathIds = [];
if (hasApi && apiAdapter.chatLeafMap && apiAdapter.parentMap) {
const leafId = apiAdapter.chatLeafMap.get(currentChatId);
if (leafId) {
let currId = leafId;
while (currId) {
activePathIds.unshift(currId);
currId = apiAdapter.parentMap.get(currId);
}
}
}
// Fallback: Use DOM order if API path is unavailable
if (activePathIds.length === 0) {
activePathIds = activeIdsFromDom;
}
// 4. Reconstruct complete message list using persistence-aware merging
for (const msgId of activePathIds) {
const mountedData = mountedElements.get(msgId);
const cachedNode = hasApi ? apiAdapter.getMessageData(msgId) : null;
if (mountedData) {
// MOUNTED: Update with live DOM references
if (cachedNode) {
nodes.push({
...cachedNode,
element: mountedData.element,
turnElement: mountedData.turnElement,
});
} else {
// FRESH: New message not yet in API cache
const newNode = this.createMessageNode(mountedData.element);
if (newNode) {
newNode.turnElement = mountedData.turnElement;
newNode.timestamp = sequentialTimestamp++;
nodes.push(newNode);
if (hasApi && typeof apiAdapter.addMessageData === 'function') {
apiAdapter.addMessageData(msgId, newNode);
}
}
}
} else if (cachedNode) {
// UNMOUNTED: Restore from cache and link to persistent turn container
const turnElement = turnMap.get(cachedNode.turnId) || null;
nodes.push({
...cachedNode,
element: null,
turnElement: turnElement,
});
}
}
return nodes;
}
/** @override */
createMessageNode(messageElement) {
let messageIdHolder = messageElement.closest(CONSTANTS.SELECTORS.MESSAGE_ID_HOLDER);
if (!messageIdHolder) messageIdHolder = messageElement.querySelector(CONSTANTS.SELECTORS.MESSAGE_ID_HOLDER);
if (!messageIdHolder) return null;
const messageId = this.getMessageId(messageIdHolder);
if (!messageId) return null;
const rawRole = this.getMessageRole(messageElement);
let role;
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;
} else {
return null; // Ignore invalid roles
}
let hasImage = false;
if (CONSTANTS.SELECTORS.RAW_ASSISTANT_IMAGE_BUBBLE && messageElement.querySelector(CONSTANTS.SELECTORS.RAW_ASSISTANT_IMAGE_BUBBLE)) {
hasImage = true;
}
if (CONSTANTS.SELECTORS.RAW_USER_IMAGE_BUBBLE && messageElement.querySelector(CONSTANTS.SELECTORS.RAW_USER_IMAGE_BUBBLE)) {
hasImage = true;
}
const type = /** @type {'image'|'text'} */ (hasImage ? 'image' : 'text');
const text = this.getJumpListDisplayText(messageElement);
// Extract current chat ID for fallback nodes
const match = window.location.pathname.match(/\/(?:c|share)\/([a-zA-Z0-9-]+)/i);
const currentChatId = match ? match[1] : null;
const rawTurnElement = messageElement.closest(CONSTANTS.SELECTORS.CONVERSATION_UNIT);
const turnElement = rawTurnElement instanceof HTMLElement ? rawTurnElement : null;
let turnId = '';
if (turnElement) {
turnId = turnElement.getAttribute(CONSTANTS.ATTRIBUTES.TURN_ID) || turnElement.getAttribute(CONSTANTS.ATTRIBUTES.TURN_ID_VIRTUAL) || '';
}
return {
id: messageId,
chatId: currentChatId,
turnId: turnId,
role: /** @type {'user'|'assistant'} */ (role),
type: type,
text: text,
timestamp: Math.floor(Temporal.Now.instant().epochMilliseconds / 1000), // UNIX timestamp
element: messageElement,
turnElement: turnElement,
};
}
/** @override */
ensureMessageContainerForImage(imageContentElement) {
// We only do this for the specific image selector.
if (!imageContentElement.matches(CONSTANTS.SELECTORS.RAW_ASSISTANT_IMAGE_BUBBLE)) {
return null;
}
// 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 scrollContainerSelector = CONSTANTS.SELECTORS.SCROLL_CONTAINER;
const scrollContainer = scrollContainerSelector ? document.querySelector(scrollContainerSelector) : null;
if (!(scrollContainer instanceof HTMLElement)) return;
// Target the provided container directly to ensure the entire message
// (including attached images) is scrolled into view.
const scrollTargetElement = element;
// Setup flag and ID for exclusive scroll control to prevent jittering
const myScrollId = Symbol();
this._activeScrollId = myScrollId;
// Dispatch a synthetic wheel event to trick ChatGPT's internal state into releasing its scroll lock.
// This prevents the site's React Scroll Restoration from snapping the view back to the previous position.
const wheelEvent = new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY: 1 });
scrollContainer.dispatchEvent(wheelEvent);
// Initial scroll position calculation and execution (behavior: 'auto' for instant move)
const calculateTargetTop = () => {
return scrollContainer.scrollTop + scrollTargetElement.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top - offset;
};
let currentTargetTop = calculateTargetTop();
scrollContainer.scrollTo({ top: currentTargetTop, behavior: 'auto' });
// --- Layout Shift Correction (Jank/Blur Prevention Applied) ---
const startTime = performance.now();
const MAX_DURATION_MS = CONSTANTS.RETRY.SCROLL_CORRECTION_MAX_MS; // Maximum time to attempt correction
let stableFrames = 0;
const adjustScroll = () => {
// Exclusive control: stop current loop if a new scrollTo is called
if (this._activeScrollId !== myScrollId) return;
// Abort correction loop immediately if target is detached from DOM
// (e.g., when a virtual placeholder is replaced by the real React element)
if (!scrollTargetElement.isConnected) return;
const now = performance.now();
if (now - startTime > MAX_DURATION_MS) return; // Stop if timeout reached
const currentRectTop = scrollTargetElement.getBoundingClientRect().top;
const containerRectTop = scrollContainer.getBoundingClientRect().top;
// The expected relative Y coordinate on screen (should match the offset)
const expectedRelativeTop = offset;
const actualRelativeTop = currentRectTop - containerRectTop;
// Detect shift (allow up to 1px rounding error)
if (Math.abs(actualRelativeTop - expectedRelativeTop) > 1) {
stableFrames = 0; // Reset stability counter
currentTargetTop = calculateTargetTop();
scrollContainer.scrollTo({ top: currentTargetTop, behavior: 'auto' }); // Instantly correct the shift
} else {
stableFrames++;
}
// Stop monitoring if position is stable for target consecutive frames
if (stableFrames >= CONSTANTS.RETRY.SCROLL_CORRECTION_STABLE_FRAMES) return;
// Check again on the next render frame
requestAnimationFrame(adjustScroll);
};
// Start the correction loop
requestAnimationFrame(adjustScroll);
}
}
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;
}
/* (2026/07/14) Remove bottom container gradient/shadows to reveal theme background */
${CONSTANTS.SELECTORS.THREAD_BOTTOM_CONTAINER},
${CONSTANTS.SELECTORS.THREAD_BOTTOM_CONTAINER}::before,
${CONSTANTS.SELECTORS.THREAD_BOTTOM_CONTAINER}::after {
background: none !important;
background-image: none !important;
box-shadow: none !important;
mask-image: none !important;
-webkit-mask-image: 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;
}
/* (2026/07/14) Hide disclaimer text to prevent overlap with Nav Console */
${CONSTANTS.SELECTORS.DISCLAIMER} {
display: none !important;
}
/* Make new chat introduction area transparent to reveal theme background */
${CONSTANTS.SELECTORS.NEW_CHAT_INTRO_CONTAINER} {
background-color: transparent !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(manager) {
const initialTitle = manager.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(manager, config, urlChanged, titleChanged) {
const currentTitle = manager.getChatTitleAndCache();
// 1. Invalidate cache on URL change to force pattern re-evaluation.
if (urlChanged) {
manager.cachedThemeSet = null;
}
// 2. Get the candidate theme based on the current context (URL, Title).
const candidateTheme = manager.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 && manager.lastAppliedThemeSet && !isNewChatPage();
if (shouldKeepPreviousTheme) {
return manager.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...';
}
}
class ChatGPTAppControllerAdapter extends BaseAppControllerAdapter {
// No-op adapter, inherits defaults
}
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 */
shouldHideStandingImages() {
// 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.
return isCanvasActive || isRightSidebarOpen;
}
/** @override */
measureLayoutBounds(instance, windowWidth, windowHeight) {
// --- 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 null;
}
}
const sidebarWidth = getSidebarWidth();
// --- Virtual Rect Calculation (if needed) ---
if (!chatRect) {
// Default width fallback (50vw per requirement)
let targetWidth = windowWidth * 0.5;
const config = instance.configManager.get();
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 = { left, right: left + effectiveWidth };
}
return {
appLeft: sidebarWidth,
appRight: windowWidth,
messageLeft: chatRect.left,
messageRight: chatRect.right,
};
}
}
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)
checkVisibility: (panel) => panel.offsetParent !== null,
attributesToWatch: [],
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),
checkVisibility: (panel) => panel.offsetParent !== null,
attributesToWatch: [],
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)
checkVisibility: (panel) => panel.offsetParent !== null,
attributesToWatch: [],
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();
debouncedTitleUpdate.cancel();
};
}
/**
* @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);
// Clean up the applied class from any existing targets to prevent styling leaks on navigation
const affectedElements = document.querySelectorAll(`.${className}`);
for (let i = 0; i < affectedElements.length; i++) {
affectedElements[i].classList.remove(className);
}
};
}
/** @override */
getTurnCompletionConfig() {
return {
strategy: CONSTANTS.OBSERVER_STRATEGIES.SENTINEL,
selector: CONSTANTS.SELECTORS.TURN_COMPLETE_SELECTOR,
};
}
/** @override */
getPlatformObserverStarters() {
// prettier-ignore
return [
this.startGlobalTitleObserver.bind(this),
this.startSidebarObserver.bind(this),
this.startCanvasObserver.bind(this),
this.startRightSidebarObserver.bind(this),
this.startResearchPanelObserver.bind(this),
this.startInputAreaObserver.bind(this),
this.startStyleOptimizationObserver.bind(this),
];
}
/** @override */
isTurnComplete(turnNode) {
if (window.location.pathname.startsWith(CONSTANTS.PATHS.SHARE)) return true;
// 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() {
return [{ configKey: 'features.timestamp.enabled' }, { configKey: 'features.collapsible_button.auto_collapse_user_message.enabled' }];
}
}
class ChatGPTFixedNavAdapter extends BaseFixedNavAdapter {
/** @override */
getScrollContainerForHeaderCheck() {
return CONSTANTS.SELECTORS.SCROLL_CONTAINER;
}
/** @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(messageNode, styleHandle) {
if (!messageNode || !messageNode.element) return;
const messageElement = messageNode.element;
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 */
getAutoScrollTooltip(isAutoScrolling) {
return 'Auto-scroll is not required on ChatGPT.';
}
/** @override */
getShiftActionText() {
return '\n[Shift] Auto-scroll';
}
/** @override */
handleScrollToMessage(node, manager) {
const attemptScroll = () => {
if (manager.isDestroyed) return false;
// If message bubble is connected, scroll to it directly (Final target reached)
if (node.element && node.element.isConnected) {
PlatformAdapters.General.scrollTo(node.element);
return true;
}
// If bubble is unmounted but persistent turn container is connected, scroll to the container
if (node.turnElement && node.turnElement.isConnected) {
PlatformAdapters.General.scrollTo(node.turnElement);
// Return false to keep progressive scroll active, waiting for the real element to mount
return false;
}
// Fallback: search closest mounted element in cache
const targetToScroll = manager.messageCacheManager.getScrollTarget(node);
if (targetToScroll) {
PlatformAdapters.General.scrollTo(targetToScroll);
}
return false;
};
const reached = attemptScroll();
if (!reached) {
const limit = CONSTANTS.RETRY.PROGRESSIVE_SCROLL_LIMIT || 20;
const timeout = CONSTANTS.TIMING.TIMEOUTS.PROGRESSIVE_SCROLL_TIMEOUT || 5000;
let attempts = 0;
const timerKey = generateUniqueId('progScrollTimer');
let isCleanedUp = false;
const cleanup = () => {
if (isCleanedUp) return;
isCleanedUp = true;
if (typeof unsubscribe === 'function') unsubscribe();
clearTimeout(timerId);
manager.manageResource(timerKey, null);
};
// Use persistent listener instead of one-time listener to ensure all retry frames are captured
const unsubscribe = manager.registerPlatformListener(EVENTS.CACHE_UPDATED, processUpdate);
function processUpdate() {
if (manager.isDestroyed) {
cleanup();
return;
}
attempts++;
const isFinalTargetReached = node.element && node.element.isConnected;
if (isFinalTargetReached) {
// Final target reached, stop listening and do precision scroll
cleanup();
attemptScroll();
} else if (attempts < limit) {
// Still waiting for real element, try scrolling to placeholder/nearest
attemptScroll();
} else {
// Max attempts reached, cleanup
cleanup();
}
}
const timerId = setTimeout(() => {
cleanup();
}, timeout);
manager.manageResource(timerKey, cleanup);
}
// Return true to indicate the adapter fully handled the scroll
return true;
}
}
class ChatGPTApiMessageAdapter extends BaseApiMessageAdapter {
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
this.parentMap = new Map(); // Mapping from node ID to parent node ID
/** @type {Map<string, string>} Mapping from chatId to its current leaf message ID */
this.chatLeafMap = new Map();
/** @type {Map<string, number>} Track fetch error counts per shared chatId (cleared on navigation) */
this._shareFetchErrors = new Map();
/** @type {Set<string>} Track permanent failures per shared chatId (persisted across navigation) */
this._permanentShareErrors = new Set();
}
/**
* Actively triggers a fetch for shared chats to synchronize the message cache.
* @private
*/
_triggerSharedChatFetch() {
if (!window.location.pathname.startsWith(CONSTANTS.PATHS.SHARE)) return;
const chatId = this._getChatIdFromCurrentUrl();
if (!chatId) return;
// Abort if this shared chat is marked as permanently failed
if (this._permanentShareErrors.has(chatId)) return;
// Abort if the temporary error limit for this shared chat has been reached
const errorCount = this._shareFetchErrors.get(chatId) || 0;
if (errorCount >= CONSTANTS.RETRY.SHARED_CHAT_FETCH_LIMIT) return;
if (this._lastProcessedShareId === chatId) return;
this._lastProcessedShareId = chatId;
let token = null;
try {
const globalContext = typeof unsafeWindow !== 'undefined' ? unsafeWindow.__remixContext : window.__remixContext;
token = globalContext?.state?.loaderData?.root?.clientBootstrap?.session?.accessToken;
} catch {
// Fallback to tokenless fetch
}
const headers = token
? {
Authorization: `Bearer ${token}`,
'X-Authorization': `Bearer ${token}`,
}
: {};
const shareEndpoint = CONSTANTS.URL_PATTERNS.CONVERSATION_ENDPOINTS.find((path) => path.includes('share'));
const targetUrl = `${window.location.origin}${shareEndpoint}/${chatId}`;
const normalizedUrl = `${shareEndpoint}/${chatId}`;
const handlePermanentFailure = (status) => {
this._permanentShareErrors.add(chatId);
Logger.error('API_MESSAGE', LOG_STYLES.RED, `Permanent sync failure for shared chat ${chatId} (HTTP ${status}). Synchronization stopped.`);
};
const handleTemporaryFailure = () => {
const currentErrors = (this._shareFetchErrors.get(chatId) || 0) + 1;
this._shareFetchErrors.set(chatId, currentErrors);
if (currentErrors < CONSTANTS.RETRY.SHARED_CHAT_FETCH_LIMIT) {
this._lastProcessedShareId = null;
} else {
Logger.debug('API_MESSAGE', LOG_STYLES.ORANGE, `Retry limit reached (${currentErrors}/${CONSTANTS.RETRY.SHARED_CHAT_FETCH_LIMIT}) for shared chat ${chatId}. Temporary sync suspended.`);
}
};
this.originalFetch(targetUrl, { method: 'GET', headers })
.then((response) => {
if (response.ok) {
return this._processIntercentedResponse(targetUrl, normalizedUrl, response).then((success) => {
if (success) {
this._shareFetchErrors.delete(chatId);
} else {
handleTemporaryFailure();
}
});
} else {
if (response.status === 404 || response.status === 410) {
handlePermanentFailure(response.status);
} else {
handleTemporaryFailure();
}
}
})
.catch(() => {
handleTemporaryFailure();
});
}
/** @override */
init() {
this.isInterceptionEnabled = true;
if (this.isInitialized) return;
// Check if unsafeWindow is available and valid
if (typeof unsafeWindow === 'undefined' || !unsafeWindow.fetch) {
Logger.error('API_MESSAGE', '', '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;
// EventBus subscription for DOM reference cleanup to prevent memory leaks
EventBus.subscribe(
EVENTS.NAVIGATION_START,
() => {
this.clearDomReferences();
this._triggerSharedChatFetch();
},
'ChatGPTApiMessageAdapter.clearDomReferences'
);
this.isInitialized = true;
Logger.debug('API_MESSAGE', LOG_STYLES.TEAL, 'Successfully intercepted unsafeWindow.fetch');
this._triggerSharedChatFetch();
} 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('API_MESSAGE', LOG_STYLES.TEAL, 'Disabled fetch interception (pass-through mode activated).');
}
// Data cache is preserved (BaseApiMessageAdapter behavior)
}
_getChatIdFromCurrentUrl() {
const match = window.location.pathname.match(/\/(?:c|share)\/([a-zA-Z0-9-]+)/i);
return match ? match[1] : null;
}
/** @override */
addMessageData(id, node) {
// Dynamically register the parent for real-time messages not present in the API JSON
if (node && node.chatId && !this.parentMap.has(id)) {
const currentLeafId = this.chatLeafMap.get(node.chatId);
if (currentLeafId && currentLeafId !== id) {
this.parentMap.set(id, currentLeafId);
}
}
super.addMessageData(id, node);
}
/** @override */
getAllMessageData() {
const currentChatId = this._getChatIdFromCurrentUrl();
// When opening a new chat screen, clean up past temporary caches (chatId === null) as they are remnants of other chats.
if (!currentChatId) {
for (const [id, node] of this.cache.entries()) {
if (node.chatId === null) {
this.cache.delete(id);
}
}
}
const allNodes = Array.from(this.cache.values());
// Filter nodes belonging to the current chat.
// If a node has no chatId (e.g. generated during a New Chat session before URL update),
// we tentatively include it. Once the URL updates, it will remain without chatId
// but will be correctly scoped to the active session.
return allNodes.filter((node) => node.chatId === currentChatId || node.chatId === null);
}
_getChatIdFromUrl(url) {
if (!url) return null;
// Match .../conversation/[ID] or .../shared_conversation/[ID] only. Must end with the ID.
// The ID must contain at least 4 hyphens.
// (e.g., 8-4-4-4-12 format)
for (const endpoint of CONSTANTS.URL_PATTERNS.CONVERSATION_ENDPOINTS) {
const pattern = new RegExp(`${escapeRegExp(endpoint)}\\/([^/]*-[^/]*-[^/]*-[^/]*-[^/]+)$`);
const match = url.match(pattern);
if (match) return match[1];
}
return null;
}
_wrappedFetch(input, init) {
// Pass-through immediately if interception is disabled or streaming is active.
if (!this.isInterceptionEnabled || this.streamingState?.isActive) return this.originalFetch(input, init);
// Only intercept GET requests. Pass-through POST (chat generation) to prevent UI freezing
// caused by buffering heavy streaming responses like Thinking models.
const method = init && init.method ? init.method.toUpperCase() : 'GET';
if (method !== 'GET') return this.originalFetch(input, init);
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
let normalizedUrl = url;
try {
normalizedUrl = new URL(url, location.href).pathname;
} catch {
// Pass-through safely if URL parsing fails
return this.originalFetch(input, init);
}
// Bypass interception entirely for non-conversation endpoints
const isConversationEndpoint = CONSTANTS.URL_PATTERNS.CONVERSATION_ENDPOINTS.some((endpoint) => normalizedUrl.includes(endpoint));
if (!isConversationEndpoint) 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 {
// Skip cloning for streaming responses (text/event-stream) and non-JSON data
// This completely prevents UI freezing caused by backpressure
const contentType = response.headers.get('content-type') || '';
if (!response.ok || !contentType.includes('application/json')) {
return response;
}
// 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(url, normalizedUrl, clonedResponse).catch((e) => {
// Rate-limit error logging
const now = Temporal.Now.instant().epochMilliseconds;
if (now - this._lastFetchObserveErrorAt > CONSTANTS.TIMING.THRESHOLDS.FETCH_ERROR_LOG_THROTTLE_MS) {
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 message data.
* @param {string} url
* @param {string} normalizedUrl
* @param {Response} response
* @returns {Promise<boolean>} True if successfully processed and cached, false otherwise.
*/
async _processIntercentedResponse(url, normalizedUrl, response) {
if (!this.isInterceptionEnabled) return false;
// Get ID from URL (Since we only intercept GET requests, the ID should always be in the URL)
const chatId = this._getChatIdFromUrl(normalizedUrl);
// This acts as a filter to ensure we are processing actual chat data,
// not other endpoints like /conversations (history list) or malformed URLs.
if (!chatId) return false;
Logger.debug('FETCH', LOG_STYLES.ORANGE, 'Target API URL intercepted:', url);
try {
const data = await response.json();
// Added strict null check for 'data' to prevent TypeError if site polyfills or backend returns null
if (!data) return false;
// Parse the JSON data and pass chatId
const messagesMap = this._extractMessages(data, chatId);
if (messagesMap.size > 0) {
// Update the leaf map with the current_node from API if not already set or if explicitly provided
if (data.current_node) {
this.chatLeafMap.set(chatId, data.current_node);
}
// Clear existing cache for THIS chat to prevent stale branches from inflating counts
for (const [id, node] of this.cache.entries()) {
if (node.chatId === chatId) {
this.cache.delete(id);
}
}
// Store in persistent cache
messagesMap.forEach((node, id) => this.addMessageData(id, node));
// Publish event
EventBus.publish(EVENTS.API_MESSAGES_LOADED, { messages: messagesMap });
return true;
}
return false;
} catch (e) {
Logger.error('API_MESSAGE ERROR', LOG_STYLES.RED, 'Failed to parse conversation JSON:', e);
return false;
}
}
/**
* Checks if a raw message object from the API is valid for UI display.
* @param {Object} msg - The raw message object from the JSON response.
* @returns {boolean} True if the message should be displayed, false otherwise.
*/
_isValidMessage(msg) {
const { recipient, metadata, content, author } = msg;
if (recipient !== 'all') {
return false;
}
if (metadata?.is_visually_hidden_from_conversation === true) {
return false;
}
const contentType = content?.content_type;
if (['model_editable_context', 'user_editable_context'].includes(contentType)) {
return false;
}
const parts = content?.parts ?? [];
if (parts.length === 0) {
return false;
}
const isAllEmptyStrings = parts.every((part) => typeof part === 'string' && part.trim() === '');
if (isAllEmptyStrings) {
return false;
}
// Strict role filtering to exclude internal system/tool messages
const role = author?.role;
if (role === 'system') {
return false;
}
if (role === 'tool') {
// Only allow tool messages if they contain a visible image asset (e.g., DALL-E)
const hasImage = parts.some((part) => typeof part === 'object' && part !== null && part.content_type === 'image_asset_pointer');
if (!hasImage) {
return false;
}
}
return true;
}
/**
* Clears DOM references from cached nodes to prevent memory leaks during navigation.
*/
clearDomReferences() {
for (const node of this.cache.values()) {
node.element = null;
node.turnElement = null;
}
// Reset share ID lock on navigation to allow re-fetching during SPA transitions
this._lastProcessedShareId = null;
// Reset temporary fetch error counter on navigation to allow retries on URL change
this._shareFetchErrors.clear();
Logger.debug('API_MESSAGE', LOG_STYLES.TEAL, 'Cleared DOM references from API cache to prevent memory leaks.');
}
/**
* Updates the active leaf ID for a specific chat based on the currently mounted IDs from the DOM.
* @param {string|null} chatId - The ID of the chat.
* @param {string[]} domIds - Array of message IDs currently present or referenced in the DOM.
*/
updateActiveBranch(chatId, domIds) {
if (!chatId || !domIds || domIds.length === 0) return;
// Determine the deepest mounted message ID (New Leaf)
const latestIdFromDom = domIds[domIds.length - 1];
const currentLeafId = this.chatLeafMap.get(chatId); // Old Leaf
if (latestIdFromDom === currentLeafId) return;
// Initial assignment
if (!currentLeafId) {
this.chatLeafMap.set(chatId, latestIdFromDom);
return;
}
// Dynamically register missing parent-child relationships using DOM order
// Required for fallback caching mechanisms that rely on parentMap traversing
for (let i = 1; i < domIds.length; i++) {
const childId = domIds[i];
const parentId = domIds[i - 1];
if (!this.parentMap.has(childId)) {
this.parentMap.set(childId, parentId);
}
}
// Check if the new leaf is actually an ancestor of the current leaf (e.g., due to DOM rendering delay of image messages)
let isAncestor = false;
let checkId = currentLeafId;
while (checkId) {
if (checkId === latestIdFromDom) {
isAncestor = true;
break;
}
checkId = this.parentMap.get(checkId);
}
if (isAncestor) {
// The DOM is just lagging behind the API cache. Do not rewind the active branch.
return;
}
// Check if the old leaf is in the ancestry of the new leaf using the robust parentMap
let isBranchSwitched = true;
let currId = latestIdFromDom;
while (currId) {
if (currId === currentLeafId) {
isBranchSwitched = false;
break;
}
currId = this.parentMap.get(currId);
}
if (isBranchSwitched) {
Logger.debug('API_MESSAGE', LOG_STYLES.TEAL, `Branch switched for chat ${chatId}. New leaf: ${latestIdFromDom}`);
}
// Update the internal state to reflect the new leaf node
this.chatLeafMap.set(chatId, latestIdFromDom);
}
/**
* Checks if a node belongs to the active branch of a specific chat.
* @param {string|null} chatId
* @param {string} nodeId
* @returns {boolean}
*/
isActiveNode(chatId, nodeId) {
if (!chatId) return true;
const leafId = this.chatLeafMap.get(chatId);
if (!leafId) return true; // Fallback if no leaf is recorded yet
let currId = leafId;
while (currId) {
if (currId === nodeId) return true;
currId = this.parentMap.get(currId);
}
return false;
}
/**
* Extracts and parses messages from the JSON object.
* Processes the entire mapping to support all branches (chat trees).
* @param {object} data - The parsed JSON response.
* @param {string} chatId - The conversation ID.
* @returns {Map<string, MessageNode>}
*/
_extractMessages(data, chatId) {
/** @type {Map<string, MessageNode>} */
const messagesMap = new Map();
if (!data || !data.mapping) return messagesMap;
const nodeMap = data.mapping;
const turnIdCache = new Map();
// 1. Build parent map and initialize active branch for tree tracking
Object.keys(nodeMap).forEach((nodeId) => {
const parentId = nodeMap[nodeId]?.parent;
if (parentId) {
this.parentMap.set(nodeId, parentId);
}
});
// Initialize active branch from current_node (if available) to support unmounted nodes on initial load
if (data.current_node) {
this.chatLeafMap.set(chatId, data.current_node);
}
/**
* Resolves the turnId for a given node.
* A new turnId is established when the role switches between 'user' and 'non-user', or when a 'system' message appears (which ChatGPT uses as an explicit turn boundary).
*/
const resolveTurnId = (nodeId) => {
if (turnIdCache.has(nodeId)) return turnIdCache.get(nodeId);
const path = [];
let currId = nodeId;
while (currId && currId !== 'client-created-root' && !turnIdCache.has(currId)) {
path.push(currId);
currId = nodeMap[currId]?.parent;
}
let currentTurnId = currId && turnIdCache.has(currId) ? turnIdCache.get(currId) : null;
// Traverse top-down to identify turn boundaries based on role changes and system messages
for (let i = path.length - 1; i >= 0; i--) {
const id = path[i];
const item = nodeMap[id];
const parentItem = item.parent ? nodeMap[item.parent] : null;
const getRoleType = (node) => {
const role = node?.message?.author?.role;
return role === 'user' ? 'user' : 'non-user';
};
const roleType = getRoleType(item);
const parentRoleType = getRoleType(parentItem);
const isSystem = item?.message?.author?.role === 'system';
// A new turn starts if this is the first node, the role is 'system', or the role type changed from the parent
if (!currentTurnId || isSystem || roleType !== parentRoleType) {
currentTurnId = id;
}
turnIdCache.set(id, currentTurnId);
}
return currentTurnId;
};
// 2. Extract and format message data
Object.keys(nodeMap).forEach((nodeId) => {
const item = nodeMap[nodeId];
const msg = item?.message;
if (msg && this._isValidMessage(msg)) {
const { id, author, content, create_time } = msg;
const rawRole = author?.role;
const role = rawRole === 'tool' ? 'assistant' : rawRole;
const parts = content?.parts ?? [];
const hasImage = parts.some((part) => typeof part === 'object' && part !== null && part.content_type === 'image_asset_pointer');
const type = hasImage ? 'image' : 'text';
// Extract and join text parts, then prepend image prefix if necessary
const textContent = parts
.filter((part) => typeof part === 'string')
.join('\n')
.trim();
const text = hasImage ? `(image) ${textContent}`.trim() : textContent;
const turnId = resolveTurnId(nodeId);
const timestamp = create_time ?? 0;
/** @type {MessageNode} */
const node = {
id,
chatId,
turnId,
role,
type,
text,
timestamp,
element: null,
turnElement: null,
};
messagesMap.set(id, node);
}
});
if (messagesMap.size > 0) {
Logger.debug('API_MESSAGES', LOG_STYLES.TEAL, `Parsed ${messagesMap.size} messages across all branches.`);
}
return messagesMap;
}
}
class ChatGPTUIManagerAdapter extends BaseUIManagerAdapter {
// No-op adapter, inherits defaults
}
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(),
ApiMessage: new ChatGPTApiMessageAdapter(),
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,
CAPABILITIES: {
TIMESTAMP: false,
API_MESSAGE: false,
},
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: 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: 'a[aria-current="page"], a.is-active',
CONVERSATION_TITLE_TEXT: '.title-text',
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',
TOP_BAR: 'top-bar-actions',
CHAT_HISTORY_SCROLLER: 'infinite-scroller.chat-history',
},
URL_PATTERNS: {
ALLOWED_ROUTES: [/^\/app/, /^\/gem/, /^\/gems\/view/, /^\/notebook/, /^\/notebooks\/view/],
},
STRINGS: {
INTERNAL_ID_PREFIX: 'gemini-msg-',
},
};
// ---- 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 */
isTargetPage() {
// Handle Gemini shared domain routing bypass dynamically
if (window.location.hostname === 'share.gemini.google') {
return true;
}
return super.isTargetPage();
}
/** @override */
isCanvasModeActive() {
return !!document.querySelector(CONSTANTS.SELECTORS.CANVAS_CONTAINER);
}
/** @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)
if (!(/^\/app\/?$/.test(path) || /^\/gems\/view\/?$/.test(path) || /^\/gem\/[^/]+\/?$/.test(path))) {
return false;
}
// Check if there are active temporary chat messages in the DOM
return !document.querySelector(CONSTANTS.SELECTORS.MESSAGE_ROOT_NODE);
}
/** @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) {
// In Gemini, the DOM is the source of truth. Returning the internally assigned ID is the normal flow.
return getVirtualId(element);
}
/** @override */
getMessageRole(messageElement) {
return messageElement.tagName.toLowerCase();
}
/** @override */
getChatTitle() {
// 1. Try to get title from document.title
const docTitle = document.title.trim();
// Remove " - Google Gemini" or " - Gemini" from the end
const extractedTitle = docTitle.replace(/\s*-\s*(Google\s+)?Gemini$/i, '').trim();
// If the extracted title is not just "Google Gemini" or "Gemini" (the default new chat titles), use it.
if (extractedTitle && !/^Google\s+Gemini$/i.test(extractedTitle) && !/^Gemini$/i.test(extractedTitle)) {
return extractedTitle;
}
// 2. Fallback: Try to get title from selected chat history item in the DOM
const chatTitle = document.querySelector(CONSTANTS.SELECTORS.CONVERSATION_TITLE_WRAPPER)?.querySelector(CONSTANTS.SELECTORS.CONVERSATION_TITLE_TEXT)?.textContent?.trim();
if (chatTitle) {
return chatTitle;
}
// 3. Fallback: 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 */
createMessageNode(messageElement) {
// In Gemini, the messageElement itself (user-query or model-response) is treated as the entity.
// Assign an internal ID to unmapped DOM elements (normal flow).
const messageId = getOrCreateVirtualId(messageElement, CONSTANTS.STRINGS.INTERNAL_ID_PREFIX);
if (!messageId) return null;
const rawRole = this.getMessageRole(messageElement);
let role;
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;
} else {
return null;
}
let hasImage = false;
if (CONSTANTS.SELECTORS.RAW_ASSISTANT_IMAGE_BUBBLE && messageElement.querySelector(CONSTANTS.SELECTORS.RAW_ASSISTANT_IMAGE_BUBBLE)) {
hasImage = true;
}
if (CONSTANTS.SELECTORS.RAW_USER_IMAGE_BUBBLE && messageElement.querySelector(CONSTANTS.SELECTORS.RAW_USER_IMAGE_BUBBLE)) {
hasImage = true;
}
const type = /** @type {'image'|'text'} */ (hasImage ? 'image' : 'text');
const text = this.getJumpListDisplayText(messageElement);
const rawTurnElement = messageElement.closest(CONSTANTS.SELECTORS.CONVERSATION_UNIT);
const turnElement = rawTurnElement instanceof HTMLElement ? rawTurnElement : null;
return {
id: messageId,
chatId: null,
turnId: '',
role: /** @type {'user'|'assistant'} */ (role),
type: type,
text: text,
timestamp: null,
element: messageElement,
turnElement: turnElement,
};
}
/** @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);
};
}
}
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.GEM_MANAGER_CONTAINER},
${CONSTANTS.SELECTORS.GEM_MANAGER_CONTAINER} > .container,
${CONSTANTS.SELECTORS.TOP_BAR},
${CONSTANTS.SELECTORS.TOP_BAR} > div {
background: none !important;
box-shadow: none !important;
}
/* Forcefully hide gradient pseudo-elements that interfere with the theme */
${CONSTANTS.SELECTORS.INPUT_CONTAINER}::before,
${CONSTANTS.SELECTORS.INPUT_CONTAINER}::after,
${CONSTANTS.SELECTORS.TOP_BAR}::before,
${CONSTANTS.SELECTORS.TOP_BAR}::after {
display: none !important;
background: none !important;
mask-image: none !important;
-webkit-mask-image: none !important;
}
/* Remove scroll mask fade effect from chat history containers */
${CONSTANTS.SELECTORS.CHAT_WINDOW},
${CONSTANTS.SELECTORS.CHAT_WINDOW_CONTENT},
${CONSTANTS.SELECTORS.CHAT_HISTORY_MAIN},
${CONSTANTS.SELECTORS.CHAT_HISTORY_SCROLLER} {
mask-image: none !important;
-webkit-mask-image: 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 {
// No-op adapter, inherits defaults
}
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...';
}
}
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;
// Set the flag immediately to prevent re-entrancy from other events.
this.isScrolling = true;
// 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 = performance.now();
while (document.querySelector(CONSTANTS.SELECTORS.CANVAS_CONTAINER)) {
if (performance.now() - startWait > AutoScrollManager.CONFIG.CANVAS_CLOSE_TIMEOUT_MS) {
Logger.warn('AUTOSCROLL', LOG_STYLES.YELLOW, 'Timed out waiting for Canvas to close. Aborting scroll.');
this.isScrolling = false;
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.');
this.isScrolling = false;
return;
}
}
// 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
// Fire-and-forget: Deliberately invoked without 'await' to allow the async routine
// to defer execution until the rendering pipeline has stabilized.
this._triggerScroll(); // Trigger the next scroll
}
}
/**
* Scrolls the container to the top and sets a timeout to check if loading has started.
* Yields to the render pipeline to ensure the framework has stabilized its scroll adjustments.
* @private
*/
async _triggerScroll() {
if (!this.isScrolling || !this.scrollContainer) return;
// --- Yield to Render Pipeline for UI Stabilization ---
// Wait for the framework to finish adjusting the scroll position after DOM mutations
// before we force it back to the top. This prevents the framework from overriding our scroll.
await new Promise((r) => requestAnimationFrame(r));
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => requestAnimationFrame(r));
// Final check to ensure the state hasn't changed during the async yield
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 */
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 */
shouldHideStandingImages() {
// Handle early exits that don't require measurement.
return PlatformAdapters.General.isCanvasModeActive() || PlatformAdapters.General.isFilePanelActive();
}
/** @override */
measureLayoutBounds(instance) {
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) {
// Signal to mutate to reset styles.
return null;
}
const chatRect = chatArea.getBoundingClientRect();
const messageRect = messageArea.getBoundingClientRect();
return {
appLeft: chatRect.left,
appRight: chatRect.right,
messageLeft: messageRect.left,
messageRight: messageRect.right,
};
}
}
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)
checkVisibility: (panel) => panel.offsetParent !== null,
attributesToWatch: [],
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();
debouncedTitleUpdate.cancel();
};
}
/** @override */
getTurnCompletionConfig() {
return {
strategy: CONSTANTS.OBSERVER_STRATEGIES.MUTATION,
observerOptions: {
childList: true,
subtree: true,
},
};
}
/** @override */
getPlatformObserverStarters() {
// prettier-ignore
return [
this.startSidebarObserver.bind(this),
this.startPanelObserver.bind(this),
this.startInputAreaObserver.bind(this),
];
}
/** @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 */
getScrollContainerForHeaderCheck() {
return CONSTANTS.SELECTORS.MAIN_APP_CONTAINER;
}
/** @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 */
isAutoScrollSupported() {
return true;
}
/** @override */
getAutoScrollTooltip(isAutoScrolling) {
return isAutoScrolling ? 'Loading history...' : 'Load full chat history';
}
/** @override */
getShiftActionText() {
return '\n[Shift] Load history';
}
}
class GeminiApiMessageAdapter extends BaseApiMessageAdapter {
// No-op adapter, inherits defaults
}
class GeminiUIManagerAdapter extends BaseUIManagerAdapter {
// No-op adapter, inherits defaults
}
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(),
ApiMessage: new GeminiApiMessageAdapter(),
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 = performance.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.API_MESSAGE_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);
return 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)) {
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 {
const result = listener(...args);
if (result instanceof Promise) {
result.catch((e) => {
Logger.error('', '', `EventBus async error in listener for event "${event}":`, e);
});
}
} 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
for (const [key, listener] of [...this.events[event].entries()]) {
try {
// Log which specific subscriber is being executed
Logger.debug('', LOG_STYLES.PURPLE, `-> Executing: ${key}`);
const result = listener(...args);
if (result instanceof Promise) {
result.catch((e) => {
Logger.error('LISTENER ERROR', LOG_STYLES.RED, `Async listener "${key}" failed for event "${event}":`, e);
});
}
} 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 {
const result = listener(...args);
if (result instanceof Promise) {
result.catch((e) => {
Logger.error('LISTENER ERROR', LOG_STYLES.RED, `Async listener failed for event "${event}":`, e);
});
}
} 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 {
const result = work();
if (result instanceof Promise) {
result.catch((e) => {
Logger.error('UI QUEUE ERROR', LOG_STYLES.RED, 'Async error in queued UI work:', e);
});
}
} catch (e) {
Logger.error('UI QUEUE ERROR', LOG_STYLES.RED, 'Error in queued UI work:', e);
}
}
// Check if new work was added during processing (e.g., from trailing edge handlers)
if (this.uiWorkQueue.length > 0) {
requestAnimationFrame(this._processUIWorkQueue.bind(this));
} else {
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}`;
}
/**
* Creates a unique consistent subscriber key for NavigationMonitor.
* @param {string} purpose - The purpose identifier from CONSTANTS.NAV_PURPOSE.
* @returns {string} A key in the format '${APPID}-purpose'.
*/
function createSubscriberKey(purpose) {
return `${APPID}-${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;
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;
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();
return 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) {
const FALLBACK_DELAY_MS = 1;
const SIMULATED_TIME_REMAINING_MS = 50;
if ('requestIdleCallback' in window) {
const id = window.requestIdleCallback(callback, { timeout });
return () => window.cancelIdleCallback(id);
} else {
// Fallback: Execute almost immediately to avoid blocking.
// This satisfies the "run by timeout" contract trivially.
const id = setTimeout(() => {
// [DO NOT REFACTOR] Duck Typing for API Compatibility
// Provide a minimal IdleDeadline-like object.
// Do not simplify or remove this object structure, as callers expect the `timeRemaining` method.
callback({
didTimeout: false,
timeRemaining: () => SIMULATED_TIME_REMAINING_MS,
});
}, FALLBACK_DELAY_MS);
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;
}
};
// [DO NOT REFACTOR] Must remain a standard function, not an arrow function.
// This ensures the dynamic `this` context from the caller is correctly captured
// and propagated to the target function via `func.apply(this, args)`.
/** @this {any} */
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();
}
/**
* @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()}`;
}
/**
* Virtual ID manager using a closure to encapsulate state.
* Manages unique IDs for DOM elements that lack native identifiers.
*/
const { getVirtualId, getOrCreateVirtualId } = (() => {
const virtualIdMap = new WeakMap();
let virtualIdCounter = 0;
return {
/**
* Gets the virtual ID for a given element if it exists.
* @param {HTMLElement} element
* @returns {string | null}
*/
getVirtualId: (element) => {
if (!(element instanceof HTMLElement)) return null;
return virtualIdMap.get(element) || null;
},
/**
* Gets the virtual ID for a given element, or creates a new one if it doesn't exist.
* @param {HTMLElement} element
* @param {string} prefix
* @returns {string | null}
*/
getOrCreateVirtualId: (element, prefix) => {
if (!(element instanceof HTMLElement)) return null;
let id = virtualIdMap.get(element);
if (!id) {
id = `${prefix}${++virtualIdCounter}`;
virtualIdMap.set(element, id);
}
return id;
},
};
})();
/**
* 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} isTargetPageFn A function that returns true if the current page is a target page.
*/
function ensureSettingsButtonPlacement(settingsButton, anchorSelector, isTargetPageFn) {
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 non-target page immediately to prevent zombie UI.
if (!isTargetPageFn()) {
if (settingsButton.element.isConnected) {
settingsButton.element.remove();
Logger.debug('UI GUARD', LOG_STYLES.CYAN, 'Non-target 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) {
// 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 = Temporal.Now.instant().epochMilliseconds;
// 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 = Temporal.Now.instant().epochMilliseconds;
}
} catch (e) {
Logger.warn('SyncManager', '', 'Failed to parse remote config. Forcing update.', e);
shouldUpdate = true;
newTimestamp = Temporal.Now.instant().epochMilliseconds;
}
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
* @description A shared, safe History API wrapper to detect SPA navigations. Prevents infinite nesting and conflicts across multiple userscripts.
* * [USAGE NOTES]
* - **Singleton Coordination**: This class acts as a centralized Singleton coordinator per `ownerId` to multiplex navigation events across multiple script instances seamlessly.
* - **Subscriber-Based Idempotency**: Subscriptions are uniquely identified via a `subscriberId`. Registering a subscriber with an existing ID will safely cancel its pending debounced execution and overwrite it with the new configuration.
* - **Persistent Hooking**: For cross-script stability, this coordinator does not implement a `destroy` or history restoration mechanism. The History API hooks remain active permanently.
* - **Listener Lifecycle**: Invoke the unsubscription token function returned by the `on()` method to safely remove the script's listeners from the shared coordinator.
*/
class NavigationMonitor {
static POST_NAVIGATION_DOM_SETTLE = 200;
/**
* @param {string} ownerId - Unique identifier to share the hook ecosystem.
*/
constructor(ownerId) {
this.ownerId = ownerId;
/** @type {Window & { __global_nav_coordinators__?: Record<string, any> }} */
const globalScope = window;
globalScope.__global_nav_coordinators__ ??= {};
if (globalScope.__global_nav_coordinators__[ownerId]) {
return globalScope.__global_nav_coordinators__[ownerId];
}
this.listeners = new Map();
this.originalHistoryMethods = { pushState: null, replaceState: null };
this.isHooked = false;
this._boundHandlePopState = null;
this._boundHandleCustomNavEvent = null;
this.lastPath = null;
globalScope.__global_nav_coordinators__[ownerId] = this;
}
/**
* @param {string} subscriberId - Unique key to identify the subscriber and prevent duplicates.
* @param {Function} onNavStart
* @param {Function} onNavSettled
* @param {Object} options - Configuration parameters for the navigation subscription.
* @param {boolean} options.trackHash - Specifies whether the subscriber triggers on location.hash modifications.
* @returns {() => void} A function to unsubscribe this listener pair.
*/
on(subscriberId, onNavStart, onNavSettled, options) {
/** @type {Window & { __global_nav_coordinators__?: Record<string, any> }} */
const globalScope = window;
const coordinator = globalScope.__global_nav_coordinators__[this.ownerId];
if (!coordinator) return () => {};
// If a subscriber with the same subscriberId already exists, cancel its debounce and overwrite it safely.
if (coordinator.listeners.has(subscriberId)) {
const existingPair = coordinator.listeners.get(subscriberId);
existingPair.debouncedNavigation.cancel();
coordinator.listeners.delete(subscriberId);
}
// Configure hash-tracking behavior based on the explicit subscription option.
const trackHash = options.trackHash;
const initialPath = trackHash ? location.pathname + location.search + location.hash : location.pathname + location.search;
// Bundle the start callback and its corresponding debounced settled callback.
const listenerPair = {
onNavStart,
debouncedNavigation: debounce(onNavSettled, NavigationMonitor.POST_NAVIGATION_DOM_SETTLE, true),
trackHash,
lastPath: initialPath,
};
coordinator.listeners.set(subscriberId, listenerPair);
if (!coordinator.isHooked) {
coordinator.lastPath = location.pathname + location.search + location.hash;
coordinator._boundHandleCustomNavEvent = () => {
const fullPath = location.pathname + location.search + location.hash;
if (fullPath === coordinator.lastPath) return;
coordinator.lastPath = fullPath;
for (const pair of coordinator.listeners.values()) {
const currentPath = pair.trackHash ? fullPath : location.pathname + location.search;
if (currentPath !== pair.lastPath) {
pair.lastPath = currentPath;
pair.onNavStart();
pair.debouncedNavigation();
}
}
};
coordinator._boundHandlePopState = () => {
globalScope.dispatchEvent(new CustomEvent(`${this.ownerId}:locationchange`));
};
this._hookHistory(coordinator);
globalScope.addEventListener(`${this.ownerId}:locationchange`, coordinator._boundHandleCustomNavEvent);
globalScope.addEventListener('popstate', coordinator._boundHandlePopState);
globalScope.addEventListener('hashchange', coordinator._boundHandlePopState);
}
// Return the unsubscription token closure directly.
return () => {
listenerPair.debouncedNavigation.cancel();
// Only remove from coordinator if this specific listener instance is still active
if (coordinator.listeners.get(subscriberId) === listenerPair) {
coordinator.listeners.delete(subscriberId);
}
};
}
/**
* @private
* @param {Object} coordinator
*/
_hookHistory(coordinator) {
const hookFlag = `__${this.ownerId}_HISTORY_HOOKED__`;
/** @type {Window & { __global_nav_coordinators__?: Record<string, any> }} */
const globalScope = window;
if (!coordinator.isHooked && !globalScope[hookFlag]) {
globalScope[hookFlag] = true;
coordinator.isHooked = true;
const ownerId = this.ownerId;
for (const m of ['pushState', 'replaceState']) {
const orig = history[m];
coordinator.originalHistoryMethods[m] = orig;
// [DO NOT REFACTOR] `wrapper` must remain a standard function to safely capture the execution-time `this`.
/** @this {History} */
const wrapper = function (...args) {
try {
return orig.apply(this, args);
} finally {
// Always dispatch the event via 'finally' to ensure navigation state changes
// are broadcasted, even if the native history method or another hooked wrapper throws an error.
globalScope.dispatchEvent(new CustomEvent(`${ownerId}:locationchange`));
}
};
history[m] = wrapper;
}
}
}
}
// =================================================================================
// 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();
/** @type {MessageNode[]} */
this.userMessages = [];
/** @type {MessageNode[]} */
this.assistantMessages = [];
/** @type {MessageNode[]} */
this.totalMessages = [];
/** @type {Map<string, { role: 'user'|'assistant', index: number, totalIndex: number }>} */
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();
});
// Rebuild cache when historical API messages are loaded
this._subscribe(EVENTS.API_MESSAGES_LOADED, () => {
this.debouncedRebuildCache();
});
// Process new message nodes detected in real-time or update DOM bindings
this._subscribe(EVENTS.API_MESSAGE_ADDED, (payload) => {
if (payload && payload.node) {
const existingInfo = this.elementMap.get(payload.node.id);
if (existingInfo) {
// Update DOM references for virtual scroll re-mounting
const existingNode = this.totalMessages[existingInfo.totalIndex];
if (existingNode && existingNode.element !== payload.node.element) {
existingNode.element = payload.node.element;
existingNode.turnElement = payload.node.turnElement;
this.debouncedNotify();
}
} else {
this.append(payload.node);
}
}
});
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;
}
// Preserve old map and arrays to maintain object identity across virtual scrolls
const oldMap = this.elementMap;
const oldTotalMessages = this.totalMessages;
this.userMessages = [];
this.assistantMessages = [];
this.totalMessages = [];
this.elementMap = new Map();
// 1. Fetch all MessageNodes from the platform adapter
const nodes = PlatformAdapters.General.extractMessageNodes();
// 2. Build arrays while reusing existing objects if present
for (let i = 0; i < nodes.length; i++) {
const newNode = nodes[i];
let targetNode = newNode;
const existingInfo = oldMap.get(newNode.id);
if (existingInfo) {
const oldNode = oldTotalMessages[existingInfo.totalIndex];
if (oldNode) {
oldNode.element = newNode.element;
oldNode.turnElement = newNode.turnElement;
oldNode.timestamp = newNode.timestamp;
oldNode.text = newNode.text;
targetNode = oldNode;
}
}
if (targetNode.role === CONSTANTS.INTERNAL_ROLES.USER) {
this.userMessages.push(targetNode);
} else if (targetNode.role === CONSTANTS.INTERNAL_ROLES.ASSISTANT) {
this.assistantMessages.push(targetNode);
}
this.totalMessages.push(targetNode);
}
// 3. Rebuild the lookup map for O(1) access
for (let i = 0; i < this.userMessages.length; i++) {
this.elementMap.set(this.userMessages[i].id, { role: /** @type {'user'|'assistant'} */ (CONSTANTS.INTERNAL_ROLES.USER), index: i, totalIndex: -1 });
}
for (let i = 0; i < this.assistantMessages.length; i++) {
this.elementMap.set(this.assistantMessages[i].id, { role: /** @type {'user'|'assistant'} */ (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].id);
if (entry) {
entry.totalIndex = i;
}
}
this.debouncedNotify();
}
/**
* Appends a new message node to the cache if it follows the last known message.
* If the message is older, triggers a full rebuild.
* @param {MessageNode} node - The message node from API or DOM fallback.
*/
append(node) {
// 1. Check for duplicates
if (this.elementMap.has(node.id)) {
return;
}
// 2. 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 lastNode = this.totalMessages.at(-1);
if (!PlatformAdapters.General.isAppendOrderValid(node, lastNode)) {
this.debouncedRebuildCache();
return;
}
// 3. Safe Append
if (node.role === CONSTANTS.INTERNAL_ROLES.USER) {
this.userMessages.push(node);
} else if (node.role === CONSTANTS.INTERNAL_ROLES.ASSISTANT) {
this.assistantMessages.push(node);
} else {
return; // Ignore invalid roles
}
this.totalMessages.push(node);
// 4. Update indices
const roleIndex = (node.role === CONSTANTS.INTERNAL_ROLES.USER ? this.userMessages : this.assistantMessages).length - 1;
const totalIndex = this.totalMessages.length - 1;
this.elementMap.set(node.id, { role: node.role, index: roleIndex, totalIndex });
// 5. Trigger UI update
this.debouncedNotify();
}
notify() {
EventBus.publish(EVENTS.CACHE_UPDATED);
}
/**
* Finds the role and index of a given message ID within the cached arrays.
* @param {string} id
* @returns {{role: 'user'|'assistant', index: number, totalIndex: number} | null}
*/
findMessageIndex(id) {
if (!id || typeof id !== 'string') return null;
return this.elementMap.get(id) || null;
}
/**
* Retrieves a message node at a specific index for a given role.
* @param {'user'|'assistant'} role
* @param {number} index
* @returns {MessageNode | null}
*/
getMessageAtIndex(role, index) {
const targetArray = role === 'user' ? this.userMessages : this.assistantMessages;
if (index >= 0 && index < targetArray.length) {
return targetArray[index];
}
return null;
}
/**
* Finds the nearest DOM element (message or turn container) to scroll to.
* @param {MessageNode} node
* @returns {HTMLElement | null}
*/
getScrollTarget(node) {
if (!node) return null;
// Check isConnected to handle virtual scrolling unmounts.
// If the specific element is unmounted, fallback to the turn container which persists in the DOM to trigger remounting.
if (node.element && node.element.isConnected) return node.element;
if (node.turnElement && node.turnElement.isConnected) return node.turnElement;
// Fallback: Find the closest mounted message or turn container
const idx = this.totalMessages.indexOf(node);
if (idx !== -1) {
let offset = 1;
while (idx - offset >= 0 || idx + offset < this.totalMessages.length) {
if (idx - offset >= 0) {
const prevNode = this.totalMessages[idx - offset];
if (prevNode.element && prevNode.element.isConnected) return prevNode.element;
if (prevNode.turnElement && prevNode.turnElement.isConnected) return prevNode.turnElement;
}
if (idx + offset < this.totalMessages.length) {
const nextNode = this.totalMessages[idx + offset];
if (nextNode.element && nextNode.element.isConnected) return nextNode.element;
if (nextNode.turnElement && nextNode.turnElement.isConnected) return nextNode.turnElement;
}
offset++;
}
}
return null;
}
clear() {
this.userMessages = [];
this.assistantMessages = [];
this.totalMessages = [];
this.elementMap.clear();
this.notify();
}
/**
* @returns {MessageNode[]}
*/
getUserMessages() {
return this.userMessages;
}
/**
* @returns {MessageNode[]}
*/
getAssistantMessages() {
return this.assistantMessages;
}
/**
* @returns {MessageNode[]}
*/
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;
this.isLayoutUpdatePending = 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 a non-target 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) {
this.isLayoutUpdatePending = true;
return;
}
this.isLayoutUpdateScheduled = true;
this.isLayoutUpdatePending = false;
EventBus.queueUIWork(() => {
try {
if (this.isDestroyed) return;
this.applyChatContentMaxWidth();
} finally {
this.isLayoutUpdateScheduled = false;
if (this.isLayoutUpdatePending) {
this.scheduleLayoutUpdate();
}
}
});
}
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();
/** @type {Map<HTMLElement, { observer: MutationObserver, cancel: () => void }>} */
this.mutationTurnObservers = new Map();
this.debouncedCacheUpdate = debounce(this._publishCacheUpdate.bind(this), CONSTANTS.TIMING.DEBOUNCE_DELAYS.CACHE_UPDATE, true);
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();
for (const { observer, cancel } of this.mutationTurnObservers.values()) {
observer.disconnect();
if (cancel) cancel();
}
this.mutationTurnObservers.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;
const setupObserver = (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);
// Observe the element if it's not already in the manager's official list.
// This ensures elements are re-observed correctly after temporary React unmounts.
if (resizeTarget instanceof HTMLElement && !this.observedElements.has(resizeTarget)) {
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 for all existing elements
const initialInputAreas = document.querySelectorAll(triggerSelector);
for (let i = 0; i < initialInputAreas.length; i++) {
const area = initialInputAreas[i];
if (area instanceof HTMLElement) {
setupObserver(area);
}
}
return () => {
sentinel.off(triggerSelector, setupObserver);
};
}
/**
* @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(HTMLElement, HTMLElement): boolean} config.checkVisibility - A function to determine if the panel is visible based on the panel and trigger elements.
* @param {string[]} config.attributesToWatch - An array of attribute names to monitor for changes.
* @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, checkVisibility, attributesToWatch, immediateCallback } = config;
let isPanelVisible = false;
let isStateUpdating = false; // Lock to prevent race conditions
let disappearanceObserver = null;
let attributeObserver = null;
let observedPanel = null;
let trackedPanel = 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 = performance.now();
// Initial deadline
loopEndTime = startTime + STABILIZATION_MS;
lastRectString = getRectString(targetPanel);
const loop = () => {
triggerUpdates();
const now = performance.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);
// Keep attribute observer attached as long as the panel is resolved in the DOM
if (panel !== trackedPanel) {
if (attributeObserver) {
attributeObserver.disconnect();
attributeObserver = null;
}
trackedPanel = panel;
if (trackedPanel && attributesToWatch.length > 0) {
attributeObserver = new MutationObserver(() => {
updatePanelState();
});
attributeObserver.observe(trackedPanel, { attributes: true, attributeFilter: attributesToWatch });
}
}
if (panel instanceof HTMLElement && checkVisibility(panel, trigger)) {
isNowVisible = true;
}
} else {
// If trigger is gone, clear tracked panel and observer
if (trackedPanel) {
if (attributeObserver) {
attributeObserver.disconnect();
attributeObserver = null;
}
trackedPanel = null;
}
}
// 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, CONSTANTS.OBSERVER_OPTIONS);
}
} 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 (attributeObserver) attributeObserver.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);
}
}
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 navigation/destroy)
this.manageResource(CONSTANTS.RESOURCE_KEYS.PAGE_SCOPE, 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();
for (const { observer, cancel } of this.mutationTurnObservers.values()) {
observer.disconnect();
if (cancel) cancel();
}
this.mutationTurnObservers.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 observer (Sentinel or 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) || this.mutationTurnObservers.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 {
const config = PlatformAdapters.Observer.getTurnCompletionConfig();
if (config.strategy === CONSTANTS.OBSERVER_STRATEGIES.SENTINEL && config.selector) {
// 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(config.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, [config.selector, sentinelCallback]);
sentinel.on(config.selector, sentinelCallback);
} else if (config.strategy === CONSTANTS.OBSERVER_STRATEGIES.MUTATION && config.observerOptions) {
// This branch handles streaming turns using MutationObserver for property changes.
// Debounce the completion check to prevent layout thrashing during rapid DOM updates.
const checkCompletion = debounce(
() => {
if (this.isDestroyed || !turnNode.isConnected) return;
if (this._isTurnComplete(turnNode)) {
const observerData = this.mutationTurnObservers.get(turnNode);
if (observerData) {
observerData.observer.disconnect();
}
this.mutationTurnObservers.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);
}
},
150,
false
); // Use setTimeout (false) to ensure timely evaluation rather than idle time
const observer = new MutationObserver(checkCompletion);
observer.observe(turnNode, config.observerOptions);
this.mutationTurnObservers.set(turnNode, { observer, cancel: checkCompletion.cancel });
}
}
}
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 Turn Observers (MutationObserver)
for (const [turnNode, { observer, cancel }] of this.mutationTurnObservers) {
if (!turnNode.isConnected) {
observer.disconnect();
if (cancel) cancel();
this.mutationTurnObservers.delete(turnNode);
}
}
// 3. Cleanup Processed Turn Nodes (Set)
for (const turnNode of this.processedTurnNodes) {
if (!turnNode.isConnected) {
this.processedTurnNodes.delete(turnNode);
}
}
// 4. 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 allNodes = this.messageCacheManager.getTotalMessages();
for (const node of allNodes) {
if (node.element) {
this.queueForInjection(node.element);
}
}
}
/** @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.isUpdatePending = 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) {
this.isUpdatePending = true;
return;
}
this.isUpdateScheduled = true;
this.isUpdatePending = false;
EventBus.queueUIWork(async () => {
try {
if (this.isDestroyed) return;
this.updateVisibility();
await this.recalculateStandingImagesLayout();
} finally {
this.isUpdateScheduled = false;
if (this.isUpdatePending) {
this.scheduleUpdate();
}
}
});
}
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#${cls.userImageId}`);
const asstImg = h(`div#${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);
};
button.onpointerdown = (e) => e.stopPropagation();
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();
let holder = msgElem.closest(CONSTANTS.SELECTORS.MESSAGE_ID_HOLDER);
if (!holder) holder = msgElem.querySelector(CONSTANTS.SELECTORS.MESSAGE_ID_HOLDER);
holder = holder || msgElem;
const msgId = PlatformAdapters.General.getMessageId(holder);
const nodeInfo = msgId ? manager.messageCacheManager.findMessageIndex(msgId) : null;
if (!nodeInfo) return;
const newIndex = nodeInfo.index + direction;
const targetNode = manager.messageCacheManager.getMessageAtIndex(nodeInfo.role, newIndex);
if (targetNode) {
const targetToScroll = manager.messageCacheManager.getScrollTarget(targetNode);
if (targetToScroll) {
scrollToElement(targetToScroll);
}
EventBus.publish(EVENTS.NAV_HIGHLIGHT_MESSAGE, targetNode);
}
};
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);
prevBtn.onpointerdown = (e) => e.stopPropagation();
nextBtn.onpointerdown = (e) => e.stopPropagation();
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);
};
topBtn.onpointerdown = (e) => e.stopPropagation();
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 allNodes = this.messageCacheManager.getTotalMessages();
const mountedMessages = [];
for (let i = 0; i < allNodes.length; i++) {
if (allNodes[i].element && allNodes[i].element.isConnected) {
mountedMessages.push(allNodes[i].element);
}
}
this._processUpdateQueue(mountedMessages, 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 allNodes = this.messageCacheManager.getTotalMessages();
const cancelFn = runBatchUpdate(
allNodes,
CONSTANTS.PROCESSING.BATCH_SIZE,
(node) => {
const message = node.element;
if (!message || !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(node.id);
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, // Now stores MessageNode | 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.isRepositionPending = 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<string, {displayText: string, lowerText: string, roleClass: string}>} */
this.searchCache = new Map();
/** @type {MessageNode[]} */
this.indexingQueue = [];
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;
if (this.idleIndexingCancelFn) {
this.idleIndexingCancelFn();
this.idleIndexingCancelFn = null;
}
// 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.state.highlightedMessage.element && this.styleHandle) {
this.state.highlightedMessage.element.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) {
let holder = msg.closest(CONSTANTS.SELECTORS.MESSAGE_ID_HOLDER);
if (!holder) holder = msg.querySelector(CONSTANTS.SELECTORS.MESSAGE_ID_HOLDER);
holder = holder || msg;
const msgId = PlatformAdapters.General.getMessageId(holder);
const nodeInfo = msgId ? this.messageCacheManager.findMessageIndex(msgId) : null;
if (nodeInfo) {
const node = this.messageCacheManager.getTotalMessages()[nodeInfo.totalIndex];
if (node && this.searchCache.has(node.id)) {
this.searchCache.delete(node.id);
}
}
}
});
// 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(node) {
// Manual navigation overrides the initial auto-scroll logic.
this.state.isInitialSelectionDone = true;
this.setHighlightAndIndices(node);
this._scrollToMessage(node);
}
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.element) {
this.state.highlightedMessage.element.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 = [];
if (this.idleIndexingCancelFn) {
this.idleIndexingCancelFn();
this.idleIndexingCancelFn = null;
}
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 cache
const validIds = new Set(totalMessages.map((m) => m.id));
for (const id of this.searchCache.keys()) {
if (!validIds.has(id)) {
this.searchCache.delete(id);
}
}
// 2. Queueing: Find messages not yet cached
this.indexingQueue = [];
for (const msg of totalMessages) {
if (!this.searchCache.has(msg.id)) {
this.indexingQueue.push(msg);
}
}
// 3. Schedule Idle Indexing
if (this.indexingQueue.length > 0) {
this._runIdleIndexing();
}
}
/** @private */
_runIdleIndexing() {
if (this.idleIndexingCancelFn) {
this.idleIndexingCancelFn();
this.idleIndexingCancelFn = null;
}
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;
const totalMessages = this.messageCacheManager.getTotalMessages();
const latestMsgId = totalMessages.length > 0 ? totalMessages[totalMessages.length - 1].id : null;
while (this.indexingQueue.length > 0 && deadline.timeRemaining() > 1) {
const msg = this.indexingQueue.shift();
// Double check if not cached
if (msg && !this.searchCache.has(msg.id)) {
const roleClass = msg.role === CONSTANTS.INTERNAL_ROLES.USER ? cls.userItem : msg.role === CONSTANTS.INTERNAL_ROLES.ASSISTANT ? cls.asstItem : null;
let rawText = msg.text;
const isLatest = msg.id === latestMsgId;
// Streaming fallback: Only extract from DOM if it is the latest message (which might be streaming)
// OR if the text is currently empty. Past messages safely use their stable cached text.
if ((isLatest || !rawText) && msg.element) {
rawText = PlatformAdapters.General.getJumpListDisplayText(msg.element);
if (msg.type === 'image' && !rawText.startsWith('(image)')) {
rawText = `(image) ${rawText}`.trim();
}
} else if (!rawText) {
rawText = '';
}
const displayText = (rawText || '').replace(/\s+/g, ' ').trim();
this.searchCache.set(msg.id, {
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 {MessageNode} targetNode
* @returns {void}
*/
setHighlightAndIndices(targetNode) {
// Retrieve cached info for O(1) access
const cachedInfo = this.messageCacheManager.findMessageIndex(targetNode.id);
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] !== targetNode) {
// 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(targetNode);
if (cachedInfo.role === CONSTANTS.INTERNAL_ROLES.USER) {
currentRoleIndex = userMessages.indexOf(targetNode);
} else {
currentRoleIndex = asstMessages.indexOf(targetNode);
}
}
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(targetNode, CONSTANTS.INTERNAL_ROLES.ASSISTANT);
} else {
newIndices[CONSTANTS.NAV_ROLES.ASSISTANT] = currentRoleIndex;
newIndices[CONSTANTS.NAV_ROLES.USER] = this.findNearestIndex(targetNode, CONSTANTS.INTERNAL_ROLES.USER);
}
this.state.highlightedMessage = targetNode;
this.state.currentIndices = newIndices;
this._renderUI();
}
/**
* @private
* @description Finds the index of the nearest preceding message of a specific role using cached data.
* @param {MessageNode} 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.id);
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.id);
// 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.id)) {
Logger.log('NAVIGATION', '', 'Highlighted message was removed from the cache. 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',
onclick: (e) => {
e.preventDefault();
e.stopPropagation();
this._navigateTo(this.state.activeRole, 'prev');
},
},
[createIconFromDef(StyleDefinitions.ICONS.arrowUp)]
);
const btnFirst = h(
`button.${btnBaseClass}.${cls.btnDanger}`,
{
title: 'First message',
style: { display: 'none' },
onclick: (e) => {
e.preventDefault();
e.stopPropagation();
this._navigateTo(this.state.activeRole, 'first');
},
},
[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',
onclick: (e) => {
e.preventDefault();
e.stopPropagation();
this._navigateTo(this.state.activeRole, 'next');
},
},
[createIconFromDef(StyleDefinitions.ICONS.arrowDown)]
);
const btnLast = h(
`button.${btnBaseClass}.${cls.btnDanger}`,
{
title: 'Last message',
style: { display: 'none' },
onclick: (e) => {
e.preventDefault();
e.stopPropagation();
this._navigateTo(this.state.activeRole, 'last');
},
},
[createIconFromDef(StyleDefinitions.ICONS.scrollToLast)]
);
const btnFold = h(
`button#${cls.bulkCollapseBtnId}.${btnBaseClass}`,
{
style: { display: 'none' },
dataset: { [CONSTANTS.DATA_KEYS.STATE]: CONSTANTS.UI_STATES.EXPANDED },
onclick: (e) => {
e.preventDefault();
e.stopPropagation();
this._toggleAllMessages();
},
},
[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,
onclick: (e) => {
e.preventDefault();
e.stopPropagation();
this._toggleJumpList(e.currentTarget);
},
},
[] // 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,
onclick: (e) => {
e.preventDefault();
e.stopPropagation();
this._startJumpInputSession(e.currentTarget);
},
},
[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) {
this.isRepositionPending = true;
return;
}
this.isRepositionScheduled = true;
this.isRepositionPending = false;
EventBus.queueUIWork(async () => {
try {
if (this.isDestroyed) return;
await this.repositionContainers();
} finally {
this.isRepositionScheduled = false;
if (this.isRepositionPending) {
this.scheduleReposition();
}
}
});
}
/**
* @returns {Promise<void>}
*/
async 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
await 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.element) {
highlightedMessage.element.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 via PlatformAdapter
const shiftActionText = PlatformAdapters.FixedNav.getShiftActionText();
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(node) {
// 1. Hook for platform-specific progressive scrolling (e.g., ChatGPT)
if (PlatformAdapters.FixedNav.handleScrollToMessage && PlatformAdapters.FixedNav.handleScrollToMessage(node, this)) {
return; // Adapter handled the scroll completely
}
// 2. Default common scroll logic (Original behavior for other platforms)
const targetToScroll = this.messageCacheManager.getScrollTarget(node);
if (targetToScroll) {
scrollToElement(targetToScroll);
// Event-driven scroll adjustment for virtual scrolling
// If the target element is unmounted, scrolling to its parent container forces the framework to mount it.
// We listen for the cache update (triggered by updateDomBinding) to perform a final precision scroll.
if (!node.element || node.element !== targetToScroll) {
const timerKey = generateUniqueId('scrollTimer');
let isCleanedUp = false;
const cleanup = () => {
if (isCleanedUp) return;
isCleanedUp = true;
unsubscribe();
clearTimeout(timerId);
this.manageResource(timerKey, null);
};
const unsubscribe = this._subscribe(EVENTS.CACHE_UPDATED, () => {
if (this.isDestroyed) return;
if (node.element && node.element.isConnected) {
cleanup();
scrollToElement(node.element);
}
});
// Safety timeout to prevent memory leaks if the element never mounts
const timerId = setTimeout(() => {
cleanup();
}, CONSTANTS.TIMING.TIMEOUTS.SCROLL_OFFSET_CLEANUP);
this.manageResource(timerKey, cleanup);
}
}
}
_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 messageElement = target.closest(CONSTANTS.SELECTORS.FIXED_NAV_MESSAGE_CONTAINERS);
if (messageElement instanceof HTMLElement && !target.closest(`a, button, input, #${cls.consoleId}`)) {
let holder = messageElement.closest(CONSTANTS.SELECTORS.MESSAGE_ID_HOLDER);
if (!holder) holder = messageElement.querySelector(CONSTANTS.SELECTORS.MESSAGE_ID_HOLDER);
holder = holder || messageElement;
const msgId = PlatformAdapters.General.getMessageId(holder);
const nodeInfo = msgId ? this.messageCacheManager.findMessageIndex(msgId) : null;
if (nodeInfo) {
const node = this.messageCacheManager.getTotalMessages()[nodeInfo.totalIndex];
if (node) this.setHighlightAndIndices(node);
}
}
}
/** @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) {
if (e.key === 'Escape') {
// Handle auto-scroll cancellation first.
if (this.autoScrollManager?.isScrolling) {
e.preventDefault();
e.stopPropagation();
EventBus.publish(EVENTS.AUTO_SCROLL_CANCEL_REQUEST);
return;
}
// Then handle jump list closure if auto-scroll is not active.
if (this.state.jumpListComponent) {
e.preventDefault();
e.stopPropagation();
this._hideJumpList();
return;
}
}
// 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 = performance.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;
}
}
}
_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.id)) {
this.searchCache.delete(lastMessage.id);
}
const jumpList = new JumpListComponent(
role,
messages,
this.state.highlightedMessage,
{
onSelect: (messageNode) => this._handleJumpListSelect(messageNode),
},
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(messageNode) {
this.navigateToMessage(messageNode);
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, () => {
// Cancel any pending integrity scan to prevent it from running on the new page
this.manageResource(CONSTANTS.RESOURCE_KEYS.INTEGRITY_SCAN, null);
this.isScanPending = false;
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.
messageElement = PlatformAdapters.General.ensureMessageContainerForImage(contentElement);
}
// If we have a valid message container, proceed.
if (messageElement) {
const node = PlatformAdapters.General.createMessageNode(messageElement);
// Always publish API_MESSAGE_ADDED when a message is detected.
// The ApiMessageManager will handle buffering (during navigation) or immediate application.
if (node) {
EventBus.publish(EVENTS.API_MESSAGE_ADDED, { messageId: node.id, node: node });
}
// 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 ApiMessageManager extends BaseManager {
/**
* @param {ConfigManager} configManager
* @param {MessageCacheManager} messageCacheManager
*/
constructor(configManager, messageCacheManager) {
super();
this.configManager = configManager;
this.messageCacheManager = messageCacheManager;
/** @type {Map<string, MessageNode>} */
this.pendingMessages = new Map(); // Buffer for messages during navigation
this.isNavigating = true; // Track navigation state (Initialize as true to buffer initial page load)
}
_onInit() {
// Register automatic cleanup of UI and listeners when destroyed
this.addDisposable(() => this.pendingMessages.clear());
this._subscribe(EVENTS.NAVIGATION_START, () => this._handleNavigationStart());
this._subscribe(EVENTS.NAVIGATION_END, () => this._handleNavigationEnd());
// Subscribe to data events
this._subscribe(EVENTS.API_MESSAGE_ADDED, (data) => this._handleApiMessageAdded(data));
this._subscribe(EVENTS.API_MESSAGES_LOADED, (data) => this._loadHistoricalMessages(data));
// Subscribe to streaming end to finalize the latest assistant message text
this._subscribe(EVENTS.STREAMING_END, () => this._handleStreamingEnd());
}
_onDestroy() {
this.pendingMessages.clear();
}
/** @private */
_handleNavigationStart() {
this.isNavigating = true;
this.pendingMessages.clear();
}
/** @private */
_handleNavigationEnd() {
this.isNavigating = false;
// Fallback strategy:
// Apply pending messages only if API didn't provide data for them.
// This handles "New Chat" scenarios or API failures/delays.
if (this.pendingMessages.size > 0) {
Logger.debug('API_MESSAGE', LOG_STYLES.TEAL, `Processing ${this.pendingMessages.size} pending messages.`);
let addedCount = 0;
this.pendingMessages.forEach((node, messageId) => {
// Only apply if NOT already present (i.e., not loaded from API)
if (!PlatformAdapters.ApiMessage.getMessageData(messageId)) {
PlatformAdapters.ApiMessage.addMessageData(messageId, node);
addedCount++;
}
});
this.pendingMessages.clear();
if (addedCount > 0) {
Logger.debug('API_MESSAGE', LOG_STYLES.TEAL, `Applied ${addedCount} fallback messages.`);
}
}
}
/**
* @private
* @param {object} detail
* @param {Map<string, MessageNode>} detail.messages
*/
_loadHistoricalMessages({ messages }) {
// Data is already stored in Adapter by the time this event fires.
if (messages && messages.size > 0) {
Logger.debug('API_MESSAGE', LOG_STYLES.TEAL, `Notified of ${messages.size} historical messages.`);
}
}
/**
* Finalizes the text of the latest assistant message upon streaming completion.
* @private
*/
_handleStreamingEnd() {
const asstMessages = this.messageCacheManager.getAssistantMessages();
if (asstMessages.length === 0) return;
const latestMsgNode = asstMessages.at(-1);
if (!latestMsgNode?.element) return;
let holder = latestMsgNode.element.closest(CONSTANTS.SELECTORS.MESSAGE_ID_HOLDER);
if (!holder) holder = latestMsgNode.element.querySelector(CONSTANTS.SELECTORS.MESSAGE_ID_HOLDER);
holder = holder || latestMsgNode.element;
const msgId = PlatformAdapters.General.getMessageId(holder);
if (!msgId) return;
const rawText = PlatformAdapters.General.getJumpListDisplayText(latestMsgNode.element);
const text = (rawText || '').replace(/\s+/g, ' ').trim();
if (!text) return;
// Update pending messages buffer
const pendingNode = this.pendingMessages.get(msgId);
if (pendingNode) {
pendingNode.text = text;
}
// Update persistent API cache
if (PlatformAdapters.ApiMessage?.getMessageData) {
const cachedNode = PlatformAdapters.ApiMessage.getMessageData(msgId);
if (cachedNode) {
cachedNode.text = text;
}
}
}
/**
* @private
* @param {object} detail
* @param {string} detail.messageId
* @param {MessageNode} detail.node
*/
_handleApiMessageAdded({ messageId, node }) {
if (!messageId || !node) return;
// If navigating and NOT a new chat page, buffer the message data.
// "New Chat" pages don't trigger history load, so we can apply immediately.
const isNewChat = PlatformAdapters.General.isNewChatPage();
if (this.isNavigating && !isNewChat) {
this.pendingMessages.set(messageId, node);
return;
}
// Normal processing (Real-time or New Chat)
if (!PlatformAdapters.ApiMessage.getMessageData(messageId)) {
Logger.debug('API_MESSAGE', LOG_STYLES.TEAL, `Added real-time message data for ${messageId}.`);
PlatformAdapters.ApiMessage.addMessageData(messageId, node);
}
}
}
// =================================================================================
// SECTION: Timestamp Management
// Description: Manages the rendering of message timestamps based on cached data.
// =================================================================================
class TimestampManager extends BaseManager {
/**
* @param {ConfigManager} configManager
* @param {MessageCacheManager} messageCacheManager
*/
constructor(configManager, messageCacheManager) {
super();
this.configManager = configManager;
this.messageCacheManager = messageCacheManager;
/** @type {WeakMap<HTMLElement, HTMLElement>} */
this.timestampDomCache = new WeakMap();
this.styleHandle = null;
this.timestampContainerTemplate = null;
this.timestampSpanTemplate = null;
this.isEnabled = false; // Track feature state
/** @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());
}
_onDestroy() {
// disable() is called via disposable.
this.timestampDomCache = new WeakMap();
}
/** @private */
_handleNavigationStart() {
// Cancel pending batch tasks immediately
this.manageResource(CONSTANTS.RESOURCE_KEYS.BATCH_TASK, null);
// Explicitly reset the WeakMap to drop references to old DOM elements
this.timestampDomCache = new WeakMap();
}
/**
* 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, insertions, dom rebinding) using the new return-based pattern
if (!this._cacheUpdateUnsub) {
this._cacheUpdateUnsub = this._subscribe(EVENTS.CACHE_UPDATED, () => this.updateAllTimestamps());
}
// Initial render
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);
// 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();
}
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 = this.isEnabled;
// 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
(messageNode) => {
const messageElement = messageNode.element;
if (!messageElement || !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) {
let anchorTarget = null;
// Adjust anchor for image messages to align with nav buttons at the top
if (messageNode.type === 'image' || messageElement.querySelector(CONSTANTS.SELECTORS.RAW_ASSISTANT_IMAGE_BUBBLE)) {
const navParent = PlatformAdapters.BubbleUI.getNavPositioningParent(messageElement);
if (navParent) {
anchorTarget = navParent;
anchorTarget.style.position = 'relative';
}
}
// Fallback for text messages
if (!anchorTarget) {
anchorTarget = messageElement.closest(CONSTANTS.SELECTORS.MESSAGE_ID_HOLDER);
if (!anchorTarget) anchorTarget = messageElement.querySelector(CONSTANTS.SELECTORS.MESSAGE_ID_HOLDER);
}
if (anchorTarget) {
anchor = anchorTarget;
// 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
roleClass = messageNode.role === CONSTANTS.INTERNAL_ROLES.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) {
timestampText = this._formatTimestamp(messageNode.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 nodeDom = this.timestampContainerTemplate.cloneNode(true);
const span = this.timestampSpanTemplate.cloneNode(true);
if (nodeDom instanceof HTMLElement && span instanceof Element) {
container = nodeDom;
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 {number | undefined} unixTimestamp
* @returns {string}
* @private
*/
_formatTimestamp(unixTimestamp) {
if (typeof unixTimestamp !== 'number' || isNaN(unixTimestamp)) {
return '';
}
const zonedDateTime = Temporal.Instant.fromEpochMilliseconds(Math.floor(unixTimestamp * 1000)).toZonedDateTimeISO(Temporal.Now.timeZoneId());
const yyyy = zonedDateTime.year;
const mm = String(zonedDateTime.month).padStart(2, '0');
const dd = String(zonedDateTime.day).padStart(2, '0');
const hh = String(zonedDateTime.hour).padStart(2, '0');
const ii = String(zonedDateTime.minute).padStart(2, '0');
const ss = String(zonedDateTime.second).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 allNodes = this.messageCacheManager.getTotalMessages();
const isNavConsoleEnabled = config.platforms[PLATFORM].features.fixed_nav_console.enabled;
const cls = this.styleHandle.classes;
const cancelFn = runBatchUpdate(
allNodes,
CONSTANTS.PROCESSING.BATCH_SIZE,
// Measure
(node, i) => {
const message = node.element;
// Skip if message is no longer in DOM
if (!message || !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 = node.role;
}
}
}
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.INTERNAL_ROLES.USER ? cls.user : cls.assistant;
const nodeDom = this.numberSpanTemplate.cloneNode(true);
if (nodeDom instanceof HTMLElement) {
span = nodeDom;
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(context) {
super();
this.context = context;
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 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.${cls.formErrorMsg}`, {}, []);
const children = [input];
// File selection button for image fields
if (options.fieldType === 'image' || options.fieldType === 'icon') {
const btn = this.create(
`button.${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.${cls.formField}`, {}, [this.create(`div.${cls.labelRow}`, {}, [this.create('label', { title: options.tooltip }, label)]), this.create(`div.${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.${cls.formErrorMsg}`, {}, []);
const container = this.create(`div.${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.${cls.submenuRow}`, {}, [
this.create('label', { title: options.title }, label), // Label on left
this.create(`label.${cls.toggleSwitch}`, { title: options.title }, [
// Switch on right
input,
this.create(`span.${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.${cls.sliderDisplay}`, {}, []);
const controlGroup = this.create(`div.${cls.sliderSubgroupControl}`, {}, [input, display]);
const containerClass = options.containerClass ? cls[options.containerClass] : cls.sliderContainer;
const container = this.create(`div.${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.${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.${cls.colorSwatchValue}`, {}, []);
const swatch = this.create(`button.${cls.colorSwatch}`, { type: 'button', title: 'Open color picker' }, [this.create(`span.${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#${this.context.pickerRootId}.${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.${cls.formField}`, {}, [
this.create(`div.${cls.labelRow}`, {}, [
this.create('label', { title: options.tooltip }, label),
this.create(`span.${cls.statusText}`, {}, []), // Error container placeholder
]),
this.create(`div.${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.${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}`;
}
// Convert space-separated classes to Emmet syntax: .class1.class2
const emmetClasses = className.trim().replace(/\s+/g, '.');
const container = this.create(`div.${emmetClasses}`, {}, children);
if (container instanceof HTMLElement) {
this._setupDynamicState(container, options);
}
return container;
}
separator(options) {
const container = this.create(`div.${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];
} else if (options.className) {
className = options.className;
}
const tag = className ? `div.${className.trim().replace(/\s+/g, '.')}` : 'div';
const container = this.create(tag, {}, 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, then format for Emmet
const fullClassName = [commonBtnClass, btnDef.className].filter(Boolean).join(' ').trim().replace(/\s+/g, '.');
const tag = btnDef.id ? `button#${btnDef.id}.${fullClassName}` : `button.${fullClassName}`;
return h(
tag,
{
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 dialogTag = this.options.id ? `dialog#${this.options.id}.${cls.dialog}` : `dialog.${cls.dialog}`;
const dialogElement = h(
dialogTag,
{
[`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 instanceof Element) {
// 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 {SettingsButtonContext} context
* @param {object} options
* @param {string} options.id
* @param {string} options.title
*/
constructor(context, options) {
super(context);
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#${this.id}`, {
type: 'button',
title: this.options.title,
onclick: (e) => {
e.preventDefault();
e.stopPropagation();
this.context.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 {UIContext} context
*/
constructor(context) {
super(context);
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.context.getCurrentConfig();
if (this.isDestroyed) return;
const currentWarning = this.context.getCurrentWarning();
const { SYSTEM_ROOT, SYSTEM_WARNING, SYSTEM_SIZE_EXCEEDED } = CONSTANTS.STORE_KEYS;
// Check size state
const sizeInfo = this.context.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.context.getCurrentThemeSet) {
this.activeThemeSet = this.context.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.context.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);
}
_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...',
() => {
if (this.activeThemeSet) {
let themeKey = this.activeThemeSet.metadata?.id;
if (themeKey === 'default') {
themeKey = CONSTANTS.THEME_IDS.DEFAULT;
}
this.context.onShowThemeModal?.(themeKey || CONSTANTS.THEME_IDS.DEFAULT);
this.hide();
}
},
{ 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...',
() => {
this.context.onShowThemeModal?.();
this.hide();
},
{ fullWidth: true, title: 'Open the theme editor to create and modify themes.' }
),
],
{}
),
ui.group(
'JSON',
[
ui.button(
`${prefix}-json-btn`,
'JSON...',
() => {
this.context.onShowJsonModal?.();
this.hide();
},
{ 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._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.context.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.context.onSave?.(newConfig);
} catch (e) {
Logger.error('UI', '', 'SettingsPanel save failed:', e);
}
}
}
/**
* 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',
};
/**
* @param {UIContext} context
*/
constructor(context) {
super(context);
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.context.getCurrentConfig();
// Guard: Stop if component was destroyed during await
if (this.isDestroyed) return;
const initialJson = JSON.stringify(currentConfig, null, 2);
const currentWarning = this.context.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.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.${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
// Apply Emmet syntax directly to the static class names
const statusMsg = ui.create('div.status-msg-display', {}, []);
const sizeInfo = ui.create('div.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.context.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.context.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',
};
/**
* @param {UIContext} context
*/
constructor(context) {
super(context);
this.modal = null;
this.dataConverter = context.dataConverter;
this.checkSize = context.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.context.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._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.context.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 wrapperEmmet = actor === 'user' ? `${cls.previewBubbleWrapper}.${cls.userPreview}` : cls.previewBubbleWrapper;
return ui.create(`div.${cls.previewContainer}`, {}, [
ui.create('label', {}, 'Preview:'),
ui.create(`div.${wrapperEmmet}`, {}, [ui.create(`div.${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.${cls.previewContainer}`, {}, [
ui.create('label', {}, 'Preview:'),
ui.create(`div.${cls.previewBubbleWrapper}`, {}, [ui.create(`div.${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.${cls.formField}`, {}, [
ui.create('label', {}, 'BG Preview:'),
ui.create(`div.${cls.previewBubbleWrapper}`, { style: { padding: '0', minHeight: '0' } }, [ui.create(`div.${cls.previewBackground}`, { dataset: { [CONSTANTS.DATA_KEYS.PREVIEW_FOR]: 'window' } }, [])]),
]);
}
_setupEventListeners() {
if (!this.modal) return;
const modalElement = this.modal.element;
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 = '';
});
}
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;
// Convert space-separated classes to dots for Emmet syntax
const moveBtnEmmet = `${commonBtn}.${cls.moveBtn}`.replace(/\s+/g, '.');
const deleteConfirmBtnEmmet = `${commonBtn}.${dangerBtn}`.replace(/\s+/g, '.');
return h(`div.${cls.headerControls}`, [
h(`div.${cls.headerRow}`, [
h('label', { htmlFor: cls.themeSelect }, 'Theme:'),
h(`div.${cls.renameArea}`, [
h(`select#${cls.themeSelect}.${commonInput}`, {
onchange: (e) => {
this.state.activeThemeKey = e.target.value;
this._initFormWithTheme(this.state.activeThemeKey);
this._renderUI();
},
}),
h(`input#${cls.renameInput}.${commonInput}`, {
type: 'text',
style: { display: 'none' },
onkeydown: (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this._handleRenameConfirm();
}
},
}),
]),
h(`div.${cls.actionArea}`, [
h(`div#${cls.mainActionsId}`, [
h(`button#${cls.renameBtn}.${commonBtn}`, { onclick: () => this._handleRenameClick() }, 'Rename'),
h(`button#${cls.upBtn}.${moveBtnEmmet}`, { onclick: () => this._handleThemeMove(-1) }, [createIconFromDef(StyleDefinitions.ICONS.arrowUp)]),
h(`button#${cls.downBtn}.${moveBtnEmmet}`, { onclick: () => this._handleThemeMove(1) }, [createIconFromDef(StyleDefinitions.ICONS.arrowDown)]),
h(`button#${cls.newBtn}.${commonBtn}`, { onclick: () => this._handleThemeNew() }, 'New'),
h(`button#${cls.copyBtn}.${commonBtn}`, { onclick: () => this._handleThemeCopy() }, 'Copy'),
h(`button#${cls.deleteBtn}.${commonBtn}`, { onclick: () => this._handleDeleteClick() }, 'Delete'),
]),
h(`div#${cls.renameActionsId}`, { style: { display: 'none' } }, [
h(`button#${cls.renameOkBtn}.${commonBtn}`, { onclick: () => this._handleRenameConfirm() }, 'OK'),
h(`button#${cls.renameCancelBtn}.${commonBtn}`, { onclick: () => this._handleActionCancel() }, 'Cancel'),
]),
h(`div#${cls.deleteConfirmGroup}.${cls.deleteConfirmGroup}`, { style: { display: 'none' } }, [
h(`span.${cls.deleteConfirmLabel}`, 'Are you sure?'),
h(`button#${cls.deleteConfirmBtn}.${deleteConfirmBtnEmmet}`, { onclick: () => this._handleThemeDeleteConfirm() }, 'Confirm Delete'),
h(`button#${cls.deleteCancelBtn}.${commonBtn}`, { onclick: () => this._handleActionCancel() }, '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.context.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',
};
/**
* @param {string} role
* @param {MessageNode[]} messages
* @param {MessageNode | null} highlightedMessage
* @param {JumpListContext} context
* @param {StyleHandle} styleHandle
* @param {string} initialFilterValue
* @param {Map<string, any>} searchCache
*/
constructor(role, messages, highlightedMessage, context, styleHandle, initialFilterValue, searchCache) {
super(context);
this.role = role;
/** @type {MessageNode[]} */
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.id);
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 roleClass = msg.role === CONSTANTS.INTERNAL_ROLES.USER ? cls.userItem : msg.role === CONSTANTS.INTERNAL_ROLES.ASSISTANT ? cls.asstItem : null;
let rawText = msg.text;
// Streaming fallback: Only extract from DOM if it is the latest message
// OR if the text is currently empty. Past messages safely use their stable cached text.
if ((isLatest || !rawText) && msg.element) {
rawText = PlatformAdapters.General.getJumpListDisplayText(msg.element);
if (msg.type === 'image' && !rawText.startsWith('(image)')) {
rawText = `(image) ${rawText}`.trim();
}
} else if (!rawText) {
rawText = '';
}
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.id, cachedData);
}
}
return {
node: 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;
// 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.${cls.filter}`, {
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)",
value: this.state.initialFilterValue,
oninput: this._handleFilter,
onkeydown: this._handleFilterKeyDown,
onclick: (e) => e.stopPropagation(),
});
const modeLabel = h(`span.${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;
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(newMessageNode) {
this.state.highlightedMessage = newMessageNode;
// 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) {
// 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 messageNode = searchableMessage.node;
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 === messageNode) {
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].node;
if (targetMessage) this.context.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].node;
if (targetMessage) this.context.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.context.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 {UIContext} context
*/
constructor(context) {
super();
this.context = context;
this.settingsButton = null;
this.settingsPanel = null;
this.isRepositionScheduled = false;
this.isRepositionPending = 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({
...this.context,
getAnchorElement: () => this.getAnchorElement(),
})
);
// 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) {
this.isRepositionPending = true;
return;
}
this.isRepositionScheduled = true;
this.isRepositionPending = false;
EventBus.queueUIWork(() => {
try {
if (this.isDestroyed) return;
this.ensureButtonPlacement();
} finally {
this.isRepositionScheduled = false;
if (this.isRepositionPending) {
this.scheduleButtonPlacement();
}
}
});
}
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 {UIContext} context
*/
constructor(context) {
super();
this.context = context;
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(this.context));
this.themeModal = this.manageFactory(CONSTANTS.RESOURCE_KEYS.THEME_MODAL, () => new ThemeModalComponent(this.context));
// 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' },
});
// Convert space-separated classes to Emmet syntax
const btnEmmetClass = modalBtnClass.trim().replace(/\s+/g, '.');
const reloadBtn = h(`button#${conflictReloadBtnId}.${btnEmmetClass}`, {
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 {AppContext} appContext
*/
constructor(appContext) {
super();
// Global UI State (Source of Truth)
this.isWarningActive = false;
this.warningMessage = '';
// Combine UIManager state to build UIContext
this.uiContext = {
...appContext,
getCurrentWarning: () => ({ show: this.isWarningActive, message: this.warningMessage }),
};
// 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.uiContext,
// 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.uiContext,
// 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.
* Designed as a persistent singleton per project prefix.
* This class does not support explicit lifecycle destruction (no destroy method), as instances are intended to live indefinitely to ensure continuous DOM monitoring across scripts.
* @property {Map<string, Set<(element: Element) => void>>} listeners
* @property {Set<string>} rules
* @property {HTMLElement | null} styleElement
* @property {CSSStyleSheet | null} sheet
* @property {WeakMap<CSSRule, string>} ruleSelectors
*/
class Sentinel {
static MAX_POLLS = 60;
static POLL_INTERVAL = 50;
/**
* @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.isSuspended = false;
// 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
/** @type {WeakMap<CSSRule, string>} */
this.ruleSelectors = new WeakMap(); // Tracks selector strings associated with CSSRule objects
/** @type {Map<string, string>} */
this.normalizedSelectors = new Map(); // Maps original selectors to browser-normalized selectors
this._boundHandleAnimationStart = this._handleAnimationStart.bind(this);
this._injectStyleElement();
document.addEventListener('animationstart', this._boundHandleAnimationStart, true);
globalScope.__global_sentinel_instances__[prefix] = this;
}
_injectStyleElement() {
// Ensure the style element is injected only once per project prefix.
this.styleElement = document.getElementById(this.styleId);
if (this.styleElement instanceof HTMLStyleElement) {
this.styleElement.disabled = this.isSuspended;
this._waitForStylesheet();
return;
}
// Create empty style element
this.styleElement = document.createElement('style');
this.styleElement.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;
if (target) {
target.appendChild(this.styleElement);
this._waitForStylesheet();
} else {
const initObserver = new MutationObserver(() => {
const retryTarget = document.head || document.documentElement;
if (retryTarget) {
initObserver.disconnect();
retryTarget.appendChild(this.styleElement);
this._waitForStylesheet();
}
});
initObserver.observe(document, { childList: true });
}
}
/**
* Ensures the style element is connected to the DOM and restores rules if it was removed.
*/
_ensureStyleGuard() {
// Lazy Recovery: If the style element is connected but the stylesheet reference (this.sheet) was missed due to a timeout caused by a long task, recover it immediately here.
if (this.styleElement instanceof HTMLStyleElement && this.styleElement.isConnected && !this.sheet && this.styleElement.sheet) {
this._syncStylesheetRules();
}
if (this.styleElement && !this.styleElement.isConnected) {
const target = document.head || document.documentElement;
if (target) {
this.sheet = null; // Clear stale stylesheet reference before reconnecting
target.appendChild(this.styleElement);
this._waitForStylesheet();
}
}
}
/**
* Periodically checks for stylesheet availability and triggers full synchronization.
* @private
*/
_waitForStylesheet() {
if (!(this.styleElement instanceof HTMLStyleElement) || !this.styleElement.isConnected) return;
const styleNode = this.styleElement;
let pollCount = 0;
const poll = () => {
if (!styleNode.isConnected) return;
if (styleNode.sheet) {
this._syncStylesheetRules();
} else if (pollCount < Sentinel.MAX_POLLS) {
pollCount++;
console.debug(`[Sentinel] Polling sheet (Attempt ${pollCount}/${Sentinel.MAX_POLLS}). requestAnimationFrame check was insufficient.`);
setTimeout(poll, Sentinel.POLL_INTERVAL);
} else {
// Calculate timeout in seconds dynamically based on constants
const timeoutSeconds = (Sentinel.MAX_POLLS * Sentinel.POLL_INTERVAL) / 1000;
console.error(`[Sentinel] Polling sheet timed out after ${timeoutSeconds} seconds.`);
}
};
if (styleNode.sheet) {
this._syncStylesheetRules();
} else {
requestAnimationFrame(() => {
if (!styleNode.isConnected) return;
if (styleNode.sheet) {
this._syncStylesheetRules();
} else {
setTimeout(poll, Sentinel.POLL_INTERVAL);
}
});
}
}
/**
* Synchronizes all active rules directly onto the connected stylesheet.
* @private
*/
_syncStylesheetRules() {
if (!(this.styleElement instanceof HTMLStyleElement) || !this.styleElement.isConnected || !this.styleElement.sheet) return;
this.styleElement.disabled = this.isSuspended;
this.sheet = this.styleElement.sheet;
try {
// Non-destructive cleanup: scan and remove only rules belonging to this instance's active selectors
for (let i = this.sheet.cssRules.length - 1; i >= 0; i--) {
const rule = this.sheet.cssRules[i];
const recordedSelector = this.ruleSelectors.get(rule);
if (this.rules.has(recordedSelector) || (rule instanceof CSSStyleRule && (this.rules.has(rule.selectorText) || [...this.rules].some((sel) => rule.selectorText === this.normalizedSelectors.get(sel))))) {
this.sheet.deleteRule(i);
}
}
// Non-destructive keyframes validation
this._ensureKeyframesRule();
} catch (e) {
console.error('[Sentinel] Failed to clear or restore base rules:', e);
}
this.rules.forEach((selector) => {
const success = this._insertRule(selector);
if (!success) {
// Rollback invalid selector to prevent infinite error loops on subsequent syncs
this.rules.delete(selector);
this.listeners.delete(selector);
}
});
}
/**
* Ensures the shared keyframes rule exists in the stylesheet.
*/
_ensureKeyframesRule() {
let hasKeyframes = false;
for (let i = 0; i < this.sheet.cssRules.length; i++) {
const rule = this.sheet.cssRules[i];
if (rule instanceof CSSKeyframesRule && rule.name === this.animationName) {
hasKeyframes = true;
break;
}
}
if (!hasKeyframes) {
const keyframes = `@keyframes ${this.animationName} { from { outline: 1px solid transparent; } to { outline: 0px solid transparent; } }`;
this.sheet.insertRule(keyframes, 0);
}
}
/**
* Helper to insert a single rule into the stylesheet
* @param {string} selector
* @returns {boolean} True if insertion was successful, false otherwise
*/
_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);
if (insertedRule instanceof CSSStyleRule) {
this.normalizedSelectors.set(selector, insertedRule.selectorText);
}
}
return true;
} catch (e) {
console.error(`[Sentinel] Rule insertion failed for selector "${selector}". The listener has been rejected and removed:`, e);
return false;
}
}
_handleAnimationStart(event) {
if (this.isSuspended) 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) {
console.error(`[Sentinel] Listener error for selector "${selector}":`, e);
}
});
}
}
}
/**
* @param {string} selector
* @param {(element: Element) => void} callback
*/
on(selector, callback) {
this._ensureStyleGuard();
// Add callback to listeners
if (!this.listeners.has(selector)) {
this.listeners.set(selector, new Set());
}
this.listeners.get(selector).add(callback);
// If selector is already registered in rules, do nothing
if (this.rules.has(selector)) return;
this.rules.add(selector);
// Apply rule
if (this.sheet) {
const success = this._insertRule(selector);
if (!success) {
// Rollback on immediate insertion failure
this.listeners.delete(selector);
this.rules.delete(selector);
}
}
}
/**
* @param {string} selector
* @param {(element: Element) => void} callback
*/
off(selector, callback) {
const callbacks = this.listeners.get(selector);
if (!callbacks) return;
const wasDeleted = callbacks.delete(callback);
if (!wasDeleted) {
return;
// Callback not found, do nothing.
}
if (callbacks.size === 0) {
// Remove listener and rule
this.listeners.delete(selector);
this.rules.delete(selector);
this.normalizedSelectors.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 || rule.selectorText === this.normalizedSelectors.get(selector)))) {
try {
this.sheet.deleteRule(i);
} catch (e) {
console.error(`[Sentinel] Failed to delete rule for selector "${selector}":`, e);
}
// We assume one rule per selector, so we can break after deletion
break;
}
}
}
}
}
suspend() {
if (this.isSuspended) return;
this.isSuspended = true;
if (this.styleElement instanceof HTMLStyleElement) {
this.styleElement.disabled = true;
}
console.debug('[Sentinel] Suspended.');
}
resume() {
if (!this.isSuspended) return;
this.isSuspended = false;
if (this.styleElement instanceof HTMLStyleElement) {
this.styleElement.disabled = false;
}
console.debug('[Sentinel] 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.${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.apiMessageManager = 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);
// Shared state for streaming status
// Store as instance property to access within AppController (e.g. for Heartbeat guard)
this.streamingState = { isActive: false };
// Inject streaming state reference into the API message adapter
PlatformAdapters.ApiMessage.streamingState = this.streamingState;
// --- 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));
const appContext = {
onSave: (newConfig) => this.handleSave(newConfig),
getCurrentConfig: () => Promise.resolve(this.configManager.get()),
dataConverter: this.dataConverter,
getCurrentThemeSet: () => this.themeManager.getThemeSet(),
checkSize: (cfg) => ({
size: this.configManager.getConfigSize(cfg),
isExceeded: this.configManager.isSizeExceeded(this.configManager.getConfigSize(cfg)),
}),
};
this.uiManager = this.manageFactory(CONSTANTS.RESOURCE_KEYS.UI_MANAGER, () => new UIManager(appContext));
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 (CONSTANTS.CAPABILITIES.API_MESSAGE) {
this.apiMessageManager = this.manageFactory(CONSTANTS.RESOURCE_KEYS.API_MESSAGE_MANAGER, () => new ApiMessageManager(this.configManager, this.messageCacheManager));
}
if (CONSTANTS.CAPABILITIES.TIMESTAMP) {
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.apiMessageManager,
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 (CONSTANTS.CAPABILITIES.TIMESTAMP && 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 = performance.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.apiMessageManager = 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 = performance.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) {
// Find the last message that is supposed to be mounted in the DOM
const lastMountedMsg = totalMessages.findLast((msg) => msg.element);
if (lastMountedMsg && !lastMountedMsg.element.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 mounted message as a proxy for the entire list stability
const lastMountedMsg = totalMessages.findLast((msg) => msg.element);
if (lastMountedMsg && !lastMountedMsg.element.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;
// 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();
} else if (!newTimestampEnabled && oldTimestampEnabled) {
this.timestampManager.disable();
} else if (newTimestampEnabled) {
// If already enabled, just force an update (e.g., if nav console was toggled)
this.timestampManager.updateAllTimestamps();
}
}
// Handle FixedNavigationManager lifecycle
const navConsoleEnabled = completeConfig.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 and register events
const monitor = new NavigationMonitor(OWNERID);
const onNavStart = () => EventBus.publish(EVENTS.NAVIGATION_START);
const onNavSettled = () => EventBus.publish(EVENTS.NAVIGATION);
const subscriberId = createSubscriberKey(CONSTANTS.NAV_PURPOSE.LIFECYCLE);
const unsubscribe = monitor.on(subscriberId, onNavStart, onNavSettled, { trackHash: false });
// Register cleanup to remove listeners on destroy
this.manageResource(CONSTANTS.RESOURCE_KEYS.NAVIGATION_MONITOR, { destroy: unsubscribe });
// 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.isTargetPage()) {
Logger.log('NON-TARGET URL', LOG_STYLES.YELLOW, 'Non-target URL detected. Suspending script functions.');
this._shutdown();
}
}
_handleNavigation() {
if (!PlatformAdapters.General.isTargetPage()) {
// 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 target page in case URL changed while waiting
if (PlatformAdapters.General.isTargetPage()) {
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 (Non-Target 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.
// API interception is required for virtual scrolling data management.
if (CONSTANTS.CAPABILITIES.API_MESSAGE) {
PlatformAdapters.ApiMessage.init();
}
// Initialize lifecycle management to handle app startup and navigation
const lifecycleManager = new LifecycleManager();
lifecycleManager.init();
})();