0 directories, 46 files

util

Home / tinai / util
import { escapeHtml, highlightCodeBlocks, typesetMathjax } from './format-utils.js';
import { copyToClipboard } from './async-utils.js';
import { getEl, setElementDisplay, strip_tags } from './dom-utils.js';
import { customConfirm } from './confirm-dialog.js';
import { openDialog } from './dialog.js';
import { attach_code_expand_buttons } from './code-overlay.js';
import { attach_table_expand_buttons } from './table-overlay.js';
import { mermaid } from './mermaid.js';
import { render_mermaid_in_container, render_mermaid_svg, render_diagram_svg, attach_diagram_expand_button } from './mermaid-render.js';
import { formatModelShortName } from './settings-utils.js';
import { UI_SETS } from './ui-sets.js';
import RankingsView from '../rankings-view.js';
import { bind_read_buttons, check_autoplay } from './read-aloud.js';
import { refresh_scratchpad_drawer } from './scratchpad-editor.js';
import { content_of } from './scrollbars.js';
import Api from '../api.js';

/** @typedef {import('../app.js').default} App */

/**
 * Renders conversation content: tabs, header, chat/scratchpad histories, and post-render hooks.
 */
class ChatRenderer {

	rankings_view = new RankingsView();

	/**
	 * @param {App} app - The main application instance.
	 */
	constructor(app) {
		this.app = app;
		this.visible_chips = new Set();
		this.document_tab_guid = null;
		this.notes_tab_guid = null;
		this.tags_tab_guid = null;
	}

	/**
	 * Sets the active tab and updates the UI practicalities.
	 * @param {string} tabName - The name of the tab to activate.
	 */
	set_active_tab(tabName) {
		const conversation = this.app.get_selected_conversation();
		const type = conversation?.[this.app.storage.KEY_CONVERSATION_TYPE] || this.app.storage.CONVERSATION_TYPE_CHAT;

		const config = this.app.storage.get_app_config();
		const experimental_features_enabled = config[this.app.storage.KEY_CONFIG_EXPERIMENTAL_FEATURES] || false;

		this.app.state_active_tab = tabName;
		this._update_tab_visibility(tabName, type, experimental_features_enabled);

		const tabs = {
			conversation: { render: () => this.render_conversation_tab(false) },
			rankings: { render: this.render_rankings_tab.bind(this) },
			tags: { render: this.render_tags_tab.bind(this) },
			context: { render: this.render_context_tab.bind(this) },
			attachments: { render: this.render_attachments_tab.bind(this) },
			memory: { render: this.render_memory_tab.bind(this) },
			document: { render: this.render_document_tab.bind(this) },
			diction: { render: this.render_diction_tab.bind(this) }
		};

		if (tabs[tabName] && typeof tabs[tabName].render === 'function') {
			tabs[tabName].render();
		}
	}

	/**
	 * Renders the content for the context tab.
	 */
	render_context_tab() {
		if (!this.app.div_chat_context_scroll || !this.app.context) return;
		content_of(this.app.div_chat_context_scroll).innerHTML = this.app.context.render();
		this.app.context.attachEventListeners();
	}

	/**
	 * Renders the content for the attachments tab.
	 */
	render_attachments_tab() {
		if (!this.app.div_chat_attachments_scroll || !this.app.attachments) return;
		content_of(this.app.div_chat_attachments_scroll).innerHTML = this.app.attachments.render();
		this.app.attachments.attachEventListeners();
	}

	/**
	 * Renders the content for the memory tab.
	 */
	render_memory_tab() {
		const conversation = this.app.get_selected_conversation();
		const type = conversation?.[this.app.storage.KEY_CONVERSATION_TYPE] || this.app.storage.CONVERSATION_TYPE_CHAT;
		if (type === this.app.storage.CONVERSATION_TYPE_CHAT) {
			if (this.app.div_chat_memory_scroll) {
				content_of(this.app.div_chat_memory_scroll).innerHTML = this.app.memory.render();
			}
		} else if (this.app.div_document_memory_scroll) {
			content_of(this.app.div_document_memory_scroll).innerHTML = this.app.memory.render();
		}
	}

	/**
	 * Renders the content for the document tab and binds change listener.
	 * Skips rebuilding while the editor holds focus or matches the stored
	 * state, so typing and the caret survive the storage event echo. A forced
	 * render (sync download) replaces the content regardless of focus.
	 */
	render_document_tab(force = false) {
		if (!this.app.div_chat_document_scroll || !this.app.document) return;
		const conversation = this.app.get_selected_conversation();
		const selected_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
		this.app.document.set_active_guid(selected_guid, this.app);
		const items = Array.isArray(conversation?.[this.app.storage.KEY_CONVERSATION_DOCUMENT]) ? conversation[this.app.storage.KEY_CONVERSATION_DOCUMENT] : [];
		const is_same_document = this.document_tab_guid === selected_guid;
		if (is_same_document && !force && (this.app.document.contains_focus() || this.app.document.matches_items(items))) return;
		this.document_tab_guid = selected_guid;
		content_of(this.app.div_chat_document_scroll).innerHTML = this.app.document.render(items);
		this.app.document.attachEventListeners((newItems, content_changed = true) => {
			if (selected_guid) {
				this.app.storage.update_conversation_field(selected_guid, this.app.storage.KEY_CONVERSATION_DOCUMENT, newItems);
				if (content_changed) {
					this.app.response_pipeline.schedule_document_title_update(selected_guid);
				}
				this.app.sync?.request();
			}
		});
	}

	/**
	 * Re-renders the active document, sticky notes, or rankings choices
	 * after a sync download so pulled content replaces the visible content immediately.
	 * @param {string} guid - The conversation that was updated by the sync.
	 */
	refresh_after_sync_down(guid) {
		if (this.document_tab_guid === guid) {
			void this.render_document_tab(true);
		}
		if (this.notes_tab_guid === guid) {
			this.render_notes_tab(true);
		}
		if (this.tags_tab_guid === guid) {
			this.render_tags_tab(true);
		}
		if (this.choices_tab_guid === guid) {
			this.render_choices_tab(true);
		}
		if (this.app.state_active_tab === 'rankings') {
			this.render_rankings_tab();
		}
	}

	/**
	 * Renders the content for the diction tab and binds change listener.
	 */
	render_diction_tab() {
		if (!this.app.div_chat_diction_scroll || !this.app.diction) return;
		const conversation = this.app.get_selected_conversation();
		const currentDiction = conversation ? conversation[this.app.storage.KEY_CONVERSATION_DICTION] || '' : '';
		content_of(this.app.div_chat_diction_scroll).innerHTML = this.app.diction.render(currentDiction);
		this.app.diction.attachEventListeners((newDiction) => {
			const selected_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
			if (selected_guid) {
				this.app.storage.update_conversation_field(selected_guid, this.app.storage.KEY_CONVERSATION_DICTION, newDiction);
				this.app.sync?.request();
			}
		});
	}

