Files
odidere/internal/service/static/js/ui.js
T

1944 lines
65 KiB
JavaScript

/**
* 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<Object>} 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');
}
// Conversations created from this UI have no title. Their label
// falls back to the creation date, so the picker stays readable.
// Both dates render in the browser locale.
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<Object>} 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 =
'<em class="message__placeholder">System Default</em>';
}
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<Object>} 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<string | null>}
*/
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<string | null>}
*/
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;
},
}),
);
}