/** * 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.$scrollUp = document.getElementById('scroll-up'); 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.$conversationNew = document.getElementById('conversation-new'); 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.$composeModel = document.getElementById('compose-model'); this.$composeModelLabel = document.getElementById('compose-model-label'); 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 { // A pending stop confirmation must not stay highlighted on the // button after the run ends and the button returns to send. if (this.$pendingConfirm?.$target === this.$send) { this._clearConfirm(); } // 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').onclick = () => 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) => { if ( this.$pendingConfirm && $bubble.contains(this.$pendingConfirm.$target) ) { this._clearConfirm(); } $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._bindMessageActions($bubble, { id: 'system' }); 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._bindMessageActions($bubble, { isError: true }); 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) { if ( this.$pendingConfirm && $bubble.contains(this.$pendingConfirm.$target) ) { this._clearConfirm(); } $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('.message__action-btn--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 both the default model selection in * Settings and the per-conversation model selection in Message * Options. defaultStored selects the default model chosen earlier; * composeStored selects the active conversation's model. Without a * stored match, the server default is used, then the first * available model. * @param {Object} data - /models response. * @param {{provider: string | null, model: string | null}} defaultStored * @param {{provider: string | null, model: string | null}} composeStored */ populateModels(data, defaultStored, composeStored = defaultStored) { this.$model.replaceChildren(); this.$composeModel.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 key = `${provider}/${model.model}`; const $option = this.document.createElement('option'); $option.value = key; $option.dataset.provider = provider; $option.dataset.model = model.model; if (model.context_size) { this.modelContextSizes.set(key, 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); this.$composeModel.appendChild($group.cloneNode(true)); } this._applyModelSelect(this.$model, data, defaultStored); this._applyModelSelect(this.$composeModel, data, composeStored); this._updateComposeModelLabel(); } /** * _applyModelSelect selects the matching option in $select, falling * back to the server default, then the first enabled option. When * no option can be selected, it replaces the select's children * with a disabled failure placeholder. * @param {HTMLSelectElement} $select * @param {Object} data * @param {{provider?: string | null, model?: string | null}} stored * @private */ _applyModelSelect($select, data, stored) { const find = (provider, model) => $select.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 = [...$select.options].find((option) => !option.disabled); } if ($option) { $select.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'; $select.replaceChildren($none); } } /** * selectComposeModel selects the given provider and model in the * Message Options dropdown, falling back to the current default * model selection when no matching option exists, and updates the * compose model label. * @param {{provider: string | null, model: string | null}} selection */ selectComposeModel({ provider, model }) { const $option = provider && model && this.$composeModel.querySelector( `option[data-provider="${CSS.escape(provider)}"]` + `[data-model="${CSS.escape(model)}"]`, ); if ($option) { this.$composeModel.value = $option.value; } else if (this.$model.value) { this.$composeModel.value = this.$model.value; } this._updateComposeModelLabel(); } /** * _updateComposeModelLabel sets the text and hover title of the * footer status bar's model indicator to the active provider and model. * @private */ _updateComposeModelLabel() { const { provider, model } = this.getSelectedModel(); const label = provider && model ? `${provider} • ${model}` : ''; this.$composeModelLabel.textContent = label; this.$composeModelLabel.title = label; this.$composeModelLabel.hidden = !label; } /** * getSelectedModel reports the conversation's selected provider * and model from Message Options, with the model's context size * when the provider reported one. * @returns {{provider: string | null, model: string | null, contextSize: number | null}} */ getSelectedModel() { return this._readModelSelect(this.$composeModel); } /** * getDefaultModel reports the default provider and model selected * in Settings. * @returns {{provider: string | null, model: string | null, contextSize: number | null}} */ getDefaultModel() { return this._readModelSelect(this.$model); } /** * _readModelSelect extracts the selected provider, model, and * context size from a model