// ==UserScript==
// @name ChatGPT-UX-Customizer
// @namespace https://github.com/p65536
// @version 2.3.5
// @license MIT
// @description Fully customize the chat UI. Automatically applies themes based on chat names to control everything from avatar icons and standing images to bubble styles and backgrounds. Adds powerful navigation features like a message jump list with search.
// @icon https://chatgpt.com/favicon.ico
// @author p65536
// @match https://chatgpt.com/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addValueChangeListener
// @grant GM_removeValueChangeListener
// @grant GM_xmlhttpRequest
// @connect raw.githubusercontent.com
// @connect *
// @run-at document-start
// @noframes
// @downloadURL https://update.greasyfork.icu/scripts/543703/ChatGPT-UX-Customizer.user.js
// @updateURL https://update.greasyfork.icu/scripts/543703/ChatGPT-UX-Customizer.meta.js
// ==/UserScript==
(() => {
'use strict';
// =================================================================================
// SECTION: Platform-Specific Definitions (DO NOT COPY TO OTHER PLATFORM)
// =================================================================================
const OWNERID = 'p65536';
const APPID = 'gptux';
const APPNAME = 'ChatGPT UX Customizer';
const ASSISTANT_NAME = 'ChatGPT';
const LOG_PREFIX = `[${APPID.toUpperCase()}]`;
// =================================================================================
// SECTION: Style Definitions
// =================================================================================
// Style definitions for styled Logger.badge()
const LOG_STYLES = {
BASE: 'color: white; padding: 2px 6px; border-radius: 4px; font-weight: bold;',
BLUE: 'background: #007bff;',
GREEN: 'background: #28a745;',
YELLOW: 'background: #ffc107; color: black;',
RED: 'background: #dc3545;',
GRAY: 'background: #6c757d;',
};
// =================================================================================
// SECTION: Logging Utility
// Description: Centralized logging interface for consistent log output across modules.
// Handles log level control, message formatting, and console API wrapping.
// =================================================================================
class Logger {
/** @property {object} levels - Defines the numerical hierarchy of log levels. */
static levels = {
error: 0,
warn: 1,
info: 2,
log: 3,
debug: 4,
};
/** @property {string} level - The current active log level. */
static level = 'log'; // Default level
/**
* Sets the current log level.
* @param {string} level The new log level. Must be one of 'error', 'warn', 'info', 'log', 'debug'.
*/
static setLevel(level) {
if (Object.prototype.hasOwnProperty.call(this.levels, level)) {
this.level = level;
} else {
Logger.badge('INVALID LEVEL', LOG_STYLES.YELLOW, 'warn', `Invalid log level "${level}". Valid levels are: ${Object.keys(this.levels).join(', ')}. Level not changed.`);
}
}
/** @param {...any} args The messages or objects to log. */
static error(...args) {
if (this.levels[this.level] >= this.levels.error) {
console.error(LOG_PREFIX, ...args);
}
}
/** @param {...any} args The messages or objects to log. */
static warn(...args) {
if (this.levels[this.level] >= this.levels.warn) {
console.warn(LOG_PREFIX, ...args);
}
}
/** @param {...any} args The messages or objects to log. */
static info(...args) {
if (this.levels[this.level] >= this.levels.info) {
console.info(LOG_PREFIX, ...args);
}
}
/** @param {...any} args The messages or objects to log. */
static log(...args) {
if (this.levels[this.level] >= this.levels.log) {
console.log(LOG_PREFIX, ...args);
}
}
/**
* Logs messages for debugging. Only active in 'debug' level.
* @param {...any} args The messages or objects to log.
*/
static debug(...args) {
if (this.levels[this.level] >= this.levels.debug) {
// Use console.debug for better filtering in browser dev tools.
console.debug(LOG_PREFIX, ...args);
}
}
/**
* Starts a timer for performance measurement. Only active in 'debug' level.
* @param {string} label The label for the timer.
*/
static time(label) {
if (this.levels[this.level] >= this.levels.debug) {
console.time(`${LOG_PREFIX} ${label}`);
}
}
/**
* Ends a timer and logs the elapsed time. Only active in 'debug' level.
* @param {string} label The label for the timer, must match the one used in time().
*/
static timeEnd(label) {
if (this.levels[this.level] >= this.levels.debug) {
console.timeEnd(`${LOG_PREFIX} ${label}`);
}
}
/**
* @param {...any} args The title for the log group.
* @returns {void}
*/
static group = (...args) => console.group(LOG_PREFIX, ...args);
/**
* @param {...any} args The title for the collapsed log group.
* @returns {void}
*/
static groupCollapsed = (...args) => console.groupCollapsed(LOG_PREFIX, ...args);
/**
* Closes the current log group.
* @returns {void}
*/
static groupEnd = () => console.groupEnd();
/**
* Logs a message with a styled badge for better visibility.
* @param {string} badgeText - The text inside the badge.
* @param {string} badgeStyle - The background-color style (from LOG_STYLES).
* @param {'log'|'warn'|'error'|'info'|'debug'} level - The console log level.
* @param {...any} args - Additional messages to log after the badge.
*/
static badge(badgeText, badgeStyle, level, ...args) {
if (this.levels[this.level] < this.levels[level]) {
return; // Respect the current log level
}
const style = `${LOG_STYLES.BASE} ${badgeStyle}`;
const consoleMethod = console[level] || console.log;
consoleMethod(
`%c${LOG_PREFIX}%c %c${badgeText}%c`,
'font-weight: bold;', // Style for the prefix
'color: inherit;', // Reset for space
style, // Style for the badge
'color: inherit;', // Reset for the rest of the message
...args
);
}
}
/**
* @description A lightweight performance monitor to track event frequency.
* Only active when Logger.level is set to 'debug'.
*/
const PerfMonitor = {
_events: {},
/**
* Logs the frequency of an event, throttled by a specified delay.
* @param {string} key A unique key for the event to track.
* @param {number} [delay] The time window in milliseconds to aggregate calls.
*/
throttleLog(key, delay = 1000) {
if (Logger.levels[Logger.level] < Logger.levels.debug) {
return;
}
const now = Date.now();
if (!this._events[key]) {
this._events[key] = { count: 1, startTime: now };
return;
}
this._events[key].count++;
if (now - this._events[key].startTime >= delay) {
const callsPerSecond = (this._events[key].count / ((now - this._events[key].startTime) / 1000)).toFixed(2);
// Use Logger.debug to ensure the output is prefixed and controlled.
Logger.badge('PerfMonitor', LOG_STYLES.GRAY, 'debug', `${key}: ${this._events[key].count} calls in ${now - this._events[key].startTime}ms (${callsPerSecond} calls/sec)`);
delete this._events[key];
}
},
/**
* Resets all performance counters.
*/
reset() {
this._events = {};
},
};
// =================================================================================
// SECTION: Execution Guard
// Description: Prevents the script from being executed multiple times per page.
// =================================================================================
class ExecutionGuard {
// A shared key for all scripts from the same author to avoid polluting the window object.
static #GUARD_KEY = `__${OWNERID}_guard__`;
// A specific key for this particular script.
static #APP_KEY = `${APPID}_executed`;
/**
* Checks if the script has already been executed on the page.
* @returns {boolean} True if the script has run, otherwise false.
*/
static hasExecuted() {
return window[this.#GUARD_KEY]?.[this.#APP_KEY] || false;
}
/**
* Sets the flag indicating the script has now been executed.
*/
static setExecuted() {
window[this.#GUARD_KEY] = window[this.#GUARD_KEY] || {};
window[this.#GUARD_KEY][this.#APP_KEY] = true;
}
}
// =================================================================================
// SECTION: Configuration and Constants
// Description: Defines default settings, global constants, and CSS selectors.
// =================================================================================
// ---- Default Settings & Theme Configuration ----
const CONSTANTS = {
CONFIG_KEY: `${APPID}_config`,
CONFIG_SIZE_RECOMMENDED_LIMIT_BYTES: 5 * 1024 * 1024, // 5MB
CONFIG_SIZE_LIMIT_BYTES: 10 * 1024 * 1024, // 10MB
CACHE_SIZE_LIMIT_BYTES: 10 * 1024 * 1024, // 10MB
ICON_SIZE: 64,
ICON_SIZE_VALUES: [64, 96, 128, 160, 192],
ICON_MARGIN: 20,
SENTINEL: {
PANEL: 'PanelObserver',
},
OBSERVER_OPTIONS: {
childList: true,
subtree: false,
},
BUTTON_VISIBILITY_THRESHOLD_PX: 128,
BATCH_PROCESSING_SIZE: 50,
RETRY: {
SCROLL_OFFSET_FOR_NAV: 40,
},
TIMING: {
DEBOUNCE_DELAYS: {
// Delay for recalculating UI elements after visibility changes
VISIBILITY_CHECK: 250,
// Delay for updating the message cache after DOM mutations
CACHE_UPDATE: 250,
// Delay for recalculating layout-dependent elements (e.g., standing images) after resize
LAYOUT_RECALCULATION: 150,
// Delay for updating navigation buttons after a message is completed
NAVIGATION_UPDATE: 100,
// Delay for repositioning UI elements like the settings button
UI_REPOSITION: 100,
// Delay for updating the theme after sidebar mutations (Gemini-specific)
THEME_UPDATE: 150,
// Delay for saving settings after user input in the settings panel
SETTINGS_SAVE: 300,
// Delay for updating the theme editor's preview pane
THEME_PREVIEW: 50,
// Delay for batching avatar injection events on initial load
AVATAR_INJECTION: 25,
},
TIMEOUTS: {
// Delay to wait for the DOM to settle after a URL change before re-scanning
POST_NAVIGATION_DOM_SETTLE: 200,
// Delay before resetting the scroll-margin-top style used for smooth scrolling offset
SCROLL_OFFSET_CLEANUP: 1500,
// Delay before reopening a modal after a settings sync conflict is resolved
MODAL_REOPEN_DELAY: 100,
// Delay to wait for panel transition animations (e.g., Canvas, File Panel) to complete
PANEL_TRANSITION_DURATION: 350,
// Fallback delay for requestIdleCallback
IDLE_EXECUTION_FALLBACK: 50,
// Grace period to confirm a 0-message page before firing NAVIGATION_END
ZERO_MESSAGE_GRACE_PERIOD: 2000,
},
},
OBSERVED_ELEMENT_TYPES: {
BODY: 'body',
INPUT_AREA: 'inputArea',
SIDE_PANEL: 'sidePanel',
},
SLIDER_CONFIGS: {
CHAT_WIDTH: {
MIN: 29,
MAX: 80,
NULL_THRESHOLD: 30,
DEFAULT: null,
},
},
Z_INDICES: {
SETTINGS_BUTTON: 10000,
SETTINGS_PANEL: 11000,
THEME_MODAL: 12000,
JSON_MODAL: 15000,
JUMP_LIST_PREVIEW: 16000,
STANDING_IMAGE: 'auto',
BUBBLE_NAVIGATION: 'auto',
NAV_CONSOLE: 'auto',
},
MODAL: {
WIDTH: 440,
PADDING: 4,
RADIUS: 8,
BTN_RADIUS: 5,
BTN_FONT_SIZE: 13,
BTN_PADDING: '5px 16px',
TITLE_MARGIN_BOTTOM: 8,
BTN_GROUP_GAP: 8,
TEXTAREA_HEIGHT: 200,
},
UI_DEFAULTS: {
SETTINGS_BUTTON_PADDING_RIGHT: '44px',
},
INTERNAL_ROLES: {
USER: 'user',
ASSISTANT: 'assistant',
},
DATA_KEYS: {
AVATAR_INJECT_ATTEMPTS: 'avatarInjectAttempts',
AVATAR_INJECT_FAILED: 'avatarInjectFailed',
},
ATTRIBUTES: {
MESSAGE_ROLE: 'data-message-author-role',
TURN_ROLE: 'data-turn',
},
SELECTORS: {
// --- Main containers ---
MAIN_APP_CONTAINER: 'div:has(> main#main)',
MESSAGE_WRAPPER_FINDER: '.w-full',
MESSAGE_WRAPPER: 'chat-wrapper',
// --- Message containers ---
CONVERSATION_UNIT: 'article[data-testid^="conversation-turn-"]',
MESSAGE_ID_HOLDER: '[data-message-id]',
MESSAGE_CONTAINER_PARENT: 'div:has(> article[data-testid^="conversation-turn-"])',
MESSAGE_ROOT_NODE: 'article[data-testid^="conversation-turn-"]',
// --- Selectors for messages ---
USER_MESSAGE: 'div[data-message-author-role="user"]',
ASSISTANT_MESSAGE: 'div[data-message-author-role="assistant"]',
// --- Selectors for finding elements to tag ---
RAW_USER_BUBBLE: 'div:has(> .whitespace-pre-wrap)',
RAW_ASSISTANT_BUBBLE: 'div:has(> .markdown)',
RAW_USER_IMAGE_BUBBLE: 'div.overflow-hidden:has(img)',
RAW_ASSISTANT_IMAGE_BUBBLE: 'div.group\\/imagegen-image',
// --- Text content ---
USER_TEXT_CONTENT: '.whitespace-pre-wrap',
ASSISTANT_TEXT_CONTENT: '.markdown',
// --- Input area ---
INPUT_AREA_BG_TARGET: 'form[data-type="unified-composer"] div[style*="border-radius"]',
INPUT_TEXT_FIELD_TARGET: 'div.ProseMirror#prompt-textarea',
INPUT_RESIZE_TARGET: 'form[data-type="unified-composer"] div[style*="border-radius"]',
// --- Input area (Button Injection) ---
INSERTION_ANCHOR: 'form[data-type="unified-composer"] div[class*="[grid-area:trailing]"]',
// --- Avatar area ---
AVATAR_USER: 'article[data-turn="user"]',
AVATAR_ASSISTANT: 'article[data-turn="assistant"]',
// --- Selectors for Avatar ---
SIDE_AVATAR_CONTAINER: '.side-avatar-container',
SIDE_AVATAR_ICON: '.side-avatar-icon',
SIDE_AVATAR_NAME: '.side-avatar-name',
// --- Other UI Selectors ---
SIDEBAR_WIDTH_TARGET: 'div[id="stage-slideover-sidebar"]',
SIDEBAR_STATE_INDICATOR: '#stage-sidebar-tiny-bar',
RIGHT_SIDEBAR: 'div.bg-token-sidebar-surface-primary.shrink-0:not(#stage-slideover-sidebar)',
CHAT_CONTENT_MAX_WIDTH: '.group\\/turn-messages, div[class*="--thread-content-max-width"].grid',
SCROLL_CONTAINER: 'div:has(> main#main)',
STANDING_IMAGE_ANCHOR: '.group\\/turn-messages, div[class*="--thread-content-max-width"].grid',
// --- Site Specific Selectors ---
BUTTON_SHARE_CHAT: '[data-testid="share-chat-button"]',
PAGE_HEADER: '#page-header',
TITLE_OBSERVER_TARGET: 'title',
// --- BubbleFeature-specific Selectors ---
BUBBLE_FEATURE_MESSAGE_CONTAINERS: 'div[data-message-author-role]',
BUBBLE_FEATURE_TURN_CONTAINERS: 'article[data-testid^="conversation-turn-"]',
// --- FixedNav-specific Selectors ---
FIXED_NAV_INPUT_AREA_TARGET: 'form[data-type="unified-composer"]',
FIXED_NAV_MESSAGE_CONTAINERS: 'div[data-message-author-role]',
FIXED_NAV_TURN_CONTAINER: 'article[data-testid^="conversation-turn-"]',
FIXED_NAV_ROLE_USER: 'user',
FIXED_NAV_ROLE_ASSISTANT: 'assistant',
FIXED_NAV_HIGHLIGHT_TARGETS: `.${APPID}-highlight-message div:has(> .whitespace-pre-wrap), .${APPID}-highlight-message div:has(> .markdown), .${APPID}-highlight-message div.overflow-hidden:has(img), .${APPID}-highlight-turn div.group\\/imagegen-image`,
// --- Turn Completion Selector ---
TURN_COMPLETE_SELECTOR: 'div.flex.justify-start:has(button[data-testid="copy-turn-action-button"])',
// --- Debug Selectors ---
DEBUG_CONTAINER_TURN: 'article[data-testid^="conversation-turn-"]',
DEBUG_CONTAINER_ASSISTANT: 'div[data-message-author-role="assistant"]',
DEBUG_CONTAINER_USER: 'div[data-message-author-role="user"]',
// --- 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',
},
};
const EVENTS = {
// Theme & Style
/**
* @description Fired when the chat title changes, signaling a potential theme change.
* @event TITLE_CHANGED
* @property {null} detail - No payload.
*/
TITLE_CHANGED: `${APPID}:titleChanged`,
/**
* @description Requests a re-evaluation and application of the current theme.
* @event THEME_UPDATE
* @property {null} detail - No payload.
*/
THEME_UPDATE: `${APPID}:themeUpdate`,
/**
* @description Fired after all theme styles, including asynchronous images, have been fully applied.
* @event THEME_APPLIED
* @property {object} detail - Contains the theme and config objects.
* @property {ThemeSet} detail.theme - The theme set that was applied.
* @property {AppConfig} detail.config - The full application configuration.
*/
THEME_APPLIED: `${APPID}:themeApplied`,
/**
* @description Fired when a width-related slider in the settings panel is changed, to preview the new width.
* @event WIDTH_PREVIEW
* @property {string | null} detail - The new width value (e.g., '60vw') or null for default.
*/
WIDTH_PREVIEW: `${APPID}:widthPreview`,
// UI & Layout
/**
* @description Fired by ThemeManager after it has applied a new chat content width.
* @event CHAT_CONTENT_WIDTH_UPDATED
* @property {null} detail - No payload.
*/
CHAT_CONTENT_WIDTH_UPDATED: `${APPID}:chatContentWidthUpdated`,
/**
* @description Fired when the main window is resized.
* @event WINDOW_RESIZED
* @property {null} detail - No payload.
*/
WINDOW_RESIZED: `${APPID}:windowResized`,
/**
* @description Fired when the sidebar's layout (width or visibility) changes.
* @event SIDEBAR_LAYOUT_CHANGED
* @property {null} detail - No payload.
*/
SIDEBAR_LAYOUT_CHANGED: `${APPID}:sidebarLayoutChanged`,
/**
* @description Requests a re-check of visibility-dependent UI elements (e.g., standing images when a panel appears).
* @event VISIBILITY_RECHECK
* @property {null} detail - No payload.
*/
VISIBILITY_RECHECK: `${APPID}:visibilityRecheck`,
/**
* @description Requests a repositioning of floating UI elements like the settings button.
* @event UI_REPOSITION
* @property {null} detail - No payload.
*/
UI_REPOSITION: `${APPID}:uiReposition`,
/**
* @description Fired when the chat input area is resized.
* @event INPUT_AREA_RESIZED
* @property {null} detail - No payload.
*/
INPUT_AREA_RESIZED: `${APPID}:inputAreaResized`,
/**
* @description Requests to reopen a modal, typically after a settings sync conflict is resolved.
* @event REOPEN_MODAL
* @property {object} detail - Context for which modal to reopen (e.g., { type: 'json' }).
*/
REOPEN_MODAL: `${APPID}:reOpenModal`,
// Navigation & Cache
/**
* @description Fired when a page navigation is about to start.
* @event NAVIGATION_START
* @property {null} detail - No payload.
*/
NAVIGATION_START: `${APPID}:navigationStart`,
/**
* @description Fired after a page navigation has completed and the UI is stable.
* @event NAVIGATION_END
* @property {null} detail - No payload.
*/
NAVIGATION_END: `${APPID}:navigationEnd`,
/**
* @description Fired when a page navigation (URL change) is detected. Used to reset manager states.
* @event NAVIGATION
* @property {null} detail - No payload.
*/
NAVIGATION: `${APPID}:navigation`,
/**
* @description Fired to request an update of the message cache, typically after a DOM mutation.
* @event CACHE_UPDATE_REQUEST
* @property {null} detail - No payload.
*/
CACHE_UPDATE_REQUEST: `${APPID}:cacheUpdateRequest`,
/**
* @description Fired after the MessageCacheManager has finished rebuilding its cache.
* @event CACHE_UPDATED
* @property {null} detail - No payload.
*/
CACHE_UPDATED: `${APPID}:cacheUpdated`,
/**
* @description Requests that a specific message element be highlighted by the navigation system.
* @event NAV_HIGHLIGHT_MESSAGE
* @property {HTMLElement} detail - The message element to highlight.
*/
NAV_HIGHLIGHT_MESSAGE: `${APPID}:nav:highlightMessage`,
// Message Lifecycle
/**
* @description Fired by Sentinel when a new message bubble's core content is added to the DOM.
* @event RAW_MESSAGE_ADDED
* @property {HTMLElement} detail - The raw bubble element that was added.
*/
RAW_MESSAGE_ADDED: `${APPID}:rawMessageAdded`,
/**
* @description Fired to request the injection of an avatar into a specific message element.
* @event AVATAR_INJECT
* @property {HTMLElement} detail - The message element (e.g., `user-query`) to inject the avatar into.
*/
AVATAR_INJECT: `${APPID}:avatarInject`,
/**
* @description Fired when a message container has been identified and is ready for further processing, such as the injection of UI addons (e.g., navigation buttons).
* @event MESSAGE_COMPLETE
* @property {HTMLElement} detail - The completed message element.
*/
MESSAGE_COMPLETE: `${APPID}:messageComplete`,
/**
* @description Fired when an entire conversation turn (user query and assistant response) is complete, including streaming.
* @event TURN_COMPLETE
* @property {HTMLElement} detail - The completed turn container element.
*/
TURN_COMPLETE: `${APPID}:turnComplete`,
/**
* @description Fired when an assistant response starts streaming.
* @event STREAMING_START
*/
STREAMING_START: `${APPID}:streamingStart`,
/**
* @description Fired when an assistant response finishes streaming.
* @event STREAMING_END
*/
STREAMING_END: `${APPID}:streamingEnd`,
/**
* @description Fired after streaming ends to trigger deferred layout updates.
* @event DEFERRED_LAYOUT_UPDATE
*/
DEFERRED_LAYOUT_UPDATE: `${APPID}:deferredLayoutUpdate`,
/**
* @description (GPTUX-only) Fired when historical timestamps are loaded from the API.
* @event TIMESTAMPS_LOADED
* @property {null} detail - No payload.
*/
TIMESTAMPS_LOADED: `${APPID}:timestampsLoaded`,
/**
* @description Fired when a new timestamp for a realtime message is recorded.
* @event TIMESTAMP_ADDED
* @property {object} detail - Contains the message ID.
* @property {string} detail.messageId - The ID of the message.
* @property {Date} detail.timestamp - The timestamp (Date object) of when the message was processed.
*/
TIMESTAMP_ADDED: `${APPID}:timestampAdded`,
// System & Config
/**
* @description Fired when a remote configuration change is detected from another tab/window.
* @event REMOTE_CONFIG_CHANGED
* @property {object} detail - Contains the new configuration string.
* @property {string} detail.newValue - The raw string of the new configuration.
*/
REMOTE_CONFIG_CHANGED: `${APPID}:remoteConfigChanged`,
/**
* @description Requests the temporary suspension of all major DOM observers (MutationObserver, Sentinel).
* @event SUSPEND_OBSERVERS
* @property {null} detail - No payload.
*/
SUSPEND_OBSERVERS: `${APPID}:suspendObservers`,
/**
* @description Requests the resumption of suspended observers and a forced refresh of the UI.
* @event RESUME_OBSERVERS_AND_REFRESH
* @property {null} detail - No payload.
*/
RESUME_OBSERVERS_AND_REFRESH: `${APPID}:resumeObserversAndRefresh`,
/**
* @description Fired when the configuration size exceeds the storage limit.
* @event CONFIG_SIZE_EXCEEDED
* @property {object} detail - Contains the error message.
* @property {string} detail.message - The warning message to display.
*/
CONFIG_SIZE_EXCEEDED: `${APPID}:configSizeExceeded`,
/**
* @description Fired to update the display state of a configuration-related warning.
* @event CONFIG_WARNING_UPDATE
* @property {object} detail - The warning state.
* @property {boolean} detail.show - Whether to show the warning.
* @property {string} detail.message - The message to display.
*/
CONFIG_WARNING_UPDATE: `${APPID}:configWarningUpdate`,
/**
* @description Fired when the configuration is successfully saved.
* @event CONFIG_SAVE_SUCCESS
* @property {null} detail - No payload.
*/
CONFIG_SAVE_SUCCESS: `${APPID}:configSaveSuccess`,
/**
* @description Fired when the configuration has been updated, signaling UI components to refresh.
* @event CONFIG_UPDATED
* @property {AppConfig} detail - The new, complete configuration object.
*/
CONFIG_UPDATED: `${APPID}:configUpdated`,
/**
* @description Fired to request a full application shutdown and cleanup.
* @event APP_SHUTDOWN
* @property {null} detail - No payload.
*/
APP_SHUTDOWN: `${APPID}:appShutdown`,
/**
* @description (ChatGPT-only) Fired by the polling scanner when it detects new messages.
* @event POLLING_MESSAGES_FOUND
* @property {null} detail - No payload.
*/
POLLING_MESSAGES_FOUND: `${APPID}:pollingMessagesFound`,
/**
* @description (Gemini-only) Requests the start of the auto-scroll process to load full chat history.
* @event AUTO_SCROLL_REQUEST
* @property {null} detail - No payload.
*/
AUTO_SCROLL_REQUEST: `${APPID}:autoScrollRequest`,
/**
* @description (Gemini-only) Requests the cancellation of an in-progress auto-scroll.
* @event AUTO_SCROLL_CANCEL_REQUEST
* @property {null} detail - No payload.
*/
AUTO_SCROLL_CANCEL_REQUEST: `${APPID}:autoScrollCancelRequest`,
/**
* @description (Gemini-only) Fired when the auto-scroll process has actively started (i.e., progress bar detected).
* @event AUTO_SCROLL_START
* @property {null} detail - No payload.
*/
AUTO_SCROLL_START: `${APPID}:autoScrollStart`,
/**
* @description (Gemini-only) Fired when the auto-scroll process has completed or been cancelled.
* @event AUTO_SCROLL_COMPLETE
* @property {null} detail - No payload.
*/
AUTO_SCROLL_COMPLETE: `${APPID}:autoScrollComplete`,
};
// ---- Site-specific Style Variables ----
const UI_PALETTE = {
bg: 'var(--main-surface-primary)',
input_bg: 'var(--bg-primary)',
text_primary: 'var(--text-primary)',
text_secondary: 'var(--text-secondary)',
border: 'var(--border-default)',
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)',
// Shared properties
slider_display_text: 'var(--text-primary)',
label_text: 'var(--text-secondary)',
error_text: 'var(--text-danger)',
dnd_indicator_color: 'var(--text-accent)',
};
const SITE_STYLES = {
PALETTE: UI_PALETTE,
ICONS: {
// For ThemeModal
folder: {
tag: 'svg',
props: { xmlns: 'http://www.w3.org/2000/svg', height: '24px', viewBox: '0 -960 960 960', width: '24px', fill: 'currentColor' },
children: [{ tag: 'path', props: { d: 'M160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h240l80 80h320q33 0 56.5 23.5T880-640v400q0 33-23.5 56.5T800-160H160Zm0-80h640v-400H447l-80-80H160v480Zm0 0v-480 480Z' } }],
},
// For BubbleUI (prev, collapse), FixedNav (prev), ThemeModal (up)
arrowUp: {
tag: 'svg',
props: { xmlns: 'http://www.w3.org/2000/svg', height: '24px', viewBox: '0 -960 960 960', width: '24px', fill: 'currentColor' },
children: [{ tag: 'path', props: { d: 'M480-528 296-344l-56-56 240-240 240 240-56 56-184-184Z' } }],
},
// For BubbleUI (next), FixedNav (next), ThemeModal (down)
arrowDown: {
tag: 'svg',
props: { xmlns: 'http://www.w3.org/2000/svg', height: '24px', viewBox: '0 -960 960 960', width: '24px', fill: 'currentColor' },
children: [{ tag: 'path', props: { d: 'M480-344 240-584l56-56 184 184 184-184 56 56-240 240Z' } }],
},
// For BubbleUI (top)
scrollToTop: {
tag: 'svg',
props: { xmlns: 'http://www.w3.org/2000/svg', height: '24px', viewBox: '0 -960 960 960', width: '24px', fill: 'currentColor' },
children: [{ tag: 'path', props: { d: 'M440-160v-480L280-480l-56-56 256-256 256 256-56 56-160-160v480h-80Zm-200-640v-80h400v80H240Z' } }],
},
// For FixedNav
scrollToFirst: {
tag: 'svg',
props: { viewBox: '0 -960 960 960' },
children: [{ tag: 'path', props: { d: 'm280-280 200-200 200 200-56 56-144-144-144 144-56-56Zm-40-360v-80h480v80H240Z' } }],
},
scrollToLast: {
tag: 'svg',
props: { viewBox: '0 -960 960 960' },
children: [{ tag: 'path', props: { d: 'M240-200v-80h480v80H240Zm240-160L280-560l56-56 144 144 144-144 56 56-200 200Z' } }],
},
bulkCollapse: {
tag: 'svg',
props: { className: 'icon-collapse', viewBox: '0 -960 960 960' },
children: [{ tag: 'path', props: { d: 'M440-440v240h-80v-160H200v-80h240Zm160-320v160h160v80H520v-240h80Z' } }],
},
bulkExpand: {
tag: 'svg',
props: { className: 'icon-expand', viewBox: '0 -960 960 960' },
children: [{ tag: 'path', props: { d: 'M200-200v-240h80v160h160v80H200Zm480-320v-160H520v-80h240v240h-80Z' } }],
},
refresh: {
tag: 'svg',
props: { xmlns: 'http://www.w3.org/2000/svg', height: '24px', viewBox: '0 -960 960 960', width: '24px', fill: 'currentColor' },
children: [
{
tag: 'path',
props: {
d: 'M480-160q-134 0-227-93t-93-227q0-134 93-227t227-93q69 0 132 28.5T720-690v-110h80v280H520v-80h168q-32-54-87-87t-121-33q-100 0-170 70t-70 170q0 100 70 170t170 70q77 0 139-44t87-116h84q-28 106-114 173t-196 67Z',
},
},
],
},
},
SETTINGS_BUTTON: {
base: {
background: 'transparent',
border: 'none',
borderRadius: '50%',
borderColor: 'transparent',
position: 'static',
margin: '0 2px 0 0',
// Updated dimensions to match native buttons
width: 'calc(var(--spacing)*9)',
height: 'calc(var(--spacing)*9)',
// Apply site icon color
color: 'var(--text-primary)',
},
hover: {
background: 'var(--interactive-bg-secondary-hover)',
borderColor: 'transparent',
},
iconDef: {
tag: 'svg',
props: { xmlns: 'http://www.w3.org/2000/svg', height: '24px', viewBox: '0 0 24 24', width: '24px', fill: 'currentColor' },
children: [
{ tag: 'path', props: { d: 'M0 0h24v24H0V0z', fill: 'none' } },
{
tag: 'path',
props: {
d: 'M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.3-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.08-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.23.08.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z',
},
},
],
},
},
SETTINGS_PANEL: {
bg: UI_PALETTE.bg,
text_primary: UI_PALETTE.text_primary,
text_secondary: UI_PALETTE.text_secondary,
border_medium: 'var(--border-medium)',
border_default: UI_PALETTE.border,
border_light: 'var(--border-light)',
toggle_bg_off: UI_PALETTE.toggle_bg_off,
toggle_bg_on: UI_PALETTE.toggle_bg_on,
toggle_knob: UI_PALETTE.toggle_knob,
},
JSON_MODAL: {
bg: UI_PALETTE.bg,
text: UI_PALETTE.text_primary,
border: UI_PALETTE.border,
btn_bg: UI_PALETTE.btn_bg,
btn_hover_bg: UI_PALETTE.btn_hover_bg,
btn_text: UI_PALETTE.btn_text,
btn_border: UI_PALETTE.btn_border,
textarea_bg: UI_PALETTE.input_bg,
textarea_text: UI_PALETTE.text_primary,
textarea_border: UI_PALETTE.border,
msg_error_text: UI_PALETTE.danger_text,
msg_success_text: UI_PALETTE.accent_text,
size_warning_text: '#FFD54F',
size_danger_text: UI_PALETTE.danger_text,
},
THEME_MODAL: {
bg: UI_PALETTE.bg,
text: UI_PALETTE.text_primary,
border: UI_PALETTE.border,
btn_bg: UI_PALETTE.btn_bg,
btn_hover_bg: UI_PALETTE.btn_hover_bg,
btn_text: UI_PALETTE.btn_text,
btn_border: UI_PALETTE.btn_border,
error_text: UI_PALETTE.danger_text,
delete_confirm_label_text: UI_PALETTE.danger_text,
delete_confirm_btn_text: 'var(--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)',
fieldset_border: 'var(--border-medium)',
legend_text: UI_PALETTE.text_secondary,
label_text: UI_PALETTE.text_secondary,
input_bg: UI_PALETTE.input_bg,
input_text: UI_PALETTE.text_primary,
input_border: UI_PALETTE.border,
slider_display_text: UI_PALETTE.text_primary,
popup_bg: UI_PALETTE.bg,
popup_border: UI_PALETTE.border,
dnd_indicator_color: UI_PALETTE.accent_text,
folderIconDef: {
tag: 'svg',
props: { xmlns: 'http://www.w3.org/2000/svg', height: '24px', viewBox: '0 -960 960 960', width: '24px', fill: 'currentColor' },
children: [{ tag: 'path', props: { d: 'M160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h240l80 80h320q33 0 56.5 23.5T880-640v400q0 33-23.5 56.5T800-160H160Zm0-80h640v-400H447l-80-80H160v480Zm0 0v-480 480Z' } }],
},
upIconDef: {
tag: 'svg',
props: { xmlns: 'http://www.w3.org/2000/svg', height: '24px', viewBox: '0 -960 960 960', width: '24px', fill: 'currentColor' },
children: [{ tag: 'path', props: { d: 'M480-528 296-344l-56-56 240-240 240 240-56 56-184-184Z' } }],
},
downIconDef: {
tag: 'svg',
props: { xmlns: 'http://www.w3.org/2000/svg', height: '24px', viewBox: '0 -960 960 960', width: '24px', fill: 'currentColor' },
children: [{ tag: 'path', props: { d: 'M480-344 240-584l56-56 184 184 184-184 56 56-240 240Z' } }],
},
},
FIXED_NAV: {
bg: 'var(--sidebar-surface-primary)',
border: 'var(--border-medium)',
separator_bg: 'var(--border-default)',
label_text: UI_PALETTE.text_secondary,
counter_bg: 'var(--bg-primary)',
counter_text: UI_PALETTE.text_primary,
counter_border: 'var(--border-accent)',
btn_bg: UI_PALETTE.btn_bg,
btn_hover_bg: UI_PALETTE.btn_hover_bg,
btn_text: UI_PALETTE.btn_text,
btn_border: UI_PALETTE.btn_border,
btn_accent_text: UI_PALETTE.accent_text,
btn_danger_text: UI_PALETTE.danger_text,
highlight_outline: UI_PALETTE.accent_text,
highlight_border_radius: '12px',
},
JUMP_LIST: {
list_bg: 'var(--sidebar-surface-primary)',
list_border: 'var(--border-medium)',
hover_outline: UI_PALETTE.accent_text,
current_outline: UI_PALETTE.accent_text,
},
CSS_IMPORTANT_FLAG: ' !important',
COLLAPSIBLE_CSS: `
.${APPID}-collapsible-parent {
position: relative;
}
.${APPID}-collapsible-parent::before {
content: '';
position: absolute;
top: -24px;
inset-inline: 0;
height: 24px;
}
/* Add a transparent border in the normal state to prevent width changes on collapse */
.${APPID}-collapsible-content {
border: 1px solid transparent;
box-sizing: border-box;
overflow: hidden;
max-height: 999999px;
}
.${APPID}-collapsible-toggle-btn {
position: absolute;
top: -24px;
width: 24px;
height: 24px;
padding: 4px;
border-radius: 5px;
box-sizing: border-box;
cursor: pointer;
visibility: hidden;
opacity: 0;
transition: visibility 0s linear 0.1s, opacity 0.1s ease-in-out;
color: var(--text-secondary);
}
.${APPID}-collapsible-toggle-btn.${APPID}-hidden {
display: none;
}
[data-message-author-role="assistant"] .${APPID}-collapsible-toggle-btn {
left: 4px;
}
[data-message-author-role="user"] .${APPID}-collapsible-toggle-btn {
right: 4px;
}
.${APPID}-collapsible-parent:hover .${APPID}-collapsible-toggle-btn {
visibility: visible;
opacity: 1;
transition-delay: 0s;
}
.${APPID}-collapsible-toggle-btn:hover {
background-color: var(--interactive-bg-secondary-hover);
color: var(--text-primary);
}
.${APPID}-collapsible-toggle-btn svg {
width: 100%;
height: 100%;
transition: transform 0.2s ease-in-out;
}
.${APPID}-collapsible.${APPID}-bubble-collapsed .${APPID}-collapsible-content {
max-height: ${CONSTANTS.BUTTON_VISIBILITY_THRESHOLD_PX}px;
border: 1px dashed var(--text-secondary);
box-sizing: border-box;
}
.${APPID}-collapsible.${APPID}-bubble-collapsed .${APPID}-collapsible-toggle-btn svg {
transform: rotate(-180deg);
}
`,
BUBBLE_NAV_CSS: `
.${APPID}-bubble-nav-container {
position: absolute;
top: 0;
bottom: 0;
width: 24px;
z-index: ${CONSTANTS.Z_INDICES.BUBBLE_NAVIGATION};
}
.${APPID}-nav-buttons {
position: relative;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
visibility: hidden;
opacity: 0;
transition: visibility 0s linear 0.1s, opacity 0.1s ease-in-out;
pointer-events: auto;
gap: 4px; /* Add gap between top and bottom groups when space is limited */
}
.${APPID}-bubble-parent-with-nav:hover .${APPID}-nav-buttons,
.${APPID}-bubble-nav-container:hover .${APPID}-nav-buttons {
visibility: visible;
opacity: 1;
transition-delay: 0s;
}
${CONSTANTS.SELECTORS.ASSISTANT_MESSAGE} .${APPID}-bubble-nav-container {
left: -25px;
}
${CONSTANTS.SELECTORS.USER_MESSAGE} .${APPID}-bubble-nav-container {
right: -25px;
}
.${APPID}-nav-group-top, .${APPID}-nav-group-bottom {
position: relative; /* Changed from absolute */
display: flex;
flex-direction: column;
gap: 4px;
width: 100%; /* Ensure groups take full width of the flex container */
}
.${APPID}-nav-group-bottom {
margin-top: auto; /* Push to the bottom if space is available */
}
.${APPID}-nav-group-top.${APPID}-hidden, .${APPID}-nav-group-bottom.${APPID}-hidden {
display: none !important;
}
.${APPID}-bubble-nav-btn {
width: 20px;
height: 20px;
padding: 2px;
border-radius: 5px;
box-sizing: border-box;
cursor: pointer;
background: ${UI_PALETTE.btn_bg};
color: ${UI_PALETTE.text_secondary};
border: 1px solid ${UI_PALETTE.border};
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease-in-out;
margin: 0 auto; /* Center the buttons within the group */
}
.${APPID}-bubble-nav-btn:hover {
background-color: ${UI_PALETTE.btn_hover_bg};
color: ${UI_PALETTE.text_primary};
}
.${APPID}-bubble-nav-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.${APPID}-bubble-nav-btn svg {
width: 100%;
height: 100%;
}
`,
};
// ---- Validation Rules ----
const THEME_VALIDATION_RULES = {
bubbleBorderRadius: { unit: 'px', min: 0, max: 50, nullable: true },
bubbleMaxWidth: { unit: '%', min: 30, max: 100, nullable: true },
};
/** @type {AppConfig} */
const DEFAULT_THEME_CONFIG = {
options: {
icon_size: CONSTANTS.ICON_SIZE,
chat_content_max_width: CONSTANTS.SLIDER_CONFIGS.CHAT_WIDTH.DEFAULT,
respect_avatar_space: true,
},
features: {
collapsible_button: {
enabled: true,
},
auto_collapse_user_message: {
enabled: false,
},
sequential_nav_buttons: {
enabled: true,
},
scroll_to_top_button: {
enabled: true,
},
fixed_nav_console: {
enabled: true,
},
load_full_history_on_chat_load: {
enabled: true,
},
timestamp: {
enabled: true,
},
},
developer: {
logger_level: 'log', // 'error', 'warn', 'info', 'log', 'debug'
},
themeSets: [
{
metadata: {
id: `${APPID}-theme-example-1`,
name: 'Project Example',
matchPatterns: ['/project1/i'],
urlPatterns: [],
},
assistant: {
name: null,
icon: null,
textColor: null,
font: null,
bubbleBackgroundColor: null,
bubblePadding: null,
bubbleBorderRadius: null,
bubbleMaxWidth: null,
standingImageUrl: null,
},
user: {
name: null,
icon: null,
textColor: null,
font: null,
bubbleBackgroundColor: null,
bubblePadding: null,
bubbleBorderRadius: null,
bubbleMaxWidth: null,
standingImageUrl: null,
},
window: {
backgroundColor: null,
backgroundImageUrl: null,
backgroundSize: null,
backgroundPosition: null,
backgroundRepeat: null,
},
inputArea: {
backgroundColor: null,
textColor: null,
},
},
],
defaultSet: {
assistant: {
name: `${ASSISTANT_NAME}`,
icon: '',
textColor: null,
font: null,
bubbleBackgroundColor: null,
bubblePadding: '6px 10px',
bubbleBorderRadius: '10px',
bubbleMaxWidth: null,
standingImageUrl: null,
},
user: {
name: 'You',
icon: '',
textColor: null,
font: null,
bubbleBackgroundColor: null,
bubblePadding: '6px 10px',
bubbleBorderRadius: '10px',
bubbleMaxWidth: null,
standingImageUrl: null,
},
window: {
backgroundColor: null,
backgroundImageUrl: null,
backgroundSize: 'cover',
backgroundPosition: 'center center',
backgroundRepeat: 'no-repeat',
},
inputArea: {
backgroundColor: null,
textColor: null,
},
},
};
// =================================================================================
// SECTION: Platform-Specific Adapter
// Description: Centralizes all platform-specific logic, such as selectors and
// DOM manipulation strategies. This isolates platform differences
// from the core application logic.
// =================================================================================
const PlatformAdapters = {
// =================================================================================
// SECTION: General Adapters
// =================================================================================
General: {
/**
* Checks if the Canvas feature is currently active on the page.
* @returns {boolean} True if Canvas mode is detected, otherwise false.
*/
isCanvasModeActive() {
return !!document.querySelector(CONSTANTS.SELECTORS.CANVAS_CONTAINER);
},
/**
* Checks if the current page URL is on the exclusion list for this platform.
* @returns {boolean} True if the page should be excluded, otherwise false.
*/
isExcludedPage() {
const excludedPatterns = [/^\/library/, /^\/codex/, /^\/gpts/, /^\/images/, /^\/apps/];
const pathname = window.location.pathname;
return excludedPatterns.some((pattern) => pattern.test(pathname));
},
/**
* Checks if the current page is the "New Chat" page.
* @returns {boolean} True if it is the new chat page, otherwise false.
*/
isNewChatPage() {
return window.location.pathname === '/';
},
/**
* Gets the platform-specific role identifier from a message element.
* @param {Element} messageElement The message element.
* @returns {string | null} The platform's role identifier (e.g., 'user', 'user-query').
*/
getMessageRole(messageElement) {
if (!messageElement) return null;
// First, check for the message role attribute (div[data-message-author-role])
const role = messageElement.getAttribute(CONSTANTS.ATTRIBUTES.MESSAGE_ROLE);
if (role) {
return role;
}
// If not found, check for the turn attribute (article[data-turn])
// This is used by the AvatarManager's self-healing Sentinel listener.
return messageElement.getAttribute(CONSTANTS.ATTRIBUTES.TURN_ROLE);
},
/**
* Gets the current chat title in a platform-specific way.
* @returns {string | null}
*/
getChatTitle() {
// gets the title from the document title.
return document.title.trim();
},
/**
* Gets the platform-specific display text from a message element for the jump list.
* This method centralizes the logic for extracting the most relevant text,
* bypassing irrelevant content like system messages or UI elements within the message container.
* @param {HTMLElement} messageElement The message element.
* @returns {string} The text content to be displayed in the jump list.
*/
getJumpListDisplayText(messageElement) {
const role = this.getMessageRole(messageElement);
let contentEl;
// 1. Check for text content first.
if (role === CONSTANTS.SELECTORS.FIXED_NAV_ROLE_USER) {
contentEl = messageElement.querySelector(CONSTANTS.SELECTORS.USER_TEXT_CONTENT);
} else {
contentEl = messageElement.querySelector(CONSTANTS.SELECTORS.ASSISTANT_TEXT_CONTENT);
}
const text = contentEl?.textContent.trim();
if (text) {
return text;
}
// 2. If no text, check for an image within the message container.
const imageSelector = [CONSTANTS.SELECTORS.RAW_USER_IMAGE_BUBBLE, CONSTANTS.SELECTORS.RAW_ASSISTANT_IMAGE_BUBBLE].join(', ');
const hasImage = messageElement.querySelector(imageSelector);
if (hasImage) {
return '(Image)';
}
// 3. If neither, return empty.
return '';
},
/**
* @description Finds the root message container element for a given content element within it.
* @param {Element} contentElement The element inside a message bubble (e.g., the text content or an image).
* @returns {HTMLElement | null} The closest parent message container element (e.g., `user-query`, `div[data-message-author-role="user"]`), or `null` if not found.
*/
findMessageElement(contentElement) {
return contentElement.closest(CONSTANTS.SELECTORS.BUBBLE_FEATURE_MESSAGE_CONTAINERS);
},
/**
* Filters out ghost/empty message containers before they are added to the cache.
* @param {Element} messageElement The message element to check.
* @returns {boolean} Returns `false` to exclude the message, `true` to keep it.
*/
filterMessage(messageElement) {
const role = this.getMessageRole(messageElement);
if (role === CONSTANTS.SELECTORS.FIXED_NAV_ROLE_ASSISTANT) {
// Check if the message has any visible content, either text or an image generated by our script.
const hasText = messageElement.querySelector(CONSTANTS.SELECTORS.ASSISTANT_TEXT_CONTENT)?.textContent?.trim();
const hasImage = messageElement.querySelector(CONSTANTS.SELECTORS.RAW_ASSISTANT_IMAGE_BUBBLE);
// If it has neither text nor an image inside it, check the turn context.
if (!hasText && !hasImage) {
const turnContainer = messageElement.closest(CONSTANTS.SELECTORS.CONVERSATION_UNIT);
// If the turn contains an image elsewhere, this empty message is likely a ghost artifact. Filter it out.
if (turnContainer && turnContainer.querySelector(CONSTANTS.SELECTORS.RAW_ASSISTANT_IMAGE_BUBBLE)) {
return false; // Exclude this ghost message
}
}
}
return true; // Keep all other messages
},
/**
* Ensures that a standalone image content element is wrapped in a proper message container.
* This is necessary for images that are not descendants of a `[data-message-author-role]` element,
* allowing them to be treated as first-class messages by the rest of the script.
* @param {HTMLElement} imageContentElement The image container element detected by Sentinel.
* @returns {HTMLElement | null} The message container (either existing or newly created).
*/
ensureMessageContainerForImage(imageContentElement) {
// If already inside a message container, do nothing and return it.
const existingContainer = imageContentElement.closest(CONSTANTS.SELECTORS.BUBBLE_FEATURE_MESSAGE_CONTAINERS);
if (existingContainer instanceof HTMLElement) {
return existingContainer;
}
// Create a new virtual message container.
const uniqueIdKey = `data-${APPID}-unique-id`;
const virtualMessage = h('div', {
'data-message-author-role': 'assistant',
[uniqueIdKey]: generateUniqueId('virtual-msg'),
});
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;
},
/**
* @description Sets up platform-specific Sentinel listeners to detect when new message content elements are added to the DOM.
* @param {(element: HTMLElement) => void} callback The function to be called when a new message content element is detected by Sentinel.
*/
initializeSentinel(callback) {
// prettier-ignore
const userContentSelector = [
`${CONSTANTS.SELECTORS.USER_MESSAGE} ${CONSTANTS.SELECTORS.RAW_USER_BUBBLE}`,
`${CONSTANTS.SELECTORS.USER_MESSAGE} ${CONSTANTS.SELECTORS.RAW_USER_IMAGE_BUBBLE}`,
].join(', ');
// prettier-ignore
const assistantContentSelector = [
`${CONSTANTS.SELECTORS.ASSISTANT_MESSAGE} ${CONSTANTS.SELECTORS.RAW_ASSISTANT_BUBBLE}`,
CONSTANTS.SELECTORS.RAW_ASSISTANT_IMAGE_BUBBLE,
].join(', ');
sentinel.on(userContentSelector, callback);
sentinel.on(assistantContentSelector, callback);
},
/**
* @description (ChatGPT) Performs an initial scan of the DOM for image messages that
* may not have been detected by Sentinel on initial load, especially in Chromium-based browsers.
* @param {MessageLifecycleManager} lifecycleManager - The instance of the MessageLifecycleManager to process the found messages.
* @returns {number} The number of new items found and processed.
*/
performInitialScan(lifecycleManager) {
Logger.badge('SCAN', LOG_STYLES.GRAY, 'debug', 'Performing initial scan for unprocessed image messages.');
// This selector specifically targets DALL-E generated images which are the primary source of this issue.
const imageSelector = CONSTANTS.SELECTORS.RAW_ASSISTANT_IMAGE_BUBBLE;
const unprocessedImages = document.querySelectorAll(`${imageSelector}:not([data-${APPID}ContentProcessed="true"])`);
if (unprocessedImages.length > 0) {
Logger.log(`Found ${unprocessedImages.length} unprocessed image(s) on initial scan.`);
unprocessedImages.forEach((imgElement) => {
lifecycleManager.processRawMessage(imgElement);
});
}
return unprocessedImages.length;
},
/**
* @description (ChatGPT) A lifecycle hook called when page navigation is complete.
* It initiates a polling scan to detect late-loading image messages, only on non-Firefox browsers and when the chat is not empty.
* @param {MessageLifecycleManager} lifecycleManager - The instance of the MessageLifecycleManager.
*/
onNavigationEnd(lifecycleManager) {
// Start polling only on non-Firefox browsers and on existing chat pages.
if (!isFirefox() && !isNewChatPage()) {
Logger.log('Non-Firefox browser and existing chat detected, starting polling scan.');
lifecycleManager.startPollingScan();
}
},
},
// =================================================================================
// SECTION: Adapters for class StyleManager
// =================================================================================
StyleManager: {
/**
* Returns the platform-specific static CSS that does not change with themes.
* @returns {string} The static CSS string.
*/
getStaticCss() {
return `
:root {
--${APPID}-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(--${APPID}-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);
}
#fixedTextUIRoot, #fixedTextUIRoot * {
color: inherit;
}
${CONSTANTS.SELECTORS.ASSISTANT_MESSAGE} ${CONSTANTS.SELECTORS.ASSISTANT_TEXT_CONTENT} {
overflow-x: auto;
padding-bottom: 8px;
}
${CONSTANTS.SELECTORS.ASSISTANT_MESSAGE} div[class*="tableContainer"],
${CONSTANTS.SELECTORS.ASSISTANT_MESSAGE} div[class*="tableWrapper"] {
width: auto;
overflow-x: auto;
box-sizing: border-box;
display: block;
}
/* (2025/07/01) ChatGPT UI change fix: Remove bottom gradient that conflicts with theme backgrounds. */
.content-fade::after {
background: none !important;
}
/* (2025/12/06) Project page top fade fix: Remove top gradient and mask only for project headers. */
main .content-fade-top:has([name="project-title"]),
main .content-fade-top:has([name="project-title"])::before,
main .content-fade-top:has([name="project-title"])::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.${APPID}-max-width-active main ${CONSTANTS.SELECTORS.CHAT_CONTENT_MAX_WIDTH} {
max-width: var(--${APPID}-chat-content-max-width) !important;
}
`;
},
/**
* Returns the complete configuration object for the settings button.
* @param {object} themeStyles - The base styles from SITE_STYLES.SETTINGS_BUTTON.
* @returns {object} The complete configuration object.
*/
getSettingsButtonConfig(themeStyles) {
return {
zIndex: CONSTANTS.Z_INDICES.SETTINGS_BUTTON,
iconDef: themeStyles.iconDef,
styles: themeStyles.base,
hoverStyles: themeStyles.hover,
};
},
},
// =================================================================================
// SECTION: Adapters for class ThemeManager
// =================================================================================
ThemeManager: {
/**
* Determines if the initial theme application should be deferred on this platform.
* @param {ThemeManager} themeManager - The main controller instance.
* @returns {boolean} True if theme application should be deferred.
*/
shouldDeferInitialTheme(themeManager) {
const initialTitle = themeManager.getChatTitleAndCache();
// Defer if the title is the ambiguous "ChatGPT" and we are NOT on the "New Chat" page.
// This indicates a transition to a specific chat page that hasn't loaded its final title yet.
if (initialTitle === 'ChatGPT' && !isNewChatPage()) {
Logger.log('Initial theme application deferred by platform adapter, waiting for final title.');
return true;
}
return false;
},
/**
* Selects the appropriate theme set based on platform-specific logic during an update check.
* @param {ThemeManager} themeManager - The instance of the theme manager.
* @param {AppConfig} config - The full application configuration.
* @param {boolean} urlChanged - Whether the URL has changed since the last check.
* @param {boolean} titleChanged - Whether the title has changed since the last check.
* @returns {ThemeSet} The theme set that should be applied.
*/
selectThemeForUpdate(themeManager, config, urlChanged, titleChanged) {
const currentTitle = themeManager.getChatTitleAndCache();
// 1. Invalidate cache on URL change to force pattern re-evaluation.
if (urlChanged) {
themeManager.cachedThemeSet = null;
}
// 2. Get the candidate theme based on the current context (URL, Title).
const candidateTheme = themeManager.getThemeSet();
// 3. Flicker prevention logic:
// If the URL changed, the title is currently "ChatGPT" (loading),
// and the resolved theme is the default one (meaning no URL pattern matched),
// then keep the previous theme to avoid a flash of the default theme before the title loads.
// Exception: Do not maintain the previous theme if we are navigating to the "New Chat" page.
const isDefaultTheme = candidateTheme.metadata.id === 'default' || candidateTheme === config.defaultSet;
const shouldKeepPreviousTheme = urlChanged && currentTitle === 'ChatGPT' && isDefaultTheme && themeManager.lastAppliedThemeSet && !isNewChatPage();
if (shouldKeepPreviousTheme) {
return themeManager.lastAppliedThemeSet;
}
// Otherwise, apply the candidate theme immediately.
// This handles cases where:
// - URL patterns matched (candidate is not default) -> Instant switch
// - Title is already loaded -> Correct theme
// - Navigating to New Chat -> Default theme
return candidateTheme;
},
/**
* Returns platform-specific CSS overrides for the style definition generator.
* @returns {object} An object containing CSS rule strings.
*/
getStyleOverrides() {
return {
user: ' margin-left: auto; margin-right: 0;',
assistant: ' margin-left: 0; margin-right: auto;',
};
},
},
// =================================================================================
// SECTION: Adapters for class BubbleUIManager
// =================================================================================
BubbleUI: {
/**
* @description Gets the platform-specific parent element for attaching navigation buttons.
* On ChatGPT, the ideal anchor is the direct parent of the main content bubble.
* This ensures the buttons are positioned correctly relative to the visible bubble.
* @param {HTMLElement} messageElement The message element.
* @returns {HTMLElement | null} The parent element for the nav container.
*/
getNavPositioningParent(messageElement) {
// 1. Handle text content first (most common case)
const textBubbleParent = messageElement.querySelector(CONSTANTS.SELECTORS.RAW_USER_BUBBLE)?.parentElement || messageElement.querySelector(CONSTANTS.SELECTORS.RAW_ASSISTANT_BUBBLE)?.parentElement;
if (textBubbleParent) {
return textBubbleParent;
}
// 2. If no text, it might be an image-only message element.
const role = PlatformAdapters.General.getMessageRole(messageElement);
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) {
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) {
// Return the PARENT of the image container as the anchor
return assistantImageContainer.parentElement;
}
}
return null;
},
/**
* @description Retrieves the necessary DOM elements for applying the collapsible button feature to a message.
* @description The returned object contains the elements needed to manage the collapsed state and position the toggle button correctly. The specific elements returned are platform-dependent.
* @param {HTMLElement} messageElement The root element of the message to be processed.
* @returns {{msgWrapper: HTMLElement, bubbleElement: HTMLElement, positioningParent: HTMLElement} | null} An object containing key elements for the feature, or `null` if the message is not eligible for the collapse feature on the current platform.
*/
getCollapsibleInfo(messageElement) {
const msgWrapper = messageElement.closest(CONSTANTS.SELECTORS.MESSAGE_WRAPPER_FINDER);
if (!(msgWrapper instanceof HTMLElement)) return null;
const role = messageElement.getAttribute('data-message-author-role');
const bubbleElement = role === CONSTANTS.SELECTORS.FIXED_NAV_ROLE_USER ? messageElement.querySelector(CONSTANTS.SELECTORS.RAW_USER_BUBBLE) : messageElement.querySelector(CONSTANTS.SELECTORS.RAW_ASSISTANT_BUBBLE);
if (!(bubbleElement instanceof HTMLElement)) return null;
const positioningParent = bubbleElement.parentElement;
if (!(positioningParent instanceof HTMLElement)) return null;
return { msgWrapper, bubbleElement, positioningParent };
},
/**
* @description Determines if a message element is eligible for sequential navigation buttons (previous/next).
* @description This method is designed for extensibility. Currently, it allows buttons on all messages.
* @param {HTMLElement} messageElement The message element to check.
* @returns {object | null} An empty object `{}` if the buttons should be rendered, or `null` to prevent rendering.
*/
getSequentialNavInfo(messageElement) {
return {};
},
/**
* @description Determines if a message element is eligible for the "Scroll to Top" button.
* @description This method is designed for extensibility. Currently, it allows buttons on all messages.
* @param {HTMLElement} messageElement The message element to check.
* @returns {object | null} An empty object `{}` if the buttons should be rendered, or `null` to prevent rendering.
*/
getScrollToTopInfo(messageElement) {
return {};
},
},
// =================================================================================
// SECTION: Toast Manager
// =================================================================================
Toast: {
getAutoScrollMessage() {
return 'Scanning layout to prevent scroll issues...';
},
},
// =================================================================================
// SECTION: Adapters for class ThemeAutomator
// =================================================================================
ThemeAutomator: {
/**
* Initializes platform-specific managers and registers them with the main application controller.
* @param {ThemeAutomator} automatorInstance - The main controller instance.
*/
initializePlatformManagers(automatorInstance) {
// =================================================================================
// SECTION: Auto Scroll Manager
// Description: Manages the "layout scan" (simulated PageUp scroll)
// to force layout calculation and prevent scroll anchoring issues.
// =================================================================================
class AutoScrollManager {
static CONFIG = {
// The minimum number of messages required to trigger the auto-scroll feature.
MESSAGE_THRESHOLD: 5, // Lower threshold for GPTUX as it's for layout scanning
// Delay between simulated PageUp scrolls (in ms)
SCAN_INTERVAL_MS: 30,
};
/**
* @param {ConfigManager} configManager
* @param {MessageCacheManager} messageCacheManager
* @param {MessageLifecycleManager} messageLifecycleManager
*/
constructor(configManager, messageCacheManager, messageLifecycleManager) {
this.configManager = configManager;
this.messageCacheManager = messageCacheManager;
this.messageLifecycleManager = messageLifecycleManager;
this.scrollContainer = null;
this.isEnabled = false;
this.isScrolling = false;
this.isInitialScrollCheckDone = false;
this.scanLoopId = null; // Use for setTimeout loop
this.boundStop = null;
this.subscriptions = [];
this.isLayoutScanComplete = false;
}
/**
* Helper to subscribe to EventBus and track the subscription for cleanup.
* Appends the listener name and a unique suffix to the key to avoid conflicts.
* @param {string} event
* @param {Function} listener
*/
_subscribe(event, listener) {
const baseKey = createEventKey(this, event);
// Use function name for debugging aid, fallback to 'anonymous'
const listenerName = listener.name || 'anonymous';
// Generate a short random suffix to guarantee uniqueness even for anonymous functions
const uniqueSuffix = Math.random().toString(36).substring(2, 7);
const key = `${baseKey}_${listenerName}_${uniqueSuffix}`;
EventBus.subscribe(event, listener, key);
this.subscriptions.push({ event, key });
}
/**
* Helper to subscribe to EventBus once and track the subscription for cleanup.
* Appends the listener name and a unique suffix to the key to avoid conflicts.
* @param {string} event
* @param {Function} listener
*/
_subscribeOnce(event, listener) {
const baseKey = createEventKey(this, event);
// Use function name for debugging aid, fallback to 'anonymous'
const listenerName = listener.name || 'anonymous';
// Generate a short random suffix to guarantee uniqueness even for anonymous functions
const uniqueSuffix = Math.random().toString(36).substring(2, 7);
const key = `${baseKey}_${listenerName}_${uniqueSuffix}`;
const wrappedListener = (...args) => {
this.subscriptions = this.subscriptions.filter((sub) => sub.key !== key);
listener(...args);
};
EventBus.once(event, wrappedListener, key);
this.subscriptions.push({ event, key });
}
init() {
this.isEnabled = this.configManager.get().features.load_full_history_on_chat_load.enabled;
this._subscribe(EVENTS.AUTO_SCROLL_REQUEST, () => this.start());
this._subscribe(EVENTS.AUTO_SCROLL_CANCEL_REQUEST, () => this.stop());
this._subscribe(EVENTS.CACHE_UPDATED, () => this._onCacheUpdated());
this._subscribe(EVENTS.NAVIGATION, () => this._onNavigation());
this._subscribe(EVENTS.APP_SHUTDOWN, () => this.destroy());
}
destroy() {
this.subscriptions.forEach(({ event, key }) => EventBus.unsubscribe(event, key));
this.subscriptions = [];
this.stop();
}
enable() {
this.isEnabled = true;
}
disable() {
this.isEnabled = false;
this.stop();
}
async start() {
if (!isFirefox()) return;
if (this.isScrolling || this.isLayoutScanComplete) return;
// Set the flag immediately to prevent re-entrancy from other events (e.g. button mashing).
this.isScrolling = true;
Logger.log('AutoScrollManager: Starting layout scan.');
const scrollContainerEl = document.querySelector(CONSTANTS.SELECTORS.SCROLL_CONTAINER);
if (!(scrollContainerEl instanceof HTMLElement)) {
Logger.badge('AUTOSCROLL WARN', LOG_STYLES.YELLOW, 'warn', 'Could not find scroll container.');
this.isScrolling = false;
return;
}
this.scrollContainer = scrollContainerEl;
EventBus.publish(EVENTS.AUTO_SCROLL_START);
EventBus.publish(EVENTS.SUSPEND_OBSERVERS);
// Hide the container to prevent visual flickering
this.scrollContainer.style.transition = 'none';
this.scrollContainer.style.opacity = '0';
this.boundStop = () => this.stop();
this.scrollContainer.addEventListener('wheel', this.boundStop, { passive: true, once: true });
this.scrollContainer.addEventListener('touchmove', this.boundStop, { passive: true, once: true });
const originalScrollTop = this.scrollContainer.scrollTop;
// Force scroll to the bottom to ensure the scan starts from the end.
this.scrollContainer.scrollTop = this.scrollContainer.scrollHeight;
const scanPageUp = () => {
if (!this.isScrolling || !this.scrollContainer) return; // Stop if cancelled
const currentTop = this.scrollContainer.scrollTop;
if (currentTop <= 0) {
// Reached the top, restore and stop
this.scrollContainer.scrollTop = originalScrollTop; // Restore original position
this.isLayoutScanComplete = true; // Set completion flag
this.stop();
return;
}
// Scroll up by one page (client height)
this.scrollContainer.scrollTop = Math.max(0, currentTop - this.scrollContainer.clientHeight);
// Continue loop after interval
this.scanLoopId = setTimeout(scanPageUp, AutoScrollManager.CONFIG.SCAN_INTERVAL_MS);
};
// Start the loop
// Add a minimal delay to ensure scrollTop change is registered before scan starts
this.scanLoopId = setTimeout(scanPageUp, CONSTANTS.TIMING.DEBOUNCE_DELAYS.LAYOUT_RECALCULATION);
}
stop(isNavigation = false) {
if (!this.isScrolling && !this.scanLoopId) return; // Prevent multiple stops
Logger.log('AutoScrollManager: Stopping layout scan.');
this.isScrolling = false;
if (this.scanLoopId) {
clearTimeout(this.scanLoopId);
this.scanLoopId = null;
}
// Restore visibility
if (this.scrollContainer) {
this.scrollContainer.style.opacity = '1';
this.scrollContainer.style.transition = '';
}
this.scrollContainer = null;
// Cleanup listeners
if (this.boundStop) {
this.scrollContainer?.removeEventListener('wheel', this.boundStop);
this.scrollContainer?.removeEventListener('touchmove', this.boundStop);
this.boundStop = null;
}
EventBus.publish(EVENTS.AUTO_SCROLL_COMPLETE);
// On navigation, ObserverManager handles observer resumption.
// All other post-scan logic (DOM rescan, cache update) is now handled
// by the listener that *requested* the scan.
if (!isNavigation) {
EventBus.publish(EVENTS.RESUME_OBSERVERS_AND_REFRESH);
}
}
/**
* @private
* @description Defines the logic to run *after* a scan completes.
*/
_onScanComplete() {
// Run the manual scan to create any missing message wrappers
if (this.messageLifecycleManager) {
this.messageLifecycleManager.scanForUnprocessedMessages();
}
// Immediately request a cache update to reflect the scan
EventBus.publish(EVENTS.CACHE_UPDATE_REQUEST);
}
/**
* @private
* @description Handles the CACHE_UPDATED event to perform the initial scroll check.
*/
_onCacheUpdated() {
if (!isFirefox()) return;
if (!this.isEnabled || this.isInitialScrollCheckDone || this.isScrolling) {
return;
}
this.isInitialScrollCheckDone = true;
const messageCount = this.messageCacheManager.getTotalMessages().length;
if (messageCount >= AutoScrollManager.CONFIG.MESSAGE_THRESHOLD) {
Logger.log(`AutoScrollManager: ${messageCount} messages found. Triggering layout scan.`);
// Register the post-scan logic to run *once* on completion
this._subscribeOnce(EVENTS.AUTO_SCROLL_COMPLETE, () => this._onScanComplete());
// Start the scan
EventBus.publish(EVENTS.AUTO_SCROLL_REQUEST);
} else {
Logger.log(`AutoScrollManager: ${messageCount} messages found. No scan needed.`);
}
}
/**
* @private
* @description Handles the NAVIGATION event to reset the manager's state.
*/
_onNavigation() {
if (this.isScrolling) {
// Stop scroll without triggering a UI refresh, as a new page is loading.
this.stop(true);
}
this.isInitialScrollCheckDone = false;
this.isLayoutScanComplete = false;
}
}
automatorInstance.autoScrollManager = new AutoScrollManager(automatorInstance.configManager, automatorInstance.messageCacheManager, automatorInstance.messageLifecycleManager);
},
/**
* Applies UI updates specific to the platform after a configuration change.
* @param {ThemeAutomator} automatorInstance - The main controller instance.
* @param {object} newConfig - The newly applied configuration object.
*/
applyPlatformSpecificUiUpdates(automatorInstance, newConfig) {
// Enable or disable the auto-scroll manager based on the new config.
if (newConfig.features.load_full_history_on_chat_load.enabled) {
automatorInstance.autoScrollManager?.enable();
} else {
automatorInstance.autoScrollManager?.disable();
}
},
},
// =================================================================================
// SECTION: Adapters for class SettingsPanelComponent
// =================================================================================
SettingsPanel: {
/**
* Returns an array of UI definitions for platform-specific feature toggles in the settings panel.
* @returns {object[]} An array of definition objects.
*/
getPlatformSpecificFeatureToggles() {
const timestampFeature = {
id: 'features.timestamp.enabled',
configKey: 'features.timestamp.enabled',
label: 'Show timestamp',
title: 'Displays the timestamp for each message.',
};
const autoCollapseFeature = {
id: 'features.auto_collapse_user_message.enabled',
configKey: 'features.auto_collapse_user_message.enabled',
label: 'Auto collapse user message',
title: 'Automatically collapses user messages that exceed the height threshold.\nOnly applies to new messages. Requires "Collapsible button" to be enabled.',
};
if (!isFirefox()) {
return [timestampFeature, autoCollapseFeature];
}
const scanLayoutFeature = {
id: 'load-history-enabled',
configKey: 'features.load_full_history_on_chat_load.enabled',
label: 'Scan layout on chat load',
title: 'When enabled, automatically scans the layout of all messages when a chat is opened. This prevents layout shifts from images loading later.',
};
return [scanLayoutFeature, timestampFeature, autoCollapseFeature];
},
},
// =================================================================================
// SECTION: Adapters for class AvatarManager
// =================================================================================
Avatar: {
/**
* Returns the platform-specific CSS for styling avatars.
* @param {string} iconSizeCssVar - The CSS variable name for icon size.
* @param {string} iconMarginCssVar - The CSS variable name for icon margin.
* @returns {string} The CSS string.
*/
getCss(iconSizeCssVar, iconMarginCssVar) {
return `
/* 1. Set the Turn container (article) min-height to prevent layout collapse with large avatars */
${CONSTANTS.SELECTORS.AVATAR_USER},
${CONSTANTS.SELECTORS.AVATAR_ASSISTANT} {
min-height: calc(var(${iconSizeCssVar}) + 3em);
position: relative !important;
overflow: visible !important;
}
/* 2. Set the message ID holder as the positioning anchor for Timestamps */
${CONSTANTS.SELECTORS.CONVERSATION_UNIT} ${CONSTANTS.SELECTORS.MESSAGE_ID_HOLDER} {
position: relative !important;
}
/* 3. Define the avatar container (injected into the centered wrapper) */
${CONSTANTS.SELECTORS.SIDE_AVATAR_CONTAINER} {
position: absolute;
top: 0; /* Align to the top of the message element */
display: flex;
flex-direction: column;
align-items: center;
width: var(${iconSizeCssVar});
pointer-events: none;
white-space: normal;
word-break: break-word;
}
${CONSTANTS.SELECTORS.SIDE_AVATAR_ICON} {
width: var(${iconSizeCssVar});
height: var(${iconSizeCssVar});
border-radius: 50%;
display: block;
box-shadow: 0 0 6px rgb(0 0 0 / 0.2);
background-size: cover;
background-position: center;
background-repeat: no-repeat;
transition: background-image 0.3s ease-in-out;
}
${CONSTANTS.SELECTORS.SIDE_AVATAR_NAME} {
font-size: 0.75rem;
text-align: center;
margin-top: 4px;
width: 100%;
background-color: rgb(0 0 0 / 0.2);
padding: 2px 6px;
border-radius: 4px;
box-sizing: border-box;
}
/* 4. Position the avatar relative to the centered wrapper */
/* User avatar (Right) */
${CONSTANTS.SELECTORS.AVATAR_USER} ${CONSTANTS.SELECTORS.SIDE_AVATAR_CONTAINER} {
left: 100%;
margin-left: var(${iconMarginCssVar});
}
/* Assistant avatar (Left) */
${CONSTANTS.SELECTORS.AVATAR_ASSISTANT} ${CONSTANTS.SELECTORS.SIDE_AVATAR_CONTAINER} {
right: 100%;
margin-right: var(${iconMarginCssVar});
}
/* 5. Icon/Name image and color rules */
${CONSTANTS.SELECTORS.AVATAR_USER} ${CONSTANTS.SELECTORS.SIDE_AVATAR_ICON} {
background-image: var(--${APPID}-user-icon);
}
${CONSTANTS.SELECTORS.AVATAR_USER} ${CONSTANTS.SELECTORS.SIDE_AVATAR_NAME} {
color: var(--${APPID}-user-textColor);
}
${CONSTANTS.SELECTORS.AVATAR_USER} ${CONSTANTS.SELECTORS.SIDE_AVATAR_NAME}::after {
content: var(--${APPID}-user-name);
}
${CONSTANTS.SELECTORS.AVATAR_ASSISTANT} ${CONSTANTS.SELECTORS.SIDE_AVATAR_ICON} {
background-image: var(--${APPID}-assistant-icon);
}
${CONSTANTS.SELECTORS.AVATAR_ASSISTANT} ${CONSTANTS.SELECTORS.SIDE_AVATAR_NAME} {
color: var(--${APPID}-assistant-textColor);
}
${CONSTANTS.SELECTORS.AVATAR_ASSISTANT} ${CONSTANTS.SELECTORS.SIDE_AVATAR_NAME}::after {
content: var(--${APPID}-assistant-name);
}
`;
},
/**
* Injects the avatar UI into the appropriate location within a message element.
* @param {HTMLElement} msgElem - The root message element.
* @param {HTMLElement} avatarContainer - The avatar container element to inject.
*/
addAvatarToMessage(msgElem, avatarContainer) {
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;
const centeredWrapper = turnContainer.querySelector(CONSTANTS.SELECTORS.CHAT_CONTENT_MAX_WIDTH);
if (!centeredWrapper) return;
// Guard: Check if avatar container already exists *inside the centered wrapper*.
// This check is still valid and ensures one avatar per turn.
if (centeredWrapper.querySelector(CONSTANTS.SELECTORS.SIDE_AVATAR_CONTAINER)) {
// If the DOM element is already there, but the article isn't marked, mark it.
// This fixes state inconsistencies.
if (!turnContainer.classList.contains(`${APPID}-avatar-processed`)) {
turnContainer.classList.add(`${APPID}-avatar-processed`);
}
return; // Already present, do nothing.
}
// Find the *first* message element within this turn.
const firstMessageElement = turnContainer.querySelector(CONSTANTS.SELECTORS.BUBBLE_FEATURE_MESSAGE_CONTAINERS);
if (!(firstMessageElement instanceof HTMLElement)) {
return; // 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;
}
const processedClass = `${APPID}-avatar-processed`;
// --- CSS-based Positioning Logic ---
// Inject the avatar directly into the *first message element*
firstMessageElement.prepend(avatarContainer);
// Mark the TURN container as processed.
if (!turnContainer.classList.contains(processedClass)) {
turnContainer.classList.add(`${APPID}-avatar-processed`);
}
},
},
// =================================================================================
// SECTION: Adapters for class StandingImageManager
// =================================================================================
StandingImage: {
/**
* Recalculates and applies the layout for standing images.
* @param {StandingImageManager} instance - The instance of the StandingImageManager.
*/
async recalculateLayout(instance) {
const rootStyle = document.documentElement.style;
// Check for Canvas mode
const isCanvasActive = PlatformAdapters.General.isCanvasModeActive();
// Check for Right Sidebar (Activity Panel)
const rightSidebar = document.querySelector(CONSTANTS.SELECTORS.RIGHT_SIDEBAR);
let isRightSidebarOpen = false;
if (rightSidebar instanceof HTMLElement && rightSidebar.offsetWidth > 0) {
const rect = rightSidebar.getBoundingClientRect();
// Robustness check: Ensure it's actually on the right side
if (rect.left > window.innerWidth / 2) {
isRightSidebarOpen = true;
}
}
// If canvas mode is active or the activity panel is open, hide standing images.
if (isCanvasActive || isRightSidebarOpen) {
rootStyle.setProperty(`--${APPID}-standing-image-assistant-width`, '0px');
rootStyle.setProperty(`--${APPID}-standing-image-user-width`, '0px');
return;
}
const chatContent = await waitForElement(CONSTANTS.SELECTORS.CHAT_CONTENT_MAX_WIDTH);
if (!chatContent) {
return;
}
await withLayoutCycle({
measure: () => {
// --- Read Phase ---
const assistantImg = document.getElementById(`${APPID}-standing-image-assistant`);
const userImg = document.getElementById(`${APPID}-standing-image-user`);
return {
chatRect: chatContent.getBoundingClientRect(),
sidebarWidth: getSidebarWidth(),
windowWidth: window.innerWidth,
windowHeight: window.innerHeight,
assistantImgHeight: assistantImg ? assistantImg.offsetHeight : 0,
userImgHeight: userImg ? userImg.offsetHeight : 0,
};
},
mutate: (measured) => {
// --- Write Phase ---
if (!measured) return;
const { chatRect, sidebarWidth, windowWidth, windowHeight, assistantImgHeight, userImgHeight } = measured;
const config = instance.configManager.get();
const iconSize = instance.configManager.getIconSize();
const respectAvatarSpace = config.options.respect_avatar_space;
const avatarGap = respectAvatarSpace ? iconSize + CONSTANTS.ICON_MARGIN * 2 : 0;
// Apply right sidebar width for positioning
rootStyle.setProperty(`--${APPID}-right-sidebar-width`, '0px');
// Assistant (left)
const assistantWidth = Math.max(0, chatRect.left - (sidebarWidth + avatarGap));
rootStyle.setProperty(`--${APPID}-standing-image-assistant-left`, sidebarWidth + 'px');
rootStyle.setProperty(`--${APPID}-standing-image-assistant-width`, assistantWidth + 'px');
// User (right)
const effectiveWindowRight = windowWidth;
const userWidth = Math.max(0, effectiveWindowRight - chatRect.right - avatarGap);
rootStyle.setProperty(`--${APPID}-standing-image-user-width`, userWidth + 'px');
// Masking
const maskValue = `linear-gradient(to bottom, transparent 0px, rgb(0 0 0 / 1) 60px, rgb(0 0 0 / 1) 100%)`;
if (assistantImgHeight >= windowHeight - 32) {
rootStyle.setProperty(`--${APPID}-standing-image-assistant-mask`, maskValue);
} else {
rootStyle.setProperty(`--${APPID}-standing-image-assistant-mask`, 'none');
}
if (userImgHeight >= windowHeight - 32) {
rootStyle.setProperty(`--${APPID}-standing-image-user-mask`, maskValue);
} else {
rootStyle.setProperty(`--${APPID}-standing-image-user-mask`, 'none');
}
},
});
},
/**
* Updates the visibility of standing images based on the current context.
* @param {StandingImageManager} instance - The instance of the StandingImageManager.
*/
updateVisibility(instance) {
const isCanvasActive = PlatformAdapters.General.isCanvasModeActive();
['user', 'assistant'].forEach((actor) => {
const imgElement = document.getElementById(`${APPID}-standing-image-${actor}`);
if (!imgElement) return;
const hasImage = !!document.documentElement.style.getPropertyValue(`--${APPID}-${actor}-standing-image`);
imgElement.style.opacity = hasImage && !isCanvasActive ? '1' : '0';
});
},
/**
* Sets up platform-specific event listeners for the StandingImageManager.
* @param {StandingImageManager} instance - The instance of the StandingImageManager.
*/
setupEventListeners(instance) {
// No platform-specific listeners for ChatGPT.
},
},
// =================================================================================
// SECTION: Adapters for class DebugManager
// =================================================================================
Debug: {
/**
* Returns the platform-specific CSS for debugging layout borders.
* @returns {string} The CSS string.
*/
getBordersCss() {
const userFrameSvg = ``;
const asstFrameSvg = ``;
const userFrameDataUri = svgToDataUrl(userFrameSvg);
const asstFrameDataUri = svgToDataUrl(asstFrameSvg);
return `
/* --- DEBUG BORDERS --- */
:root {
--dbg-layout-color: rgb(26 188 156 / 0.8); /* Greenish */
--dbg-user-color: rgb(231 76 60 / 0.8); /* Reddish */
--dbg-asst-color: rgb(52 152 219 / 0.8); /* Blueish */
--dbg-comp-color: rgb(22 160 133 / 0.8); /* Cyan */
--dbg-zone-color: rgb(142 68 173 / 0.9); /* Purplish */
--dbg-neutral-color: rgb(128 128 128 / 0.7); /* Gray */
}
/* Layout Containers */
${CONSTANTS.SELECTORS.SIDEBAR_WIDTH_TARGET} { outline: 2px solid var(--dbg-layout-color) !important; }
${CONSTANTS.SELECTORS.CHAT_CONTENT_MAX_WIDTH} { outline: 2px dashed var(--dbg-layout-color) !important; }
${CONSTANTS.SELECTORS.INPUT_AREA_BG_TARGET} { outline: 1px solid var(--dbg-layout-color) !important; }
#${APPID}-nav-console { outline: 1px dotted var(--dbg-layout-color) !important; }
/* Message Containers */
${CONSTANTS.SELECTORS.DEBUG_CONTAINER_TURN} { outline: 1px solid var(--dbg-neutral-color) !important; outline-offset: -1px; }
${CONSTANTS.SELECTORS.DEBUG_CONTAINER_USER} { outline: 2px solid var(--dbg-user-color) !important; outline-offset: -2px; }
${CONSTANTS.SELECTORS.RAW_USER_BUBBLE} { outline: 1px dashed var(--dbg-user-color) !important; outline-offset: -4px; }
${CONSTANTS.SELECTORS.DEBUG_CONTAINER_ASSISTANT} { outline: 2px solid var(--dbg-asst-color) !important; outline-offset: -2px; }
${CONSTANTS.SELECTORS.RAW_ASSISTANT_BUBBLE} { outline: 1px dashed var(--dbg-asst-color) !important; outline-offset: -4px; }
/* Components */
${CONSTANTS.SELECTORS.SIDE_AVATAR_CONTAINER} { outline: 1px solid var(--dbg-comp-color) !important; }
${CONSTANTS.SELECTORS.SIDE_AVATAR_ICON} { outline: 1px dotted var(--dbg-comp-color) !important; }
${CONSTANTS.SELECTORS.SIDE_AVATAR_NAME} { outline: 1px dotted var(--dbg-comp-color) !important; }
/* Standing Image Debug Overrides */
#${APPID}-standing-image-user {
background-image: url("${userFrameDataUri}") !important;
z-index: 15000 !important;
opacity: 0.7 !important;
min-width: 30px !important;
}
#${APPID}-standing-image-assistant {
background-image: url("${asstFrameDataUri}") !important;
z-index: 15000 !important;
opacity: 0.7 !important;
min-width: 30px !important;
}
/* Interactive Zones */
.${APPID}-collapsible-parent::before {
outline: 1px solid var(--dbg-zone-color) !important;
content: 'HOVER AREA' !important;
color: var(--dbg-zone-color);
font-size: 10px;
display: flex;
align-items: center;
justify-content: center;
}
.${APPID}-bubble-nav-container { outline: 1px dashed var(--dbg-zone-color) !important; }
`;
},
},
// =================================================================================
// SECTION: Adapters for class ObserverManager
// =================================================================================
Observer: {
/**
* Returns an array of functions that start platform-specific observers.
* Each function, when called, should return a cleanup function to stop its observer.
* @returns {Array} An array of observer starter functions.
*/
// prettier-ignore
getPlatformObserverStarters() {
return [
this.startGlobalTitleObserver,
this.startSidebarObserver,
this.startCanvasObserver,
this.startRightSidebarObserver,
this.startResearchPanelObserver,
this.startInputAreaObserver,
];
},
/**
* @private
* @description A generic observer for side panels that handles appearance, disappearance, resizing, and immediate state callbacks.
* @param {object} dependencies - The ObserverManager dependencies ({ observeElement, unobserveElement }).
* @param {string} triggerSelector - The selector for the element that triggers the panel's existence check.
* @param {string} observerType - The type identifier for ObserverManager (e.g., CONSTANTS.OBSERVED_ELEMENT_TYPES.SIDE_PANEL).
* @param {function(HTMLElement): HTMLElement|null} targetResolver - A function to resolve the actual panel element from the trigger element.
* @param {number} transitionDelay - Unused in the new loop implementation (kept for signature compatibility).
* @param {function(): void} [immediateCallback] - An optional callback executed immediately and repeatedly during the animation loop.
* @returns {() => void} A cleanup function.
*/
_startGenericPanelObserver(dependencies, triggerSelector, observerType, targetResolver, transitionDelay, immediateCallback) {
const { observeElement, unobserveElement } = dependencies;
let isPanelVisible = false;
let isStateUpdating = false; // Lock to prevent race conditions
let disappearanceObserver = null;
let observedPanel = null;
let animationLoopId = null;
const ANIMATION_DURATION = 500; // ms
// Function to run the layout update loop
const startUpdateLoop = () => {
if (animationLoopId) cancelAnimationFrame(animationLoopId);
const startTime = Date.now();
const loop = () => {
// Run the callback (e.g., VISIBILITY_RECHECK or SIDEBAR_LAYOUT_CHANGED)
if (immediateCallback) immediateCallback();
// Also trigger UI repositioning for smooth movement
EventBus.publish(EVENTS.UI_REPOSITION);
if (Date.now() - startTime < ANIMATION_DURATION) {
animationLoopId = requestAnimationFrame(loop);
} else {
animationLoopId = null;
}
};
loop();
};
// This is the single source of truth for updating the UI based on panel visibility.
const updatePanelState = () => {
if (isStateUpdating) return; // Prevent concurrent executions
isStateUpdating = true;
try {
const trigger = document.querySelector(triggerSelector);
let isNowVisible = false;
let panel = null;
if (trigger instanceof HTMLElement) {
panel = targetResolver(trigger);
// Check if the panel exists and is visible in the DOM (offsetParent is non-null).
if (panel instanceof HTMLElement && panel.offsetParent !== null) {
isNowVisible = true;
}
}
// Do nothing if the state hasn't changed.
if (isNowVisible === isPanelVisible) {
// If visible, ensure we are still observing the same element (defensive)
if (isNowVisible && panel && panel !== observedPanel) {
// If the element reference changed but logic says it's still visible, switch observation
if (observedPanel) unobserveElement(observedPanel);
observedPanel = panel;
observeElement(observedPanel, observerType);
}
return;
}
isPanelVisible = isNowVisible;
if (isNowVisible && panel) {
// --- Panel just appeared ---
Logger.badge('PANEL STATE', LOG_STYLES.GRAY, 'debug', 'Panel appeared:', triggerSelector);
startUpdateLoop();
observedPanel = panel;
observeElement(observedPanel, observerType);
// Setup a lightweight observer to detect when the panel is removed from DOM.
// We observe the parent because the panel itself might be removed.
if (panel.parentElement) {
disappearanceObserver?.disconnect();
disappearanceObserver = new MutationObserver(() => {
// Re-check state if the parent container's children change.
updatePanelState();
});
disappearanceObserver.observe(panel.parentElement, { childList: true, subtree: false });
}
} else {
// --- Panel just disappeared ---
Logger.badge('PANEL STATE', LOG_STYLES.GRAY, 'debug', 'Panel disappeared:', triggerSelector);
startUpdateLoop();
disappearanceObserver?.disconnect();
disappearanceObserver = null;
if (observedPanel) {
unobserveElement(observedPanel);
observedPanel = null;
}
}
} finally {
isStateUpdating = false; // Release the lock
}
};
// Use Sentinel to efficiently detect when the trigger might have been added.
sentinel.on(triggerSelector, updatePanelState);
// Perform an initial check in case the panel is already present on load.
updatePanelState();
// Return the cleanup function.
return () => {
sentinel.off(triggerSelector, updatePanelState);
disappearanceObserver?.disconnect();
if (observedPanel) {
unobserveElement(observedPanel);
}
if (animationLoopId) cancelAnimationFrame(animationLoopId);
};
},
/**
* @private
* @description Starts a stateful observer for the right sidebar (Activity/Thread flyout).
* @param {object} dependencies
* @returns {() => void}
*/
startRightSidebarObserver(dependencies) {
// Use explicit reference to PlatformAdapters.Observer instead of 'this' to avoid context issues
return PlatformAdapters.Observer._startGenericPanelObserver(
dependencies,
CONSTANTS.SELECTORS.RIGHT_SIDEBAR, // Trigger
CONSTANTS.OBSERVED_ELEMENT_TYPES.SIDE_PANEL, // Observer Type
(el) => el, // Target Resolver (Trigger is the Panel)
0, // Transition Delay (Immediate UI reposition is preferred here, or use PANEL_TRANSITION_DURATION if animation needs waiting)
() => EventBus.publish(EVENTS.SIDEBAR_LAYOUT_CHANGED) // Immediate callback
);
},
/**
* @private
* @description Starts a stateful observer for the Research Panel.
* @param {object} dependencies
* @returns {() => void}
*/
startResearchPanelObserver(dependencies) {
// Use explicit reference to PlatformAdapters.Observer instead of 'this' to avoid context issues
return PlatformAdapters.Observer._startGenericPanelObserver(
dependencies,
CONSTANTS.SELECTORS.RESEARCH_PANEL, // Trigger
CONSTANTS.OBSERVED_ELEMENT_TYPES.SIDE_PANEL, // Observer Type
// Target Resolver: The trigger is inside a section, inside the main div.
// We need the parent div that holds the width.
(el) => el.closest(CONSTANTS.SELECTORS.SIDEBAR_SURFACE_PRIMARY),
CONSTANTS.TIMING.TIMEOUTS.PANEL_TRANSITION_DURATION, // Transition Delay
() => EventBus.publish(EVENTS.VISIBILITY_RECHECK) // Immediate callback
);
},
/**
* @private
* @description Starts a stateful observer to detect the appearance and disappearance of the Canvas panel using a high-performance hybrid approach.
* @param {{observeElement: function(HTMLElement, string): void, unobserveElement: function(HTMLElement): void}} dependencies The required methods from ObserverManager.
* @returns {() => void} A cleanup function.
*/
startCanvasObserver(dependencies) {
// Use explicit reference to PlatformAdapters.Observer instead of 'this' to avoid context issues
return PlatformAdapters.Observer._startGenericPanelObserver(
dependencies,
CONSTANTS.SELECTORS.CANVAS_CONTAINER, // Trigger (Button)
CONSTANTS.OBSERVED_ELEMENT_TYPES.SIDE_PANEL, // Observer Type
(el) => el.closest(CONSTANTS.SELECTORS.CANVAS_RESIZE_TARGET), // Target Resolver (Find Parent Panel)
CONSTANTS.TIMING.TIMEOUTS.PANEL_TRANSITION_DURATION, // Transition Delay
() => EventBus.publish(EVENTS.VISIBILITY_RECHECK) // Immediate callback
);
},
/**
* @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 attributeObserver = null;
let animationLoopId = null;
const ANIMATION_DURATION = 500; // ms
// Function to run the layout update loop
const startUpdateLoop = () => {
if (animationLoopId) cancelAnimationFrame(animationLoopId);
const startTime = Date.now();
const loop = () => {
// Publish update immediately
EventBus.publish(EVENTS.SIDEBAR_LAYOUT_CHANGED);
if (Date.now() - startTime < ANIMATION_DURATION) {
animationLoopId = requestAnimationFrame(loop);
} else {
animationLoopId = null;
}
};
loop();
};
const setupAttributeObserver = (sidebarContainer) => {
const stateIndicator = sidebarContainer.querySelector(CONSTANTS.SELECTORS.SIDEBAR_STATE_INDICATOR);
attributeObserver?.disconnect(); // Disconnect previous observer if any
if (stateIndicator) {
attributeObserver = new MutationObserver(() => {
// Start the update loop immediately upon detection
startUpdateLoop();
});
attributeObserver.observe(stateIndicator, {
attributes: true,
attributeFilter: ['class'],
});
// Trigger once initially
EventBus.publish(EVENTS.SIDEBAR_LAYOUT_CHANGED);
}
};
// Use Sentinel to detect when the sidebar container is added.
sentinel.on(CONSTANTS.SELECTORS.SIDEBAR_WIDTH_TARGET, setupAttributeObserver);
// Initial check in case the sidebar is already present.
const initialSidebar = document.querySelector(CONSTANTS.SELECTORS.SIDEBAR_WIDTH_TARGET);
if (initialSidebar) {
setupAttributeObserver(initialSidebar);
}
// Return the cleanup function for all resources created by this observer.
return () => {
sentinel.off(CONSTANTS.SELECTORS.SIDEBAR_WIDTH_TARGET, setupAttributeObserver);
attributeObserver?.disconnect();
if (animationLoopId) cancelAnimationFrame(animationLoopId);
};
},
/**
* @private
* @description Starts a stateful observer for the input area to detect resizing and DOM reconstruction (button removal).
* @param {object} dependencies The ObserverManager dependencies.
* @returns {() => void} A cleanup function.
*/
startInputAreaObserver(dependencies) {
const { observeElement, unobserveElement } = dependencies;
let observedInputArea = null;
const setupObserver = (inputArea) => {
if (inputArea === observedInputArea) return;
// Cleanup previous observers
if (observedInputArea) {
unobserveElement(observedInputArea);
}
observedInputArea = inputArea;
// Resize Observer (via ObserverManager)
const resizeTargetSelector = CONSTANTS.SELECTORS.INPUT_RESIZE_TARGET;
const resizeTarget = inputArea.matches(resizeTargetSelector) ? inputArea : inputArea.querySelector(resizeTargetSelector);
if (resizeTarget instanceof HTMLElement) {
observeElement(resizeTarget, CONSTANTS.OBSERVED_ELEMENT_TYPES.INPUT_AREA);
}
// Trigger initial placement
EventBus.publish(EVENTS.UI_REPOSITION);
};
const selector = CONSTANTS.SELECTORS.INSERTION_ANCHOR;
sentinel.on(selector, setupObserver);
// Initial check
const initialInputArea = document.querySelector(selector);
if (initialInputArea instanceof HTMLElement) {
setupObserver(initialInputArea);
}
return () => {
sentinel.off(selector, setupObserver);
if (observedInputArea) unobserveElement(observedInputArea);
};
},
/**
* Checks if a conversation turn is complete based on ChatGPT's DOM structure.
* @param {HTMLElement} turnNode The turn container element.
* @returns {boolean} True if the turn is complete.
*/
isTurnComplete(turnNode) {
// A turn is complete if it's an assistant message that has rendered its action buttons.
// User message turns are handled implicitly and don't trigger this "complete" state in the context of streaming.
const assistantActions = turnNode.querySelector(CONSTANTS.SELECTORS.TURN_COMPLETE_SELECTOR);
return !!assistantActions;
},
},
// =================================================================================
// SECTION: Adapters for class TimestampManager
// =================================================================================
Timestamp: {
originalFetch: unsafeWindow.fetch.bind(unsafeWindow),
isInitialized: false,
init() {
if (this.isInitialized) return; // Only run the fetch wrapper once
try {
unsafeWindow.fetch = this._wrappedFetch.bind(this);
this.isInitialized = true;
} catch (e) {
Logger.badge('FETCH WRAP FAILED', LOG_STYLES.RED, 'error', 'Could not wrap fetch:', e);
unsafeWindow.fetch = this.originalFetch; // Restore original on failure
}
},
cleanup() {
if (!this.isInitialized) return;
unsafeWindow.fetch = this.originalFetch;
this.isInitialized = false;
},
hasTimestampLogic() {
return true;
},
_getChatIdFromUrl(url) {
if (!url) return null;
// Match .../conversation/[ID] only. Must end with the ID.
// The ID must contain at least 4 hyphens.
// (e.g., 8-4-4-4-12 format)
const match = url.match(/\/backend-api\/conversation\/([^/]*-[^/]*-[^/]*-[^/]*-[^/]+)$/);
return match ? match[1] : null;
},
_wrappedFetch(input, init) {
// Let the original fetch proceed immediately
const responsePromise = this.originalFetch(input, init);
// Check if this is the URL we want to intercept
const url = typeof input === 'string' ? input : input?.url;
const chatId = this._getChatIdFromUrl(url);
// Only log and process if it matches our target API
if (chatId) {
Logger.badge('FETCH', LOG_STYLES.GRAY, 'debug', 'Target API URL intercepted:', url);
// Handle response processing in an async then() block
// to keep the main fetch call synchronous (returning a Promise immediately).
responsePromise
.then(async (response) => {
// Make the callback async
// Only proceed if the response was successful
if (response && response.ok && response.status === 200) {
// Use response.clone() to create a safe copy for us to read
const clonedResponse = response.clone();
// Await the parsed map
const timestamps = await this._processResponse(clonedResponse);
// Event publishing is now the responsibility of fetch
if (timestamps.size > 0) {
EventBus.publish(EVENTS.TIMESTAMPS_LOADED, { chatId, timestamps });
}
}
})
.catch(() => {
// Ignore fetch errors
});
}
// Return the original, untouched promise to the caller immediately
return responsePromise;
},
async _processResponse(response) {
/** @type {Map} */
const newTimestamps = new Map();
try {
const data = await response.json();
let added = 0;
if (data && data.mapping) {
Object.values(data.mapping).forEach((item) => {
if (item && item.message && item.message.id && item.message.create_time) {
// Add to our temporary map. We don't check for existence,
// TimestampManager will handle merging/overwriting.
newTimestamps.set(item.message.id, new Date(item.message.create_time * 1000));
added++;
}
});
if (added > 0) {
Logger.badge('TIMESTAMPS', LOG_STYLES.GRAY, 'debug', `Parsed ${added} historical timestamps.`);
} else {
Logger.badge('TIMESTAMPS', LOG_STYLES.GRAY, 'debug', 'API response processed, but no valid timestamps were found in data.mapping.');
}
} else {
Logger.badge('TIMESTAMPS', LOG_STYLES.GRAY, 'debug', 'API response processed, but data.mapping was not found or was empty.');
}
} catch (e) {
Logger.badge('TIMESTAMP ERROR', LOG_STYLES.RED, 'error', 'Failed to parse conversation JSON:', e);
}
// Always return the map (it might be empty)
return newTimestamps;
},
},
// =================================================================================
// SECTION: Adapters for class UIManager
// =================================================================================
UIManager: {
repositionSettingsButton(settingsButton) {
if (!settingsButton?.element) return;
withLayoutCycle({
measure: () => {
// Read phase
const anchor = document.querySelector(CONSTANTS.SELECTORS.INSERTION_ANCHOR);
if (!(anchor instanceof HTMLElement)) return { anchor: null };
// Ghost Detection Logic
const existingBtn = document.getElementById(settingsButton.element.id);
const isGhost = existingBtn && existingBtn !== settingsButton.element;
// Check if button is already inside (only if it's the correct instance)
const isInside = !isGhost && anchor.contains(settingsButton.element);
return {
anchor,
isGhost,
existingBtn,
shouldInject: !isInside,
};
},
mutate: (measured) => {
// Write phase
// Guard: Check for excluded page immediately to prevent zombie UI.
if (PlatformAdapters.General.isExcludedPage()) {
if (settingsButton.element.isConnected) {
settingsButton.element.remove();
Logger.badge('UI GUARD', LOG_STYLES.GRAY, 'debug', 'Excluded page detected during UI update. Button removed.');
}
return;
}
if (!measured || !measured.anchor) {
// Hide if anchor is gone
settingsButton.element.style.display = 'none';
return;
}
const { anchor, isGhost, existingBtn, shouldInject } = measured;
// Safety Check: Ensure the anchor is still part of the document
if (!anchor.isConnected) {
return;
}
// 1. Ghost Buster
if (isGhost && existingBtn) {
Logger.badge('GHOST BUSTER', LOG_STYLES.YELLOW, 'warn', 'Detected non-functional ghost button. Removing...');
existingBtn.remove();
}
// 2. Injection
if (shouldInject || isGhost) {
anchor.prepend(settingsButton.element);
Logger.badge('UI INJECTION', LOG_STYLES.BLUE, 'debug', 'Settings button injected into anchor.');
}
settingsButton.element.style.display = '';
},
});
},
},
// =================================================================================
// SECTION: Adapters for class FixedNavigationManager
// =================================================================================
FixedNav: {
/**
* @description (ChatGPT) A lifecycle hook for `FixedNavigationManager` to handle UI state changes related to infinite scrolling.
* @description This is a no-op on ChatGPT as the platform does not use an infinite scroll mechanism to load older chat messages. It exists for architectural consistency with the Gemini version.
* @param {FixedNavigationManager} fixedNavManagerInstance The instance of the `FixedNavigationManager`.
* @param {HTMLElement | null} highlightedMessage The currently highlighted message element.
* @param {number} previousTotalMessages The total number of messages before the cache update.
* @returns {void}
*/
handleInfiniteScroll(fixedNavManagerInstance, highlightedMessage, previousTotalMessages) {
// 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.
},
/**
* Applies additional, platform-specific highlight classes if needed.
* For ChatGPT, this handles assistant-generated images which are not direct children
* of the message element that receives the primary highlight class.
* @param {HTMLElement} messageElement The currently highlighted message element.
*/
applyAdditionalHighlight(messageElement) {
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(`${APPID}-highlight-turn`);
}
}
},
/**
* @description Returns an array of platform-specific UI elements, such as buttons and separators,
* to be added to the left side of the navigation console.
* @param {FixedNavigationManager} fixedNavManagerInstance The instance of the FixedNavigationManager.
* @returns {Element[]} An array of `Element` objects. Returns an empty array
* if no platform-specific buttons are needed for the current platform.
*/
getPlatformSpecificButtons(fixedNavManagerInstance) {
if (!isFirefox()) {
return [];
}
const autoscrollBtn = h(
`button#${APPID}-autoscroll-btn.${APPID}-nav-btn`,
{
title: 'Run layout scan and rescan DOM',
dataset: { originalTitle: 'Run layout scan and rescan DOM' },
onclick: () => {
// 1. Subscribe once to the completion event
fixedNavManagerInstance._subscribeOnce(EVENTS.AUTO_SCROLL_COMPLETE, () => {
// 2. Perform the "DOM Rescan" logic *after* scan is complete.
if (fixedNavManagerInstance.messageLifecycleManager) {
fixedNavManagerInstance.messageLifecycleManager.scanForUnprocessedMessages();
}
EventBus.publish(EVENTS.CACHE_UPDATE_REQUEST);
});
// 3. Start the scan.
EventBus.publish(EVENTS.AUTO_SCROLL_REQUEST);
},
},
[createIconFromDef(SITE_STYLES.ICONS.scrollToTop)] // Use 'scrollToTop' icon
);
return [autoscrollBtn, h(`div.${APPID}-nav-separator`)];
},
/**
* @description Updates the state (disabled, title) of platform-specific buttons in the navigation console.
* @param {HTMLButtonElement} autoscrollBtn The platform-specific button element.
* @param {boolean} isAutoScrolling The shared `isAutoScrolling` state from FixedNavigationManager.
* @param {object | null} autoScrollManager The platform-specific AutoScrollManager instance.
*/
updatePlatformSpecificButtonState(autoscrollBtn, isAutoScrolling, autoScrollManager) {
if (!isFirefox()) {
autoscrollBtn.disabled = true;
autoscrollBtn.title = 'Layout scan is not required on your browser.';
autoscrollBtn.style.opacity = '0.5';
return;
}
const isScanComplete = autoScrollManager?.isLayoutScanComplete;
const isDisabled = isAutoScrolling || isScanComplete;
autoscrollBtn.disabled = isDisabled;
autoscrollBtn.style.opacity = '1';
if (isScanComplete) {
autoscrollBtn.title = 'Layout scan complete';
} else if (isAutoScrolling) {
autoscrollBtn.title = 'Scanning layout...';
} else {
autoscrollBtn.title = autoscrollBtn.dataset.originalTitle;
}
},
},
};
// =================================================================================
// SECTION: Declarative Style Mapper
// Description: Single source of truth for all theme-driven style generation.
// This array declaratively maps configuration properties to CSS variables and rules.
// The StyleGenerator engine processes this array to build the final CSS.
// =================================================================================
/**
* @param {string} actor - 'user' or 'assistant'
* @param {object} [overrides={}] - Platform-specific overrides.
* @returns {object[]} An array of style definition objects for the given actor.
*/
function createActorStyleDefinitions(actor, overrides = {}) {
const actorUpper = actor.toUpperCase();
const important = SITE_STYLES.CSS_IMPORTANT_FLAG;
return [
{
configKey: `${actor}.name`,
fallbackKey: `defaultSet.${actor}.name`,
cssVar: `--${APPID}-${actor}-name`,
transformer: (value) => (value ? `'${value.replace(/'/g, "\\'")}'` : null),
},
{
configKey: `${actor}.icon`,
fallbackKey: `defaultSet.${actor}.icon`,
cssVar: `--${APPID}-${actor}-icon`,
},
{
configKey: `${actor}.standingImageUrl`,
fallbackKey: `defaultSet.${actor}.standingImageUrl`,
cssVar: `--${APPID}-${actor}-standing-image`,
},
{
configKey: `${actor}.textColor`,
fallbackKey: `defaultSet.${actor}.textColor`,
cssVar: `--${APPID}-${actor}-textColor`,
selector: `${CONSTANTS.SELECTORS[`${actorUpper}_MESSAGE`]} ${CONSTANTS.SELECTORS[`${actorUpper}_TEXT_CONTENT`]}`,
property: 'color',
generator: (value) => {
if (actor !== 'assistant' || !value) return '';
// This generator is specific to the assistant and is common across platforms.
const childSelectors = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul li', 'ol li', 'ul li::marker', 'ol li::marker', 'strong', 'em', 'blockquote', 'table', 'th', 'td'];
const fullSelectors = childSelectors.map((s) => `${CONSTANTS.SELECTORS.ASSISTANT_MESSAGE} ${CONSTANTS.SELECTORS.ASSISTANT_TEXT_CONTENT} ${s}`);
return `${fullSelectors.join(', ')} { color: var(--${APPID}-assistant-textColor); }`;
},
},
{
configKey: `${actor}.font`,
fallbackKey: `defaultSet.${actor}.font`,
cssVar: `--${APPID}-${actor}-font`,
selector: `${CONSTANTS.SELECTORS[`${actorUpper}_MESSAGE`]} ${CONSTANTS.SELECTORS[`${actorUpper}_TEXT_CONTENT`]}`,
property: 'font-family',
},
{
configKey: `${actor}.bubbleBackgroundColor`,
fallbackKey: `defaultSet.${actor}.bubbleBackgroundColor`,
cssVar: `--${APPID}-${actor}-bubble-bg`,
selector: `${CONSTANTS.SELECTORS[`${actorUpper}_MESSAGE`]} ${CONSTANTS.SELECTORS[`RAW_${actorUpper}_BUBBLE`]}`,
property: 'background-color',
},
{
configKey: `${actor}.bubblePadding`,
fallbackKey: `defaultSet.${actor}.bubblePadding`,
cssVar: `--${APPID}-${actor}-bubble-padding`,
selector: `${CONSTANTS.SELECTORS[`${actorUpper}_MESSAGE`]} ${CONSTANTS.SELECTORS[`RAW_${actorUpper}_BUBBLE`]}`,
property: 'padding',
},
{
configKey: `${actor}.bubbleBorderRadius`,
fallbackKey: `defaultSet.${actor}.bubbleBorderRadius`,
cssVar: `--${APPID}-${actor}-bubble-radius`,
selector: `${CONSTANTS.SELECTORS[`${actorUpper}_MESSAGE`]} ${CONSTANTS.SELECTORS[`RAW_${actorUpper}_BUBBLE`]}`,
property: 'border-radius',
},
{
configKey: `${actor}.bubbleMaxWidth`,
fallbackKey: `defaultSet.${actor}.bubbleMaxWidth`,
cssVar: `--${APPID}-${actor}-bubble-maxwidth`,
generator: (value) => {
if (!value) return '';
const selector = `${CONSTANTS.SELECTORS[`${actorUpper}_MESSAGE`]} ${CONSTANTS.SELECTORS[`RAW_${actorUpper}_BUBBLE`]}`;
const cssVar = `--${APPID}-${actor}-bubble-maxwidth`;
const extraRule = overrides[actor] || '';
return `${selector} { max-width: var(${cssVar})${important};${extraRule} }`;
},
},
];
}
const STYLE_DEFINITIONS = {
user: createActorStyleDefinitions('user', PlatformAdapters.ThemeManager.getStyleOverrides()),
assistant: createActorStyleDefinitions('assistant', PlatformAdapters.ThemeManager.getStyleOverrides()),
window: [
{
configKey: 'window.backgroundColor',
fallbackKey: 'defaultSet.window.backgroundColor',
cssVar: `--${APPID}-window-bg-color`,
selector: CONSTANTS.SELECTORS.MAIN_APP_CONTAINER,
property: 'background-color',
},
{
configKey: 'window.backgroundImageUrl',
fallbackKey: 'defaultSet.window.backgroundImageUrl',
cssVar: `--${APPID}-window-bg-image`,
generator: (value) =>
value ? `${CONSTANTS.SELECTORS.MAIN_APP_CONTAINER} { background-image: var(--${APPID}-window-bg-image)${SITE_STYLES.CSS_IMPORTANT_FLAG}; background-attachment: fixed${SITE_STYLES.CSS_IMPORTANT_FLAG}; }` : '',
},
{
configKey: 'window.backgroundSize',
fallbackKey: 'defaultSet.window.backgroundSize',
cssVar: `--${APPID}-window-bg-size`,
generator: (value) => (value ? `${CONSTANTS.SELECTORS.MAIN_APP_CONTAINER} { background-size: var(--${APPID}-window-bg-size)${SITE_STYLES.CSS_IMPORTANT_FLAG}; }` : ''),
},
{
configKey: 'window.backgroundPosition',
fallbackKey: 'defaultSet.window.backgroundPosition',
cssVar: `--${APPID}-window-bg-pos`,
generator: (value) => (value ? `${CONSTANTS.SELECTORS.MAIN_APP_CONTAINER} { background-position: var(--${APPID}-window-bg-pos)${SITE_STYLES.CSS_IMPORTANT_FLAG}; }` : ''),
},
{
configKey: 'window.backgroundRepeat',
fallbackKey: 'defaultSet.window.backgroundRepeat',
cssVar: `--${APPID}-window-bg-repeat`,
generator: (value) => (value ? `${CONSTANTS.SELECTORS.MAIN_APP_CONTAINER} { background-repeat: var(--${APPID}-window-bg-repeat)${SITE_STYLES.CSS_IMPORTANT_FLAG}; }` : ''),
},
],
inputArea: [
{
configKey: 'inputArea.backgroundColor',
fallbackKey: 'defaultSet.inputArea.backgroundColor',
cssVar: `--${APPID}-input-bg`,
selector: CONSTANTS.SELECTORS.INPUT_AREA_BG_TARGET,
property: 'background-color',
generator: (value) => (value ? `${CONSTANTS.SELECTORS.INPUT_TEXT_FIELD_TARGET} { background-color: transparent; }` : ''),
},
{
configKey: 'inputArea.textColor',
fallbackKey: 'defaultSet.inputArea.textColor',
cssVar: `--${APPID}-input-color`,
selector: CONSTANTS.SELECTORS.INPUT_TEXT_FIELD_TARGET,
property: 'color',
},
],
};
// Flatten the structured definitions into a single array for easier iteration.
const ALL_STYLE_DEFINITIONS = Object.values(STYLE_DEFINITIONS).flat();
// =================================================================================
// SECTION: Event-Driven Architecture (Pub/Sub)
// Description: A event bus for decoupled communication between classes.
// =================================================================================
const EventBus = {
events: {},
uiWorkQueue: [],
isUiWorkScheduled: false,
_logAggregation: {},
// prettier-ignore
_aggregatedEvents: new Set([
EVENTS.RAW_MESSAGE_ADDED,
EVENTS.AVATAR_INJECT,
EVENTS.MESSAGE_COMPLETE,
EVENTS.TURN_COMPLETE,
EVENTS.SIDEBAR_LAYOUT_CHANGED,
EVENTS.VISIBILITY_RECHECK,
EVENTS.UI_REPOSITION,
EVENTS.INPUT_AREA_RESIZED,
EVENTS.TIMESTAMP_ADDED,
]),
_aggregationDelay: 500, // ms
/**
* Subscribes a listener to an event using a unique key.
* If a subscription with the same event and key already exists, it will be overwritten.
* @param {string} event The event name.
* @param {Function} listener The callback function.
* @param {string} key A unique key for this subscription (e.g., 'ClassName.methodName').
*/
subscribe(event, listener, key) {
if (!key) {
Logger.error('EventBus.subscribe requires a unique key.');
return;
}
if (!this.events[event]) {
this.events[event] = new Map();
}
this.events[event].set(key, listener);
},
/**
* Subscribes a listener that will be automatically unsubscribed after one execution.
* @param {string} event The event name.
* @param {Function} listener The callback function.
* @param {string} key A unique key for this subscription.
*/
once(event, listener, key) {
if (!key) {
Logger.error('EventBus.once requires a unique key.');
return;
}
const onceListener = (...args) => {
this.unsubscribe(event, key);
listener(...args);
};
this.subscribe(event, onceListener, key);
},
/**
* Unsubscribes a listener from an event using its unique key.
* @param {string} event The event name.
* @param {string} key The unique key used during subscription.
*/
unsubscribe(event, key) {
if (!this.events[event] || !key) {
return;
}
this.events[event].delete(key);
if (this.events[event].size === 0) {
delete this.events[event];
}
},
/**
* Publishes an event, calling all subscribed listeners with the provided data.
* @param {string} event The event name.
* @param {...any} args The data to pass to the listeners.
*/
publish(event, ...args) {
if (!this.events[event]) {
return;
}
if (Logger.levels[Logger.level] >= Logger.levels.debug) {
// --- Aggregation logic START ---
if (this._aggregatedEvents.has(event)) {
if (!this._logAggregation[event]) {
this._logAggregation[event] = { timer: null, count: 0 };
}
const aggregation = this._logAggregation[event];
aggregation.count++;
clearTimeout(aggregation.timer);
aggregation.timer = setTimeout(() => {
const finalCount = this._logAggregation[event]?.count || 0;
if (finalCount > 0) {
console.log(LOG_PREFIX, `Event Published: ${event} (x${finalCount})`);
}
delete this._logAggregation[event];
}, this._aggregationDelay);
// Execute subscribers for the aggregated event, but without the verbose individual logs.
[...this.events[event].values()].forEach((listener) => {
try {
listener(...args);
} catch (e) {
Logger.error(`EventBus error in listener for event "${event}":`, e);
}
});
return; // End execution here for aggregated events in debug mode.
}
// --- Aggregation logic END ---
// In debug mode, provide detailed logging for NON-aggregated events.
const subscriberKeys = [...this.events[event].keys()];
// Use groupCollapsed for a cleaner default view
console.groupCollapsed(LOG_PREFIX, `Event Published: ${event}`);
if (args.length > 0) {
console.log(' - Payload:', ...args);
} else {
console.log(' - Payload: (No data)');
}
// Displaying subscribers helps in understanding the event's impact.
if (subscriberKeys.length > 0) {
console.log(' - Subscribers:\n' + subscriberKeys.map((key) => ` > ${key}`).join('\n'));
} else {
console.log(' - Subscribers: (None)');
}
// Iterate with keys for better logging
this.events[event].forEach((listener, key) => {
try {
// Log which specific subscriber is being executed
Logger.debug(`-> Executing: ${key}`);
listener(...args);
} catch (e) {
// Enhance error logging with the specific subscriber key
Logger.badge('LISTENER ERROR', LOG_STYLES.RED, 'error', `Listener "${key}" failed for event "${event}":`, e);
}
});
console.groupEnd();
} else {
// Iterate over a copy of the values in case a listener unsubscribes itself.
[...this.events[event].values()].forEach((listener) => {
try {
listener(...args);
} catch (e) {
Logger.badge('LISTENER ERROR', LOG_STYLES.RED, 'error', `Listener failed for event "${event}":`, e);
}
});
}
},
/**
* Queues a function to be executed on the next animation frame.
* Batches multiple UI updates into a single repaint cycle.
* @param {Function} workFunction The function to execute.
*/
queueUIWork(workFunction) {
this.uiWorkQueue.push(workFunction);
if (!this.isUiWorkScheduled) {
this.isUiWorkScheduled = true;
requestAnimationFrame(this._processUIWorkQueue.bind(this));
}
},
/**
* @private
* Processes all functions in the UI work queue.
*/
_processUIWorkQueue() {
// Prevent modifications to the queue while processing.
const queueToProcess = [...this.uiWorkQueue];
this.uiWorkQueue.length = 0;
for (const work of queueToProcess) {
try {
work();
} catch (e) {
Logger.badge('UI QUEUE ERROR', LOG_STYLES.RED, 'error', 'Error in queued UI work:', e);
}
}
this.isUiWorkScheduled = false;
},
};
/**
* Creates a unique, consistent event subscription key for EventBus.
* @param {object} context The `this` context of the subscribing class instance.
* @param {string} eventName The full event name from the EVENTS constant.
* @returns {string} A key in the format 'ClassName.purpose'.
*/
function createEventKey(context, eventName) {
// Extract a meaningful 'purpose' from the event name
const parts = eventName.split(':');
const purpose = parts.length > 1 ? parts.slice(1).join('_') : parts[0];
let contextName = 'UnknownContext';
if (context && context.constructor && context.constructor.name) {
contextName = context.constructor.name;
}
return `${contextName}.${purpose}`;
}
// =================================================================================
// SECTION: Data Conversion Utilities
// Description: Handles image optimization.
// =================================================================================
class DataConverter {
/**
* Converts an image file to an optimized Data URL.
* @param {File} file The image file object.
* @param {{ maxWidth?: number, maxHeight?: number, quality?: number }} options
* @returns {Promise} A promise that resolves with the optimized Data URL.
*/
imageToOptimizedDataUrl(file, { maxWidth, maxHeight, quality = 0.85 }) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => {
const img = new Image();
img.onload = () => {
// Check if we can skip re-compression
const isWebP = file.type === 'image/webp';
const needsResize = (maxWidth && img.width > maxWidth) || (maxHeight && img.height > maxHeight);
if (isWebP && !needsResize) {
// It's an appropriately sized WebP, so just use the original Data URL.
if (event.target && typeof event.target.result === 'string') {
resolve(event.target.result);
} else {
reject(new Error('Failed to read file as a data URL.'));
}
return;
}
// Otherwise, proceed with canvas-based resizing and re-compression.
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Failed to get 2D context from canvas.'));
return;
}
let { width, height } = img;
if (needsResize) {
const ratio = width / height;
if (maxWidth && width > maxWidth) {
width = maxWidth;
height = width / ratio;
}
if (maxHeight && height > maxHeight) {
height = maxHeight;
width = height * ratio;
}
}
canvas.width = Math.round(width);
canvas.height = Math.round(height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
resolve(canvas.toDataURL('image/webp', quality));
};
img.onerror = (err) => reject(new Error('Failed to load image.'));
if (event.target && typeof event.target.result === 'string') {
img.src = event.target.result;
} else {
reject(new Error('Failed to read file as a data URL.'));
}
};
reader.onerror = (err) => reject(new Error('Failed to read file.'));
reader.readAsDataURL(file);
});
}
}
// =================================================================================
// SECTION: Utility Functions
// Description: General helper functions used across the script.
// =================================================================================
/**
* Schedules a function to run when the browser is idle.
* @param {(deadline: IdleDeadline) => void} callback The function to execute.
* @param {number} [timeout] The maximum delay in milliseconds.
* @returns {void}
*/
function runWhenIdle(callback, timeout = 2000) {
if ('requestIdleCallback' in window) {
window.requestIdleCallback(callback, { timeout });
} else {
setTimeout(callback, CONSTANTS.TIMING.TIMEOUTS.IDLE_EXECUTION_FALLBACK);
}
}
/**
* @param {Function} func
* @param {number} delay
* @param {boolean} useIdle
* @returns {((...args: any[]) => void) & { cancel: () => void }}
*/
function debounce(func, delay, useIdle) {
let timeout;
const debounced = function (...args) {
clearTimeout(timeout);
timeout = setTimeout(() => {
if (useIdle) {
// After the debounce delay, schedule the actual execution for when the browser is idle.
runWhenIdle(() => func.apply(this, args));
} else {
// Execute immediately after the delay without waiting for idle time.
func.apply(this, args);
}
}, delay);
};
debounced.cancel = () => {
clearTimeout(timeout);
};
return debounced;
}
/**
* Helper function to check if an item is a non-array object.
* @param {unknown} item The item to check.
* @returns {item is Record}
*/
function isObject(item) {
return !!(item && typeof item === 'object' && !Array.isArray(item));
}
/**
* Creates a deep copy of a JSON-serializable object.
* @template T
* @param {T} obj The object to clone.
* @returns {T} The deep copy of the object.
*/
function deepClone(obj) {
return structuredClone(obj);
}
/**
* Recursively resolves the configuration by overlaying source properties onto the target object.
* The target object is mutated. This handles recursive updates for nested objects but overwrites arrays/primitives.
*
* [MERGE BEHAVIOR]
* Keys present in 'source' but missing in 'target' are ignored.
* The 'target' object acts as a schema; it must contain all valid keys.
*
* @param {object} target The target object (e.g., a deep copy of default config).
* @param {object} source The source object (e.g., user config).
* @returns {object} The mutated target object.
*/
function resolveConfig(target, source) {
for (const key in source) {
// Security: Prevent prototype pollution
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
continue;
}
if (Object.prototype.hasOwnProperty.call(source, key)) {
// Strict check: Ignore keys that do not exist in the target (default config).
if (!Object.prototype.hasOwnProperty.call(target, key)) {
continue;
}
const sourceVal = source[key];
const targetVal = target[key];
if (isObject(sourceVal) && isObject(targetVal)) {
// If both are objects, recurse
resolveConfig(targetVal, sourceVal);
} else if (typeof sourceVal !== 'undefined') {
// Otherwise, overwrite or set the value from the source
target[key] = sourceVal;
}
}
}
return target;
}
/**
* Checks if the current page is the "New Chat" page.
* This is determined by checking if the URL path matches the platform-specific pattern.
* @returns {boolean} True if it is the new chat page, otherwise false.
*/
function isNewChatPage() {
return PlatformAdapters.General.isNewChatPage();
}
/**
* Checks if the current browser is Firefox.
* @returns {boolean} True if the browser is Firefox, otherwise false.
*/
function isFirefox() {
return navigator.userAgent.includes('Firefox');
}
/**
* @typedef {Node|string|number|boolean|null|undefined} HChild
*/
/**
* Creates a DOM element using a hyperscript-style syntax.
* @param {string} tag - Tag name with optional ID/class (e.g., "div#app.container", "my-element").
* @param {object | HChild | HChild[]} [propsOrChildren] - Attributes object or children.
* @param {HChild | HChild[]} [children] - Children (if props are specified).
* @returns {HTMLElement|SVGElement} The created DOM element.
*/
function h(tag, propsOrChildren, children) {
const SVG_NS = 'http://www.w3.org/2000/svg';
const match = tag.match(/^([a-z0-9-]+)(#[\w-]+)?((\.[\w-]+)*)$/i);
if (!match) throw new Error(`Invalid tag syntax: ${tag}`);
const [, tagName, id, classList] = match;
const isSVG = ['svg', 'circle', 'rect', 'path', 'g', 'line', 'text', 'use', 'defs', 'clipPath'].includes(tagName);
const el = isSVG ? document.createElementNS(SVG_NS, tagName) : document.createElement(tagName);
if (id) el.id = id.slice(1);
if (classList) {
const classes = classList.replace(/\./g, ' ').trim();
if (classes) {
el.classList.add(...classes.split(/\s+/));
}
}
let props = {};
let childrenArray;
if (propsOrChildren && Object.prototype.toString.call(propsOrChildren) === '[object Object]') {
props = propsOrChildren;
childrenArray = children;
} else {
childrenArray = propsOrChildren;
}
// --- Start of Attribute/Property Handling ---
const directProperties = new Set(['value', 'checked', 'selected', 'readOnly', 'disabled', 'multiple', 'textContent']);
const urlAttributes = new Set(['href', 'src', 'action', 'formaction']);
const safeProtocols = new Set(['https:', 'http:', 'mailto:', 'tel:', 'blob:', 'data:']);
for (const [key, value] of Object.entries(props)) {
// 0. Handle `ref` callback (highest priority after props parsing).
if (key === 'ref' && typeof value === 'function') {
value(el);
}
// 1. Security check for URL attributes.
else if (urlAttributes.has(key)) {
const url = String(value);
try {
const parsedUrl = new URL(url); // Throws if not an absolute URL.
if (safeProtocols.has(parsedUrl.protocol)) {
el.setAttribute(key, url);
} else {
el.setAttribute(key, '#');
Logger.badge('UNSAFE URL', LOG_STYLES.YELLOW, 'warn', `Blocked potentially unsafe protocol "${parsedUrl.protocol}" in attribute "${key}":`, url);
}
} catch {
el.setAttribute(key, '#');
Logger.badge('INVALID URL', LOG_STYLES.YELLOW, 'warn', `Blocked invalid or relative URL in attribute "${key}":`, url);
}
}
// 2. Direct property assignments.
else if (directProperties.has(key)) {
el[key] = value;
}
// 3. Other specialized handlers.
else if (key === 'style' && typeof value === 'object') {
Object.assign(el.style, value);
} else if (key === 'dataset' && typeof value === 'object') {
for (const [dataKey, dataVal] of Object.entries(value)) {
el.dataset[dataKey] = dataVal;
}
} else if (key.startsWith('on')) {
if (typeof value === 'function') {
el.addEventListener(key.slice(2).toLowerCase(), value);
}
} else if (key === 'className') {
const classes = String(value).trim();
if (classes) {
el.classList.add(...classes.split(/\s+/));
}
} else if (key.startsWith('aria-')) {
el.setAttribute(key, String(value));
}
// 4. Default attribute handling.
else if (value !== false && value !== null) {
el.setAttribute(key, value === true ? '' : String(value));
}
}
// --- End of Attribute/Property Handling ---
const fragment = document.createDocumentFragment();
/**
* Appends a child node or text to the document fragment.
* @param {HChild} child - The child to append.
*/
function append(child) {
if (child === null || child === false || typeof child === 'undefined') return;
if (typeof child === 'string' || typeof child === 'number') {
fragment.appendChild(document.createTextNode(String(child)));
} else if (Array.isArray(child)) {
child.forEach(append);
} else if (child instanceof Node) {
fragment.appendChild(child);
} else {
throw new Error('Unsupported child type');
}
}
append(childrenArray);
el.appendChild(fragment);
return el;
}
/**
* @description A dispatch table object that maps UI schema types to their respective rendering functions.
*/
const UI_SCHEMA_RENDERERS = {
_renderContainer(def) {
let className = def.className;
if (!className) {
const classMap = {
'compound-slider': `${APPID}-compound-slider-container`,
'compound-container': `${APPID}-compound-form-field-container`,
'slider-container': `${APPID}-slider-container`,
'container-row': `${APPID}-submenu-row`,
'container-stacked-row': `${APPID}-submenu-row ${APPID}-submenu-row-stacked`,
};
className = classMap[def.type] || '';
}
const element = h(`div`, { className });
if (def.children) {
element.appendChild(buildUIFromSchema(def.children));
}
return element;
},
fieldset(def) {
const element = h(`fieldset.${APPID}-submenu-fieldset`, [h('legend', def.legend)]);
if (def.children) {
element.appendChild(buildUIFromSchema(def.children));
}
return element;
},
separator(def) {
let element = h(`hr.${APPID}-theme-separator`, { tabIndex: -1 });
if (def.legend) {
element = h('fieldset', [h('legend', def.legend), element]);
}
return element;
},
'submenu-separator': (def) => h(`div.${APPID}-submenu-separator`),
textarea(def, formId) {
return h(`div.${APPID}-form-field`, [
h('label', { htmlFor: formId, title: def.tooltip }, def.label),
h('textarea', { id: formId, rows: def.rows }),
h(`div.${APPID}-form-error-msg`, { 'data-error-for': def.id.replace(/\./g, '-') }),
]);
},
textfield(def, formId) {
const isImageField = ['image', 'icon'].includes(def.fieldType);
const inputWrapperChildren = [h('input', { type: 'text', id: formId })];
if (isImageField) {
inputWrapperChildren.push(h(`button.${APPID}-local-file-btn`, { type: 'button', 'data-target-id': def.id.replace(/\./g, '-'), title: 'Select local file' }, [createIconFromDef(SITE_STYLES.ICONS.folder)]));
}
return h(`div.${APPID}-form-field`, [
h('label', { htmlFor: formId, title: def.tooltip }, def.label),
h(`div.${APPID}-input-wrapper`, inputWrapperChildren),
h(`div.${APPID}-form-error-msg`, { 'data-error-for': def.id.replace(/\./g, '-') }),
]);
},
colorfield(def, formId) {
const hint = 'Click the swatch to open the color picker.\nAccepts any valid CSS color string.';
const fullTooltip = def.tooltip ? `${def.tooltip}\n---\n${hint}` : hint;
return h(`div.${APPID}-form-field`, [
h('label', { htmlFor: formId, title: fullTooltip }, def.label),
h(`div.${APPID}-color-field-wrapper`, [
h('input', { type: 'text', id: formId, autocomplete: 'off' }),
h(`button.${APPID}-color-swatch`, { type: 'button', 'data-controls-color': def.id.replace(/\./g, '-'), title: 'Open color picker' }, [h(`span.${APPID}-color-swatch-checkerboard`), h(`span.${APPID}-color-swatch-value`)]),
]),
]);
},
select(def, formId) {
return h(`div.${APPID}-form-field`, [
h('label', { htmlFor: formId, title: def.tooltip }, def.label),
h('select', { id: formId }, [h('option', { value: '' }, '(not set)'), ...def.options.map((o) => h('option', { value: o }, o))]),
]);
},
slider(def, formId) {
const wrapperTag = def.containerClass ? `div.${def.containerClass}` : 'div';
const inputId = `${formId}-slider`;
return h(wrapperTag, [
h('label', { htmlFor: inputId, title: def.tooltip }, def.label),
h(`div.${APPID}-slider-subgroup-control`, [h('input', { type: 'range', id: inputId, min: def.min, max: def.max, step: def.step, dataset: def.dataset }), h('span', { 'data-slider-display-for': def.id })]),
]);
},
paddingslider(def, formId) {
const createSubgroup = (name, suffix, min, max, step) => {
const sliderId = `${APPID}-form-${def.actor}-bubblePadding-${suffix}`;
return h(`div.${APPID}-slider-subgroup`, [
h('label', { htmlFor: sliderId }, name),
h(`div.${APPID}-slider-subgroup-control`, [
h('input', { type: 'range', id: sliderId, min, max, step, dataset: { nullThreshold: 0, sliderFor: sliderId, unit: 'px' } }),
h('span', { 'data-slider-display-for': sliderId }),
]),
]);
};
return h(`div.${APPID}-form-field`, { id: formId }, [h(`div.${APPID}-compound-slider-container`, [createSubgroup('Padding Top/Bottom:', `tb`, -1, 30, 1), createSubgroup('Padding Left/Right:', `lr`, -1, 30, 1)])]);
},
preview(def) {
const wrapperClass = `${APPID}-preview-bubble-wrapper ${def.actor === 'user' ? 'user-preview' : ''}`;
return h(`div.${APPID}-preview-container`, [h('label', 'Preview:'), h('div', { className: wrapperClass }, [h(`div.${APPID}-preview-bubble`, { 'data-preview-for': def.actor }, [h('span', 'Sample Text')])])]);
},
'preview-input': (def) =>
h(`div.${APPID}-preview-container`, [h('label', 'Preview:'), h(`div.${APPID}-preview-bubble-wrapper`, [h(`div.${APPID}-preview-input-area`, { 'data-preview-for': 'inputArea' }, [h('span', 'Sample input text')])])]),
'preview-background': (def) =>
h(`div.${APPID}-form-field`, [h('label', 'BG Preview:'), h(`div.${APPID}-preview-bubble-wrapper`, { style: { padding: '0', minHeight: '0' } }, [h(`div.${APPID}-preview-background`, { 'data-preview-for': 'window' })])]),
button: (def) => h(`button#${def.id}.${APPID}-modal-button`, { title: def.title, style: { width: def.fullWidth ? '100%' : 'auto' } }, def.text),
label: (def) => h('label', { htmlFor: def.for, title: def.title }, def.text),
toggle: (def, formId) => h(`label.${APPID}-toggle-switch`, [h('input', { type: 'checkbox', id: formId }), h(`span.${APPID}-toggle-slider`)]),
};
// Assign aliases for container types
['container', 'grid', 'compound-slider', 'compound-container', 'slider-container', 'container-row', 'container-stacked-row'].forEach((type) => {
UI_SCHEMA_RENDERERS[type] = UI_SCHEMA_RENDERERS._renderContainer;
});
/**
* @description Recursively builds a DOM fragment from a declarative schema object.
* This function is the core of the declarative UI system, translating object definitions into DOM elements.
* @param {Array