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;