2 directories, 29 files

tinai

Home / testing / ai / tinai
import { createElementFromHTML, getEl } from './util/dom-utils.js';
import { SelectionManager } from './util/selection-manager.js';
import { customConfirm } from './util/confirm-dialog.js';

/**
 * class ConversationIndex
 * Manages the secondary navigation index for a single conversation.
 * Handles the display, selection, bookmarking, and deletion of individual responses
 * within the active conversation's history.
 */
class ConversationIndex {

	#storage;
	#app_callbacks;
	#selection;
	#scrollTimeout = null;

	div_index_list;
	btn_conversation_index_select;
	btn_conversation_index_favorite;
	btn_conversation_index_delete;

	SCROLL_DELAY;
	BREAKPOINT_MOBILE;

	/**
	 * Initializes the ConversationIndex instance with storage, callbacks, and element references.
	 * @param {Storage} storage_instance - Storage manager instance.
	 * @param {object} app_callbacks - Application callback methods.
	 * @param {object} elements - DOM element references.
	 * @param {object} breakpoints - Responsive breakpoint constants.
	 * @param {number} scroll_delay - Scroll animation delay in milliseconds.
	 */
	constructor(storage_instance, app_callbacks, elements, breakpoints, scroll_delay) {
		this.#storage = storage_instance;
		this.#app_callbacks = app_callbacks;

		this.div_index_list = elements.div_index_list;
		this.btn_conversation_index_select = elements.btn_conversation_index_select;
		this.btn_conversation_index_favorite = elements.btn_conversation_index_favorite;
		this.btn_conversation_index_delete = elements.btn_conversation_index_delete;

		this.SCROLL_DELAY = scroll_delay;
		this.BREAKPOINT_MOBILE = breakpoints.BREAKPOINT_MOBILE;

		this.#selection = new SelectionManager(() => this._update_selection_ui());
	}

	/**
	 * Cancels any pending scheduled scroll operation.
	 */
	cancel_scroll() {
		if (this.#scrollTimeout) {
			clearTimeout(this.#scrollTimeout);
			this.#scrollTimeout = null;
		}
	}

	//region Response Selection and Actions

	/**
	 * Updates the selection controls UI state.
	 * @private
	 */
	_update_selection_ui() {
		this.#selection.updateControls(
			this.btn_conversation_index_select,
			[this.btn_conversation_index_favorite, this.btn_conversation_index_delete],
			'Select'
		);
	}

	/**
	 * Toggles multi-selection mode for individual responses within the current conversation.
	 */
	toggle_response_selection_mode() {
		this.#selection.toggleMode();
		this._update_selection_ui();
		this.on_conversation_index_updated();
	}

