9 directories, 58 files

tinai

Home / tinai
import Api from './api.js';
import Conversation from './conversation.js';
import ContentList from './content.js';
import ConversationIndex from './conversation-index.js';
import Context from './context.js';
import Memory from './memory.js';
import Attachments from './attachments.js';
import DocumentIndex from './document-index.js';
import Document from './document.js';
import ChoicesEditor from './choices-editor.js';
import StickyNotesEditor from './sticky-notes-editor.js';
import Diction from './diction.js';
import ScratchpadIndex from './scratchpad-index.js';
import ToolsIndex from './tools-index.js';
import ScratchpadConversation from './scratchpad-conversation.js';
import SideDrawer from './util/side-drawer.js';
import Sync from './sync.js';

import { getEl, queryEl, toggleElementClass, addSafeEventListener, watch_compact_select_label, watch_compact_button_label } from './util/dom-utils.js';
import { initElements } from './util/elements.js';
import { scroller_of } from './util/scrollbars.js';
import { init_diagram_overlay, is_diagram_overlay_open, close_diagram_overlay } from './util/diagram-overlay.js';
import { init_code_overlay, is_code_overlay_open, close_code_overlay } from './util/code-overlay.js';
import { init_table_overlay, is_table_overlay_open, close_table_overlay } from './util/table-overlay.js';
import { init_pinch_zoom } from './util/pinch-zoom.js';
import { init_swipe_navigation } from './util/swipe-navigation.js';
import { init_history_navigation } from './util/history-navigation.js';
import { debounce } from './util/async-utils.js';
import {
	populatePresetDropdown,
	populateFamilyDropdown
} from './util/settings-utils.js';
import { fadeOutLoaderAfterLayout } from './util/theme-utils.js';
import { bind_voice_preview, stop_playback } from './util/read-aloud.js';
import { customConfirm, customAlert } from './util/confirm-dialog.js';
import { export_scratchpad } from './util/scratchpad-export.js';
import VersionOverlay from './util/version-overlay.js';
import InstantAnswer from './util/instant-answer.js';
import ChatHelpers from './util/chat-helpers.js';
import OptionsManager from './util/options-manager.js';
import ChatRenderer from './util/chat-renderer.js';
import Share from './util/share.js';
import ResponsePipeline from './util/response-pipeline.js';
import { mermaid } from './util/mermaid.js';

/**
 * Chat API response. Most fields are dynamic and may be absent depending on the provider and request.
 * @typedef {Object} ChatResponse
 * @property {string} [title] - Updated conversation title.
 * @property {string} [conversationTitle] - Updated conversation title (alternative field name).
 * @property {string} [summary] - Updated conversation summary.
 * @property {string} [conversationSummary] - Updated conversation summary (alternative field name).
 * @property {string} [proposed_query] - Follow-up query proposed by the model.
 * @property {Array} [topics] - Conversation topics.
 * @property {Array} [considerations] - Conversation considerations.
 * @property {string|string[]} [thinking] - Thought lines or newline-separated thoughts.
 * @property {boolean} [chain] - Whether the response is part of a chained query.
 * @property {string} [query] - The original user prompt query (assigned when storing the response).
 */

/**
 * Main application class for TinAI.
 * Manages UI state, layout, local storage synchronization, and coordinates
 * communication between the API service and conversation rendering.
 */
class App {

	SCROLL_DELAY = 30;
	BREAKPOINT_MOBILE = 780;
	BREAKPOINT_TABLET = 1560;

	#api = new Api();
	#conversation = new Conversation();
	#context;
	#memory = new Memory();
	#attachments;
	#document_index;
	#document = new Document();
	#choices = new ChoicesEditor();
	#sticky = new StickyNotesEditor();
	#diction = new Diction();
	#scratchpad_index;
	#tools_index;
	#scratchpad_conversation = new ScratchpadConversation();
	#scratchpad_drawer = new SideDrawer();
	#sync;
	#storage;
	#content_list;
	#conversation_index;
	prompt_drafts = {};
	active_conversation_guid = null;

	version_overlay = new VersionOverlay(this);
	instant_answer = new InstantAnswer(this);
	chat_helpers = new ChatHelpers(this);
	options_manager = new OptionsManager(this);
	chat_renderer = new ChatRenderer(this);
	share = new Share(this);
	response_pipeline = new ResponsePipeline(this);

	get api() {
		return this.#api;
	}

	get storage() {
		return this.#storage;
	}

	get conversation() {
		return this.#conversation;
	}

	get scratchpad_conversation() {
		return this.#scratchpad_conversation;
	}

	get context() {
		return this.#context;
	}

	get sticky() {
		return this.#sticky;
	}

	get memory() {
		return this.#memory;
	}

	get attachments() {
		return this.#attachments;
	}

	get document() {
		return this.#document;
	}

	get choices() {
		return this.#choices;
	}

	get diction() {
		return this.#diction;
	}

	get content_list() {
		return this.#content_list;
	}

	get conversation_index() {
		return this.#conversation_index;
	}

	get document_index() {
		return this.#document_index;
	}

	get scratchpad_index() {
		return this.#scratchpad_index;
	}

	get tools_index() {
		return this.#tools_index;
	}

	get scratchpad_drawer() {
		return this.#scratchpad_drawer;
	}

	get sync() {
		return this.#sync;
	}


	#applied_v_open = false;

	state_v_open = false;
	state_i_open = false;
	state_active_tab = 'conversation';
	index_button_label = '';

	//region Initialization