	/**
	 * Regenerates the conversation title after the most recent message was deleted.
	 * Uses the summaries of the remaining responses via the conversation_title utility,
	 * or a "Cleared" timestamp title when no responses remain.
	 * @param {Object} conversation - The conversation object.
	 * @param {string} guid - The conversation GUID.
	 */
	async update_conversation_title_after_delete(conversation, guid) {
		if (conversation?.[this.app.storage.KEY_CONVERSATION_CUSTOM_TITLE]) return;
		const history = conversation?.[this.app.storage.KEY_CONVERSATION_HISTORY] || [];

		let title = '';
		if (history.length === 0) {
			title = 'Cleared: ' + format_cleared_date(new Date());
		} else {
			const summaries = history
				.map(item => (item?.summary || item?.query || '').trim())
				.filter(text => text !== '');
			if (summaries.length === 0) return;

			const config = this.app.storage.get_app_config();
			const utilityModel = config[this.app.storage.KEY_CONFIG_UTILITY_MODEL] || 'gemini-3.5-fl';
			try {
				const response = await this.app.api.evaluate_utility(summaries.join('\n'), 'conversation_title', utilityModel);
				title = (response && !response.error && response.explanation)
					? String(response.explanation).trim().replace(/^["']+|["']+$/g, '')
					: '';
			} catch (error) {
				return;
			}
			if (title === '') return;
		}

		conversation[this.app.storage.KEY_CONVERSATION_TITLE] = title;
		if (history.length > 0 && history[history.length - 1]) {
			history[history.length - 1].title = title;
		}
		this.app.storage.save_conversation(guid, conversation);
		this.on_conversation_updated(false);

		const renamed_type = conversation[this.app.storage.KEY_CONVERSATION_TYPE];
		if (renamed_type === this.app.storage.CONVERSATION_TYPE_SCRATCHPAD) {
			this.app.scratchpad_index?.on_conversation_index_updated();
		} else if (renamed_type === this.app.storage.CONVERSATION_TYPE_RANKING || renamed_type === this.app.storage.CONVERSATION_TYPE_COLORS || renamed_type === this.app.storage.CONVERSATION_TYPE_STICKY_NOTES) {
			this.app.tools_index?.on_tools_index_updated();
		} else {
			this.app.content_list?.on_app_index_updated();
		}
	}

	/**
	 * Shows or hides the sync switch in the tab bar and the sync icon on the
	 * send button, based on the selected conversation's SYNCED setting. The
	 * Collaborate and Share buttons share the sync switch's visibility, with
	 * Collaborate additionally gated behind the Collaboration experimental
	 * feature.
	 */
	_update_sync_indicators() {
		const storage = this.app.storage;
		const selected_guid = storage.get_app_config()[storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
		const supported = !!selected_guid && selected_guid !== 'scratchpad' && selected_guid !== 'tools';
		const conversation = this.app.get_selected_conversation();
		const synced = supported && !!conversation?.[storage.KEY_CONVERSATION_SYNCED];

		if (this.app.label_sync_switch) {
			this.app.label_sync_switch.style.display = supported ? '' : 'none';
		}
		if (this.app.checkbox_sync_switch) {
			this.app.checkbox_sync_switch.checked = synced;
		}
		if (this.app.btn_send) {
			this.app.btn_send.classList.toggle('sending-sync', synced);
		}
		const collaboration_enabled = this.app.options_manager?.is_collaboration_enabled() || false;
		if (this.app.btn_share) {
			this.app.btn_share.style.display = supported ? '' : 'none';
		}
		if (this.app.btn_collaborate) {
			this.app.btn_collaborate.style.display = supported && collaboration_enabled ? '' : 'none';
		}
		this.app.share?.update_share_indicator();
	}

	/**
	 * Renders the chat interface header, displaying the title of the selected
	 * conversation and its thinking indicator state.
	 * A custom title (set through the title's edit button) wins over the
	 * newest revision's conversation title; otherwise the title comes from
	 * the newest revision (the overall conversation title).
	 */
	render_conversation_header() {
		const conversation = this.app.get_selected_conversation();
		const history = conversation?.[this.app.storage.KEY_CONVERSATION_HISTORY] || [];

		let title = conversation?.[this.app.storage.KEY_CONVERSATION_TITLE] || 'New Conversation';

		if (!conversation?.[this.app.storage.KEY_CONVERSATION_CUSTOM_TITLE]) {
			for (let i = history.length - 1; i >= 0; i--) {
				const item = history[i];
				if (item && (item.title || item.conversationTitle)) {
					title = item.conversationTitle || title;
					break;
				}
			}
		}

		const sync_icon = conversation?.[this.app.storage.KEY_CONVERSATION_SYNCED] ? ' <span class="span-title-sync as-icon" title="Synced to server">&#9729;&#65038;</span>' : '';

		if (this.app.div_title) {
			let h4_title = this.app.div_title.querySelector('h4');
			if (h4_title) {
				h4_title.innerHTML = title + sync_icon;
			} else {
				this.app.div_title.insertAdjacentHTML('afterbegin', `<h4>${title}${sync_icon}</h4>`);
			}
		}

		if (this.app.btn_title_edit) {
			this.app.btn_title_edit.style.display = conversation ? 'inline-block' : 'none';
		}

		const selected_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
		this.set_header_loading(!!selected_guid && this.app.api.is_request_active(selected_guid));
	}

	/**
	 * Opens the title dialog: a text entry pre-filled with the conversation's
	 * title, a magic wand that fills the entry with a generated title, a Save
	 * button (Enter in the entry works too) that marks the title custom and
	 * stops the automatic updates, a Clear button that resumes the automatic
	 * updates and regenerates the title once, and a Cancel button that leaves
	 * the title alone. Escape also cancels.
	 */
	edit_conversation_title() {
		const conversation = this.app.get_selected_conversation();
		if (!conversation) return;

		const input = document.createElement('input');
		input.type = 'text';
		input.className = 'prompt-style-input';
		input.maxLength = 100;
		input.value = conversation[this.app.storage.KEY_CONVERSATION_TITLE] || '';

		const wand = document.createElement('button');
		wand.type = 'button';
		wand.className = 'as-icon';
		wand.title = 'Generate a title automatically';
		wand.innerHTML = '&#129668;';
		wand.addEventListener('click', async () => {
			const text = this.#title_source_text(conversation);
			if (!text) return;
			wand.disabled = true;
			try {
				const title = await this.#generated_title(text);
				if (title) input.value = title;
			} finally {
				wand.disabled = false;
			}
		});

		const row = document.createElement('div');
		row.className = 'div-title-dialog-row';
		row.append(input, wand);

		const apply = (save) => {
			if (save) {
				this.#save_custom_title(input.value.trim());
			}
		};
		const close = openDialog({
			title: 'Edit Title',
			content: row,
			buttons: [
				{ caption: 'Save', onclick: (close) => { apply(true); close(); } },
				{ caption: 'Clear', onclick: (close) => { this.#resume_automatic_title(conversation); close(); } },
				{ caption: 'Cancel', onclick: (close) => close() }
			],
			click_outside: true
		});
		input.addEventListener('keydown', (e) => {
			if (e.key !== 'Enter') return;
			e.preventDefault();
			apply(true);
			close();
		});
		input.focus();
	}

	/**
	 * Saves a custom title for the selected conversation: an empty value
	 * clears the custom flag so automatic updates resume, a non-empty value
	 * marks the title custom, which stops the automatic updates.
	 * @param {string} value - The new title.
	 */
	#save_custom_title(value) {
		const config = this.app.storage.get_app_config();
		const guid = config[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
		if (guid) {
			const conv = this.app.storage.get_conversation(guid);
			conv[this.app.storage.KEY_CONVERSATION_CUSTOM_TITLE] = value !== '';
			conv[this.app.storage.KEY_CONVERSATION_TITLE] = value;
			this.app.storage.save_conversation(guid, conv);
			this.app.storage.update_app_index(guid);
			this.app.content_list?.on_app_index_updated?.();
			this.app.tools_index?.on_tools_index_updated?.();
			this.app.scratchpad_index?.on_conversation_index_updated?.();
		}
		this.render_conversation_header();
	}

	/**
	 * Clears the custom title flag so automatic title updates resume, then
	 * runs the automatic title generation once. Dispatches on the
	 * conversation type; chat-like conversations regenerate from history.
	 * @param {Object} conversation - The conversation.
	 */
	#resume_automatic_title(conversation) {
		const storage = this.app.storage;
		const config = storage.get_app_config();
		const guid = config[storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
		if (guid) {
			const conv = storage.get_conversation(guid);
			conv[storage.KEY_CONVERSATION_CUSTOM_TITLE] = false;
			storage.save_conversation(guid, conv);
			const type = conv[storage.KEY_CONVERSATION_TYPE];
			if (type === storage.CONVERSATION_TYPE_DOCUMENT) {
				void this.app.response_pipeline.update_document_title(guid);
			} else if (type === storage.CONVERSATION_TYPE_SCRATCHPAD) {
				void this.app.response_pipeline.update_scratchpad_title(guid);
			} else if (type === storage.CONVERSATION_TYPE_RANKING) {
				void this.app.response_pipeline.update_ranking_title(guid);
			} else if (type === storage.CONVERSATION_TYPE_STICKY_NOTES) {
				void this.app.response_pipeline.update_sticky_title(guid);
			} else {
				void this.update_conversation_title_after_delete(conv, guid);
			}
		}
		this.render_conversation_header();
	}

	/**
	 * Builds the text the conversation_title utility works on, based on the
	 * conversation type.
	 * @param {Object} conversation - The conversation.
	 * @returns {string} The source text, empty when there is nothing to read.
	 */
	#title_source_text(conversation) {
		const storage = this.app.storage;
		const type = conversation[storage.KEY_CONVERSATION_TYPE];
		const strip = (value) => String(value || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
		if (type === storage.CONVERSATION_TYPE_DOCUMENT) {
			const items = Array.isArray(conversation[storage.KEY_CONVERSATION_DOCUMENT]) ? conversation[storage.KEY_CONVERSATION_DOCUMENT] : [];
			return items.map(item => strip(item?.value)).filter(Boolean).join('\n').slice(0, 4000);
		}
		if (type === storage.CONVERSATION_TYPE_SCRATCHPAD) {
			const history = conversation[storage.KEY_CONVERSATION_HISTORY] || [];
			const items = Array.isArray(history[history.length - 1]?.items) ? history[history.length - 1].items : [];
			return items.map(item => strip(item?.value)).filter(Boolean).join('\n').slice(0, 4000);
		}
		if (type === storage.CONVERSATION_TYPE_RANKING || type === storage.CONVERSATION_TYPE_COLORS) {
			const items = Array.isArray(conversation[storage.KEY_CONVERSATION_CHOICES]) ? conversation[storage.KEY_CONVERSATION_CHOICES] : [];
			return items.map(item => strip(item?.value)).filter(Boolean).join('\n').slice(0, 4000);
		}
		if (type === storage.CONVERSATION_TYPE_STICKY_NOTES) {
			const notes = Array.isArray(conversation[storage.KEY_CONVERSATION_STICKIES]) ? conversation[storage.KEY_CONVERSATION_STICKIES] : [];
			return notes
				.map(note => strip([note?.title, ...(note?.items || []).map(item => item?.text)].join(' ')))
				.filter(Boolean)
				.join('\n')
				.slice(0, 4000);
		}
		const history = conversation[storage.KEY_CONVERSATION_HISTORY] || [];
		return history
			.map(item => strip(item?.summary || item?.query))
			.filter(Boolean)
			.join('\n')
			.slice(0, 4000);
	}

	/**
	 * Runs the conversation_title utility on the given text.
	 * @param {string} text - The source text.
	 * @returns {Promise<string>} The generated title, empty on failure.
	 */
	async #generated_title(text) {
		const config = this.app.storage.get_app_config();
		const utility_model = config[this.app.storage.KEY_CONFIG_UTILITY_MODEL] || 'gemini-3.5-fl';
		try {
			const response = await this.app.api.evaluate_utility(text, 'conversation_title', utility_model);
			if (!response || response.error || response.evaluation === false) return '';
			return String(response.explanation || '').trim().replace(/^["']+|["']+$/g, '');
		} catch (error) {
			return '';
		}
	}

	/**
	 * Shows or hides the thinking indicator dot in the conversation title bar.
	 * @param {boolean} is_loading - Whether the selected conversation has an active request.
	 */
	set_header_loading(is_loading) {
		if (this.app.span_title_loading) {
			this.app.span_title_loading.style.display = is_loading ? 'inline-flex' : 'none';
		}
	}

	/**
	 * Renders the conversation tab based on conversation type.
	 * @param {boolean} [scroll_to_last=true] - Whether to auto-scroll to the latest item.
	 * @returns {Promise<void>}
	 */
	async render_conversation_tab(scroll_to_last = true) {
		const conversation = this.app.get_selected_conversation();
		const type = conversation?.[this.app.storage.KEY_CONVERSATION_TYPE] || this.app.storage.CONVERSATION_TYPE_CHAT;

		if (type === this.app.storage.CONVERSATION_TYPE_SCRATCHPAD) {
			await this.render_scratchpad_conversation(scroll_to_last);
		} else if (type === this.app.storage.CONVERSATION_TYPE_RANKING || type === this.app.storage.CONVERSATION_TYPE_COLORS) {
			this.render_choices_tab();
		} else if (type === this.app.storage.CONVERSATION_TYPE_STICKY_NOTES) {
			this.render_notes_tab();
		} else {
			await this.render_chat_conversation(scroll_to_last);
		}
	}

	/**
	 * Renders the sticky notes board for a sticky notes conversation. Skips
	 * rebuilding while the editor holds focus or matches the stored state, so
	 * typing and the caret survive the storage event echo.
	 * @param {boolean} [force=false] - Whether to force a rebuild even if focused.
	 */
	render_notes_tab(force = false) {
		if (!this.app.div_notes_scroll || !this.app.sticky) return;
		const conversation = this.app.get_selected_conversation();
		const selected_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
		const notes = this.#sticky_notes_of(conversation);
		const tags = this.#sticky_tags_of(conversation);
		const is_same_notes = this.notes_tab_guid === selected_guid;
		if (is_same_notes && !force && (this.app.sticky.contains_focus() || this.app.sticky.matches_content(notes, tags))) return;
		this.notes_tab_guid = selected_guid;
		content_of(this.app.div_notes_scroll).innerHTML = this.app.sticky.render(notes, tags);
		this.app.sticky.attach(content_of(this.app.div_notes_scroll), this.#sticky_change_callback(selected_guid));
		this.app.sticky.set_active_guid(selected_guid, this.app);
	}

	/**
	 * Renders the tag manager for a sticky notes conversation's Tags tab,
	 * with the same skip and save behavior as the notes board.
	 * @param {boolean} [force=false] - Whether to force a rebuild even if focused.
	 */
	render_tags_tab(force = false) {
		if (!this.app.div_chat_tags_scroll || !this.app.sticky) return;
		const conversation = this.app.get_selected_conversation();
		const selected_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
		const notes = this.#sticky_notes_of(conversation);
		const tags = this.#sticky_tags_of(conversation);
		const is_same_tags = this.tags_tab_guid === selected_guid;
		if (is_same_tags && !force && (this.app.sticky.contains_focus() || this.app.sticky.matches_tags(tags))) return;
		this.tags_tab_guid = selected_guid;
		content_of(this.app.div_chat_tags_scroll).innerHTML = this.app.sticky.render_tags(tags, notes);
		this.app.sticky.attach_tags(content_of(this.app.div_chat_tags_scroll), this.#sticky_change_callback(selected_guid));
		this.app.sticky.set_active_guid(selected_guid, this.app);
	}

	/**
	 * Returns the conversation's sticky notes array.
	 * @param {Object|null} conversation - The conversation.
	 * @returns {Array} The notes.
	 */
	#sticky_notes_of(conversation) {
		return Array.isArray(conversation?.[this.app.storage.KEY_CONVERSATION_STICKIES]) ? conversation[this.app.storage.KEY_CONVERSATION_STICKIES] : [];
	}

	/**
	 * Returns the conversation's tag vocabulary array.
	 * @param {Object|null} conversation - The conversation.
	 * @returns {Array} The tags.
	 */
	#sticky_tags_of(conversation) {
		return Array.isArray(conversation?.[this.app.storage.KEY_CONVERSATION_TAGS]) ? conversation[this.app.storage.KEY_CONVERSATION_TAGS] : [];
	}

	/**
	 * Returns the change callback that persists the editor's notes and tags
	 * to the conversation and schedules the title update.
	 * @param {string} selected_guid - The conversation guid.
	 * @returns {Function} The change callback.
	 */
	#sticky_change_callback(selected_guid) {
		return (newNotes, newTags) => {
			if (selected_guid) {
				const conversation = this.app.storage.get_conversation(selected_guid);
				if (conversation) {
					conversation[this.app.storage.KEY_CONVERSATION_STICKIES] = newNotes;
					conversation[this.app.storage.KEY_CONVERSATION_TAGS] = newTags;
					this.app.storage.save_conversation(selected_guid, conversation);
				}
				this.app.response_pipeline?.schedule_sticky_title_update(selected_guid);
				this.app.sync?.request();
			}
		};
	}

	/**
	 * Renders the Rankings tab for a ranking conversation: the recorded rank
	 * blocks with their results, and query chips that re-rank the list.
	 */
	render_rankings_tab() {
		if (!this.app.div_chat_rankings_scroll) return;
		const conversation = this.app.get_selected_conversation();
		const app_defaults = this.app.storage.get_app_defaults();
		const blocks = Array.isArray(conversation?.[this.app.storage.KEY_CONVERSATION_RANKINGS]) ? conversation[this.app.storage.KEY_CONVERSATION_RANKINGS] : [];
		const settings = {
			show_suggested: (conversation && conversation[this.app.storage.KEY_CONVERSATION_SHOW_SUGGESTED_QUERIES] !== undefined)
				? conversation[this.app.storage.KEY_CONVERSATION_SHOW_SUGGESTED_QUERIES]
				: (app_defaults?.[this.app.storage.KEY_CONFIG_SHOW_SUGGESTED_QUERIES] ?? false),
			show_related: (conversation && conversation[this.app.storage.KEY_CONVERSATION_SHOW_RELATED_QUERIES] !== undefined)
				? conversation[this.app.storage.KEY_CONVERSATION_SHOW_RELATED_QUERIES]
				: (app_defaults?.[this.app.storage.KEY_CONFIG_SHOW_RELATED_QUERIES] ?? false)
		};
		settings.existing_choices = (Array.isArray(conversation?.[this.app.storage.KEY_CONVERSATION_CHOICES]) ? conversation[this.app.storage.KEY_CONVERSATION_CHOICES] : [])
			.map(item => this.#choice_text(item?.value).toLowerCase());
		const content = content_of(this.app.div_chat_rankings_scroll);
		const auto_run = (conversation && conversation[this.app.storage.KEY_CONVERSATION_AUTO_RUN_PROPOSED_QUERIES] !== undefined)
			? conversation[this.app.storage.KEY_CONVERSATION_AUTO_RUN_PROPOSED_QUERIES]
			: (app_defaults?.[this.app.storage.KEY_CONFIG_AUTO_RUN_PROPOSED_QUERIES] ?? false);
		content.innerHTML = this.rankings_view.render(blocks, settings);

		// related chips are possible rankings: fill the prompt and highlight Rank
		content.querySelectorAll('.span-related-query').forEach(chip => {
			chip.onclick = () => this.#apply_ranking_query(chip.textContent.trim(), auto_run);
		});
		// suggested chips refine the choices themselves: fill the prompt and highlight Send
		content.querySelectorAll('.span-suggested-query').forEach(chip => {
			chip.onclick = () => this.#apply_refine_query(chip.textContent.trim(), auto_run);
		});
		// plus buttons prepend the additional item to the current choices
		content.querySelectorAll('.div-rankings-add').forEach(button => {
			button.onclick = () => this.#add_additional_choice(button.dataset.choice);
		});

		content.querySelectorAll('.div-rankings-delete').forEach(button => {
			button.onclick = () => this.#delete_ranking(blocks, button.dataset.guid);
		});
		content.querySelectorAll('.div-rankings-revisit').forEach(button => {
			button.onclick = () => this.#revisit_ranking(blocks, button.dataset.guid);
		});

		// a freshly recorded ranking scrolls into view
		if (this.rankings_scroll_pending) {
			this.rankings_scroll_pending = false;
			content.querySelector('.div-rankings-block:last-of-type')?.scrollIntoView({ block: 'start' });
		}
	}

	/**
	 * Removes one recorded ranking after confirmation.
	 * @param {Array<Object>} blocks - The conversation's rank blocks.
	 * @param {string} block_guid - The guid of the block to delete.
	 */
	async #delete_ranking(blocks, block_guid) {
		const block = blocks.find(b => b.guid === block_guid);
		if (!block) return;
		const preview = (block.prompt || '').trim().slice(0, 60) || 'this ranking';
		if (!(await customConfirm('Delete Ranking', `Are you sure you want to delete the ranking for "${preview}"? This cannot be undone.`))) {
			return;
		}
		const config = this.app.storage.get_app_config();
		const guid = config[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID] || '';
		const conversation = this.app.storage.get_conversation(guid);
		if (!conversation) return;
		conversation[this.app.storage.KEY_CONVERSATION_RANKINGS] = blocks.filter(b => b.guid !== block_guid);
		this.app.storage.save_conversation(guid, conversation);
		this.app.sync?.request();
		this.render_rankings_tab();
	}

	/**
	 * Loads the choices from a recorded ranking back into the Choices tab and
	 * restores its prompt, after warning that the current choices will be
	 * replaced.
	 * @param {Array<Object>} blocks - The conversation's rank blocks.
	 * @param {string} block_guid - The guid of the block to revisit.
	 */
	async #revisit_ranking(blocks, block_guid) {
		const block = blocks.find(b => b.guid === block_guid);
		if (!block) return;
		if (!(await customConfirm('Revisit Ranking', 'The current choices will be replaced with the ones from this ranking. Continue?'))) {
			return;
		}
		const config = this.app.storage.get_app_config();
		const guid = config[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID] || '';
		const conversation = this.app.storage.get_conversation(guid);
		if (!conversation) return;
		conversation[this.app.storage.KEY_CONVERSATION_CHOICES] = (block.items || [])
			.filter(item => item.guid)
			.map(item => ({ guid: item.guid, value: this.#choice_html(item.text || '') }));
		this.app.storage.save_conversation(guid, conversation);
		this.app.sync?.request();

		if (this.app.prompt) {
			this.app.prompt.value = block.prompt || '';
			this.app.prompt_drafts[guid] = block.prompt || '';
			this.app.resize_prompt_textarea();
		}
		this.app.set_active_tab('conversation');
		this.app.validate_prompt();
	}

	/**
	 * Converts stored plain-text choice content into the editor's inline html.
	 * @param {string} text - The plain text.
	 * @returns {string} The inline html.
	 */
	#choice_html(text) {
		return escapeHtml(String(text || '')).replace(/\n/g, '<br>');
	}

	/**
	 * Converts a choice value into plain text.
	 * @param {string} value - The choice's inline html.
	 * @returns {string} The plain text.
	 */
	#choice_text(value) {
		return strip_tags(String(value || '').replace(/<br\s*\/?>/gi, '\n')).trim();
	}

	/**
	 * Fills the prompt with a related query (a possible ranking) and
	 * highlights the Rank button; auto-run executes it immediately.
	 * @param {string} query - The related query.
	 * @param {boolean} auto_run - Whether to run the ranking right away.
	 */
	#apply_ranking_query(query, auto_run) {
		if (this.app.prompt) {
			this.app.prompt.value = query;
			this.app.resize_prompt_textarea();
		}
		this.app.btn_rank?.classList.add('btn-action-highlight');
		this.app.btn_send?.classList.remove('btn-action-highlight');
		this.app.validate_prompt();
		if (auto_run) {
			this.app.response_pipeline.send_rank_action();
		} else {
			// the action happens on the Choices tab, where the prompt lives
			this.app.set_active_tab('conversation');
		}
	}

	/**
	 * Fills the prompt with a suggestion (a choice refinement) and highlights
	 * the Send button; auto-run executes it immediately.
	 * @param {string} query - The refinement prompt.
	 * @param {boolean} auto_run - Whether to run it right away.
	 */
	#apply_refine_query(query, auto_run) {
		if (this.app.prompt) {
			this.app.prompt.value = query;
			this.app.resize_prompt_textarea();
		}
		this.app.btn_send?.classList.add('btn-action-highlight');
		this.app.btn_rank?.classList.remove('btn-action-highlight');
		this.app.validate_prompt();
		if (auto_run) {
			this.app.send();
		} else {
			// the refinement runs on the Choices tab, where the prompt lives
			this.app.set_active_tab('conversation');
		}
	}

	/**
	 * Prepends an additional item from a ranking result to the top of the
	 * current choice list.
	 * @param {string} choice - The plain-text choice to add.
	 */
	#add_additional_choice(choice) {
		const config = this.app.storage.get_app_config();
		const guid = config[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID] || '';
		const conversation = this.app.storage.get_conversation(guid);
		if (!conversation || !choice) return;
		const choices = Array.isArray(conversation[this.app.storage.KEY_CONVERSATION_CHOICES]) ? conversation[this.app.storage.KEY_CONVERSATION_CHOICES] : [];
		conversation[this.app.storage.KEY_CONVERSATION_CHOICES] = [
			{ guid: crypto.randomUUID(), value: this.#choice_html(choice) },
			...choices
		];
		this.app.storage.save_conversation(guid, conversation);
		this.app.validate_prompt();
		this.app.response_pipeline?.schedule_ranking_title_update(guid);
	}

	/**
	 * Renders the Choices editor for a ranking conversation: one plain-text
	 * editable item per stored choice. Skips rebuilding while the editor holds
	 * focus or matches the stored state, so typing and the caret survive the
	 * storage event echo.
	 * @param {boolean} [force=false] - Whether to force a rebuild even if focused.
	 */
	render_choices_tab(force = false) {
		if (!this.app.div_choices_scroll || !this.app.choices) return;
		const conversation = this.app.get_selected_conversation();
		const selected_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
		const items = Array.isArray(conversation?.[this.app.storage.KEY_CONVERSATION_CHOICES]) ? conversation[this.app.storage.KEY_CONVERSATION_CHOICES] : [];
		const is_same_choices = this.choices_tab_guid === selected_guid;
		if (is_same_choices && !force && (this.app.choices.contains_focus() || this.app.choices.matches_items(items))) return;
		this.choices_tab_guid = selected_guid;
		content_of(this.app.div_choices_scroll).innerHTML = this.app.choices.render(items);
		this.app.choices.attach(content_of(this.app.div_choices_scroll), (newItems) => {
			if (selected_guid) {
				this.app.storage.update_conversation_field(selected_guid, this.app.storage.KEY_CONVERSATION_CHOICES, newItems);
				this.app.validate_prompt();
				this.app.response_pipeline?.schedule_ranking_title_update(selected_guid);
				this.app.sync?.request();
			}
		});
		this.app.choices.set_active_guid(selected_guid, this.app);
	}

	/**
	 * Renders chat history items for standard conversation.
	 * @param {boolean} [scroll_to_last=true] - Whether to auto-scroll.
	 * @returns {Promise<void>}
	 */
	async render_chat_conversation(scroll_to_last = true) {
		if (!this.app.div_response) return;

		const conversation = this.app.get_selected_conversation();
		const app_defaults = this.app.storage.get_app_defaults();
		const show_suggested = (conversation && conversation[this.app.storage.KEY_CONVERSATION_SHOW_SUGGESTED_QUERIES] !== undefined)
			? conversation[this.app.storage.KEY_CONVERSATION_SHOW_SUGGESTED_QUERIES]
			: (app_defaults?.[this.app.storage.KEY_CONFIG_SHOW_SUGGESTED_QUERIES] ?? false);
		const show_related = (conversation && conversation[this.app.storage.KEY_CONVERSATION_SHOW_RELATED_QUERIES] !== undefined)
			? conversation[this.app.storage.KEY_CONVERSATION_SHOW_RELATED_QUERIES]
			: (app_defaults?.[this.app.storage.KEY_CONFIG_SHOW_RELATED_QUERIES] ?? false);

		const options = {
			show_suggested: show_suggested,
			show_related: show_related
		};

		this.app.div_response.innerHTML = this.app.conversation.render(conversation, options);
		await this._post_render_conversation(scroll_to_last);
	}

	/**
	 * Renders conversation items for scratchpad mode.
	 * @param {boolean} [scroll_to_last=true] - Whether to auto-scroll.
	 * @returns {Promise<void>}
	 */
	async render_scratchpad_conversation(scroll_to_last = true) {
		if (!this.app.div_response) return;

		const conversation = this.app.get_selected_conversation();
		const app_defaults = this.app.storage.get_app_defaults();
		const show_suggested = (conversation && conversation[this.app.storage.KEY_CONVERSATION_SHOW_SUGGESTED_QUERIES] !== undefined)
			? conversation[this.app.storage.KEY_CONVERSATION_SHOW_SUGGESTED_QUERIES]
			: (app_defaults?.[this.app.storage.KEY_CONFIG_SHOW_SUGGESTED_QUERIES] ?? false);
		const show_related = (conversation && conversation[this.app.storage.KEY_CONVERSATION_SHOW_RELATED_QUERIES] !== undefined)
			? conversation[this.app.storage.KEY_CONVERSATION_SHOW_RELATED_QUERIES]
			: (app_defaults?.[this.app.storage.KEY_CONFIG_SHOW_RELATED_QUERIES] ?? false);

		const options = {
			show_suggested: show_suggested,
			show_related: show_related
		};

		this.app.div_response.innerHTML = this.app.scratchpad_conversation.render(conversation, options);
		await this._post_render_conversation(scroll_to_last);
		const selected_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
		refresh_scratchpad_drawer(this.app, selected_guid);
	}

	/**
	 * Measures the scratchpad's sticky toolbar height for the drawer to
	 * start beneath it: the toolbar sticks flush to the top of the chat
	 * scroll area, which coincides with the drawer's positioning container.
	 * @returns {number|null} Top offset in px, or null when no toolbar is shown.
	 */
	get_scratchpad_drawer_top_offset() {
		const header = this.app.div_response?.querySelector('.div-chat-scratchpad-buttons');
		if (!header) return null;
		return header.getBoundingClientRect().height;
	}

	/**
	 * Renders Mermaid diagrams in the specified container element.
	 * @param {HTMLElement} container
	 */
	async _render_mermaid_in_container(container) {
		await render_mermaid_in_container(container);
	}

	/**
	 * Renders Mermaid code into a node and inserts the resulting SVG, retrying
	 * once with automatic syntax repairs. Throws the render error when the
	 * diagram still cannot be rendered.
	 * @param {HTMLElement} node
	 * @param {string} code
	 */
	async _render_mermaid_svg(node, code) {
		await render_mermaid_svg(node, code);
	}

	/**
	 * Renders Mermaid code to an SVG element, retrying once with automatic
	 * syntax repairs applied. Mermaid's temporary render elements are cleaned
	 * up between attempts.
	 * @param {string} code - The mermaid source code.
	 * @returns {Promise<{svg: SVGElement|null, bind_functions: function(Element): void|null, error: string}>}
	 */
	async _render_diagram_svg(code) {
		return render_diagram_svg(code);
	}

	/**
	 * Adds (or refreshes) the expand button on a diagram container, placed in
	 * the container's top right corner. With a rendered SVG it opens the
	 * diagram overlay; without one (code fallback) clicking retries the
	 * render and shows the render error in the overlay when the diagram still
	 * cannot be drawn.
	 * @param {HTMLElement} node - The diagram container.
	 * @param {string} code - The mermaid source used to retry rendering.
	 */
	_attach_diagram_expand_button(node, code = '') {
		attach_diagram_expand_button(node, code);
	}

	/**
	 * Re-renders all Mermaid diagrams in the container so already rendered
	 * diagrams pick up the current theme colors, then re-runs MathJax so any
	 * equations inside the diagrams are typeset again.
	 * @param {HTMLElement} container
	 */
	async rerender_mermaid_theme(container) {
		if (typeof mermaid === 'undefined' || !container) return;
		const nodes = Array.from(container.querySelectorAll('[data-mermaid]'));
		for (let i = 0; i < nodes.length; i++) {
			const code = (nodes[i].getAttribute('data-mermaid') || '').trim();
			if (!code) continue;
			try {
				await this._render_mermaid_svg(nodes[i], code);
			} catch (e) {
				console.warn('Mermaid theme re-render error for diagram:', e);
			}
		}

		await typesetMathjax(container);
	}

	/**
	 * Performs post-rendering setup like syntax highlighting, listeners attachment, and scrolling.
	 * @param {boolean} scroll_to_last - Whether to scroll to bottom.
	 */
	async _post_render_conversation(scroll_to_last) {
		highlightCodeBlocks(this.app.div_response);
		await this._render_mermaid_in_container(this.app.div_response);
		attach_code_expand_buttons(this.app.div_response);
		attach_table_expand_buttons(this.app.div_response);
		await typesetMathjax(this.app.div_response);

		this._attach_conversation_listeners();

		if (scroll_to_last) {
			this.app.scroll_to_last_item();
		}
	}

	/**
	 * Attaches click event listeners to interactive elements inside the conversation response.
	 */
	_attach_conversation_listeners() {
		bind_read_buttons(
			this.app.div_response,
			() => {
				const config = this.app.storage.get_app_config();
				return {
					tts_model: config[this.app.storage.KEY_CONFIG_TTS_MODEL] || '',
					voice: config[this.app.storage.KEY_CONFIG_TTS_VOICE] || ''
				};
			},
			(index) => {
				const guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
				const conversation = guid ? this.app.storage.get_conversation(guid) : null;
				const history = conversation?.[this.app.storage.KEY_CONVERSATION_HISTORY];
				const item = history?.[Number(index)];
				if (!item) return '';
				if (conversation[this.app.storage.KEY_CONVERSATION_TYPE] === this.app.storage.CONVERSATION_TYPE_SCRATCHPAD) {
					return String(item.conversationTitle || conversation[this.app.storage.KEY_CONVERSATION_TITLE] || '').trim();
				}
				return String(item.title || '').trim();
			},
			() => this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID],
			() => this.app.storage
		);
		check_autoplay(this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID]);

		this.app.div_response.querySelectorAll('.copyable-code').forEach((btn) => {
			btn.onclick = () => {
				const code = btn.getAttribute('data-code');
				void copyToClipboard(code, btn);
			};
		});

		this.app.div_response.querySelectorAll('.copyable-table').forEach((btn) => {
			btn.onclick = () => {
				const table = btn.closest('table') || btn.nextElementSibling;
				const tableData = btn.getAttribute('data-table');
				void copyToClipboard(table || tableData, btn);
			};
		});

		this.app.div_response.querySelectorAll('.btn-copy-response, .btn-copy-scratchpad').forEach((btn) => {
			btn.onclick = () => {
				const index = btn.getAttribute('data-index');
				const responseDiv = this.app.div_response.querySelector(`.div-response-content[data-index="${index}"]`);
				if (responseDiv) {
					void copyToClipboard(responseDiv, btn);
				}
			};
		});

		this.app.div_response.querySelectorAll('.btn-redo-response, .btn-redo-scratchpad').forEach((btn) => {
			btn.onclick = async () => {
				const index = parseInt(btn.getAttribute('data-index'), 10);
				const selected_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
				if (selected_guid && !isNaN(index)) {
					const conversation = this.app.storage.get_conversation(selected_guid);
					const history = conversation?.[this.app.storage.KEY_CONVERSATION_HISTORY];
					if (history && history[index]) {
						const item = history[index];
						const query = item.query || '';
						const summary = item.summary || '';
						let message = `Do you want to Re-send this query?\n\nQuery: "${query}"`;
						if (summary) {
							message += `\n\nSummary: "${summary}"`;
						}
						if (await customConfirm('Re-send Query', message)) {
							history.splice(index);
							conversation[this.app.storage.KEY_CONVERSATION_HISTORY] = history;
							this.app.storage.save_conversation(selected_guid, conversation);
							this.app.storage.update_app_index(selected_guid, false);

							this.app.prompt.value = query;
							this.app.prompt_drafts[selected_guid] = query;
							this.app.resize_prompt_textarea();
							this.app.validate_prompt();
							this.app.send();
						}
					}
				}
			};
		});

		this.app.div_response.querySelectorAll('.btn-undo-scratchpad').forEach((btn) => {
			btn.onclick = async () => {
				const index = parseInt(btn.getAttribute('data-index'), 10);
				const message = index === 0
					? 'Are you sure you want to remove this scratchpad entry?'
					: 'Are you sure you want to delete the latest item and revert to the previous one?';
				if (await customConfirm('Undo', message)) {
					const selected_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
					if (selected_guid && !isNaN(index)) {
						const conversation = this.app.storage.get_conversation(selected_guid);
						const history = conversation?.[this.app.storage.KEY_CONVERSATION_HISTORY];
						if (history && history[index]) {
							history.splice(index);
							conversation[this.app.storage.KEY_CONVERSATION_HISTORY] = history;
							this.app.storage.save_conversation(selected_guid, conversation);
							this.app.storage.update_app_index(selected_guid, false);
							this.on_conversation_updated(false);
						}
					}
				}
			};
		});

		this.app.div_response.querySelectorAll('.btn-delete-response').forEach((btn) => {
			btn.onclick = async () => {
				const index = parseInt(btn.getAttribute('data-index'), 10);
				const selected_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
				if (selected_guid && !isNaN(index)) {
					const conversation = this.app.storage.get_conversation(selected_guid);
					const history = conversation?.[this.app.storage.KEY_CONVERSATION_HISTORY];
					if (history && history[index]) {
						const item = history[index];
						const summary = item.summary || item.query || 'this response';
						if (await customConfirm('Delete Response', `Are you sure you want to delete: "${summary}"?`)) {
							const wasLastMessage = (index === history.length - 1);
							history.splice(index, 1);
							conversation[this.app.storage.KEY_CONVERSATION_HISTORY] = history;
							this.app.storage.save_conversation(selected_guid, conversation);
							this.on_conversation_updated(false);
							if (wasLastMessage) {
								await this.update_conversation_title_after_delete(conversation, selected_guid);
							}
						}
					}
				}
			};
		});

		this.app.div_response.querySelectorAll('.btn-delete-scratchpad').forEach((btn) => {
			btn.onclick = async () => {
				if (await customConfirm('Delete Scratchpad', 'Are you sure you want to delete this entire scratchpad conversation?')) {
					const config = this.app.storage.get_app_config();
					const selected_guid = config[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
					if (selected_guid) {
						delete this.app.prompt_drafts[selected_guid];
						this.app.content_list?.index_delete(selected_guid);
						if (this.app.scratchpad_index) {
							this.app.scratchpad_index.on_conversation_index_updated();
						}
						const remaining = this.app.scratchpad_index ? this.app.scratchpad_index.get_scratchpad_conversations() : [];
						if (remaining.length > 0) {
							this.app.storage.update_app_config(this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID, remaining[0][this.app.storage.KEY_INDEX_GUID]);
							this.on_conversation_updated(false);
						} else {
							this.app.close_conversation();
							this.on_conversation_updated(false);
						}
					}
				}
			};
		});

		this.app.div_response.querySelectorAll('.p-thinking-header').forEach((header) => {
			header.onclick = () => {
				const container = header.closest('.div-thinking-content');
				if (!container) return;
				const isExpanded = container.classList.contains('thinking-expanded');
				const arrow = header.querySelector('.span-thinking-arrow');
				const textDiv = container.querySelector('.div-thinking-text');
				if (isExpanded) {
					container.classList.remove('thinking-expanded');
					container.classList.add('thinking-collapsed');
					if (arrow) arrow.textContent = '\u25b6';
					if (textDiv) textDiv.style.display = 'none';
				} else {
					container.classList.remove('thinking-collapsed');
					container.classList.add('thinking-expanded');
					if (arrow) arrow.textContent = '\u25bc';
					if (textDiv) textDiv.style.display = 'block';
				}
			};
		});

		this.app.div_response.querySelectorAll('.proposed-query-btn, .related-query-btn, .button-clarification, .span-clickable-query, .span-query-chip').forEach((btn) => {
			btn.onclick = () => {
				const query = btn.getAttribute('data-query') || btn.textContent.trim();
				if (query) {
					this.app.send_suggested_query(query);
				}
			};
		});

		this.app.div_response.querySelectorAll('.bookmark-response-btn').forEach((btn) => {
			btn.onclick = () => {
				const index = parseInt(btn.getAttribute('data-index'), 10);
				const selected_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
				if (selected_guid && !isNaN(index)) {
					const conversation = this.app.storage.get_conversation(selected_guid);
					const history = conversation[this.app.storage.KEY_CONVERSATION_HISTORY];
					if (history && history[index]) {
						history[index].bookmark = !history[index].bookmark;
						conversation[this.app.storage.KEY_CONVERSATION_HISTORY] = history;
						this.app.storage.save_conversation(selected_guid, conversation);
						this.on_conversation_updated(false);
					}
				}
			};
		});

		this.app.div_response.querySelectorAll('a[href^="#"]').forEach((anchor) => {
			anchor.onclick = (e) => {
				e.preventDefault();
				const id = decodeURIComponent((anchor.getAttribute('href') || '').slice(1));
				const target = document.getElementById(id) || document.getElementsByName(id)[0];
				if (target) {
					target.scrollIntoView({ behavior: 'smooth' });
				}
			};
		});

		this.app.div_response.querySelectorAll('.chain-toggle-btn').forEach((btn) => {
			btn.onclick = () => {
				const index = parseInt(btn.getAttribute('data-index'), 10);
				const selected_guid = this.app.storage.get_app_config()[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
				if (selected_guid && !isNaN(index)) {
					const conversation = this.app.storage.get_conversation(selected_guid);
					const history = conversation[this.app.storage.KEY_CONVERSATION_HISTORY];
					if (history && history[index]) {
						history[index].chain = (history[index].chain === false);
						conversation[this.app.storage.KEY_CONVERSATION_HISTORY] = history;
						this.app.storage.save_conversation(selected_guid, conversation);
						this.on_conversation_updated(false);
					}
				}
			};
		});

		this._bind_response_toolbar_wrap();
	}

	/**
	 * Watches each response toolbar in the rendered conversation and toggles
	 * its wrapped class whenever the buttons do not fit one row at the
	 * current width: copy with redo/undo/delete stay on the first row and
	 * the audio controls move to a full-width second row. Re-checked on
	 * container resize, so narrowing the surface (e.g. a pinned drawer)
	 * re-splits the rows.
	 */
	_bind_response_toolbar_wrap() {
		this.app.div_response?.querySelectorAll('.div-chat-response-buttons').forEach((bar) => {
			let last_width = null;
			const update = () => {
				const width = bar.clientWidth;
				if (width === last_width) return;
				last_width = width;
				bar.classList.remove('wrapped');
				bar.classList.add('wrap-check');
				const overflow = bar.scrollWidth > bar.clientWidth + 1;
				bar.classList.remove('wrap-check');
				bar.classList.toggle('wrapped', overflow);
			};
			update();
			new ResizeObserver(update).observe(bar);
		});
	}

	/**
	 * Updates full conversation UI whenever active conversation data is modified.
	 * @param {boolean} [scroll_to_last=true] - Whether to scroll to bottom.
	 */
	on_conversation_updated(scroll_to_last = true) {
		const config = this.app.storage.get_app_config();
		const selected_guid = config[this.app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];

		// selection changed: empty the drawer now so stale content never
		// lingers while the new conversation renders
		if (this.app.scratchpad_drawer && this.app.scratchpad_drawer.source !== selected_guid) {
			this.app.scratchpad_drawer.clear();
		}

		this.app._sync_prompt_draft(selected_guid);
		this._update_sync_indicators();

		if (!selected_guid) {
			this.app.state_i_open = false;
			this._reset_chat_view();
			this.app.apply_panels_layout();
			this.app.validate_conversation_index_button();
			this.app.content_list?.apply_selected_index_class?.();
			this.app.scratchpad_index?.highlight_active_index_item?.();
			return;
		}

		if (selected_guid === 'tools') {
			this.app.state_i_open = true;
			if (window.innerWidth <= this.app.BREAKPOINT_TABLET) {
				this.app.state_v_open = false;
			}
			this.app.apply_panels_layout();
			this._setup_tools_conversation_ui();
			this._reset_chat_view();
			this.app.content_list?.apply_selected_index_class?.();
			this.app.validate_conversation_index_button();
			return;
		}

		if (selected_guid === 'scratchpad') {
			this.app.state_i_open = true;
			if (window.innerWidth <= this.app.BREAKPOINT_TABLET) {
				this.app.state_v_open = false;
			}
			this.app.apply_panels_layout();
			this._setup_scratchpad_conversation_ui();
			this._reset_chat_view();
			this.app.content_list?.apply_selected_index_class?.();
			this.app.scratchpad_index?.highlight_active_index_item?.();
			this.app.validate_conversation_index_button();
			return;
		}

		setElementDisplay(this.app.div_chat_empty, 'none');
		setElementDisplay(this.app.div_chat_empty_header, 'none');
		this.app.div_chat_container?.classList.remove('div-chat-empty-state');
		(this.app.div_chat_ui_elements || []).forEach(el => setElementDisplay(el, ''));
		setElementDisplay(this.app.div_chat_title_bar, '');
		setElementDisplay(this.app.div_chat_prompt_inset, 'block');

		const conversation = this.app.storage.get_conversation(selected_guid);
		const app_defaults = this.app.storage.get_app_defaults();
		const type = conversation?.[this.app.storage.KEY_CONVERSATION_TYPE] || this.app.storage.CONVERSATION_TYPE_CHAT;

		if (type === this.app.storage.CONVERSATION_TYPE_CHAT) {
			const count = this.app.get_conversation_item_count();
			if (count === 0 && this.app.state_i_open) {
				this.app.state_i_open = false;
				this.app.apply_panels_layout();
			}
			this._setup_chat_conversation_ui();
			if (this.app.btn_conversation_new) this.app.btn_conversation_new.textContent = 'New Chat';
		} else if (type === this.app.storage.CONVERSATION_TYPE_SCRATCHPAD) {
			this._setup_scratchpad_conversation_ui();
			if (this.app.btn_conversation_new) this.app.btn_conversation_new.textContent = 'New';
		} else if (type === this.app.storage.CONVERSATION_TYPE_RANKING || type === this.app.storage.CONVERSATION_TYPE_COLORS) {
			this._setup_tools_conversation_ui();
			if (this.app.btn_conversation_new) this.app.btn_conversation_new.textContent = 'New';
		} else if (type === this.app.storage.CONVERSATION_TYPE_STICKY_NOTES) {
			this._setup_tools_conversation_ui();
			if (this.app.btn_conversation_new) this.app.btn_conversation_new.textContent = 'New';
		} else if (type === this.app.storage.CONVERSATION_TYPE_DOCUMENT) {
			this._setup_document_conversation_ui(conversation);
		}

		this.app.content_list?.apply_selected_index_class?.();
		this.app.scratchpad_index?.highlight_active_index_item?.();
		this.app.tools_index?.highlight_active_index_item?.();

		this._update_tab_visibility(this.app.state_active_tab, type, config[this.app.storage.KEY_CONFIG_EXPERIMENTAL_FEATURES]);


		const is_active_request = this.app.api.is_request_active(selected_guid);
		if (is_active_request) {
			this.app.show_progress_ui(selected_guid);
		} else {
			this.app.hide_progress_ui();
			if (this.app.prompt) {
				this.app.validate_prompt();
				this.app.resize_prompt_textarea();
			}
		}

		const verbosity = conversation?.[this.app.storage.KEY_CONVERSATION_VERBOSITY] || app_defaults?.[this.app.storage.KEY_CONFIG_DEFAULT_VERBOSITY] || 'standard';
		const family = conversation?.[this.app.storage.KEY_CONVERSATION_MODEL_FAMILY] || app_defaults?.[this.app.storage.KEY_CONFIG_DEFAULT_MODEL_FAMILY] || 'gemini';
		const model_version = conversation?.[this.app.storage.KEY_CONVERSATION_MODEL_VERSION] || app_defaults?.[this.app.storage.KEY_CONFIG_DEFAULT_MODEL_VERSION] || 'gemini-3.5-flash-lite';
		const short_model_name = formatModelShortName(family, model_version, Api.MODELS);

		if (this.app.span_option_model) {
			this.app.span_option_model.innerHTML = `<span style="opacity: 0.5">Model:</span> ${short_model_name}`;
		}
		if (this.app.span_option_verbosity) {
			this.app.span_option_verbosity.innerHTML = `<span style="opacity: 0.5">Verbosity:</span> ${verbosity}`;
		}

		const show_suggested = (conversation && conversation[this.app.storage.KEY_CONVERSATION_SHOW_SUGGESTED_QUERIES] !== undefined)
			? conversation[this.app.storage.KEY_CONVERSATION_SHOW_SUGGESTED_QUERIES]
			: (app_defaults?.[this.app.storage.KEY_CONFIG_SHOW_SUGGESTED_QUERIES] ?? false);
		const show_related = (conversation && conversation[this.app.storage.KEY_CONVERSATION_SHOW_RELATED_QUERIES] !== undefined)
			? conversation[this.app.storage.KEY_CONVERSATION_SHOW_RELATED_QUERIES]
			: (app_defaults?.[this.app.storage.KEY_CONFIG_SHOW_RELATED_QUERIES] ?? false);
		const enforce_topics = (conversation && conversation[this.app.storage.KEY_CONVERSATION_ENFORCE_TOPICS] !== undefined)
			? conversation[this.app.storage.KEY_CONVERSATION_ENFORCE_TOPICS]
			: (app_defaults?.[this.app.storage.KEY_CONFIG_ENFORCE_TOPICS] ?? false);
		const auto_run = (conversation && conversation[this.app.storage.KEY_CONVERSATION_AUTO_RUN_PROPOSED_QUERIES] !== undefined)
			? conversation[this.app.storage.KEY_CONVERSATION_AUTO_RUN_PROPOSED_QUERIES]
			: (app_defaults?.[this.app.storage.KEY_CONFIG_AUTO_RUN_PROPOSED_QUERIES] ?? false);

		if (this.app.span_option_suggested) {
			this.app.span_option_suggested.innerHTML = `<span style="opacity: 0.5">Suggested:</span> ${show_suggested ? 'ON' : 'OFF'}`;
		}
		if (this.app.span_option_related) {
			this.app.span_option_related.innerHTML = `<span style="opacity: 0.5">Related:</span> ${show_related ? 'ON' : 'OFF'}`;
		}

		if (this.app.span_option_search) {
			const google_search = conversation?.[this.app.storage.KEY_CONVERSATION_SEARCH] || false;
			this.app.span_option_search.innerHTML = `<span style="opacity: 0.5">Search:</span> ${google_search ? 'ON' : 'OFF'}`;
			this.app.span_option_search.style.display = this.visible_chips.has('search') ? '' : 'none';
		}

		if (this.app.span_option_enforce_topics) {
			this.app.span_option_enforce_topics.innerHTML = `<span style="opacity: 0.5">Enforce Topics:</span> ${enforce_topics ? 'ON' : 'OFF'}`;
		}
		if (this.app.span_option_auto_run) {
			this.app.span_option_auto_run.innerHTML = `<span style="opacity: 0.5">Auto-Run:</span> ${auto_run ? 'ON' : 'OFF'}`;
		}

		this.render_conversation_header();

		if (this.app.state_active_tab === 'conversation') {
			void this.render_conversation_tab(scroll_to_last);
		} else if (this.app.state_active_tab === 'context') {
			this.render_context_tab();
		} else if (this.app.state_active_tab === 'tags') {
			this.render_tags_tab();
		} else if (this.app.state_active_tab === 'attachments') {
			this.render_attachments_tab();
		} else if (this.app.state_active_tab === 'memory') {
			this.render_memory_tab();
		} else if (this.app.state_active_tab === 'document') {
			this.render_document_tab();
		} else if (this.app.state_active_tab === 'diction') {
			this.render_diction_tab();
		} else if (this.app.state_active_tab === 'rankings') {
			this.render_rankings_tab();
		}

		this.app.validate_conversation_index_button();
	}

	/**
	 * Resets the chat view to its empty state when no conversation is selected.
	 */
	_reset_chat_view() {
		setElementDisplay(this.app.div_chat_empty, 'grid');
		setElementDisplay(this.app.div_chat_empty_header, 'block');
		this.app.div_chat_container?.classList.add('div-chat-empty-state');
		(this.app.div_chat_ui_elements || []).forEach(el => setElementDisplay(el, 'none'));
		setElementDisplay(this.app.div_options_chips, 'none');

		if (this.app.div_title) this.app.div_title.innerHTML = '';
		this.set_header_loading(false);

		setElementDisplay(this.app.div_chat_prompt_container, 'none');
		setElementDisplay(this.app.div_chat_title_bar, 'none');
		this._update_tab_visibility(null, null, false);
		setElementDisplay(this.app.div_chat_prompt_inset, 'none');

		setElementDisplay(this.app.btn_empty_new_scratchpad, 'block');
		setElementDisplay(this.app.btn_show_scratchpads_new, 'block');
		setElementDisplay(this.app.btn_empty_new_chat, 'block');
		setElementDisplay(this.app.btn_show_content, 'block');
		setElementDisplay(this.app.div_choices_container, 'none');
		this.app.choices?.set_drawer_visible?.(false);
		setElementDisplay(this.app.div_notes_container, 'none');
		this.app.sticky?.set_drawer_visible?.(false);
		const config = this.app.storage?.get_app_config();
		const experimental_features_enabled = config?.[this.app.storage.KEY_CONFIG_EXPERIMENTAL_FEATURES] || false;
		const documents_enabled = experimental_features_enabled && (config?.[this.app.storage.KEY_CONFIG_DOCUMENTS] || false);
		setElementDisplay(this.app.select_new_document, 'block');
		for (const option of this.app.select_new_document?.options || []) {
			if (option.value && option.value !== 'PAGE') option.hidden = !documents_enabled;
		}
		const documentRow = getEl('id-div-empty-document-row');
		if (documentRow) {
			documentRow.style.display = 'flex';
		}
	}

	/**
	 * Builds the inventories of every toggleable element in the prompt box
	 * area, with the current experimental feature flags applied.
	 * @param {boolean} experimentalFeaturesEnabled - Whether experimental features are enabled.
	 * @returns {{tabs: Object, rows: Object, chips: Object, flags: Object}} The element inventories and feature flags.
	 */
	_ui_inventory(experimentalFeaturesEnabled) {
		const config = this.app.storage.get_app_config();
		const enabled = (key) => experimentalFeaturesEnabled && (config?.[key] || false);
		const flags = {
			attachments: enabled(this.app.storage.KEY_CONFIG_ATTACHMENTS),
			memory: enabled(this.app.storage.KEY_CONFIG_MEMORY),
			diction: enabled(this.app.storage.KEY_CONFIG_DICTION)
		};
		const tabs = {
			conversation: { btn: this.app.btn_tab_conversation, scroll: this.app.div_chat_container_scroll, prompt: true },
			rankings: { btn: this.app.btn_tab_rankings, scroll: this.app.div_chat_rankings_scroll },
			tags: { btn: this.app.btn_tab_tags, scroll: this.app.div_chat_tags_scroll },
			context: { btn: this.app.btn_tab_context, scroll: this.app.div_chat_context_scroll },
			attachments: { btn: this.app.btn_tab_attachments, scroll: this.app.div_chat_attachments_scroll, feature: 'attachments' },
			memory: { btn: this.app.btn_tab_memory, scroll: this.app.div_chat_memory_scroll, alt_scroll: this.app.div_document_memory_scroll, feature: 'memory' },
			document: { btn: this.app.btn_tab_document, scroll: this.app.div_chat_document_scroll, prompt: true },
			diction: { btn: this.app.btn_tab_diction, scroll: this.app.div_chat_diction_scroll, feature: 'diction' }
		};
		const rows = {
			nav: this.app.div_buttons_nav,
			chat: this.app.div_buttons_chat,
			document: this.app.div_buttons_document
		};
		const chips = {
			model: this.app.span_option_model,
			verbosity: this.app.span_option_verbosity,
			suggested: this.app.span_option_suggested,
			related: this.app.span_option_related,
			search: this.app.span_option_search,
			'enforce-topics': this.app.span_option_enforce_topics,
			'auto-run': this.app.span_option_auto_run
		};
		return { tabs, rows, chips, flags };
	}

	/**
	 * Applies the UI visibility sets for the current conversation mode: the
	 * tab bar buttons, prompt button rows, and option chips, then activates
	 * the requested tab with its scroll container. Falls back to the first
	 * visible tab of the set when the requested tab is not available in this
	 * mode or is gated off by its experimental feature. The scratchpad
	 * drawer is available while a scratchpad conversation is open on its
	 * conversation tab.
	 * @param {string|null} activeTabName - The name of the requested active tab.
	 * @param {string|null} conversationType - The type of the current conversation.
	 * @param {boolean} experimentalFeaturesEnabled - Whether experimental features are enabled.
	 */
	_update_tab_visibility(activeTabName, conversationType, experimentalFeaturesEnabled) {
		const { tabs, rows, chips, flags } = this._ui_inventory(experimentalFeaturesEnabled);

		for (const key in tabs) {
			if (tabs[key].btn) {
				setElementDisplay(tabs[key].btn, 'none');
				tabs[key].btn.classList.remove('active');
			}
			if (tabs[key].scroll) {
				setElementDisplay(tabs[key].scroll, 'none');
			}
			if (tabs[key].alt_scroll) {
				setElementDisplay(tabs[key].alt_scroll, 'none');
			}
		}
		for (const key in rows) {
			setElementDisplay(rows[key], 'none');
		}
		for (const key in chips) {
			setElementDisplay(chips[key], 'none');
		}

		setElementDisplay(this.app.div_tab_bar, 'none');
		setElementDisplay(this.app.div_chat_prompt_container, 'none');
		this.app.select_scratchpad_export?.classList.add('export-hidden');
		this.app.scratchpad_drawer?.set_visible(false);

		if (!activeTabName || !conversationType) {
			return;
		}

		const setName = conversationType === this.app.storage.CONVERSATION_TYPE_DOCUMENT
			? 'document'
			: conversationType === this.app.storage.CONVERSATION_TYPE_RANKING
				? 'ranking'
				: conversationType === this.app.storage.CONVERSATION_TYPE_STICKY_NOTES
					? 'stickies'
					: 'chat';
		const set = UI_SETS[setName];

		if (tabs.conversation.btn) {
			tabs.conversation.btn.textContent = conversationType === this.app.storage.CONVERSATION_TYPE_RANKING
				? 'Choices'
				: conversationType === this.app.storage.CONVERSATION_TYPE_COLORS
					? 'Palette'
					: conversationType === this.app.storage.CONVERSATION_TYPE_STICKY_NOTES
						? 'Notes'
						: 'Conversation';
		}

		setElementDisplay(this.app.div_tab_bar, '');
		for (const key of set.rows) {
			setElementDisplay(rows[key], '');
		}
		for (const key of set.chips) {
			setElementDisplay(chips[key], '');
		}
		this.visible_chips = new Set(set.chips);

		const visibleTabs = set.tabs.filter((key) => !tabs[key].feature || flags[tabs[key].feature]);
		for (const key of visibleTabs) {
			setElementDisplay(tabs[key].btn, 'inline-block');
		}

		const active = visibleTabs.includes(activeTabName) ? activeTabName : visibleTabs[0];
		this.app.state_active_tab = active;

		// document mode swaps the new-chat button for the preview/edit toggle;
		// scratchpad mode shows the export select next to it
		const is_document = conversationType === this.app.storage.CONVERSATION_TYPE_DOCUMENT;
		const is_scratchpad = conversationType === this.app.storage.CONVERSATION_TYPE_SCRATCHPAD;
		const is_ranking = conversationType === this.app.storage.CONVERSATION_TYPE_RANKING;
		setElementDisplay(this.app.btn_document_preview, is_document ? 'inline-block' : 'none');
		setElementDisplay(this.app.btn_conversation_new, is_document ? 'none' : 'inline-block');
		setElementDisplay(this.app.btn_rank, is_ranking ? 'inline-block' : 'none');
		this.app.div_chat_buttons?.classList.toggle('ranking-buttons', is_ranking);
		this.app.select_scratchpad_export?.classList.toggle('export-hidden', !is_scratchpad);
		this.app.div_chat_buttons?.classList.toggle('document-buttons', is_document);
		this.app.div_chat_buttons?.classList.toggle('scratchpad-buttons', is_scratchpad);

		const tab = tabs[active];
		tab.btn.classList.add('active');

		// the option chips ride with prompt-bearing tabs, and with the
		// Rankings tab where the Suggested/Related pills control chip visibility
		const show_chips = !!tab.prompt || (conversationType === this.app.storage.CONVERSATION_TYPE_RANKING && active === 'rankings');
		setElementDisplay(this.app.div_options_chips, show_chips ? 'block' : 'none');
		let scroll = tab.scroll;
		if (active === 'memory' && conversationType === this.app.storage.CONVERSATION_TYPE_DOCUMENT) {
			scroll = tab.alt_scroll;
		}
		if (scroll) {
			setElementDisplay(scroll, 'grid');
		}

		// tool conversations host the Choices editor on their conversation
		// tab instead of the chat history, with the thinking drawer beside it
		const is_tool_type = conversationType === this.app.storage.CONVERSATION_TYPE_RANKING || conversationType === this.app.storage.CONVERSATION_TYPE_COLORS;
		const choices_ui = is_tool_type && active === 'conversation';
		setElementDisplay(this.app.div_choices_container, choices_ui ? 'flex' : 'none');
		if (choices_ui) {
			setElementDisplay(this.app.div_chat_container_scroll, 'none');
		}
		this.app.choices?.set_drawer_visible?.(choices_ui);
		if (choices_ui) {
			// the container just became measurable: re-run the pin feasibility
			// check that update_availability skipped while it was hidden
			this.app.choices?.set_drawer_visible?.(true);
		}

		// sticky notes host their board on the conversation tab the same way
		const notes_ui = conversationType === this.app.storage.CONVERSATION_TYPE_STICKY_NOTES && active === 'conversation';
		setElementDisplay(this.app.div_notes_container, notes_ui ? 'flex' : 'none');
		if (notes_ui) {
			setElementDisplay(this.app.div_chat_container_scroll, 'none');
		}
		this.app.sticky?.set_drawer_visible?.(notes_ui);
		if (notes_ui) {
			this.app.sticky?.set_drawer_visible?.(true);
		}

		if (this.app.div_chat_prompt_container) {
			setElementDisplay(this.app.div_chat_prompt_container, tab.prompt ? 'grid' : 'none');
		}
		// the document editor chrome (toolbars + scroll area) shows with its tab
		const document_ui = conversationType === this.app.storage.CONVERSATION_TYPE_DOCUMENT && active === 'document';
		setElementDisplay(this.app.div_document_container, document_ui ? 'flex' : 'none');

		this.app.scratchpad_drawer?.set_visible(
			conversationType === this.app.storage.CONVERSATION_TYPE_SCRATCHPAD && active === 'conversation'
		);
	}

	/**
	 * Sets up the center panel for the tools index: the shared conversation
	 * toolbar (Select/Bookmark/Delete) drives the tool entries, and the tool
	 * trigger buttons are rendered at the top of the index list.
	 */
	_setup_tools_conversation_ui() {
		setElementDisplay(this.app.div_index_list, '');
		setElementDisplay(this.app.div_structure_center_index_options, 'grid');
		setElementDisplay(this.app.div_structure_center_document_options, 'none');
		this.app.tools_index.on_tools_index_updated();
	}

	/**
	 * Sets up the center panel for a standard chat conversation.
	 */
	_setup_chat_conversation_ui() {
		setElementDisplay(this.app.div_index_list, '');
		setElementDisplay(this.app.div_structure_center_index_options, 'grid');
		setElementDisplay(this.app.div_structure_center_document_options, 'none');
		this.app.conversation_index.on_conversation_index_updated();
	}

	/**
	 * Sets up the center panel for a scratchpad conversation.
	 */
	_setup_scratchpad_conversation_ui() {
		setElementDisplay(this.app.div_index_list, '');
		setElementDisplay(this.app.div_structure_center_index_options, 'grid');
		setElementDisplay(this.app.div_structure_center_document_options, 'none');
		this.app.scratchpad_index.on_conversation_index_updated();
	}

	/**
	 * Sets up the center panel for a document conversation. The document type
	 * is displayed as a section header above the index list. Opening and
	 * closing the column itself is handled at selection time; Page documents
	 * keep it closed.
	 * @param {Object} conversation - The document conversation.
	 */
	_setup_document_conversation_ui(conversation) {
		const doc_type = conversation?.[this.app.storage.KEY_CONVERSATION_DOCUMENT_TYPE];
		setElementDisplay(this.app.div_index_list, '');
		setElementDisplay(this.app.div_structure_center_index_options, 'none');
		setElementDisplay(this.app.div_structure_center_document_options, 'grid');
		this.app.document_index.render(this.app.div_index_list, this.app.storage.get_document_type_label(doc_type));
	}
}

/**
 * Formats a date as "April 1, 1988 at 14:36".
 * @param {Date} date - The date to format.
 * @returns {string} The formatted date string.
 */
function format_cleared_date(date) {
	const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
	const hours = String(date.getHours()).padStart(2, '0');
	const minutes = String(date.getMinutes()).padStart(2, '0');
	return `${months[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()} at ${hours}:${minutes}`;
}

export default ChatRenderer;
🌐
chat-renderer.js ×
Type: Web, text/x-c++
70.00 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File