	/**
	 * Toggles the bookmarked status of all responses currently selected in the response index.
	 */
	bookmark_selected_responses() {
		const selectedIndices = this.#selection.selectedItems;
		if (selectedIndices.length === 0) return;

		const config = this.#storage.get_app_config();
		const guid = config[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
		if (!guid) return;

		const conversation = this.#storage.get_conversation(guid);
		let history = conversation[this.#storage.KEY_CONVERSATION_HISTORY] || [];

		selectedIndices.forEach(index => {
			if (history[index]) {
				const isBookmarked = history[index].BOOKMARKED || false;
				history[index].BOOKMARKED = !isBookmarked;
			}
		});

		conversation[this.#storage.KEY_CONVERSATION_HISTORY] = history;
		this.#storage.save_conversation(guid, conversation);

		this.#selection.clear();
		this._update_selection_ui();
		this.on_conversation_index_updated();
		this.#app_callbacks.on_conversation_updated_main_panel(false);
	}

	/**
	 * Deletes all responses currently selected in the response index from the history after user confirmation.
	 */
	async delete_selected_responses() {
		const selectedIndices = this.#selection.selectedItems;
		const count = selectedIndices.length;
		if (count === 0) return;

		const message = count === 1
			? 'Are you sure you want to delete this response from the conversation?'
			: `Are you sure you want to delete ${count} selected responses from the conversation?`;

		if (await customConfirm('Delete Responses', message)) {
			const config = this.#storage.get_app_config();
			const guid = config[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
			if (!guid) return;

			const conversation = this.#storage.get_conversation(guid);
			let history = conversation[this.#storage.KEY_CONVERSATION_HISTORY] || [];

			// Filter out items whose index is in the selection array
			history = history.filter((_, index) => !selectedIndices.includes(index));

			conversation[this.#storage.KEY_CONVERSATION_HISTORY] = history;
			this.#storage.save_conversation(guid, conversation);

			this.#selection.clear();
			this._update_selection_ui();
			this.on_conversation_index_updated();
			this.#app_callbacks.on_conversation_updated_main_panel(false);
		}
	}

	//endregion

	//region HTML Generation

	/**
	 * Creates and returns a DOM element for a single response entry in the conversation index.
	 * @param {Object} data - The response data object.
	 * @param {number} index - The numerical index of the response in history.
	 * @returns {HTMLElement} The created index item element.
	 */
	create_response_index_item(data, index) {
		const isChecked = this.#selection.isSelected(index);
		const html = this._create_response_item_html(data, index, this.#selection.isSelectionMode, isChecked);
		const index_item = createElementFromHTML(html);
		if (!index_item) return document.createElement('div');

		const chk = index_item.querySelector('.response-item-checkbox');
		if (chk) {
			chk.onclick = (e) => {
				e.stopPropagation();
				this.#selection.toggleItem(index, chk.checked);
				this._update_selection_ui();
			};
		}

		index_item.onclick = () => {
			const el = getEl('chat-item-' + index);
			if (el) {
				this.cancel_scroll();
				if (window.innerWidth <= this.BREAKPOINT_MOBILE) {
					this.#scrollTimeout = setTimeout(() => {
						this.#scrollTimeout = null;
						el.scrollIntoView({ behavior: 'smooth' });
					}, this.SCROLL_DELAY);
				} else {
					el.scrollIntoView({ behavior: 'smooth' });
				}
			}
			if (window.innerWidth <= this.BREAKPOINT_MOBILE) {
				this.#app_callbacks.set_i_open(false);
				this.#app_callbacks.apply_panels_layout();
			}
		};
		return index_item;
	}

	/**
	 * Generates the HTML string for a single response index item.
	 * @param {Object} data - The response data object.
	 * @param {number} index - The numerical index of the response in history.
	 * @param {boolean} isSelectionMode - Whether response selection mode is active.
	 * @param {boolean} isChecked - Whether the checkbox for this item should be checked.
	 * @returns {string} The HTML string for the response index item.
	 */
	_create_response_item_html(data, index, isSelectionMode, isChecked) {
		const iconClass = data.BOOKMARKED ? 'bookmark-icon-active' : 'bookmark-icon-inactive';
		return `
			<div class="div-index-item div-index-item-flex ${data.BOOKMARKED ? 'div-index-item-bookmarked' : ''}"
				 data-index="${index}">
				${isSelectionMode ? `<input type="checkbox" ${isChecked ? 'checked' : ''} class="response-item-checkbox index-item-checkbox-margin">` : ''}
				<span class="index-item-icon as-icon ${iconClass}">&#128278;</span>
				<span class="index-item-text-grow">${data.title || ''}</span>
			</div>
		`;
	}

	//endregion

	//region UI Updates

	/**
	 * Highlights the item in the response index that corresponds to the chat response currently in the user's viewport.
	 */
	highlight_active_index_item() {
		const anchors = document.querySelectorAll('[id^="chat-item-"]');
		const scrollContainer = document.querySelector('.div-chat-container-scroll');
		if (!scrollContainer || anchors.length === 0 || !this.div_index_list) return;

		const containerRect = scrollContainer.getBoundingClientRect();
		let activeIndex = 0;

		// Check if the last anchor is visible
		const lastAnchor = anchors[anchors.length - 1];
		const lastRect = lastAnchor.getBoundingClientRect();
		const isLastVisible = (lastRect.top < containerRect.bottom && lastRect.bottom > containerRect.top);

		if (isLastVisible) {
			activeIndex = anchors.length - 1;
		} else {
			// Find anchor closest to the top of the screen/container
			let minDistance = Infinity;
			anchors.forEach((anchor, i) => {
				const rect = anchor.getBoundingClientRect();
				const distance = Math.abs(rect.top - containerRect.top);
				if (distance < minDistance) {
					minDistance = distance;
					activeIndex = i;
				}
			});
		}

		const indexItems = this.div_index_list.querySelectorAll('.div-index-item');
		indexItems.forEach((item) => {
			if (parseInt(item.dataset.index, 10) === activeIndex) {
				item.classList.add('div-index-item-selected');
			} else {
				item.classList.remove('div-index-item-selected');
			}
		});
	}

	/**
	 * Renders the conversation index panel based on the current conversation history.
	 */
	on_conversation_index_updated() {
		const conversation = this.#app_callbacks.get_selected_conversation();
		const history = conversation ? conversation[this.#storage.KEY_CONVERSATION_HISTORY] : null;

		if (!this.div_index_list) return;
		this.div_index_list.innerHTML = '';

		if (history && history.length > 0) {
			if (this.btn_conversation_index_select) this.btn_conversation_index_select.disabled = false;
		} else {
			if (this.btn_conversation_index_select) this.btn_conversation_index_select.disabled = true;
		}

		if (!history || history.length === 0) {
			if (this.btn_conversation_index_favorite) this.btn_conversation_index_favorite.disabled = true;
			if (this.btn_conversation_index_delete) this.btn_conversation_index_delete.disabled = true;
			return;
		}

		this._update_selection_ui();

		const indexedHistory = history.map((data, index) => ({ data, index }));
		const bookmarked = indexedHistory.filter(item => item.data.BOOKMARKED === true);
		const others = indexedHistory.filter(item => item.data.BOOKMARKED !== true);

		bookmarked.forEach(item => {
			if (item.data.title) {
				this.div_index_list.appendChild(this.create_response_index_item(item.data, item.index));
			}
		});

		if (bookmarked.length > 0 && others.length > 0) {
			const hr = document.createElement('hr');
			hr.className = 'hr-chat-response-divider';
			this.div_index_list.appendChild(hr);
		}

		others.forEach(item => {
			if (item.data.title) {
				this.div_index_list.appendChild(this.create_response_index_item(item.data, item.index));
			}
		});

		this.highlight_active_index_item();
	}

	//endregion
}

export default ConversationIndex;
🌐
conversation-index.js ×
Type: Web, text/plain
10.19 Kilobytes
Last Modified 2026-09-04 18:10:30
⬇ Download File