	/**
	 * Initializes the application state, mermaid diagrams, UI elements, and event listeners.
	 * @param {Storage} storage - Storage manager instance.
	 */
	constructor(storage) {
		this.#storage = storage;
		this.#sync = new Sync(this);
		const initial_config = this.#storage.get_app_config();
		const initial_guid = initial_config[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
		const isScratchpadRoot = (initial_guid === 'scratchpad');
		const isToolsRoot = (initial_guid === 'tools');
		const initialConv = initial_guid && !isScratchpadRoot && !isToolsRoot ? this.#storage.get_conversation(initial_guid) : null;
		const isScratchpadConv = initialConv?.[this.#storage.KEY_CONVERSATION_TYPE] === this.#storage.CONVERSATION_TYPE_SCRATCHPAD;
		const isScratchpad = isScratchpadRoot || isScratchpadConv;
		const isToolConv = initialConv?.[this.#storage.KEY_CONVERSATION_TYPE] === this.#storage.CONVERSATION_TYPE_RANKING
			|| initialConv?.[this.#storage.KEY_CONVERSATION_TYPE] === this.#storage.CONVERSATION_TYPE_COLORS
			|| initialConv?.[this.#storage.KEY_CONVERSATION_TYPE] === this.#storage.CONVERSATION_TYPE_STICKY_NOTES;
		const isDocumentConv = initialConv?.[this.#storage.KEY_CONVERSATION_TYPE] === this.#storage.CONVERSATION_TYPE_DOCUMENT;

		this.state_v_open = window.innerWidth > this.BREAKPOINT_MOBILE;
		this.state_i_open = isScratchpad || isToolsRoot || isToolConv || (isDocumentConv && initialConv?.[this.#storage.KEY_CONVERSATION_DOCUMENT_TYPE] !== this.#storage.DOCUMENT_TYPE_PAGE);
		if ((isScratchpad || isToolsRoot || isToolConv) && window.innerWidth <= this.BREAKPOINT_TABLET) {
			this.state_v_open = false;
		}

		this.initialize_mermaid();

		init_diagram_overlay();
		init_table_overlay();
		init_pinch_zoom(this);
		init_swipe_navigation(this);
		init_history_navigation(this);
		init_code_overlay({
			on_analyze: async (code, language, on_thinking) => {
				const model_key = this.get_selected_model_key();
				const selected_conversation = this.#storage.get_selected_conversation();
				const meta_context = {};
				if (selected_conversation && selected_conversation[this.#storage.KEY_CONVERSATION_SEARCH]) {
					meta_context.search = true;
				}
				return await this.api.analyze_code(code, language, model_key, meta_context, on_thinking);
			},
			load_analysis: (hash) => this.#storage.get_code_analysis(hash),
			save_analysis: (hash, data) => this.#storage.save_code_analysis(hash, data),
			single_column_breakpoint: this.BREAKPOINT_MOBILE
		});
		this.init_elements();
		this._populate_model_dropdowns();
		this.init_content_list();
		this.init_conversation_index();
		this.init_document_index();
		this.init_scratchpad_index();
		this.init_tools_index();
		this.init_context();
		this.init_attachments();
		this.init_listeners();
		this.#sync.request();
		this.init_interactions();
		this.on_app_config();
		// on_app_config already rendered the right index for a selected
		// scratchpad/tool/document conversation; only chat conversations live
		// in the content list
		const boot_type = initialConv?.[this.#storage.KEY_CONVERSATION_TYPE];
		if (!initial_guid || isScratchpadRoot || isToolsRoot || !boot_type || boot_type === this.#storage.CONVERSATION_TYPE_CHAT) {
			this.#content_list?.on_app_index_updated?.();
		}
		const layout_body = getEl('id-div-structure-body');
		const previous_layout_class = layout_body ? layout_body.className : null;
		this.handle_responsive_layout(window.innerWidth, window.innerWidth);
		this.apply_panels_layout();
		fadeOutLoaderAfterLayout(!!layout_body && previous_layout_class !== layout_body.className);
		this.validate_prompt();

		// final boot render: the selected conversation owns the index panel
		// and center view, whatever earlier boot steps have rendered
		this.on_conversation_updated();
		this.scroll_to_last_item();
		void this.version_overlay.init_version_check();
	}

	/**
	 * Hides the full version overlay modal.
	 * @see VersionOverlay.hide_version_overlay
	 */
	hide_version_overlay() {
		this.version_overlay.hide_version_overlay();
	}

	/**
	 * Initializes Mermaid diagram rendering with colors resolved from the current
	 * theme variables. Called on startup and again whenever the theme changes.
	 */
	initialize_mermaid() {
		if (typeof mermaid === 'undefined') return;
		try {
			const root_style = getComputedStyle(document.documentElement);
			const theme_color = (name) => root_style.getPropertyValue(name).trim();
			const shadow_color = theme_color('--FG-muted');
			mermaid.initialize({
				startOnLoad: false,
				securityLevel: 'loose',
				theme: 'base',
				themeVariables: {
					darkMode: true,
					fontFamily: "'Muli', 'Source Sans Pro', sans-serif",
					background: 'transparent',
					dropShadow: `drop-shadow(1px 2px 2px color-mix(in srgb, ${shadow_color} 35%, transparent))`,
					primaryColor: theme_color('--BG-panel2'),
					primaryTextColor: theme_color('--FG-base'),
					primaryBorderColor: theme_color('--AC-trim'),
					lineColor: theme_color('--AC-trim'),
					secondaryColor: theme_color('--BG-panel'),
					tertiaryColor: theme_color('--BG-base'),
					edgeLabelBackground: theme_color('--BG-panel2'),
					clusterBkg: theme_color('--BG-base'),
					clusterBorder: theme_color('--BD-panel-l'),
					defaultLinkColor: theme_color('--AC-trim'),
					titleColor: theme_color('--FG-base'),
					nodeBorder: theme_color('--AC-trim'),
					nodeTextColor: theme_color('--FG-base'),
					actorBkg: theme_color('--BG-panel2'),
					actorBorder: theme_color('--AC-trim'),
					actorTextColor: theme_color('--FG-base'),
					actorLineColor: theme_color('--BD-panel-l'),
					signalColor: theme_color('--AC-trim'),
					signalTextColor: theme_color('--FG-base'),
					labelBoxBkgColor: theme_color('--BG-panel2'),
					labelBoxBorderColor: theme_color('--BD-panel-l'),
					labelTextColor: theme_color('--FG-base'),
					loopTextColor: theme_color('--FG-base'),
					noteBkgColor: theme_color('--BG-base'),
					noteBorderColor: theme_color('--BD-panel-l'),
					noteTextColor: theme_color('--FG-base')
				},
				suppressErrorRendering: true,
				useMaxWidth: true
			});
		} catch (e) {
			console.error('Failed to initialize mermaid:', e);
		}
	}

	/**
	 * Maps DOM elements to class properties for structured access.
	 */
	/**
	 * Looks up all shared DOM elements (see util/elements.js) and assigns them
	 * as instance properties, then resets the chat view to its empty state.
	 */
	init_elements() {
		Object.assign(this, initElements());
		this.#scratchpad_drawer.bind(this.div_scratchpad_drawer, () => this.chat_renderer.get_scratchpad_drawer_top_offset());
		this.chat_renderer._reset_chat_view();
	}

	/**
	 * Populates the model dropdowns with options from Api.MODELS and Api.PRESETS.
	 * @private
	 */
	_populate_model_dropdowns() {
		populatePresetDropdown(this.select_default_model, this._visible_presets());
		populatePresetDropdown(this.select_conversation_model, this._visible_presets());
		populateFamilyDropdown(this.select_default_model_family, this._visible_families());
		populateFamilyDropdown(this.select_conversation_model_family, this._visible_families());
	}

	/**
	 * Returns model families visible in the UI.
	 * @returns {Object} Families data from models.json.
	 */
	_visible_families() {
		return { ...Api.MODELS };
	}

	/**
	 * Returns presets visible in the UI, filtering out those belonging to hidden experimental families.
	 * @returns {Object} Filtered presets from model-presets.json.
	 */
	_visible_presets() {
		const families = this._visible_families();
		const presets = {};
		for (const key in Api.PRESETS) {
			const provider = Api.PRESETS[key]?.provider;
			if (!Api.MODELS?.[provider] || families[provider]) {
				presets[key] = Api.PRESETS[key];
			}
		}
		return presets;
	}

	/**
	 * Returns utility models visible in the UI, filtering out those belonging to hidden experimental families.
	 * @returns {Object} Filtered utility models from utility-models.json.
	 */
	_visible_utility_models() {
		const families = this._visible_families();
		const utilityModels = {};
		for (const key in Api.UTILITY_MODELS) {
			const provider = Api.UTILITY_MODELS[key]?.provider;
			if (!Api.MODELS?.[provider] || families[provider]) {
				utilityModels[key] = Api.UTILITY_MODELS[key];
			}
		}
		return utilityModels;
	}

	/**
	 * Initializes the ContentList instance.
	 */
	init_content_list() {
		const app_callbacks = {
			update_app_config: this.#storage.update_app_config.bind(this.#storage),
			apply_panels_layout: this.apply_panels_layout.bind(this),
			render_conversation_header: this.chat_renderer.render_conversation_header.bind(this.chat_renderer),
			close_conversation: this.close_conversation.bind(this),
			set_v_open: this.set_v_open.bind(this),
			set_i_open: this.set_i_open.bind(this),
			on_conversation_updated: this.on_conversation_updated.bind(this),
			set_active_tab: this.set_active_tab.bind(this),
			is_request_active: (guid) => this.#api.is_request_active(guid),
			is_any_tool_loading: () => this._is_any_tool_loading(),
			is_scratchpad_active_loading: () => this._is_any_scratchpad_loading(),
			abort_request: (guid) => this.stop_response(guid),
			conversation_deleted: (guid, synced) => this.#on_conversation_deleted(guid, synced),
			count_shared_copies: (guids) => this.share.count_shared_copies(guids)
		};

		const elements = {
			div_list: this.div_list,
			div_title: this.div_title,
			btn_content_select: this.btn_content_select,
			btn_content_archive: this.btn_content_archive,
			btn_content_delete: this.btn_content_delete,
			btn_archives_toggle: this.btn_archives_toggle,
			btn_content: this.btn_content,
			btn_show_content: this.btn_show_content
		};

		const breakpoints = {
			BREAKPOINT_MOBILE: this.BREAKPOINT_MOBILE,
			BREAKPOINT_TABLET: this.BREAKPOINT_TABLET
		};

		this.#content_list = new ContentList(this.#storage, app_callbacks, elements, breakpoints);
	}

	/**
	 * Initializes the ConversationIndex instance.
	 */
	init_conversation_index() {
		const app_callbacks = {
			get_selected_conversation: this.get_selected_conversation.bind(this),
			set_i_open: this.set_i_open.bind(this),
			apply_panels_layout: this.apply_panels_layout.bind(this),
			on_conversation_updated_main_panel: this.on_conversation_updated.bind(this)
		};

		const elements = {
			div_index_list: this.div_index_list,
			btn_conversation_index_select: this.btn_conversation_index_select,
			btn_conversation_index_favorite: this.btn_conversation_index_favorite,
			btn_conversation_index_delete: this.btn_conversation_index_delete
		};

		const breakpoints = {
			BREAKPOINT_MOBILE: this.BREAKPOINT_MOBILE
		};

		this.#conversation_index = new ConversationIndex(this.#storage, app_callbacks, elements, breakpoints, this.SCROLL_DELAY);
	}

	/**
	 * Initializes the DocumentIndex instance.
	 */
	init_document_index() {
		this.#document_index = new DocumentIndex(this.#storage, {});
	}

	/**
	 * Initializes the ScratchpadIndex instance with callbacks and DOM elements.
	 */
	init_scratchpad_index() {
		const app_callbacks = {
			get_selected_conversation: this.get_selected_conversation.bind(this),
			set_i_open: this.set_i_open.bind(this),
			apply_panels_layout: this.apply_panels_layout.bind(this),
			on_conversation_updated_main_panel: this.on_conversation_updated.bind(this),
			close_conversation: this.close_conversation.bind(this),
			update_app_config: (key, val) => this.#storage.update_app_config(key, val),
			create_new_scratchpad: () => this.#content_list?.index_new?.(this.#storage.CONVERSATION_TYPE_SCRATCHPAD),
			is_request_active: (guid) => this.#api.is_request_active(guid),
			abort_request: (guid) => this.stop_response(guid),
			conversation_deleted: (guid, synced) => this.#on_conversation_deleted(guid, synced),
			count_shared_copies: (guids) => this.share.count_shared_copies(guids)
		};

		const elements = {
			div_index_list: this.div_index_list,
			btn_conversation_index_select: this.btn_conversation_index_select,
			btn_conversation_index_favorite: this.btn_conversation_index_favorite,
			btn_conversation_index_delete: this.btn_conversation_index_delete
		};

		const breakpoints = {
			BREAKPOINT_MOBILE: this.BREAKPOINT_MOBILE
		};

		this.#scratchpad_index = new ScratchpadIndex(this.#storage, app_callbacks, elements, breakpoints);
	}

	/**
	 * Initializes the ToolsIndex instance with callbacks and DOM elements.
	 */
	init_tools_index() {
		const app_callbacks = {
			create_new_ranking: () => this.#content_list?.index_new?.(this.#storage.CONVERSATION_TYPE_RANKING),
			create_new_palettes: () => this.#content_list?.index_new?.(this.#storage.CONVERSATION_TYPE_COLORS),
			create_new_sticky_notes: () => this.#content_list?.index_new?.(this.#storage.CONVERSATION_TYPE_STICKY_NOTES),
			set_i_open: this.set_i_open.bind(this),
			apply_panels_layout: this.apply_panels_layout.bind(this),
			update_app_config: (key, val) => this.#storage.update_app_config(key, val),
			on_conversation_updated_main_panel: this.on_conversation_updated.bind(this),
			is_request_active: (guid) => this.#api.is_request_active(guid),
			experimental_enabled: () => !!this.#storage.get_app_config()[this.#storage.KEY_CONFIG_EXPERIMENTAL_FEATURES],
			close_conversation: this.close_conversation.bind(this),
			abort_request: (guid) => this.stop_response(guid),
			conversation_deleted: (guid, synced) => this.#on_conversation_deleted(guid, synced),
			count_shared_copies: (guids) => this.share.count_shared_copies(guids)
		};

		const elements = {
			div_index_list: this.div_index_list,
			btn_conversation_index_select: this.btn_conversation_index_select,
			btn_conversation_index_favorite: this.btn_conversation_index_favorite,
			btn_conversation_index_delete: this.btn_conversation_index_delete
		};

		const breakpoints = {
			BREAKPOINT_MOBILE: this.BREAKPOINT_MOBILE
		};

		this.#tools_index = new ToolsIndex(this.#storage, app_callbacks, elements, breakpoints);
	}

	/**
	 * Initializes the Context instance.
	 */
	init_context() {
		const app_callbacks = {
			renderContext: this.chat_renderer.render_context_tab.bind(this.chat_renderer)
		};
		this.#context = new Context(this.#storage, app_callbacks);
	}

	/**
	 * Initializes the Attachments instance.
	 */
	init_attachments() {
		const app_callbacks = {
			renderAttachments: this.chat_renderer.render_attachments_tab.bind(this.chat_renderer)
		};
		this.#attachments = new Attachments(this.#storage, app_callbacks);
	}

	/**
	 * Returns true when the verbosity picker is open or was just closed, so a
	 * click on a cost table row is a stray dismiss click and must be ignored.
	 * @param {HTMLSelectElement|null} select - The verbosity select paired with the table.
	 * @returns {boolean}
	 */
	verbosity_click_blocked(select) {
		if (!select) return false;
		if (select.dataset.picker_open) return true;
		return performance.now() - Number(select.dataset.picker_closed_at || -1e12) < 300;
	}

	/**
	 * Sets up event listeners for window storage changes, window resizing, and scroll events.
	 */
	init_listeners() {
		window.addEventListener('storage', (event) => {
			this.handle_storage_change(event);
		});

		window.addEventListener('tinai:request-start', (event) => {
			const guid = event.detail?.guid;
			this.update_conversation_loading_indicators(guid, true);
		});

		window.addEventListener('tinai:request-stop', (event) => {
			const guid = event.detail?.guid;
			this.update_conversation_loading_indicators(guid, false);
			const selected_guid = this.#storage.get_app_config()[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID] || '';
			if (!guid || guid === selected_guid) {
				if (!this.#api.is_request_active(selected_guid)) {
					this.hide_progress_ui();
				}
			}
		});

		window.addEventListener('tinai:thinking', (event) => {
			const text = event.detail?.text;
			const guid = event.detail?.guid;
			if (text !== undefined && text !== null) {
				this.send_thinking(text, guid);
			}
		});

		window.addEventListener('tinai:tokens', (event) => {
			this.response_pipeline.update_token_display(event.detail?.guid);
		});

		let lastWidth = window.innerWidth;
		const debouncedResize = debounce(() => {
			const currentWidth = window.innerWidth;
			this.handle_responsive_layout(lastWidth, currentWidth);
			this.resize_prompt_textarea();
			this.instant_answer.resize_instant_answer_textarea();
			lastWidth = currentWidth;
			this.apply_panels_layout();
		}, 100);

		window.addEventListener('resize', debouncedResize);

		const scrollContainer = queryEl('.div-chat-container-scroll');
		if (scrollContainer) {
			// SimpleBar initializes after construction, so listen on the host in
			// capture phase and keep only scrolls of its own scroller
			scrollContainer.addEventListener('scroll', (e) => {
				if (e.target !== scroller_of(scrollContainer)) return;
				if (this.#conversation_index) {
					this.#conversation_index.highlight_active_index_item();
				}
			}, true);
		}
	}

	/**
	 * Binds user interaction events such as clicks and keyboard inputs to their respective handlers.
	 */
	init_interactions() {
		addSafeEventListener(this.btn_send, 'click', this.send.bind(this));
		addSafeEventListener(this.btn_rank, 'click', () => this.response_pipeline.send_rank_action());

		if (this.prompt) {
			this.prompt.oninput = () => {
				this.btn_rank?.classList.remove('btn-action-highlight');
				this.btn_send?.classList.remove('btn-action-highlight');
				const config = this.#storage.get_app_config();
				const current_guid = config[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID] || '';
				this.prompt_drafts[current_guid] = this.prompt.value;
				this.validate_prompt();
				this.resize_prompt_textarea();
			};
			this.prompt.onkeydown = this.handle_keydown.bind(this);
		}

		addSafeEventListener(this.select_theme_family, 'change', (e) => this.options_manager._on_theme_family_change(e));
		addSafeEventListener(this.select_theme, 'change', (e) => this.update_app_options_setting(e));
		addSafeEventListener(this.select_utility_model, 'change', (e) => {
			this.update_app_options_setting(e);
			this.options_manager.update_utility_model_cost_display();
		});
		addSafeEventListener(this.select_tts_model, 'change', (e) => {
			this.update_app_options_setting(e);
			this.options_manager.update_tts_cost_display();
		});
		addSafeEventListener(this.select_tts_voice, 'change', (e) => this.update_app_options_setting(e));
		bind_voice_preview(this.btn_tts_preview, () => ({
			tts_model: this.select_tts_model?.value || '',
			voice: this.select_tts_voice?.value || '',
			voice_name: this.select_tts_voice?.options[this.select_tts_voice.selectedIndex]?.text || ''
		}));
		addSafeEventListener(this.btn_tts_clear_cache, 'click', async () => {
			const confirmed = await customConfirm(
				'Clear Voice Cache',
				'Delete all cached read-aloud audio from this device? Cached speech is not re-billed; it will be synthesized and charged again when read back.',
				'Clear',
				'Cancel'
			);
			if (!confirmed) return;
			stop_playback();
			try {
				await this.#storage.clear_cached_audio();
			} catch (err) {
				// the cache store may not exist yet
			}
		});
		addSafeEventListener(this.select_default_verbosity, 'change', (e) => this._update_setting('app', e));
		addSafeEventListener(this.select_conversation_verbosity, 'change', (e) => this._update_setting('conversation', e));
		addSafeEventListener(this.select_default_model, 'change', (e) => this._on_preset_change('app', e));
		addSafeEventListener(this.select_conversation_model, 'change', (e) => this._on_preset_change('conversation', e));
		addSafeEventListener(this.select_default_model_family, 'change', (e) => this._on_family_change('app', e));
		addSafeEventListener(this.select_conversation_model_family, 'change', (e) => this._on_family_change('conversation', e));
		addSafeEventListener(this.select_default_model_version, 'change', (e) => this._on_version_change('app', e));
		addSafeEventListener(this.select_conversation_model_version, 'change', (e) => this._on_version_change('conversation', e));
		addSafeEventListener(this.select_default_thinking_level, 'change', (e) => this._on_thinking_level_change('app', e));
		addSafeEventListener(this.select_conversation_model_level, 'change', (e) => this._on_thinking_level_change('conversation', e));

		addSafeEventListener(this.checkbox_experimental_features, 'change', (e) => this.options_manager.update_experimental_features_setting(e));
		addSafeEventListener(this.input_max_tool_use, 'change', (e) => this.options_manager.update_max_tool_use_setting(e));
		addSafeEventListener(this.checkbox_allow_tool_requests, 'change', (e) => this.options_manager.update_allow_tool_requests_setting(e));
		addSafeEventListener(this.input_ui_zoom, 'input', (e) => this.options_manager.update_ui_zoom_setting(e));
		addSafeEventListener(this.checkbox_default_show_suggestions, 'change', (e) => this._update_setting('app', e));
		addSafeEventListener(this.checkbox_default_show_related, 'change', (e) => this._update_setting('app', e));
		addSafeEventListener(this.checkbox_default_enforce_topics, 'change', (e) => this._update_setting('app', e));
		addSafeEventListener(this.checkbox_default_auto_send_prompts, 'change', (e) => this._update_setting('app', e));
		addSafeEventListener(this.checkbox_default_play_chime, 'change', (e) => this._update_setting('app', e));
		addSafeEventListener(this.checkbox_default_web_search, 'change', (e) => this._update_setting('app', e));
		addSafeEventListener(this.checkbox_default_synced, 'change', (e) => this._update_setting('app', e));
		addSafeEventListener(this.checkbox_default_disable_safeguards, 'change', (e) => this._update_setting('app', e));
		addSafeEventListener(this.checkbox_default_encourage_creativity, 'change', (e) => this._update_setting('app', e));

		addSafeEventListener(this.checkbox_conversation_show_suggestions, 'change', (e) => this._update_setting('conversation', e));
		addSafeEventListener(this.checkbox_conversation_show_related, 'change', (e) => this._update_setting('conversation', e));
		addSafeEventListener(this.checkbox_conversation_show_related, 'change', (e) => this._update_setting('conversation', e));
		addSafeEventListener(this.checkbox_conversation_enforce_topics, 'change', (e) => this._update_setting('conversation', e));
		addSafeEventListener(this.checkbox_conversation_auto_send_prompts, 'change', (e) => this._update_setting('conversation', e));
		addSafeEventListener(this.checkbox_conversation_disable_safeguards, 'change', (e) => this._update_setting('conversation', e));
		addSafeEventListener(this.checkbox_conversation_encourage_creativity, 'change', (e) => this._update_setting('conversation', e));
		addSafeEventListener(this.checkbox_conversation_google_search, 'change', (e) => this._update_setting('conversation', e));
		addSafeEventListener(this.checkbox_sync_switch, 'change', (e) => this._update_sync_setting(e));

		for (const select of [this.select_default_verbosity, this.select_conversation_verbosity]) {
			addSafeEventListener(select, 'beforetoggle', (e) => {
				if (e.newState === 'open') {
					e.target.dataset.picker_open = '1';
				} else {
					delete e.target.dataset.picker_open;
					e.target.dataset.picker_closed_at = String(performance.now());
				}
			});
		}

		if (this.div_default_model_costs) {
			addSafeEventListener(this.div_default_model_costs, 'click', (e) => {
				const tr = e.target.closest('tr[data-verbosity]');
				if (tr && this.select_default_verbosity && !this.verbosity_click_blocked(this.select_default_verbosity)) {
					const verbosity = tr.getAttribute('data-verbosity');
					if (verbosity && this.select_default_verbosity.value !== verbosity) {
						this.select_default_verbosity.value = verbosity;
						this.select_default_verbosity.dispatchEvent(new Event('change'));
					}
				}
			});
		}

		if (this.div_conversation_model_costs) {
			addSafeEventListener(this.div_conversation_model_costs, 'click', (e) => {
				const tr = e.target.closest('tr[data-verbosity]');
				if (tr && this.select_conversation_verbosity && !this.verbosity_click_blocked(this.select_conversation_verbosity)) {
					const verbosity = tr.getAttribute('data-verbosity');
					if (verbosity && this.select_conversation_verbosity.value !== verbosity) {
						this.select_conversation_verbosity.value = verbosity;
						this.select_conversation_verbosity.dispatchEvent(new Event('change'));
					}
				}
			});
		}

		addSafeEventListener(this.btn_conversation_new, 'click', () => {
			if (this.is_scratchpad_active()) {
				this.#content_list?.index_new?.(this.#storage.CONVERSATION_TYPE_SCRATCHPAD);
			} else if (this.is_document_active()) {
				this.#content_list?.index_new?.(this.#storage.CONVERSATION_TYPE_DOCUMENT, this.get_active_document_type());
			} else if (this.is_tool_conversation_active()) {
				this.#content_list?.index_new?.(this.get_selected_conversation()?.[this.#storage.KEY_CONVERSATION_TYPE]);
			} else {
				this.#content_list?.index_new?.(this.#storage.CONVERSATION_TYPE_CHAT);
			}
		});

		watch_compact_select_label(this.select_scratchpad_export, 'Export', 'XPT.');
		addSafeEventListener(this.select_scratchpad_export, 'change', async () => {			const select = this.select_scratchpad_export;
			const format = select.value;
			select.value = '';
			if (!format) return;
			const storage = this.#storage;
			const guid = storage.get_app_config()[storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
			const conversation = guid ? storage.get_conversation(guid) : null;
			if (!conversation || conversation[storage.KEY_CONVERSATION_TYPE] !== storage.CONVERSATION_TYPE_SCRATCHPAD) return;
			const history = conversation[storage.KEY_CONVERSATION_HISTORY] || [];
			const index = history.length - 1;
			const revision = history[index];
			const responseDiv = document.querySelector(`.div-response-content[data-index="${index}"]`);
			if (!revision || !responseDiv) return;
			const items = Array.isArray(revision.items) ? revision.items : [];
			const title = String(revision.conversationTitle || conversation[storage.KEY_CONVERSATION_TITLE] || '').trim();
			try {
				await export_scratchpad(items, responseDiv, title, format);
			} catch (err) {
				customAlert('Export Failed', String(err?.message || err));
			}
		});

		addSafeEventListener(this.btn_content_new, 'click', this.close_conversation.bind(this));
		addSafeEventListener(this.btn_empty_new_chat, 'click', () => this.#content_list?.index_new?.(this.#storage.CONVERSATION_TYPE_CHAT));
		addSafeEventListener(this.select_new_document, 'change', () => {
			const document_type = this.select_new_document.value;
			if (!document_type) return;
			this.select_new_document.value = '';
			this.#content_list?.index_new?.(this.#storage.CONVERSATION_TYPE_DOCUMENT, document_type);
		});
		addSafeEventListener(this.btn_empty_new_scratchpad, 'click', () => this.#content_list?.index_new?.(this.#storage.CONVERSATION_TYPE_SCRATCHPAD));

		addSafeEventListener(this.btn_show_content, 'click', this.toggle_content_panel.bind(this));
		addSafeEventListener(this.btn_show_scratchpads_new, 'click', () => {
			if (window.innerWidth <= this.BREAKPOINT_TABLET) {
				this.state_v_open = false;
				this.state_i_open = true;
			} else {
				this.state_i_open = true;
			}
			this.apply_panels_layout();
			this.#storage.update_app_config(this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID, 'scratchpad');
			this.on_conversation_updated();
		});
		addSafeEventListener(this.btn_content, 'click', this.toggle_content_panel.bind(this));
		addSafeEventListener(this.btn_conversation_index, 'click', this.toggle_conversation_index.bind(this));
		addSafeEventListener(this.btn_close, 'click', this.close_conversation.bind(this));

		addSafeEventListener(this.btn_content_select, 'click', () => this.#content_list?.toggle_content_selection_mode?.());
		addSafeEventListener(this.btn_content_archive, 'click', () => this.#content_list?.archive_selected_content?.());
		addSafeEventListener(this.btn_archives_toggle, 'click', () => this.#content_list?.toggle_archives?.());
		addSafeEventListener(this.btn_content_delete, 'click', () => this.#content_list?.delete_selected_content?.());

		// the center index toolbar serves three lists rendered into the same
		// container: the scratchpad responses, the tools index (the 'tools'
		// root or an active tool conversation), and the active conversation's
		// response index. The tools root must be checked explicitly — with it
		// selected there is no conversation to infer the list from, and the
		// fallback would re-render the response index over the tools list.
		const center_index_target = () => {
			if (this.is_scratchpad_active()) return this.#scratchpad_index;
			if (this.is_tools_active() || this.is_tool_conversation_active()) return this.#tools_index;
			return this.#conversation_index;
		};

		addSafeEventListener(this.btn_conversation_index_select, 'click', () => center_index_target().toggle_response_selection_mode());

		addSafeEventListener(this.btn_conversation_index_favorite, 'click', () => center_index_target().bookmark_selected_responses());

		addSafeEventListener(this.btn_conversation_index_delete, 'click', () => center_index_target().delete_selected_responses());

		addSafeEventListener(this.btn_instant_answer, 'click', this.instant_answer.show_instant_answer.bind(this.instant_answer));
		addSafeEventListener(this.btn_share, 'click', () => this.share.open_share_dialog());
		addSafeEventListener(this.btn_instant_answer_close, 'click', this.hide_instant_answer.bind(this));
		addSafeEventListener(this.btn_instant_answer_send, 'click', this.instant_answer.send_instant_answer.bind(this.instant_answer));
		addSafeEventListener(this.select_instant_answer_model, 'change', () => {
			this.#storage.update_app_config(this.#storage.KEY_CONFIG_INSTANT_ANSWER_MODEL, this.select_instant_answer_model.value);
			this.instant_answer.update_instant_answer_search_button();
			this.instant_answer.update_instant_answer_model_cost_display();
		});
		addSafeEventListener(this.btn_instant_answer_search, 'click', () => this.instant_answer.toggle_instant_answer_search());
		addSafeEventListener(this.btn_instant_answer_auto_read, 'click', () => this.instant_answer.handle_instant_answer_auto_read_click());
		addSafeEventListener(this.btn_instant_answer_play, 'click', () => this.instant_answer.toggle_answer_playback());
		addSafeEventListener(this.btn_instant_answer_clear, 'click', () => this.instant_answer.clear_answer());
		addSafeEventListener(this.instant_answer_query, 'keydown', this.instant_answer.handle_instant_answer_keydown.bind(this.instant_answer));
		addSafeEventListener(this.instant_answer_query, 'input', () => this.instant_answer.resize_instant_answer_textarea());
		addSafeEventListener(this.div_instant_answer_overlay, 'click', (e) => {
			if (e.target === this.div_instant_answer_overlay) {
				this.hide_instant_answer();
			}
		});
		addSafeEventListener(this.btn_app_options, 'click', this.options_manager.show_app_options.bind(this.options_manager));
		this.options_manager.init_settings_tabs();
		addSafeEventListener(this.btn_conversation_options, 'click', this.show_conversation_options.bind(this));
		watch_compact_button_label(this.btn_conversation_options, 'Options', 'Opts.');
		addSafeEventListener(this.btn_options_close, 'click', this.hide_app_options.bind(this));
		addSafeEventListener(this.btn_conversation_options_close, 'click', this.hide_conversation_options.bind(this));
		addSafeEventListener(this.div_app_options_overlay, 'click', (e) => {
			if (e.target === this.div_app_options_overlay) {
				this.hide_app_options();
			}
		});
		addSafeEventListener(this.div_conversation_options_overlay, 'click', (e) => {
			if (e.target === this.div_conversation_options_overlay) {
				this.hide_conversation_options();
			}
		});
		addSafeEventListener(this.btn_show_changelog, 'click', () => this.version_overlay.show_version_overlay());
		addSafeEventListener(this.btn_version_close, 'click', () => this.hide_version_overlay());
		addSafeEventListener(this.div_version_overlay, 'click', (e) => {
			if (e.target === this.div_version_overlay) {
				this.hide_version_overlay();
			}
		});
		addSafeEventListener(this.div_dialog_backdrop, 'click', this.handle_backdrop_click.bind(this));

		window.addEventListener('keydown', (e) => {
			if (e.key === 'Escape') {
				this.dismiss_topmost_overlay();
			}
		});

		addSafeEventListener(this.btn_tab_conversation, 'click', () => this.set_active_tab('conversation'));
		addSafeEventListener(this.btn_tab_rankings, 'click', () => this.set_active_tab('rankings'));
		addSafeEventListener(this.btn_title_edit, 'click', () => this.chat_renderer.edit_conversation_title());
		addSafeEventListener(this.btn_tab_tags, 'click', () => this.set_active_tab('tags'));
		addSafeEventListener(this.btn_tab_context, 'click', () => this.set_active_tab('context'));
		addSafeEventListener(this.btn_tab_attachments, 'click', () => this.set_active_tab('attachments'));
		addSafeEventListener(this.btn_tab_memory, 'click', () => this.set_active_tab('memory'));
		addSafeEventListener(this.btn_tab_document, 'click', () => this.set_active_tab('document'));
		addSafeEventListener(this.btn_tab_diction, 'click', () => this.set_active_tab('diction'));

		addSafeEventListener(this.span_option_model, 'click', this.show_conversation_options.bind(this));
		addSafeEventListener(this.span_option_verbosity, 'click', this.show_conversation_options.bind(this));
		addSafeEventListener(this.span_option_suggested, 'click', () => this._toggle_conversation_option(this.#storage.KEY_CONVERSATION_SHOW_SUGGESTED_QUERIES));
		addSafeEventListener(this.span_option_related, 'click', () => this._toggle_conversation_option(this.#storage.KEY_CONVERSATION_SHOW_RELATED_QUERIES));
		addSafeEventListener(this.span_option_search, 'click', () => this._toggle_conversation_option(this.#storage.KEY_CONVERSATION_SEARCH));
		addSafeEventListener(this.span_option_enforce_topics, 'click', () => this._toggle_conversation_option(this.#storage.KEY_CONVERSATION_ENFORCE_TOPICS));
		addSafeEventListener(this.span_option_auto_run, 'click', () => this._toggle_conversation_option(this.#storage.KEY_CONVERSATION_AUTO_RUN_PROPOSED_QUERIES));
	}

	/**
	 * Handles keydown events in the prompt textarea.
	 * @param {KeyboardEvent} e - The keyboard event object.
	 */
	handle_keydown(e) {
		if (e.ctrlKey && e.key === 'Enter') {
			e.preventDefault();
			if (!this.btn_send || !this.btn_send.disabled) {
				this.send();
			}
		}
	}

	//endregion

	//region Configuration & Local Storage

	/**
	 * Responds to localStorage changes, ensuring application state remains synced across different browser tabs.
	 * @param {StorageEvent} event - The storage event object containing change details.
	 */
	handle_storage_change(event) {
		const config = this.#storage.get_app_config();
		const selected_guid = config[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];

		if (event.key === this.#storage.KEY_APP_CONFIG) {
			this.on_app_config();
		}
		if (event.key === this.#storage.KEY_APP_INDEX) {
			this.#content_list?.on_app_index_updated?.();
			if (this.is_scratchpad_active()) {
				this.#scratchpad_index?.on_conversation_index_updated?.();
			}
		}
		if (event.key === this.#storage.KEY_CONFIG_DEFAULTS) {
			this.options_manager.apply_app_defaults();
		}
		if (event.key === this.#storage.KEY_APP_CONVERSATION_PREFIX + selected_guid) {
			if (!selected_guid || !this.#api.is_request_active(selected_guid)) {
				this.on_conversation_updated();
			}
		}
	}

	/**
	 * Updates the UI components when the global application configuration changes.
	 * @see OptionsManager.on_app_config
	 */
	on_app_config() {
		this.options_manager.on_app_config();
	}

	/**
	 * Saves the currently selected theme or utility model to the application configuration.
	 * @param {Event} e - The change event.
	 * @see OptionsManager.update_app_options_setting
	 */
	update_app_options_setting(e) {
		this.options_manager.update_app_options_setting(e);
	}

	/**
	 * Hides the application options form overlay.
	 * @see OptionsManager.hide_app_options
	 */
	hide_app_options() {
		this.options_manager.hide_app_options();
	}

	/**
	 * Shows the conversation options form overlay.
	 * @see OptionsManager.show_conversation_options
	 */
	show_conversation_options() {
		this.options_manager.show_conversation_options();
	}

	/**
	 * Hides the conversation options form overlay.
	 * @see OptionsManager.hide_conversation_options
	 */
	hide_conversation_options() {
		this.options_manager.hide_conversation_options();
	}

	/**
	 * Handles change events on the model presets dropdown with confirmation prompt.
	 * @param {('app'|'conversation')} type - The scope of settings.
	 * @param {Event} e - The change event.
	 * @see OptionsManager._on_preset_change
	 */
	_on_preset_change(type, e) {
		void this.options_manager._on_preset_change(type, e);
	}

	/**
	 * Handles change events on the model family dropdown.
	 * @param {('app'|'conversation')} type - The scope of settings.
	 * @param {Event} e - The change event.
	 * @see OptionsManager._on_family_change
	 */
	_on_family_change(type, e) {
		this.options_manager._on_family_change(type, e);
	}

	/**
	 * Handles change events on the model version dropdown.
	 * @param {('app'|'conversation')} type - The scope of settings.
	 * @param {Event} e - The change event.
	 * @see OptionsManager._on_version_change
	 */
	_on_version_change(type, e) {
		this.options_manager._on_version_change(type, e);
	}

	/**
	 * Handles change events on the thinking level dropdown.
	 * @param {('app'|'conversation')} type - The scope of settings.
	 * @param {Event} e - The change event.
	 * @see OptionsManager._on_thinking_level_change
	 */
	_on_thinking_level_change(type, e) {
		this.options_manager._on_thinking_level_change(type, e);
	}

	/**
	 * Updates a specific configuration setting from user interaction with a form element.
	 * @param {('app'|'conversation')} type - The scope of the setting.
	 * @param {Event} e - The input or change event.
	 * @see OptionsManager._update_setting
	 */
	_update_setting(type, e) {
		this.options_manager._update_setting(type, e);
	}

	/**
	 * Toggles server sync for the active conversation from the options switch.
	 * @param {Event} e - The change event.
	 * @see OptionsManager._update_sync_setting
	 */
	_update_sync_setting(e) {
		void this.options_manager._update_sync_setting(e);
	}

	/**
	 * Toggles a boolean configuration option for the currently active conversation.
	 * @param {string} key - The conversation option key to toggle.
	 * @see OptionsManager._toggle_conversation_option
	 */
	_toggle_conversation_option(key) {
		this.options_manager._toggle_conversation_option(key);
	}

	/**
	 * Plays a subtle sound alert upon receiving a completion response.
	 * @see ChatHelpers.play_alert_sound
	 */
	play_alert_sound() {
		this.chat_helpers.play_alert_sound();
	}

	//endregion

	//region Layout & Responsive

	/**
	 * Adjusts the prompt placeholder text and panel visibility states based on window resize events.
	 * @param {number} lastWidth - The previous window width.
	 * @param {number} currentWidth - The new current window width.
	 */
	handle_responsive_layout(lastWidth, currentWidth) {
		if (this.prompt) {
			if (currentWidth <= this.BREAKPOINT_MOBILE) {
				this.prompt.placeholder = 'Ctrl + \u27a5 to Send';
			} else {
				this.prompt.placeholder = 'Ctrl + Enter to Send';
			}
		}

		const config = this.#storage.get_app_config();
		const isSelected = !!config[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];

		// Transitioning from tablet/desktop to mobile
		if (lastWidth > this.BREAKPOINT_MOBILE && currentWidth <= this.BREAKPOINT_MOBILE && isSelected) {
			this.state_v_open = false;
			this.state_i_open = false;
		}

		// Transitioning from mobile to tablet/desktop
		if (lastWidth <= this.BREAKPOINT_MOBILE && currentWidth > this.BREAKPOINT_MOBILE) {
			this.state_v_open = true;
			this.state_i_open = false;
		}

		// Transitioning from desktop to tablet (ensure only one panel open)
		if (lastWidth > this.BREAKPOINT_TABLET && currentWidth <= this.BREAKPOINT_TABLET) {
			if (this.state_v_open && this.state_i_open) {
				this.state_i_open = false;
			}
		}
	}

	/**
	 * Sets the open state of the left conversations panel.
	 * @param {boolean} value - Whether the panel is open.
	 */
	set_v_open(value) {
		this.state_v_open = value;
	}

	/**
	 * Sets the open state of the center conversation index panel.
	 * @param {boolean} value - Whether the panel is open.
	 */
	set_i_open(value) {
		this.state_i_open = value;
	}

	/**
	 * Toggles the visibility of the left-side content panel.
	 */
	toggle_content_panel() {
		if (window.innerWidth <= this.BREAKPOINT_TABLET) {
			this.state_i_open = false;
		}
		this.state_v_open = !this.state_v_open;
		if (this.state_v_open && window.innerWidth <= this.BREAKPOINT_TABLET) {
			this.state_i_open = false;
		}
		this.apply_panels_layout();
	}

	/**
	 * Toggles the visibility of the center conversation index (response list) panel.
	 */
	toggle_conversation_index() {
		if (window.innerWidth <= this.BREAKPOINT_TABLET) {
			this.state_v_open = false;
		}
		this.state_i_open = !this.state_i_open;
		if (this.state_i_open && window.innerWidth <= this.BREAKPOINT_TABLET) {
			this.state_v_open = false;
		}
		this.apply_panels_layout();
	}

	/**
	 * Updates the main layout container's CSS classes to reflect the current open/closed states of side panels,
	 * and requests a debounced sync when the content panel becomes visible.
	 */
	apply_panels_layout() {
		const body = getEl('id-div-structure-body');
		if (!body) return;

		const was_v_open = this.#applied_v_open;
		this.#applied_v_open = this.state_v_open;

		body.classList.remove('div-structure-body-v-i-c', 'div-structure-body-v-c', 'div-structure-body-i-c', 'div-structure-body-c');

		if (this.state_v_open && this.state_i_open) {
			body.classList.add('div-structure-body-v-i-c');
		} else if (this.state_v_open && !this.state_i_open) {
			body.classList.add('div-structure-body-v-c');
		} else if (!this.state_v_open && this.state_i_open) {
			body.classList.add('div-structure-body-i-c');
		} else {
			body.classList.add('div-structure-body-c');
		}

		if (this.btn_content) {
			toggleElementClass(this.btn_content, 'underlined-button', !!this.state_v_open);
		}
		if (this.btn_conversation_index) {
			toggleElementClass(this.btn_conversation_index, 'underlined-button', !!this.state_i_open);
		}

		this.update_nav_button_states();

		if (this.state_v_open && !was_v_open) {
			this.#sync.request();
		}
	}

	/**
	 * Refreshes the top-bar navigation buttons: the Content button shows the
	 * 📁/📂 state of the content column, and the index button carries a ❌
	 * and a Close tooltip while the index column is open. The index button is
	 * hidden entirely when there is no index to show (welcome screen, empty
	 * conversation, page documents).
	 */
	update_nav_button_states() {
		if (this.btn_content) {
			const v_open = !!this.state_v_open;
			this.btn_content.textContent = (v_open ? '📂' : '📁') + ' Content';
			this.btn_content.title = v_open ? 'Close Content' : 'Open Content';
		}
			if (this.btn_conversation_index) {
				const label = this.index_button_label;
				if (!label) {
					this.btn_conversation_index.style.display = 'none';
					this.btn_conversation_index.title = '';
				} else {
					const i_open = !!this.state_i_open;
					this.btn_conversation_index.style.display = '';
					this.btn_conversation_index.innerHTML = i_open ? label + ' <span class="span-nav-close">❌</span>' : label;
					this.btn_conversation_index.title = (i_open ? 'Close ' : 'Open ') + label;
				}
			}
	}

	//endregion

	//region Conversation / Chat

	/**
	 * Handles clicks on the shared backdrop to dismiss the topmost open overlay.
	 * @param {Event} e - The click event.
	 */
	handle_backdrop_click(e) {
		if (e.target !== this.div_dialog_backdrop) return;
		this.dismiss_topmost_overlay();
	}

	/**
	 * Dismisses the topmost active overlay (diagram, code, table, notes,
	 * version, instant answer, or options). Dialogs built by openDialog are
	 * intentionally left to their own handlers.
	 * @returns {boolean} True if an overlay was dismissed.
	 */
	dismiss_topmost_overlay() {
		if (document.querySelector('.div-dialog-overlay.active')) {
			return false;
		}
		if (is_diagram_overlay_open()) {
			close_diagram_overlay();
		} else if (is_code_overlay_open()) {
			close_code_overlay();
		} else if (is_table_overlay_open()) {
			close_table_overlay();
		} else if (getEl('id-notes-overlay')?.classList.contains('active')) {
			this.#sticky.close_overlay();
		} else if (this.div_version_overlay && this.div_version_overlay.classList.contains('active')) {
			this.hide_version_overlay();
		} else if (this.div_instant_answer_overlay && this.div_instant_answer_overlay.classList.contains('active')) {
			this.hide_instant_answer();
		} else if (this.div_conversation_options_overlay && this.div_conversation_options_overlay.classList.contains('active')) {
			this.hide_conversation_options();
		} else if (this.div_app_options_overlay && this.div_app_options_overlay.classList.contains('active')) {
			this.hide_app_options();
		} else {
			return false;
		}
		return true;
	}

	/**
	 * Sets the active tab and updates the UI practicalities.
	 * @param {string} tabName - The name of the tab to activate.
	 * @see ChatRenderer.set_active_tab
	 */
	set_active_tab(tabName) {
		this.chat_renderer.set_active_tab(tabName);
	}

	/**
	 * Renders Mermaid diagrams in the specified container element.
	 * @param {HTMLElement} container
	 * @see ChatRenderer._render_mermaid_in_container
	 */
	_render_mermaid_in_container(container) {
		return this.chat_renderer._render_mermaid_in_container(container);
	}

	/**
	 * Re-renders already rendered Mermaid diagrams so they pick up the current theme.
	 * @see ChatRenderer.rerender_mermaid_theme
	 */
	rerender_mermaid_diagrams() {
		void this.chat_renderer.rerender_mermaid_theme(this.div_response);
		void this.chat_renderer.rerender_mermaid_theme(this.div_instant_answer_response);
	}

	/**
	 * Updates full conversation UI whenever active conversation data is modified.
	 * @param {boolean} [scroll_to_last=true] - Whether to scroll to bottom.
	 * @see ChatRenderer.on_conversation_updated
	 */
	on_conversation_updated(scroll_to_last = true) {
		this.chat_renderer.on_conversation_updated(scroll_to_last);
	}

	/**
	 * Notifies the server-backed services that a conversation was deleted:
	 * the sync service removes the synced copy and the share service removes
	 * the shared copy, so the public link stops working.
	 * @param {string} guid - The deleted conversation guid.
	 * @param {boolean} synced - Whether the conversation was synced.
	 */
	#on_conversation_deleted(guid, synced) {
		this.#sync.conversation_deleted(guid, synced);
		this.share.conversation_deleted(guid);
	}

	/**
	 * Deselects the current conversation, closes associated panels, and resets the interface.
	 */
	close_conversation() {
		this.state_i_open = false;
		this.state_v_open = window.innerWidth > this.BREAKPOINT_MOBILE;
		this.#storage.update_app_config(this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID, '');
		this.apply_panels_layout();
		this.on_conversation_updated();
	}

	/**
	 * Scrolls the chat container to the most recent response in the history.
	 * @see ChatHelpers.scroll_to_last_item
	 */
	scroll_to_last_item() {
		this.chat_helpers.scroll_to_last_item();
	}

	/**
	 * Validates the state of the conversation index button.
	 * @see ChatHelpers.validate_conversation_index_button
	 */
	validate_conversation_index_button() {
		this.chat_helpers.validate_conversation_index_button();
	}

	/**
	 * Returns the number of responses in the currently selected conversation.
	 * @returns {number} The count of history items.
	 * @see ChatHelpers.get_conversation_item_count
	 */
	get_conversation_item_count() {
		return this.chat_helpers.get_conversation_item_count();
	}

	/**
	 * Checks whether the prompt textarea contains valid text.
	 * @returns {boolean} True if prompt is non-empty and conversation is idle.
	 * @see ChatHelpers.validate_prompt
	 */
	validate_prompt() {
		return this.chat_helpers.validate_prompt();
	}

	/**
	 * Adjusts the height of the prompt textarea to match its content.
	 * @see ChatHelpers.resize_prompt_textarea
	 */
	resize_prompt_textarea() {
		this.chat_helpers.resize_prompt_textarea();
	}

	/**
	 * Checks if any scratchpad conversation currently has an active API request running.
	 * @returns {boolean}
	 * @see ChatHelpers._is_any_scratchpad_loading
	 */
	_is_any_scratchpad_loading() {
		return this.chat_helpers._is_any_scratchpad_loading();
	}

	/**
	 * Retrieves the conversation object for the currently selected GUID from storage.
	 * @returns {Object|null} The conversation object or null if none is selected.
	 * @see ChatHelpers.get_selected_conversation
	 */
	get_selected_conversation() {
		return this.chat_helpers.get_selected_conversation();
	}

	/**
	 * Checks if a response or history item contains valid content to be rendered or sent.
	 * @param {Object} item - History turn or response payload.
	 * @returns {boolean}
	 * @see ChatHelpers._has_valid_content
	 */
	_has_valid_content(item) {
		return this.chat_helpers._has_valid_content(item);
	}

	/**
	 * Updates the loading indicator for a conversation or all conversations across the UI.
	 * @param {string|null} [guid=null] - Conversation GUID.
	 * @param {boolean|null} [isLoading=null] - Explicit loading state.
	 * @see ResponsePipeline.update_conversation_loading_indicators
	 */
	update_conversation_loading_indicators(guid = null, isLoading = null) {
		this.response_pipeline.update_conversation_loading_indicators(guid, isLoading);
	}

	/**
	 * Prepares the payload and initiates sending a prompt to the API.
	 * @see ResponsePipeline.send
	 */
	send() {
		this.response_pipeline.send();
	}

	/**
	 * Displays the progress indicator UI and stop button during request processing.
	 * @param {string|null} [guid=null] - Optional conversation GUID.
	 * @see ResponsePipeline.show_progress_ui
	 */
	show_progress_ui(guid = null) {
		this.response_pipeline.show_progress_ui(guid);
	}

	/**
	 * Hides the progress UI and restores the prompt input area.
	 * @see ResponsePipeline.hide_progress_ui
	 */
	hide_progress_ui() {
		this.response_pipeline.hide_progress_ui();
	}

	/**
	 * Aborts the active API request for the specified GUID (or currently selected conversation) and resets UI.
	 * @param {string} [targetGuid] - Optional GUID to abort.
	 * @see ResponsePipeline.stop_response
	 */
	stop_response(targetGuid) {
		this.response_pipeline.stop_response(targetGuid);
	}

	/**
	 * Appends streamed thinking chunks to the currently pending history item for a specific conversation.
	 * @param {string|Array<string>} chunk - Streamed thinking text snippet.
	 * @param {string} [targetGuid] - Conversation GUID.
	 * @see ResponsePipeline.send_thinking
	 */
	send_thinking(chunk, targetGuid) {
		this.response_pipeline.send_thinking(chunk, targetGuid);
	}

	/**
	 * Populates prompt textarea with a suggested query and initiates request execution.
	 * @param {string} query - The proposed query string.
	 * @see ResponsePipeline.send_suggested_query
	 */
	send_suggested_query(query) {
		this.response_pipeline.send_suggested_query(query);
	}

	//endregion

	//region Utilities / Helpers

	/**
	 * Synchronizes the prompt textarea content with the draft stored for the given conversation,
	 * and requests a sync whenever the active conversation changes.
	 * @param {string} new_guid - The newly selected conversation GUID.
	 */
	_sync_prompt_draft(new_guid) {
		const current_key = new_guid || '';
		if (this.active_conversation_guid !== current_key) {
			if (this.active_conversation_guid !== null) {
				this.#sync.request();
			}
			if (this.active_conversation_guid !== null && this.prompt) {
				this.prompt_drafts[this.active_conversation_guid] = this.prompt.value;
			}
			this.active_conversation_guid = current_key;
			if (this.prompt) {
				let draft = this.prompt_drafts[current_key];
				if (draft === undefined && current_key) {
					const conv = this.#storage.get_conversation(current_key);
					const history = conv?.[this.#storage.KEY_CONVERSATION_HISTORY];
					const pendingItem = Array.isArray(history) ? history.find(item => item && item.pending) : null;
					if (pendingItem && pendingItem.query) {
						draft = pendingItem.query;
						this.prompt_drafts[current_key] = draft;
					}
				}
				this.prompt.value = draft || '';
				this.resize_prompt_textarea();
				this.validate_prompt();
			}
		}
	}

	/**
	 * Resolves the model key for the selected conversation using the same
	 * precedence as chat requests: the conversation's explicit model preset,
	 * then an explicit family/version selection, then the app default model.
	 * @returns {string} The model key.
	 */
	get_selected_model_key() {
		const selected_conversation = this.#storage.get_selected_conversation();
		const app_defaults = this.#storage.get_app_defaults();
		const has_explicit_model = !!(selected_conversation?.[this.#storage.KEY_CONVERSATION_MODEL_FAMILY] || selected_conversation?.[this.#storage.KEY_CONVERSATION_MODEL_VERSION]);
		let model_key = selected_conversation?.[this.#storage.KEY_CONVERSATION_MODEL];
		if (model_key === undefined || model_key === null || model_key === '') {
			model_key = has_explicit_model ? (selected_conversation?.[this.#storage.KEY_CONVERSATION_MODEL_VERSION] || '') : (app_defaults?.[this.#storage.KEY_CONFIG_DEFAULT_MODEL] || '');
		}
		return model_key || '';
	}

	/**
	 * Hides the Instant Answer modal overlay and clears both the query and response.
	 * @see InstantAnswer.hide_instant_answer
	 */
	hide_instant_answer() {
		this.instant_answer.hide_instant_answer();
	}

	/**
	 * Checks whether the active conversation is a scratchpad.
	 * @returns {boolean} True if scratchpad is active.
	 */
	is_scratchpad_active() {
		const config = this.#storage.get_app_config();
		const selected_guid = config[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
		if (selected_guid === 'scratchpad') {
			return true;
		}
		const conversation = this.#storage.get_conversation(selected_guid);
		return conversation && conversation[this.#storage.KEY_CONVERSATION_TYPE] === this.#storage.CONVERSATION_TYPE_SCRATCHPAD;
	}

	/**
	 * Checks whether the tools index root is selected.
	 * @returns {boolean} True if the tools index is active.
	 */
	is_tools_active() {
		const config = this.#storage.get_app_config();
		return config[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID] === 'tools';
	}

	/**
	 * Checks whether the active conversation is a tool conversation (Ranking, Colors Palettes, or Sticky Notes).
	 * @returns {boolean} True if a tool conversation is active.
	 */
	is_tool_conversation_active() {
		const conversation = this.get_selected_conversation();
		const type = conversation?.[this.#storage.KEY_CONVERSATION_TYPE];
		return type === this.#storage.CONVERSATION_TYPE_RANKING
			|| type === this.#storage.CONVERSATION_TYPE_COLORS
			|| type === this.#storage.CONVERSATION_TYPE_STICKY_NOTES;
	}

	/**
	 * Checks whether the active conversation is a ranking tool conversation.
	 * @returns {boolean} True if a ranking conversation is active.
	 */
	is_ranking_active() {
		const conversation = this.get_selected_conversation();
		return !!conversation && conversation[this.#storage.KEY_CONVERSATION_TYPE] === this.#storage.CONVERSATION_TYPE_RANKING;
	}

	/**
	 * Checks whether the active conversation is a sticky notes tool conversation.
	 * @returns {boolean} True if a sticky notes conversation is active.
	 */
	is_sticky_active() {
		const conversation = this.get_selected_conversation();
		return !!conversation && conversation[this.#storage.KEY_CONVERSATION_TYPE] === this.#storage.CONVERSATION_TYPE_STICKY_NOTES;
	}

	/**
	 * Checks whether any tool conversation has a request in flight.
	 * @returns {boolean} True when a tool conversation is loading.
	 */
	_is_any_tool_loading() {
		const index = this.#storage.get_app_index() || [];
		return index.some(item => {
			const guid = item[this.#storage.KEY_INDEX_GUID];
			const conversation = this.#storage.get_conversation(guid);
			const type = conversation?.[this.#storage.KEY_CONVERSATION_TYPE];
			return (type === this.#storage.CONVERSATION_TYPE_RANKING
				|| type === this.#storage.CONVERSATION_TYPE_COLORS
				|| type === this.#storage.CONVERSATION_TYPE_STICKY_NOTES) && this.#api.is_request_active(guid);
		});
	}

	/**
	 * Checks whether the active conversation is a document.
	 * @returns {boolean} True if a document is active.
	 */
	is_document_active() {
		const conversation = this.get_selected_conversation();
		return !!conversation && conversation[this.#storage.KEY_CONVERSATION_TYPE] === this.#storage.CONVERSATION_TYPE_DOCUMENT;
	}

	/**
	 * Returns the document type of the active conversation.
	 * @returns {string|null} The document type constant, or null when no document is active.
	 */
	get_active_document_type() {
		const conversation = this.get_selected_conversation();
		if (!conversation || conversation[this.#storage.KEY_CONVERSATION_TYPE] !== this.#storage.CONVERSATION_TYPE_DOCUMENT) {
			return null;
		}
		return conversation[this.#storage.KEY_CONVERSATION_DOCUMENT_TYPE] || null;
	}

	//endregion
}

export default App;
🌐
app.js ×
Type: Web, text/plain
61.42 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File