/** * UI renders the chat and owns every DOM interaction: messages, * streaming drafts, the compose area, the toolbar, and the modals. * It reports user actions to main.js through the handlers given at * construction, and main.js calls back with data to render. * * Division of labor: * - UI decides how things look and when a click counts. Destructive * actions (send-as-stop, reset, delete) need a second press. * - Main decides what the clicks mean: it sends the requests and * changes the state, then calls UI with the result. * - UI never sends requests and never reads or writes localStorage. * It keeps presentation state only: scroll-follow, open and * closed sections, recording indicators, and the set of tool * calls that wait for an approval. * * Saved messages are rendered from row objects: { id, role, * sequence, updated_at, content, metadata, voice }. content is the * model-visible message JSON, the same object the server stores. * Drafts are rendered from the unfinished output that main.js * accumulates; drafts have no row and are never cached. */ const ICONS_URL = '/static/icons.svg'; /** * extractText concatenates the text of a message content field. * The field is either a plain string or an array of content parts; * only text and refusal parts carry text. * @param {string | Array} content * @returns {string} */ export function extractText(content) { if (typeof content === 'string') return content; if (!Array.isArray(content)) return ''; return content .filter((part) => part.type === 'text' || part.type === 'refusal') .map((part) => part.text || part.refusal || '') .join('\n'); } // CONVERSATION_DATE formats conversation timestamps in the browser // locale. Until the server sets a title from the first user message, an // untitled conversation uses its formatted creation date as its label. const CONVERSATION_DATE = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short', }); /** * conversationLabel returns the text that names a conversation in * the picker: its title, or its creation date when it has none. * @param {{title: string | null, created_at: string}} conversation * @returns {string} */ export function conversationLabel(conversation) { return ( conversation.title ?? CONVERSATION_DATE.format(new Date(conversation.created_at)) ); } export class UI { /** * @param {Object} options * @param {Document} options.document * @param {Object} options.handlers - callbacks main.js provides. */ constructor({ document, handlers }) { this.document = document; this.handlers = handlers; // Presentation state. this.isAutoScrollEnabled = false; this.modelContextSizes = new Map(); this.pendingApprovals = []; this.$pendingConfirm = null; // Chat and compose. this.$chat = document.getElementById('chat'); this.$chatLoading = document.getElementById('chat-loading'); this.$attachments = document.getElementById('attachments'); this.$compose = document.querySelector('.compose'); this.$textInput = document.getElementById('text-input'); this.$fileInput = document.getElementById('file-input'); this.$attach = document.getElementById('attach'); this.$location = document.getElementById('location'); this.$ptt = document.getElementById('ptt'); this.$transcribe = document.getElementById('transcribe'); this.$send = document.getElementById('send'); // Last enabled state of the send button. setBusy restores it // when a run ends. this.sendEnabled = false; // Toolbar. this.$context = document.getElementById('context'); this.$reset = document.getElementById('reset'); this.$scrollDown = document.getElementById('scroll-down'); this.$mute = document.getElementById('mute'); this.$settings = document.getElementById('settings'); // Settings modal. this.$settingsOverlay = document.getElementById('settings-overlay'); this.$settingsClose = document.getElementById('settings-close'); this.$settingsTabs = document.querySelectorAll('.settings-tab'); this.$settingsPanels = document.querySelectorAll('.settings-tab-panel'); this.$model = document.getElementById('model'); this.$voice = document.getElementById('voice'); this.$systemMessageInput = document.getElementById('system-message-input'); this.$saveSystemMessage = document.getElementById('save-system-message'); // Conversations modal. this.$conversations = document.getElementById('conversations'); this.$conversationsOverlay = document.getElementById( 'conversations-overlay', ); this.$conversationsClose = document.getElementById('conversations-close'); this.$conversationsSearch = document.getElementById('conversation-search'); this.$conversationsList = document.getElementById('conversation-list'); this.$tplConversationItem = document.getElementById( 'tpl-conversation-item', ); this.$tplRenameModal = document.getElementById('tpl-rename-modal'); // Compose settings modal. this.$composeSettings = document.getElementById('compose-settings'); this.$composeSettingsOverlay = document.getElementById( 'compose-settings-overlay', ); this.$composeSettingsClose = document.getElementById( 'compose-settings-close', ); this.$maxTurnsInput = document.getElementById('max-turns-input'); this.$reasoningBudgetInput = document.getElementById( 'reasoning-budget-input', ); // Message templates. this.$tplUserMessage = document.getElementById('tpl-user-message'); this.$tplAssistantMessage = document.getElementById( 'tpl-assistant-message', ); this.$tplReasoningMessage = document.getElementById( 'tpl-reasoning-message', ); this.$tplToolMessage = document.getElementById('tpl-tool-message'); this.$tplSystemMessage = document.getElementById('tpl-system-message'); this.$tplErrorMessage = document.getElementById('tpl-error-message'); this.$tplAttachmentChip = document.getElementById('tpl-attachment-chip'); this.$tplImagePill = document.getElementById('tpl-image-pill'); this.$tplDebugRow = document.getElementById('tpl-debug-row'); this.$tplEditModal = document.getElementById('tpl-edit-modal'); this.$tplEditToolModal = document.getElementById('tpl-edit-tool-modal'); this._bindEvents(); this._initViewport(); } // ==================== // COMPOSE AREA // ==================== /** * getComposeText reads the compose text without changing it. * @returns {string} */ getComposeText() { return this.$textInput.value; } /** * takeComposeText reads the compose text and clears the field. * Call it only when the text is accepted for sending; main.js * restores it with appendComposeText when a request fails. * @returns {string} */ takeComposeText() { const text = this.$textInput.value; this.$textInput.value = ''; this._resizeTextarea(); return text; } /** * appendComposeText puts text back into the compose field after a * failed send, keeping any text typed in the meantime. * @param {string} text */ appendComposeText(text) { if (!text) return; const existing = this.$textInput.value; this.$textInput.value = existing ? `${existing}\n${text}` : text; this._resizeTextarea(); } /** focusCompose moves the caret into the compose field. */ focusCompose() { this.$textInput.focus(); } /** * setBusy reflects whether work is running. While busy, the send * button acts as a stop control and the input controls are * disabled; the compose text stays editable so nothing typed is * lost. * @param {boolean} busy */ setBusy(busy) { this.$send.classList.toggle('loading', busy); if (busy) { this.$send.setAttribute('aria-label', 'Stop'); // While busy the send button is the stop control, so it must // stay clickable whatever the last enabled state was. this.$send.disabled = false; } else { // Restore the enabled state recorded before the run. A caller // cannot leave the button disabled by forgetting to call // updateSendEnabled after this. this.setSendEnabled(this.sendEnabled); } this.$chatLoading.hidden = !busy; this.$ptt.disabled = busy; this.$transcribe.disabled = busy; this.$attach.disabled = busy; this.$location.disabled = busy; } /** * setSendEnabled enables the send button. The button stays * clickable while busy, because it is the stop control then. * @param {boolean} enabled */ setSendEnabled(enabled) { // Record the state even while busy, so that setBusy can // restore it when the run ends. this.sendEnabled = enabled; if (this.$send.classList.contains('loading')) return; this.$send.disabled = !enabled; this.$send.setAttribute( 'aria-label', enabled ? 'Send message' : 'Start a conversation to enable send', ); } /** * setRecording reflects the push-to-talk state. * @param {boolean} recording */ setRecording(recording) { this.$ptt.classList.toggle('recording', recording); this.$ptt.setAttribute('aria-pressed', String(recording)); } /** * setTranscribing reflects the record-to-text state. * @param {boolean} transcribing */ setTranscribing(transcribing) { this.$transcribe.classList.toggle( 'compose__action-btn--recording', transcribing, ); } /** * renderAttachments shows one chip per attached file. onRemove is * called with the index of the chip the user removed. * @param {File[]} files * @param {(index: number) => void} onRemove */ renderAttachments(files, onRemove) { this.$attachments.replaceChildren(); for (const [index, file] of files.entries()) { const $chip = this.$tplAttachmentChip.content.cloneNode(true).firstElementChild; const $name = $chip.querySelector('.compose__attachment-name'); $name.textContent = file.name; $name.title = file.name; $chip .querySelector('.compose__attachment-remove') .addEventListener('click', () => onRemove(index)); this.$attachments.appendChild($chip); } } /** * setLocationActive reflects whether the next message will include * the GPS location. * @param {boolean} active */ setLocationActive(active) { this.$location.classList.toggle('compose__action-btn--location', active); this.$location.classList.toggle('active', active); } // ==================== // MESSAGES // ==================== /** * renderMessages renders saved message rows, in the order given, * into the chat. Bubbles that already exist are skipped, so * calling it again with the same rows is safe. * * A tool result fills the bubble of the call it answers, wherever * that bubble is. A tool result whose call has no bubble renders * as a standalone bubble with its output only. * * @param {Array} rows - saved message rows, in order. */ renderMessages(rows) { // Map each tool call ID in rows to its result row. const callIds = new Set(); for (const row of rows) { for (const call of row.content?.tool_calls ?? []) { callIds.add(call.id); } } const results = new Map(); for (const row of rows) { const callId = row.content?.tool_call_id; if (row.role === 'tool' && callIds.has(callId)) { results.set(callId, row); } } for (const row of rows) { switch (row.role) { case 'system': if (!this._hasBubble(row.id)) this._renderSystemMessage(row); break; case 'user': if (!this._hasBubble(row.id)) this._renderUserMessage(row); break; case 'assistant': this._renderAssistantMessage(row, results); break; case 'tool': { const $bubble = this.$chat.querySelector( `[data-tool-call-id="${CSS.escape(row.content?.tool_call_id || '')}"]`, ); if ($bubble) { this._fillToolOutput($bubble, row); break; } if (this._hasBubble(row.id)) break; this._renderOrphanToolResult(row); break; } } } } /** * _hasBubble reports whether a bubble with the given data-id is * rendered. * @param {string} id */ _hasBubble(id) { return this.$chat.querySelector(`[data-id="${CSS.escape(id)}"]`) !== null; } /** * attachToolResult fills the output section of the tool bubble * for one call. The bubble exists once its assistant message is * rendered. When it does not, one is created: from the parent * row's call when the call is found, or from the result row alone * when the assistant message is unknown or no longer has the * call. * * @param {Object} parentRow - the assistant message row that owns * the call, or null. * @param {string} callId * @param {Object} resultRow - the saved tool result row. */ attachToolResult(parentRow, callId, resultRow) { const $bubble = this._findToolBubble(parentRow?.id, callId) ?? this.$chat.querySelector(`[data-tool-call-id="${CSS.escape(callId)}"]`); if ($bubble) { this._fillToolOutput($bubble, resultRow); return; } const call = parentRow?.content?.tool_calls?.find((c) => c.id === callId) ?? null; this._renderToolBubble(call ? parentRow : null, call, resultRow); } /** * removeMessageBubbles removes the bubbles of one saved message * row: the text bubble, the reasoning bubble, and the tool * bubbles. It does not remove the rows of other messages, such as * tool results that belong to a deleted assistant message. * @param {string} id - message row ID. */ removeMessageBubbles(id) { this.$chat .querySelectorAll( `[data-id="${CSS.escape(id)}"],` + ` [data-id="${CSS.escape(`${id}/reasoning`)}"],` + ` [data-parent-id="${CSS.escape(id)}"]`, ) .forEach(($bubble) => { $bubble.remove(); }); } /** * clearToolOutput empties the output section of the bubble that * shows one tool result, after the result row was deleted. The * call itself stays: it can run again. * @param {Object} resultRow - the deleted tool result row. */ clearToolOutput(resultRow) { const id = CSS.escape(resultRow.id); const $bubble = this.$chat.querySelector( `[data-parent-id][data-id="${id}"]`, ); if (!$bubble) return; $bubble.querySelector('[data-output]').textContent = ''; $bubble .querySelector('.message__tool-section--output') .setAttribute('hidden', ''); $bubble.dataset.id = `${$bubble.dataset.parentId}/${$bubble.dataset.toolCallId}`; } /** * replaceDraftWithSaved swaps the streaming bubbles of one draft * for the bubbles of its saved message row, keeping each bubble's * open or closed state. resultRows are the tool results saved for * the same message, when known. * @param {string} draftId - the draft ID, which equals the saved * row ID: the server allocates it before the first fragment. * @param {Object} row - the saved assistant message row. * @param {Object[]} resultRows */ replaceDraftWithSaved(draftId, row, resultRows = []) { const openOf = (selector) => { const $bubble = this.$chat.querySelector(selector); return $bubble?.querySelector('details')?.open ?? null; }; const textOpen = openOf(`[data-id="${CSS.escape(draftId)}"]`); const reasoningOpen = openOf( `[data-id="${CSS.escape(`${draftId}/reasoning`)}"]`, ); const toolOpen = new Map(); for (const $bubble of this.$chat.querySelectorAll( `[data-parent-id="${CSS.escape(draftId)}"][data-tool-call-id]`, )) { toolOpen.set( $bubble.dataset.toolCallId, $bubble.querySelector('details')?.open, ); } this.removeDraft(draftId); this.renderMessages([row, ...resultRows]); const $text = this.$chat.querySelector(`[data-id="${CSS.escape(row.id)}"]`); if ($text && textOpen !== null) { $text.querySelector('details').open = textOpen; } const $reasoning = this.$chat.querySelector( `[data-id="${CSS.escape(`${row.id}/reasoning`)}"]`, ); if ($reasoning && reasoningOpen !== null) { $reasoning.querySelector('details').open = reasoningOpen; } for (const call of row.content?.tool_calls ?? []) { const $bubble = this._findToolBubble(row.id, call.id); if ($bubble && toolOpen.has(call.id)) { $bubble.querySelector('details').open = toolOpen.get(call.id); } } } /** * renderDraft renders the unfinished output of one streaming * assistant message. Repeated calls update the bubbles in place. * Draft bubbles have no message actions: their content is not * saved and may never be. * @param {Object} draft - { id, reasoning, text, refusal, * toolCalls: [{ id, function: { name, arguments } }] }. */ renderDraft(draft) { if (draft.reasoning) { let $bubble = this.$chat.querySelector( `[data-id="${CSS.escape(`${draft.id}/reasoning`)}"]`, ); if (!$bubble) $bubble = this._createDraftReasoning(draft.id); $bubble.querySelector('.message__content').textContent = draft.reasoning; } const text = draft.refusal || draft.text; if (text) { let $bubble = this.$chat.querySelector( `[data-id="${CSS.escape(draft.id)}"]`, ); if (!$bubble) $bubble = this._createDraftText(draft.id); $bubble.querySelector('.message__content').textContent = text; } for (const [index, call] of draft.toolCalls.entries()) { if (!call) continue; const callId = call.id || `#${index}`; let $bubble = this._findToolBubble(draft.id, callId); if (!$bubble) $bubble = this._createDraftToolBubble(draft.id, callId); $bubble.querySelector('.message__collapsible-label').textContent = call.function.name || 'Tool call'; $bubble.querySelector('[data-args]').textContent = call.function.arguments; } this.scrollToBottom(); } /** * removeDraft removes the streaming bubbles of one draft. Call it * when a draft is replaced by its saved message, or when its * stream is lost: an unfinished draft is not a saved message, and * a re-sync either delivers the saved message or proves that none * was saved. * @param {string} draftId */ removeDraft(draftId) { this.removeMessageBubbles(draftId); } /** * renderSystemDefault renders the system message bubble shown * when no conversation is selected. It shows the default system * message for the next conversation. * @param {string} text */ renderSystemDefault(text) { this.$chat.querySelector('[data-id="system"]')?.remove(); const $bubble = this.$tplSystemMessage.content.cloneNode(true).firstElementChild; $bubble.dataset.id = 'system'; const $content = $bubble.querySelector('.message__content'); if (text?.trim()) { $content.textContent = text; $bubble.dataset.copyText = text; } else { $content.innerHTML = 'System Default'; } this.$chat.insertBefore($bubble, this.$chatLoading); } /** * renderError renders one error message in the chat. Errors are * display-only: the delete button removes the bubble without a * request. * @param {string} message */ renderError(message) { const $bubble = this.$tplErrorMessage.content.cloneNode(true).firstElementChild; $bubble.querySelector('.message__content').textContent = message; $bubble.dataset.copyText = message; this.$chat.insertBefore($bubble, this.$chatLoading); this.scrollToBottom(); } /** * clearChat removes every message bubble. The loading spinner * stays. */ clearChat() { for (const $bubble of [...this.$chat.children]) { if ($bubble !== this.$chatLoading) $bubble.remove(); } } /** * renderConversation replaces the whole chat with rows, keeping * each bubble's open or closed state and the scroll position. * @param {Array} rows */ renderConversation(rows) { const open = new Map(); for (const $bubble of this.$chat.querySelectorAll('.message')) { const details = $bubble.querySelector('details'); if (details) open.set($bubble.dataset.id, details.open); } const { scrollTop } = this.$chat; this.clearChat(); this.renderMessages(rows); for (const [id, isOpen] of open) { const $bubble = this.$chat.querySelector(`[data-id="${CSS.escape(id)}"]`); if ($bubble) $bubble.querySelector('details').open = isOpen; } this.$chat.scrollTop = scrollTop; this.scrollToBottom(); } // ==================== // TOOL APPROVALS // ==================== /** * setPendingApprovals shows the approve and deny buttons on the * tool bubbles named by the given executions, and hides them on * every other bubble. The set is remembered, so bubbles rendered * later show their buttons too. * @param {Array<{executionId: string, messageId: string, toolCallId: string}>} pending */ setPendingApprovals(pending) { this.pendingApprovals = pending; for (const $bubble of this.$chat.querySelectorAll( '[data-parent-id][data-tool-call-id]', )) { const $approval = $bubble.querySelector('.message__approval'); if (!$approval) continue; const isPending = pending.some( ({ messageId, toolCallId }) => messageId === $bubble.dataset.parentId && toolCallId === $bubble.dataset.toolCallId, ); $approval.hidden = !isPending; } } // ==================== // MESSAGE DETAILS // ==================== /** * setPlayButton switches the play button of one message between * its play and stop icon, for speech that starts or ends. * @param {string} messageId * @param {boolean} playing */ setPlayButton(messageId, playing) { const $bubble = this.$chat.querySelector( `[data-id="${CSS.escape(messageId)}"]`, ); const $btn = $bubble?.querySelector('[data-action="play"]'); if (!$btn) return; $btn.replaceChildren(this._icon(playing ? 'stop' : 'volume')); $btn.classList.toggle('playing', playing); $btn.setAttribute('aria-label', playing ? 'Stop' : 'Play'); } /** * updateContext shows the token counter in the toolbar. When the * selected model reports its context size, the counter is the * used percentage; otherwise it is the token count. * @param {number} tokens - total tokens of the last inference. * @param {number | null} contextSize */ updateContext(tokens, contextSize) { if (tokens <= 0) { this.$context.hidden = true; return; } this.$context.hidden = false; this.$context.classList.remove( 'footer__toolbar-context--warning', 'footer__toolbar-context--critical', ); const tokenLabel = tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k tokens` : `${tokens} tokens`; if (contextSize > 0) { const pct = Math.round((tokens / contextSize) * 100); this.$context.textContent = `${pct}%`; this.$context.title = tokenLabel; if (pct >= 90) { this.$context.classList.add('footer__toolbar-context--critical'); } else if (pct >= 70) { this.$context.classList.add('footer__toolbar-context--warning'); } } else { this.$context.textContent = tokenLabel; this.$context.title = ''; } } // ==================== // SETTINGS MODAL // ==================== /** * populateModels fills the model selection. stored selects the * model chosen earlier; without it the server default is used, * then the first available model. * @param {Object} data - /models response. * @param {{provider: string | null, model: string | null}} stored */ populateModels(data, stored) { this.$model.replaceChildren(); this.modelContextSizes.clear(); for (const provider of Object.keys(data.providers).sort()) { const $group = this.document.createElement('optgroup'); $group.label = provider; for (const model of [...data.providers[provider]].sort((a, b) => a.model.localeCompare(b.model), )) { const $option = this.document.createElement('option'); $option.value = model.model; $option.dataset.provider = provider; $option.dataset.model = model.model; if (model.context_size) { this.modelContextSizes.set(model.model, model.context_size); } switch (model.status) { case 'available': $option.textContent = model.model; break; case 'not_found': $option.textContent = `~~${model.model}~~ (not found)`; $option.disabled = true; $option.classList.add('model-unavailable-not-found'); break; default: $option.textContent = `${model.model} (provider error)`; $option.disabled = true; $option.classList.add('model-unavailable-error'); } $group.appendChild($option); } this.$model.appendChild($group); } const find = (provider, model) => this.$model.querySelector( `option[data-provider="${CSS.escape(provider ?? '')}"]` + `[data-model="${CSS.escape(model ?? '')}"]`, ); let $option = (stored.provider && stored.model && find(stored.provider, stored.model)) || find(data.default_provider, data.default_model); if (!$option) { $option = [...this.$model.options].find((option) => !option.disabled); } if ($option) { this.$model.value = $option.value; } else { // No model answered: name the failure in the selection. const $none = this.document.createElement('option'); $none.value = ''; $none.disabled = true; $none.textContent = 'Failed to load'; this.$model.replaceChildren($none); } } /** * getSelectedModel reports the selected provider and model, with * the model's context size when the provider reported one. * @returns {{provider: string | null, model: string | null, contextSize: number | null}} */ getSelectedModel() { const $option = this.$model.options[this.$model.selectedIndex]; const model = $option?.dataset.model ?? null; return { provider: $option?.dataset.provider ?? null, model, contextSize: model ? (this.modelContextSizes.get(model) ?? null) : null, }; } /** * populateVoices fills the voice selection from the TTS server. * @param {string[]} voices * @param {string} selected */ populateVoices(voices, selected) { const labels = { af: '🇺🇸 American English (F)', am: '🇺🇸 American English (M)', bf: '🇬🇧 British English (F)', bm: '🇬🇧 British English (M)', ef: '🇪🇸 Spanish (F)', em: '🇪🇸 Spanish (M)', ff: '🇫🇷 French (F)', fm: '🇫🇷 French (M)', hf: '🇮🇳 Hindi (F)', hm: '🇮🇳 Hindi (M)', if: '🇮🇹 Italian (F)', im: '🇮🇹 Italian (M)', jf: '🇯🇵 Japanese (F)', jm: '🇯🇵 Japanese (M)', kf: '🇰🇷 Korean (F)', km: '🇰🇷 Korean (M)', pf: '🇧🇷 Brazilian Portuguese (F)', pm: '🇧🇷 Brazilian Portuguese (M)', zf: '🇨🇳 Mandarin Chinese (F)', zm: '🇨🇳 Mandarin Chinese (M)', }; this.$voice.replaceChildren(); const $auto = this.document.createElement('option'); $auto.value = ''; $auto.textContent = '🌐 Auto'; this.$voice.appendChild($auto); const groups = new Map(); for (const voice of voices) { const prefix = voice.substring(0, 2); if (!groups.has(prefix)) groups.set(prefix, []); groups.get(prefix).push(voice); } for (const prefix of [...groups.keys()].sort()) { const $group = this.document.createElement('optgroup'); $group.label = labels[prefix] ?? prefix.toUpperCase(); for (const voice of groups.get(prefix).sort()) { const name = voice.substring(3).replace(/_/g, ' '); const $option = this.document.createElement('option'); $option.value = voice; $option.textContent = name.charAt(0).toUpperCase() + name.slice(1); $group.appendChild($option); } this.$voice.appendChild($group); } if (voices.length === 0) { // No voice answered: name the failure in the selection. const $none = this.document.createElement('option'); $none.value = ''; $none.disabled = true; $none.textContent = 'Failed to load'; this.$voice.appendChild($none); return; } this.$voice.value = this.$voice.querySelector( `option[value="${CSS.escape(selected)}"]`, ) ? selected : ''; } /** * setSystemMessageDraft fills the system message textarea of the * settings modal. * @param {string} text */ setSystemMessageDraft(text) { this.$systemMessageInput.value = text; } // ==================== // COMPOSE SETTINGS MODAL // ==================== /** * openComposeSettings shows the compose settings with the given * values. Closing it reports the values back, even unchanged. * @param {{maxIterations: number, reasoningBudget: number | null}} values */ openComposeSettings({ maxIterations, reasoningBudget }) { this.$maxTurnsInput.value = String(maxIterations); this.$reasoningBudgetInput.value = reasoningBudget == null ? '' : String(reasoningBudget); this.$composeSettingsOverlay.classList.add('open'); this.$composeSettingsClose.focus(); } // ==================== // CONVERSATIONS MODAL // ==================== /** * openConversations shows the conversation picker and asks * main.js for a fresh list. The search field starts empty and * holds the caret, so typing filters the list at once. */ openConversations() { this.$conversationsSearch.value = ''; this.$conversationsOverlay.classList.add('open'); this.$conversationsSearch.focus(); this.handlers.onConversationsOpen(); } /** closeConversations hides the picker. */ closeConversations() { this.$conversationsOverlay.classList.remove('open'); this.$conversations.focus(); } /** * renderConversationList fills the picker with the rows given, * in the order given. main.js filters the rows; the row that * matches currentId is marked as the conversation in view. The * search field decides which empty message shows. * @param {Array<{id: string, title: string | null, created_at: string, updated_at: string}>} rows * @param {{currentId?: string | null}} [options] */ renderConversationList(rows, { currentId = null } = {}) { this.$conversationsList.replaceChildren(); if (rows.length === 0) { const $empty = this.document.createElement('div'); $empty.className = 'conversations__empty'; $empty.textContent = this.$conversationsSearch.value ? 'No conversations match the search.' : 'No conversations yet.'; this.$conversationsList.appendChild($empty); return; } for (const conversation of rows) { const $item = this.$tplConversationItem.content.cloneNode(true).firstElementChild; $item.dataset.conversationId = conversation.id; $item.querySelector('.conversations__item-title').textContent = conversationLabel(conversation); $item.querySelector('.conversations__item-created').textContent = `Created ${CONVERSATION_DATE.format( new Date(conversation.created_at), )}`; $item.querySelector('.conversations__item-updated').textContent = `Updated ${CONVERSATION_DATE.format( new Date(conversation.updated_at), )}`; if (conversation.id === currentId) { $item.classList.add('conversations__item--current'); $item.setAttribute('aria-current', 'true'); } this.$conversationsList.appendChild($item); } } // ==================== // MUTE // ==================== /** * setMuted switches the mute icon. * @param {boolean} muted */ setMuted(muted) { this.$mute.replaceChildren(this._icon(muted ? 'volume-off' : 'volume')); this.$mute.classList.toggle('footer__toolbar-btn--muted', muted); this.$mute.setAttribute('aria-label', muted ? 'Unmute' : 'Mute'); } // ==================== // EDIT MODALS // ==================== /** * editText opens the text edit modal. It resolves with the edited * text when saved, and with null when cancelled. The modal shows * the raw text; markdown renders after saving. * @param {{title: string, value: string}} options * @returns {Promise} */ editText({ title, value }) { const $modal = this.$tplEditModal.content.cloneNode(true).firstElementChild; $modal.querySelector('h2').textContent = title; $modal.querySelector('#edit-textarea').value = value; return this._openModal( $modal, ($modal) => $modal.querySelector('#edit-textarea').value, ); } /** * editConversationTitle opens the rename modal with the current * title. It resolves with the trimmed new title when saved, and * with null when cancelled. An empty title is not a rename, so * the modal stays open until the user enters one or cancels. * @param {string} title * @returns {Promise} */ editConversationTitle(title) { const $modal = this.$tplRenameModal.content.cloneNode(true).firstElementChild; $modal.querySelector('#rename-input').value = title; return this._openModal($modal, ($modal) => { const value = $modal.querySelector('#rename-input').value.trim(); if (value === '') { throw new Error('the title must not be empty'); } return value; }); } /** * editToolCall opens the tool edit modal, which edits the call's * name and arguments and, when a result exists, its text. It * resolves with { name, arguments, result } when saved, and with * null when cancelled. The call is edited as JSON; saving stays in * the modal until the JSON parses. * @param {{name: string, arguments: string, result: string}} options * @returns {Promise<{name: string, arguments: string, result: string} | null>} */ editToolCall({ name, arguments: args, result }) { const $modal = this.$tplEditToolModal.content.cloneNode(true).firstElementChild; const $call = $modal.querySelector('#edit-tool-call'); try { $call.value = JSON.stringify( { name, arguments: JSON.parse(args || '{}') }, null, 2, ); } catch { $call.value = JSON.stringify({ name, arguments: args }, null, 2); } $modal.querySelector('#edit-tool-result').value = result; return this._openModal($modal, ($modal) => { const parsed = JSON.parse($modal.querySelector('#edit-tool-call').value); return { name: String(parsed.name ?? ''), arguments: typeof parsed.arguments === 'string' ? parsed.arguments : JSON.stringify(parsed.arguments ?? {}), result: $modal.querySelector('#edit-tool-result').value, }; }); } // ==================== // SCROLL // ==================== /** * setAutoScroll turns following the newest output on or off, and * syncs the toolbar indicator. Following is off until the user * turns it on; scrolling up turns it off. * @param {boolean} enabled */ setAutoScroll(enabled) { this.isAutoScrollEnabled = enabled; this.$scrollDown.classList.toggle('following', enabled); } /** scrollToBottom follows the output only while enabled. */ scrollToBottom() { if (this.isAutoScrollEnabled) { this.$chat.scrollTop = this.$chat.scrollHeight; } } /** * scrollToLastUserMessage anchors the last user message to the * top of the viewport, so the reply grows below it. No effect * while following, or when the message is already visible. */ scrollToLastUserMessage() { const $last = this.$chat.querySelectorAll('.message--user'); const $message = $last[$last.length - 1]; if (!$message || this.isAutoScrollEnabled) return; const { scrollTop, clientHeight } = this.$chat; const top = $message.offsetTop - this.$chat.offsetTop; const bottom = top + $message.offsetHeight; if (top >= scrollTop && bottom <= scrollTop + clientHeight) return; this.$chat.scrollTop = top; } // ==================== // PRIVATE: EVENT BINDING // ==================== _bindEvents() { // One delegated click listener routes every data-action. this.document.addEventListener('click', (event) => this._handleClick(event), ); // Press-and-hold for push-to-talk and transcription. this.$compose.addEventListener('mousedown', (event) => this._handlePressStart(event), ); this.$compose.addEventListener('touchstart', (event) => this._handlePressStart(event), ); this.document.addEventListener('mouseup', (event) => this._handlePressEnd(event), ); this.document.addEventListener('touchend', (event) => this._handlePressEnd(event), ); this.document.addEventListener('touchcancel', (event) => this._handlePressEnd(event), ); this.$ptt.addEventListener('contextmenu', (event) => event.preventDefault(), ); this.$transcribe.addEventListener('contextmenu', (event) => event.preventDefault(), ); this.document.addEventListener('keydown', (event) => this._handleKeyDown(event), ); this.document.addEventListener('keyup', (event) => this._handleKeyUp(event), ); // Compose field. this.$textInput.addEventListener('keydown', (event) => { if (event.code === 'Enter' && !event.shiftKey) { event.preventDefault(); this.handlers.onComposeSubmit(); } }); this.$textInput.addEventListener('input', () => { this._resizeTextarea(); this.handlers.onComposeInput(); }); // Attachments. this.$attach.addEventListener('click', () => this.$fileInput.click()); this.$fileInput.addEventListener('change', (event) => { this.handlers.onAttachFiles(event.target.files); this.$fileInput.value = ''; }); // Chat scroll. this.$chat.addEventListener('scroll', () => this._handleChatScroll(), { passive: true, }); // Settings modal. this.$settings.addEventListener('click', () => this._openSettings()); this.$settingsClose.addEventListener('click', () => this._closeSettings()); this.$settingsOverlay.addEventListener('click', (event) => { if (event.target === this.$settingsOverlay) this._closeSettings(); }); this.document.addEventListener('keydown', (event) => { if ( event.key === 'Escape' && this.$settingsOverlay.classList.contains('open') ) { event.preventDefault(); this._closeSettings(); } }); this.$settingsTabs.forEach(($tab) => { $tab.addEventListener('click', () => this._switchTab($tab.dataset.tab)); }); this.$saveSystemMessage.addEventListener('click', () => this._saveSystemMessage(), ); this.$model.addEventListener('change', () => { const { provider, model } = this.getSelectedModel(); this.handlers.onModelChange({ provider, model }); }); this.$voice.addEventListener('change', () => this.handlers.onVoiceChange(this.$voice.value), ); // Conversations modal. The toolbar button opens it through the // data-action route; these listeners close it and report the // search text and the selected row. this.$conversationsClose.addEventListener('click', () => this.closeConversations(), ); this.$conversationsOverlay.addEventListener('click', (event) => { if (event.target === this.$conversationsOverlay) { this.closeConversations(); } }); this.document.addEventListener('keydown', (event) => { if ( event.key === 'Escape' && this.$conversationsOverlay.classList.contains('open') ) { event.preventDefault(); this.closeConversations(); } }); this.$conversationsSearch.addEventListener('input', () => this.handlers.onConversationSearch(this.$conversationsSearch.value), ); this.$conversationsList.addEventListener('click', (event) => { const $item = event.target.closest('[data-conversation-id]'); if (!$item) return; const id = $item.dataset.conversationId; const $rename = event.target.closest('.conversations__item-rename'); if ($rename) { this._clearConfirm(); this.handlers.onRenameConversation(id); return; } const $delete = event.target.closest('.conversations__item-delete'); if ($delete) { // Deleting is irreversible: the second press on the same // button deletes, any other click cancels the request. this._confirm(`conversation:${id}`, $delete, 'pending', () => this.handlers.onDeleteConversation(id), ); return; } this._clearConfirm(); this.handlers.onSelectConversation(id); }); // Compose settings modal. this.$composeSettings.addEventListener('click', () => this.handlers.onComposeSettings(), ); this.$composeSettingsClose.addEventListener('click', () => this._closeComposeSettings(), ); this.$composeSettingsOverlay.addEventListener('click', (event) => { if (event.target === this.$composeSettingsOverlay) { this._closeComposeSettings(); } }); } _handleClick(event) { const $target = event.target.closest('[data-action]'); const action = $target?.dataset.action; // A click outside any action button cancels every pending // confirmation. if (!action) { this._clearConfirm(); return; } const $message = $target.closest('.message'); const messageId = $message?.dataset.messageId || $message?.dataset.id; switch (action) { case 'send': if (this.$send.classList.contains('loading')) { this._confirm('stop', $target, 'pending', () => this.handlers.onStop(), ); } else { this._clearConfirm(); this.handlers.onComposeSubmit(); } break; case 'reset': this._confirm('reset', $target, 'footer__toolbar-btn--pending', () => this.handlers.onReset(), ); break; case 'delete': // Error bubbles are display-only: remove without a request. if ( !$message || (!$message.dataset.id && !$message.dataset.messageId) ) { this._clearConfirm(); $message?.remove(); break; } this._confirm( `delete:${messageId}:${$message.dataset.field ?? ''}` + `:${$message.dataset.toolCallId ?? ''}`, $target, 'pending', () => this.handlers.onDelete({ id: messageId, field: $message.dataset.field || null, toolCallId: $message.dataset.toolCallId || null, }), ); break; case 'approve': case 'deny': { this._clearConfirm(); const execution = this.pendingApprovals.find( ({ messageId: mid, toolCallId }) => mid === $message?.dataset.parentId && toolCallId === $message?.dataset.toolCallId, ); if (!execution) break; if (action === 'approve') { this.handlers.onApprove(execution); } else { this.handlers.onDeny(execution); } break; } case 'edit': this._clearConfirm(); this.handlers.onEdit({ id: messageId, field: $message?.dataset.field || null, toolCallId: $message?.dataset.toolCallId || null, }); break; case 'play': this._clearConfirm(); if (messageId) this.handlers.onPlay(messageId); break; case 'copy': { this._clearConfirm(); const text = $message?.dataset.copyText || ''; this._copyToClipboard($target, text); break; } case 'inspect': { this._clearConfirm(); const isOpen = $message.classList.toggle('message--debug-open'); $target.setAttribute('aria-expanded', String(isOpen)); break; } case 'scroll-up': this._clearConfirm(); this.setAutoScroll(false); this.$chat.scrollTop = 0; break; case 'scroll-down': // Set scrollTop first, so the scroll listener sees the // bottom position and does not turn following off again. this._clearConfirm(); this.$chat.scrollTop = this.$chat.scrollHeight; this.setAutoScroll(true); break; case 'mute': this._clearConfirm(); this.handlers.onToggleMute(); break; case 'location': this._clearConfirm(); this.handlers.onLocationToggle(); break; case 'conversations': this._clearConfirm(); this.openConversations(); break; case 'settings': this._clearConfirm(); this._openSettings(); break; case 'compose-settings': this._clearConfirm(); this.handlers.onComposeSettings(); break; case 'attach': // Handled by the listener on the button itself. break; case 'ptt': case 'transcribe': // Handled by press-and-hold. break; default: this._clearConfirm(); } } /** * _confirm runs action on the second press of the same control. * The first press marks the control; any other click clears the * mark. key tells the presses apart. */ _confirm(key, $target, className, action) { if ( this.$pendingConfirm?.key === key && this.$pendingConfirm?.$target === $target ) { this._clearConfirm(); action(); return; } this._clearConfirm(); this.$pendingConfirm = { key, $target }; $target.classList.add(className); } _clearConfirm() { const pending = this.$pendingConfirm; if (!pending) return; pending.$target.classList.remove('pending', 'footer__toolbar-btn--pending'); this.$pendingConfirm = null; } _handlePressStart(event) { const $target = event.target.closest('[data-action]'); const action = $target?.dataset.action; if (action !== 'ptt' && action !== 'transcribe') return; // Let the document-level click handler ignore these; touch and // mouse both arrive as presses here. if (event.type === 'touchstart') { event.preventDefault(); event.stopPropagation(); } else if (event.sourceCapabilities?.firesTouchEvents) { return; } else { event.preventDefault(); event.stopPropagation(); } if (action === 'ptt') this.handlers.onPushToTalkStart(); else this.handlers.onTranscribeStart(); } _handlePressEnd() { // The handlers ignore the end of a press that never started, // so both controls can share the release listeners. this.handlers.onPushToTalkEnd(); this.handlers.onTranscribeEnd(); } _handleKeyDown(event) { if (event.code !== 'Space' || event.repeat) return; if (this._isFormField(event.target)) return; event.preventDefault(); this.handlers.onPushToTalkStart(); } _handleKeyUp(event) { if (event.code !== 'Space') return; if (this._isFormField(event.target)) return; event.preventDefault(); this.handlers.onPushToTalkEnd(); } _isFormField(target) { return ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName); } _resizeTextarea() { const textarea = this.$textInput; textarea.style.height = 'auto'; const maxHeight = parseFloat(getComputedStyle(textarea).maxHeight); textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight)}px`; } _handleChatScroll() { // Disable-only: scrolling away from the bottom turns following // off. Being at the bottom does not turn it back on; only the // scroll-down button does. const { scrollHeight, scrollTop, clientHeight } = this.$chat; if (scrollHeight - scrollTop - clientHeight > 100) { this.setAutoScroll(false); } } // ==================== // PRIVATE: MODALS // ==================== _openSettings() { this.$settingsOverlay.classList.add('open'); this.handlers.onSettingsOpen(); this._switchTab('model-voice'); this.$settingsClose.focus(); } _closeSettings() { this.$settingsOverlay.classList.remove('open'); this.$settings.focus(); } _switchTab(name) { this.$settingsTabs.forEach(($tab) => { const isActive = $tab.dataset.tab === name; $tab.classList.toggle('active', isActive); $tab.setAttribute('aria-selected', String(isActive)); }); this.$settingsPanels.forEach(($panel) => { const isActive = $panel.dataset.tabPanel === name; $panel.classList.toggle('active', isActive); $panel.hidden = !isActive; }); } _saveSystemMessage() { this.handlers.onSaveSystemMessage(this.$systemMessageInput.value); // Brief visual feedback on the save button. this.$saveSystemMessage.replaceChildren(this._icon('check')); this.$saveSystemMessage.classList.add('settings-save-btn--success'); setTimeout(() => { this.$saveSystemMessage.textContent = 'Save'; this.$saveSystemMessage.classList.remove('settings-save-btn--success'); }, 1500); } _closeComposeSettings() { this.$composeSettingsOverlay.classList.remove('open'); this.$composeSettings.focus(); const budget = this.$reasoningBudgetInput.value.trim(); this.handlers.onComposeSettingsChange({ maxIterations: Number(this.$maxTurnsInput.value), reasoningBudget: budget === '' ? null : Number(budget), }); } /** * _openModal shows one edit modal and resolves with the value * read by readValue when saved. Cancel, Escape, and a click * outside resolve with null. A readValue that throws keeps the * modal open and marks the error. */ _openModal($modal, readValue) { return new Promise((resolve) => { const $overlay = this.document.createElement('div'); $overlay.className = 'modal-overlay'; $overlay.appendChild($modal); this.document.body.appendChild($overlay); const $save = $modal.querySelector('.modal__save'); const close = (value) => { $overlay.remove(); this.document.removeEventListener('keydown', onKey); resolve(value); }; const onKey = (event) => { if (event.key === 'Escape') close(null); }; $modal .querySelector('.modal__close') .addEventListener('click', () => close(null)); $overlay.addEventListener('click', (event) => { if (event.target === $overlay) close(null); }); this.document.addEventListener('keydown', onKey); $save.addEventListener('click', () => { let value; try { value = readValue($modal); } catch (error) { console.error('invalid input:', error); for (const $field of $modal.querySelectorAll( '.modal__textarea, .modal__input', )) { $field.classList.add('modal__textarea--error'); } $save.classList.add('modal__save--error'); setTimeout(() => { for (const $field of $modal.querySelectorAll( '.modal__textarea, .modal__input', )) { $field.classList.remove('modal__textarea--error'); } $save.classList.remove('modal__save--error'); }, 500); return; } close(value); }); $modal.querySelector('textarea, input')?.focus(); }); } // ==================== // PRIVATE: RENDERING // ==================== _renderUserMessage(row) { const $bubble = this.$tplUserMessage.content.cloneNode(true).firstElementChild; $bubble.dataset.id = row.id; const text = extractText(row.content?.content); $bubble.querySelector('.message__content').textContent = text; $bubble.dataset.copyText = text; this._renderImagePills($bubble, row); this._populateDebug($bubble.querySelector('.message__debug-list'), row); this.$chat.insertBefore($bubble, this.$chatLoading); this.scrollToBottom(); } _renderAssistantMessage(row, results) { const content = row.content; if ( (content?.reasoning_content || content?.reasoning) && !this._hasBubble(`${row.id}/reasoning`) ) { this._renderReasoningMessage(row); } const text = content?.refusal || extractText(content?.content); if (text && !this._hasBubble(row.id)) { this._renderTextMessage(row, text); } for (const call of content?.tool_calls ?? []) { if (this._findToolBubble(row.id, call.id)) continue; this._renderToolBubble(row, call, results.get(call.id) ?? null); } } _renderTextMessage(row, text) { const $bubble = this.$tplAssistantMessage.content.cloneNode(true).firstElementChild; $bubble.dataset.id = row.id; $bubble.querySelector('.message__content').innerHTML = this._renderMarkdown(text); $bubble.dataset.voice = row.voice || ''; $bubble.dataset.copyText = text; this._populateDebug($bubble.querySelector('.message__debug-list'), row); this.$chat.insertBefore($bubble, this.$chatLoading); this.scrollToBottom(); } _renderReasoningMessage(row) { const $bubble = this.$tplReasoningMessage.content.cloneNode(true).firstElementChild; $bubble.dataset.id = `${row.id}/reasoning`; $bubble.dataset.parentId = row.id; $bubble.dataset.field = 'reasoning'; $bubble.dataset.messageId = row.id; const reasoning = row.content?.reasoning_content || ''; $bubble.querySelector('.message__content').textContent = reasoning; $bubble.dataset.copyText = reasoning; this._populateDebug($bubble.querySelector('.message__debug-list'), row); this.$chat.insertBefore($bubble, this.$chatLoading); this.scrollToBottom(); } /** * _renderToolBubble renders the bubble for one tool call of an * assistant message. result is the saved tool result row, when * one exists. parentRow links the bubble to the assistant * message; without it the bubble belongs to a result whose * assistant message is unknown, shows output only, and reports * no actions that need the parent. */ _renderToolBubble(parentRow, call, result) { const $bubble = this.$tplToolMessage.content.cloneNode(true).firstElementChild; const callId = call?.id ?? result?.content?.tool_call_id ?? ''; $bubble.dataset.id = result?.id ?? `${parentRow?.id ?? ''}/${callId}`; if (parentRow) { $bubble.dataset.parentId = parentRow.id; $bubble.dataset.messageId = parentRow.id; $bubble.dataset.field = 'tool'; } if (callId) $bubble.dataset.toolCallId = callId; $bubble.querySelector('.message__collapsible-label').textContent = call?.function?.name || result?.content?.name || 'Tool call'; const $args = $bubble.querySelector('[data-args]'); if (call) { $args.textContent = this._prettyJSON(call.function?.arguments); } else { // A result without its assistant message shows only output. $args.closest('.message__tool-section').remove(); } const copyParts = []; if (call) { copyParts.push( `${call.function?.name || 'unknown'}(${call.function?.arguments || '{}'})`, ); } copyParts.push(extractText(result?.content?.content) || ''); $bubble.dataset.copyText = copyParts.filter(Boolean).join('\n\n'); if (result) this._fillToolOutput($bubble, result); this._populateDebug($bubble.querySelector('.message__debug-list'), { ...result, voice: parentRow?.voice, }); this.$chat.insertBefore($bubble, this.$chatLoading); this._applyApproval($bubble); this.scrollToBottom(); } /** _renderOrphanToolResult renders a result row without parent. */ _renderOrphanToolResult(row) { this._renderToolBubble(null, null, row); } /** * _fillToolOutput fills the output section of a tool bubble with * the text of its saved result row. */ _fillToolOutput($bubble, result) { const output = this._prettyJSON(extractText(result.content?.content)); $bubble.dataset.id = result.id; $bubble.querySelector('.message__tool-section--output').hidden = false; $bubble.querySelector('[data-output]').textContent = output; // Keep the copy text in step with the output now shown. const label = $bubble.querySelector( '.message__collapsible-label', ).textContent; const args = $bubble.querySelector('[data-args]')?.textContent.trim(); $bubble.dataset.copyText = [`${label}(${args || '{}'})`, output] .filter(Boolean) .join('\n\n'); } _renderSystemMessage(row) { // A saved system row replaces the no-conversation placeholder. this.$chat.querySelector('[data-id="system"]')?.remove(); const $bubble = this.$tplSystemMessage.content.cloneNode(true).firstElementChild; $bubble.dataset.id = row.id; const text = extractText(row.content?.content); $bubble.querySelector('.message__content').textContent = text; $bubble.dataset.copyText = text; this.$chat.insertBefore($bubble, this.$chatLoading); } _createDraftReasoning(draftId) { const $bubble = this.$tplReasoningMessage.content.cloneNode(true).firstElementChild; $bubble.dataset.id = `${draftId}/reasoning`; $bubble.dataset.parentId = draftId; $bubble.classList.add('message--streaming'); $bubble.querySelector('.message__collapsible').open = true; for (const $button of $bubble.querySelectorAll('.message__action-btn')) { $button.remove(); } this.$chat.insertBefore($bubble, this.$chatLoading); return $bubble; } _createDraftText(draftId) { const $bubble = this.$tplAssistantMessage.content.cloneNode(true).firstElementChild; $bubble.dataset.id = draftId; $bubble.classList.add('message--streaming'); for (const $button of $bubble.querySelectorAll('.message__action-btn')) { $button.remove(); } this.$chat.insertBefore($bubble, this.$chatLoading); return $bubble; } _createDraftToolBubble(draftId, callId) { const $bubble = this.$tplToolMessage.content.cloneNode(true).firstElementChild; $bubble.dataset.id = `${draftId}/${callId}`; $bubble.dataset.parentId = draftId; $bubble.dataset.toolCallId = callId; $bubble.classList.add('message--streaming'); $bubble.querySelector('.message__collapsible').open = true; $bubble.querySelector('.message__actions').remove(); this.$chat.insertBefore($bubble, this.$chatLoading); return $bubble; } /** * _findToolBubble finds the bubble of one call. callId may be a * '#index' key while a draft's call has no ID yet. */ _findToolBubble(parentId, callId) { if (!parentId || !callId) return null; return this.$chat.querySelector( `[data-parent-id="${CSS.escape(parentId)}"]` + `[data-tool-call-id="${CSS.escape(callId)}"]`, ); } _renderImagePills($bubble, row) { const parts = Array.isArray(row.content?.content) ? row.content.content.filter((part) => part.type === 'image_url') : []; if (parts.length === 0) return; const $wrapper = this.document.createElement('div'); $wrapper.className = 'message__image-pills'; for (const part of parts) { const $pill = this.$tplImagePill.content.cloneNode(true).firstElementChild; // The server does not store the original file name with the // image; name it by its data format instead. const format = part.image_url?.url?.match(/^data:image\/(\w+)/)?.[1]; $pill.querySelector('.message__image-pill-name').textContent = format ? `Image (${format})` : 'Image'; $wrapper.appendChild($pill); } $bubble .querySelector('.message__body') .insertBefore($wrapper, $bubble.querySelector('.message__actions')); } /** * _populateDebug fills the inspect panel of a bubble from the * message's inference metadata and the voice used for playback. * Missing metadata shows as a single 'No data' row. */ _populateDebug($list, row) { const meta = { ...row?.metadata, voice: row?.voice }; if (meta.voice) this._appendDebugRow($list, 'Voice', meta.voice); const usage = meta.usage; if (usage) { this._appendDebugRow($list, 'Prompt tokens', String(usage.prompt_tokens)); this._appendDebugRow( $list, 'Completion tokens', String(usage.completion_tokens), ); this._appendDebugRow($list, 'Total tokens', String(usage.total_tokens)); if (usage.completion_tokens_details?.reasoning_tokens > 0) { this._appendDebugRow( $list, 'Reasoning tokens', String(usage.completion_tokens_details.reasoning_tokens), ); } if (usage.prompt_tokens_details?.cached_tokens > 0) { this._appendDebugRow( $list, 'Cached tokens', String(usage.prompt_tokens_details.cached_tokens), ); } } const timings = meta.timings; if (timings) { if (timings.prompt_ms > 0) { this._appendDebugRow( $list, 'Prompt eval', `${timings.prompt_ms.toFixed(1)} ms ` + `(${timings.prompt_per_second.toFixed(0)} tok/s)`, ); } if (timings.predicted_ms > 0) { this._appendDebugRow( $list, 'Predicted eval', `${timings.predicted_ms.toFixed(1)} ms ` + `(${timings.predicted_per_second.toFixed(0)} tok/s)`, ); } } if ($list.children.length === 0) { this._appendDebugRow($list, 'No data', ''); } } _appendDebugRow($list, label, value) { const $row = this.$tplDebugRow.content.cloneNode(true); $row.querySelector('dt').textContent = label; $row.querySelector('dd').textContent = value; $list.appendChild($row); } /** * _applyApproval shows or hides the approve and deny buttons of * one bubble, according to the pending approval set. */ _applyApproval($bubble) { const $approval = $bubble.querySelector('.message__approval'); if (!$approval) return; $approval.hidden = !this.pendingApprovals.some( ({ messageId, toolCallId }) => messageId === $bubble.dataset.parentId && toolCallId === $bubble.dataset.toolCallId, ); } /** _prettyJSON renders JSON text indented, or raw on failure. */ _prettyJSON(text) { try { return JSON.stringify(JSON.parse(text || 'null'), null, 2); } catch { return text || ''; } } /** * _renderMarkdown converts Markdown text to sanitized HTML. It * wraps tables in a scrollable container for small screens, and * falls back to escaped plain text when the libraries are * unavailable. */ _renderMarkdown(text) { if (!text) return ''; try { if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') { const container = this.document.createElement('div'); container.innerHTML = marked.parse(text); for (const table of container.querySelectorAll('table')) { const wrapper = this.document.createElement('div'); wrapper.className = 'message__table-scroll'; table.replaceWith(wrapper); wrapper.appendChild(table); } return DOMPurify.sanitize(container.innerHTML); } } catch (error) { console.error('failed to render markdown:', error); } const div = this.document.createElement('div'); div.textContent = text; div.style.whiteSpace = 'pre-wrap'; return div.innerHTML; } _copyToClipboard($button, text) { navigator.clipboard.writeText(text).then( () => { $button.replaceChildren(this._icon('check')); $button.classList.add('message__action-btn--success'); setTimeout(() => { $button.replaceChildren(this._icon('copy')); $button.classList.remove('message__action-btn--success'); }, 1500); }, (error) => console.error('failed to copy:', error), ); } /** * _initViewport keeps the page inside the visual viewport on * mobile, where the on-screen keyboard resizes the window. */ _initViewport() { if (!window.visualViewport) return; const container = this.document.querySelector('.container'); if (!container) return; const handler = () => { const { height } = window.visualViewport; if (height > 0) container.style.height = `${height}px`; }; window.visualViewport.addEventListener('resize', handler); handler(); } _icon(name) { const $svg = this.document.createElementNS( 'http://www.w3.org/2000/svg', 'svg', ); $svg.setAttribute('class', 'icon'); const $use = this.document.createElementNS( 'http://www.w3.org/2000/svg', 'use', ); $use.setAttribute('href', `${ICONS_URL}#${name}`); $svg.appendChild($use); return $svg; } } // Configure marked to highlight code blocks with highlight.js. // marked v8+ removed the highlight option from setOptions; use the // marked-highlight extension instead. if ( typeof marked !== 'undefined' && typeof markedHighlight !== 'undefined' && typeof hljs !== 'undefined' ) { marked.use( markedHighlight.markedHighlight({ langPrefix: 'hljs language-', highlight(code, lang) { const language = hljs.getLanguage(lang) ? lang : 'plaintext'; return hljs.highlight(code, { language }).value; }, }), ); }