/** * Flynn Chat Page * * Session selector, message input, streaming tool events, * markdown-rendered responses, slash commands, and web search. */ /* global hljs */ import { renderSafeMarkdown } from '../lib/markdown.js'; let _currentSession = null; let _sending = false; let _cancelling = false; let _searchMode = false; let _slashPopupIndex = -1; let _elements = {}; let _pendingAttachments = []; let _sessionSort = 'recent'; // ── Slash Command Definitions ─────────────────────────────── const SLASH_COMMANDS = [ { name: '/help', desc: 'Show available commands' }, { name: '/reset', desc: 'Reset session' }, { name: '/compact', desc: 'Compact context' }, { name: '/usage', desc: 'Show token usage' }, { name: '/status', desc: 'Show system health' }, { name: '/model', desc: 'Show current model' }, { name: '/stop', desc: 'Stop active response' }, { name: '/cancel', desc: 'Alias for /stop' }, { name: '/approvals', desc: 'List pending guarded actions' }, { name: '/approve', desc: 'Approve latest or by id' }, { name: '/deny', desc: 'Deny latest or by id' }, ]; // ── Helpers ───────────────────────────────────────────────── function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } function highlightCode() { if (typeof hljs !== 'undefined') { document.querySelectorAll('#chat-messages pre code').forEach(block => { hljs.highlightElement(block); }); } } function formatMessageTimestamp(timestamp) { const date = new Date(typeof timestamp === 'number' ? timestamp : Date.now()); if (Number.isNaN(date.getTime())) { return ''; } const now = new Date(); const isSameDay = now.getFullYear() === date.getFullYear() && now.getMonth() === date.getMonth() && now.getDate() === date.getDate(); const timeText = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit', }).format(date); if (isSameDay) { return timeText; } const dateText = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric', }).format(date); return `${dateText}, ${timeText}`; } function createTimestampEl(role, timestamp) { const ts = document.createElement('div'); const alignClass = role === 'user' ? 'self-end text-right' : 'self-start text-left'; ts.className = `px-1 text-[11px] leading-none text-zinc-600 select-none ${alignClass}`; ts.textContent = formatMessageTimestamp(timestamp); return ts; } function formatSessionLastActive(timestamp) { if (typeof timestamp !== 'number' || !Number.isFinite(timestamp)) { return 'no activity'; } const date = new Date(timestamp); if (Number.isNaN(date.getTime())) { return 'no activity'; } const now = new Date(); const sameDay = now.getFullYear() === date.getFullYear() && now.getMonth() === date.getMonth() && now.getDate() === date.getDate(); if (sameDay) { return `today ${new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' }).format(date)}`; } return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', }).format(date); } function sortSessions(sessions, mode) { const copy = [...sessions]; if (mode === 'messages') { copy.sort((a, b) => { const byMessages = (b.messageCount ?? 0) - (a.messageCount ?? 0); if (byMessages !== 0) {return byMessages;} return String(a.id ?? '').localeCompare(String(b.id ?? '')); }); return copy; } if (mode === 'name') { copy.sort((a, b) => String(a.id ?? '').localeCompare(String(b.id ?? ''))); return copy; } // Default: most recent activity first, then message count, then name. copy.sort((a, b) => { const aTs = typeof a.lastMessageAt === 'number' ? a.lastMessageAt : 0; const bTs = typeof b.lastMessageAt === 'number' ? b.lastMessageAt : 0; const byTime = bTs - aTs; if (byTime !== 0) {return byTime;} const byMessages = (b.messageCount ?? 0) - (a.messageCount ?? 0); if (byMessages !== 0) {return byMessages;} return String(a.id ?? '').localeCompare(String(b.id ?? '')); }); return copy; } function createMessageEl(role, content, timestamp = Date.now()) { const wrapper = document.createElement('div'); const roleClasses = { user: 'flex flex-col gap-1.5 max-w-[85%] md:max-w-[75%] self-end group', assistant: 'flex flex-col gap-1.5 max-w-[85%] md:max-w-[75%] self-start group', system: 'flex flex-col gap-1.5 self-stretch max-w-full group', error: 'flex flex-col gap-1.5 max-w-[85%] md:max-w-[75%] self-start group', }; wrapper.className = roleClasses[role] || roleClasses.assistant; const div = document.createElement('div'); const messageClasses = { user: 'rounded-lg px-3.5 py-2.5 text-sm leading-relaxed break-words whitespace-pre-wrap bg-blue-500/15 border border-blue-500/25 text-zinc-50', assistant: 'rounded-lg px-3.5 py-2.5 text-sm leading-relaxed break-words whitespace-pre-wrap bg-zinc-900 border border-zinc-800 text-zinc-50', system: 'rounded-lg px-3.5 py-2.5 leading-relaxed break-words whitespace-pre-wrap bg-zinc-800 text-zinc-400 border border-zinc-700 text-xs', error: 'rounded-lg px-3.5 py-2.5 text-sm leading-relaxed break-words whitespace-pre-wrap bg-red-500/15 border border-red-500/30 text-zinc-50', }; div.className = messageClasses[role] || messageClasses.assistant; div.dataset.messageBody = 'true'; // Keep role as a data attribute for querySelector compatibility div.dataset.role = role; if (role === 'assistant' || role === 'system') { div.innerHTML = renderSafeMarkdown(content); setTimeout(highlightCode, 0); } else { div.textContent = content; } wrapper.appendChild(div); wrapper.appendChild(createTimestampEl(role, timestamp)); // Add action buttons (copy for all, edit for user) outside the message box if (role !== 'system') { wrapper.appendChild(createMessageActions(role)); } return wrapper; } function createToolEventEl(event, data) { const group = document.createElement('div'); group.className = 'border border-zinc-800 rounded-md my-1 overflow-hidden'; const header = document.createElement('div'); header.className = 'flex items-center gap-2 px-3 py-1.5 bg-zinc-800 cursor-pointer text-xs text-zinc-500 hover:text-zinc-400 select-none'; if (event === 'tool_start') { header.innerHTML = ` ${escapeHtml(data.tool)}`; } else if (event === 'tool_end') { const icon = data.result?.success ? '✓' : '✗'; const cls = data.result?.success ? 'text-green-500' : 'text-red-500'; header.innerHTML = `${icon} ${escapeHtml(data.tool)}`; } header.addEventListener('click', () => { body.classList.toggle('open'); }); const body = document.createElement('div'); body.className = 'tool-event-body px-3 py-2 text-xs text-zinc-400 bg-zinc-900 whitespace-pre-wrap break-all max-h-48 overflow-y-auto font-mono'; if (event === 'tool_start' && data.args) { body.textContent = JSON.stringify(data.args, null, 2); } else if (event === 'tool_end' && data.result) { body.textContent = data.result.output || data.result.error || '(no output)'; } group.appendChild(header); group.appendChild(body); return group; } function scrollToBottom() { const msgs = _elements.messages; if (msgs) { msgs.scrollTop = msgs.scrollHeight; } } function isSupportedImageMime(mimeType) { return mimeType === 'image/jpeg' || mimeType === 'image/png' || mimeType === 'image/gif' || mimeType === 'image/webp'; } function readFileAsBase64(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onerror = () => reject(new Error('Failed to read file')); reader.onload = () => { const result = String(reader.result || ''); // result is like: data:;base64, const comma = result.indexOf(','); if (comma === -1) { reject(new Error('Unexpected file encoding')); return; } resolve(result.slice(comma + 1)); }; reader.readAsDataURL(file); }); } function renderPendingAttachments() { const el = _elements.attachments; if (!el) {return;} el.innerHTML = ''; if (!_pendingAttachments.length) { el.classList.add('hidden'); return; } el.classList.remove('hidden'); for (let i = 0; i < _pendingAttachments.length; i++) { const att = _pendingAttachments[i]; const chip = document.createElement('div'); chip.className = 'inline-flex items-center gap-2 px-2.5 py-1 rounded-full bg-zinc-900 border border-zinc-800 text-zinc-400 text-xs'; const name = document.createElement('span'); name.className = 'max-w-[220px] overflow-hidden text-ellipsis whitespace-nowrap'; name.textContent = att.filename || 'attachment'; const rm = document.createElement('button'); rm.className = 'text-zinc-500 hover:text-zinc-50 cursor-pointer text-base leading-none appearance-none border-0 bg-transparent'; rm.type = 'button'; rm.title = 'Remove attachment'; rm.textContent = '\u00d7'; rm.addEventListener('click', () => { _pendingAttachments.splice(i, 1); renderPendingAttachments(); }); chip.appendChild(name); chip.appendChild(rm); el.appendChild(chip); } } function clearPendingAttachments() { _pendingAttachments = []; if (_elements.fileInput) { _elements.fileInput.value = ''; } renderPendingAttachments(); } // ── Message Action Buttons ────────────────────────────────── function getMessageText(el) { const messageBody = el?.dataset?.messageBody === 'true' ? el : el?.closest?.('.group')?.querySelector?.('[data-message-body="true"]'); if (!messageBody) { return ''; } // For user messages, textContent is the raw text. // For assistant/system messages rendered as markdown, extract plain text. return (messageBody.textContent || '').trim(); } function createMessageActions(role) { const bar = document.createElement('div'); bar.className = 'flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity'; // Copy button — all messages const copyBtn = document.createElement('button'); copyBtn.className = 'inline-flex items-center justify-center w-6 h-6 rounded text-zinc-500 hover:text-zinc-50 hover:bg-zinc-800 transition-colors'; copyBtn.title = 'Copy'; copyBtn.innerHTML = COPY_ICON; copyBtn.addEventListener('click', () => { const msgEl = bar.closest('.group')?.querySelector('[data-message-body="true"]'); if (!msgEl) {return;} const text = getMessageText(msgEl); navigator.clipboard.writeText(text).then(() => { copyBtn.innerHTML = CHECK_ICON; copyBtn.classList.add('text-green-500'); setTimeout(() => { copyBtn.innerHTML = COPY_ICON; copyBtn.classList.remove('text-green-500'); }, 1500); }); }); bar.appendChild(copyBtn); // Edit button — user messages only if (role === 'user') { const editBtn = document.createElement('button'); editBtn.className = 'inline-flex items-center justify-center w-6 h-6 rounded text-zinc-500 hover:text-zinc-50 hover:bg-zinc-800 transition-colors'; editBtn.title = 'Edit'; editBtn.innerHTML = EDIT_ICON; editBtn.addEventListener('click', () => { const msgEl = bar.closest('.group')?.querySelector('[data-message-body="true"]'); if (!msgEl) {return;} const text = getMessageText(msgEl); const input = _elements.input; if (input) { input.value = text; input.focus(); // Trigger auto-resize input.style.height = 'auto'; input.style.height = Math.min(input.scrollHeight, 150) + 'px'; } }); bar.appendChild(editBtn); } return bar; } // ── Search Mode ───────────────────────────────────────────── function setSearchMode(active) { _searchMode = active; const btn = _elements.searchBtn; const input = _elements.input; if (!btn || !input) {return;} if (active) { btn.classList.add('active'); input.placeholder = 'What do you want to search for?'; } else { btn.classList.remove('active'); input.placeholder = 'Type a message...'; } input.focus(); } // ── Slash Popup ───────────────────────────────────────────── function getFilteredCommands(text) { const prefix = text.toLowerCase(); return SLASH_COMMANDS.filter(c => c.name.startsWith(prefix)); } function showSlashPopup(filtered) { const popup = _elements.slashPopup; if (!popup) {return;} popup.innerHTML = ''; if (filtered.length === 0) { hideSlashPopup(); return; } for (let i = 0; i < filtered.length; i++) { const item = document.createElement('div'); item.className = 'flex items-baseline gap-3 px-3 py-2 cursor-pointer transition-colors' + (i === _slashPopupIndex ? ' bg-zinc-800' : ''); item.innerHTML = `${escapeHtml(filtered[i].name)}${escapeHtml(filtered[i].desc)}`; item.addEventListener('click', () => { selectSlashCommand(filtered[i].name); }); // Touch-friendly: handle pointerenter for hover on mobile item.addEventListener('pointerenter', () => { _slashPopupIndex = i; updatePopupSelection(filtered); }); popup.appendChild(item); } popup.classList.remove('hidden'); } function hideSlashPopup() { const popup = _elements.slashPopup; if (popup) {popup.classList.add('hidden');} _slashPopupIndex = -1; } function updatePopupSelection(_filtered) { const popup = _elements.slashPopup; if (!popup) {return;} const items = popup.children; for (let i = 0; i < items.length; i++) { if (i === _slashPopupIndex) { items[i].classList.add('bg-zinc-800'); } else { items[i].classList.remove('bg-zinc-800'); } } } function selectSlashCommand(name) { const input = _elements.input; if (!input) {return;} input.value = name; hideSlashPopup(); input.focus(); } function handleSlashPopupInput() { const input = _elements.input; if (!input) {return;} const text = input.value; // Show popup only when text starts with / and is at most a single word (the command itself) if (text.startsWith('/') && !text.includes(' ')) { const filtered = getFilteredCommands(text); // Clamp selection index if (_slashPopupIndex >= filtered.length) {_slashPopupIndex = filtered.length - 1;} showSlashPopup(filtered); } else { hideSlashPopup(); } } // ── Slash Command Handlers ────────────────────────────────── function parseSlashCommand(text) { const trimmed = text.trim(); if (!trimmed.startsWith('/')) {return null;} const parts = trimmed.split(/\s+/); const cmd = parts[0].toLowerCase(); const args = parts.slice(1).join(' '); switch (cmd) { case '/help': return { type: 'help' }; case '/reset': return { type: 'reset' }; case '/compact': return { type: 'compact' }; case '/usage': return { type: 'usage' }; case '/status': return { type: 'status' }; case '/model': return { type: 'model', args }; case '/stop': return { type: 'stop' }; case '/cancel': return { type: 'cancel' }; case '/approvals': return { type: 'approvals' }; case '/approve': return { type: 'approve', args }; case '/deny': return { type: 'deny', args }; default: return null; } } function showSystemMessage(content) { _elements.messages.appendChild(createMessageEl('system', content, Date.now())); scrollToBottom(); } async function executeAgentSlashCommand(client, command, commandArgs = '') { const normalizedArgs = (commandArgs ?? '').trim(); const message = normalizedArgs ? `/${command} ${normalizedArgs}` : `/${command}`; const metadata = { isCommand: true, command, ...(normalizedArgs ? { commandArgs: normalizedArgs } : {}), }; const stream = client.stream('agent.send', { message, metadata }); const done = await stream.result; return done?.content ?? done?.text ?? ''; } async function handleSlashCommand(cmd, client) { switch (cmd.type) { case 'help': { const lines = [ '**Available Commands**', '', '| Command | Description |', '|---------|-------------|', '| `/help` | Show this help |', '| `/reset` | Reset the current session |', '| `/compact` | Ask the agent to compact context |', '| `/usage` | Show token usage stats |', '| `/status` | Show system health |', '| `/model [tier|provider]` | Show or set model tier/provider |', '| `/stop` | Stop active response |', '| `/cancel` | Alias for `/stop` |', '| `/approvals` | List pending guarded actions |', '| `/approve [id]` | Approve latest pending or specific id |', '| `/deny [id] [reason]` | Deny latest pending or specific id |', '', 'Type `/` to see autocomplete suggestions.', ]; showSystemMessage(lines.join('\n')); return true; } case 'reset': { try { const result = await executeAgentSlashCommand(client, 'reset'); _elements.messages.innerHTML = ''; showSystemMessage(result || 'Session reset.'); } catch (err) { showSystemMessage(`Failed to reset: ${err.message}`); } return true; } case 'compact': { try { const result = await executeAgentSlashCommand(client, 'compact'); showSystemMessage(result || 'Compaction requested.'); } catch (err) { showSystemMessage(`Failed to compact: ${err.message}`); } return true; } case 'usage': { try { const result = await executeAgentSlashCommand(client, 'usage'); showSystemMessage(result || 'No usage data available.'); } catch (err) { showSystemMessage(`Failed to fetch usage: ${err.message}`); } return true; } case 'status': { try { const result = await executeAgentSlashCommand(client, 'status'); showSystemMessage(result || 'Status unavailable.'); } catch (err) { showSystemMessage(`Failed to fetch status: ${err.message}`); } return true; } case 'model': { try { const result = await executeAgentSlashCommand(client, 'model', cmd.args ?? ''); showSystemMessage(result || 'Model info unavailable.'); } catch (err) { showSystemMessage(`Failed to fetch model info: ${err.message}`); } return true; } case 'stop': case 'cancel': { try { const result = await executeAgentSlashCommand(client, 'stop'); showSystemMessage(result || 'Cancellation requested.'); } catch (err) { showSystemMessage(`Failed to stop: ${err.message}`); } return true; } case 'approvals': { try { const result = await executeAgentSlashCommand(client, 'approvals'); showSystemMessage(result || 'No pending approvals.'); } catch (err) { showSystemMessage(`Failed to list approvals: ${err.message}`); } return true; } case 'approve': { try { const result = await executeAgentSlashCommand(client, 'approve', cmd.args ?? ''); showSystemMessage(result || 'Approved.'); } catch (err) { showSystemMessage(`Failed to approve: ${err.message}`); } return true; } case 'deny': { try { const result = await executeAgentSlashCommand(client, 'deny', cmd.args ?? ''); showSystemMessage(result || 'Denied.'); } catch (err) { showSystemMessage(`Failed to deny: ${err.message}`); } return true; } default: return false; } } // ── Session Management ────────────────────────────────────── async function loadSessions(client) { const select = _elements.sessionSelect; if (!select) {return;} try { const result = await client.call('sessions.list'); const sessions = sortSessions(result.sessions ?? [], _sessionSort); // Preserve current selection const current = _currentSession; select.innerHTML = ''; if (sessions.length === 0) { const opt = document.createElement('option'); opt.value = ''; opt.textContent = 'No sessions'; select.appendChild(opt); } else { for (const s of sessions) { const opt = document.createElement('option'); opt.value = s.id; const msgCount = s.messageCount ?? 0; const msgLabel = msgCount === 1 ? '1 msg' : `${msgCount} msgs`; opt.textContent = `${s.id} · ${msgLabel} · ${formatSessionLastActive(s.lastMessageAt)}`; if (s.id === current) {opt.selected = true;} select.appendChild(opt); } } // Update current session _currentSession = select.value || null; } catch { // Ignore — sessions may not be available } } async function loadHistory(client) { const msgs = _elements.messages; if (!msgs || !_currentSession) {return;} msgs.innerHTML = ''; try { const result = await client.call('sessions.history', { sessionId: _currentSession }); const messages = result.messages ?? []; for (const msg of messages) { const role = msg.role ?? 'assistant'; const content = msg.content ?? msg.text ?? ''; msgs.appendChild(createMessageEl(role, content, msg.timestamp ?? Date.now())); } scrollToBottom(); } catch { msgs.innerHTML = '
Could not load history
'; } } // ── Send Message ──────────────────────────────────────────── async function sendMessage(client, overrideText) { const input = _elements.input; const rawText = (overrideText ?? input?.value ?? '').trim(); const hasText = Boolean(rawText); const hasAttachments = Boolean(_pendingAttachments.length > 0); if ((!hasText && !hasAttachments) || _sending) {return;} // Check for slash commands first (text only) if (hasText) { const cmd = parseSlashCommand(rawText); if (cmd) { if (!overrideText) {input.value = '';} hideSlashPopup(); const handled = await handleSlashCommand(cmd, client); if (handled) {return;} // If not fully handled (e.g. /compact), fall through to send as message } } _sending = true; _cancelling = false; updateSendButton(); if (!overrideText && input) {input.value = '';} // Apply search mode prefix let messageText = rawText; if (_searchMode && hasText && !rawText.startsWith('/')) { messageText = `Search the web for: ${rawText}`; setSearchMode(false); } const userDisplay = hasText ? rawText : `Sent ${_pendingAttachments.length} attachment(s)`; _elements.messages.appendChild(createMessageEl('user', userDisplay, Date.now())); scrollToBottom(); // Create placeholder for assistant response const statusLine = document.createElement('div'); statusLine.className = 'px-1 text-[11px] leading-none text-zinc-500 select-none hidden'; statusLine.textContent = 'Run status: queued'; _elements.messages.appendChild(statusLine); const placeholder = document.createElement('div'); placeholder.className = 'rounded-lg px-3.5 py-2.5 text-sm leading-relaxed break-words whitespace-pre-wrap bg-zinc-900 border border-zinc-800 text-zinc-50 streaming-cursor'; placeholder.innerHTML = 'Thinking...'; _elements.messages.appendChild(placeholder); scrollToBottom(); try { const stream = client.stream('agent.send', { message: messageText, attachments: _pendingAttachments }); stream.on('tool_start', (data) => { const el = createToolEventEl('tool_start', data); _elements.messages.insertBefore(el, placeholder); scrollToBottom(); }); stream.on('tool_end', (data) => { // Replace the last tool_start spinner with completion marker const events = _elements.messages.querySelectorAll('.border.border-zinc-800.rounded-md'); const last = events[events.length - 1]; if (last) { const header = last.firstElementChild; if (header && data.tool) { const icon = data.result?.success !== false ? '✓' : '✗'; const cls = data.result?.success !== false ? 'text-green-500' : 'text-red-500'; header.innerHTML = `${icon} ${escapeHtml(data.tool)}`; } // Add result body const body = last.lastElementChild; if (body && data.result) { body.textContent = data.result.output || data.result.error || '(no output)'; } } scrollToBottom(); }); stream.on('context_warning', (data) => { const note = document.createElement('div'); note.className = 'rounded-lg px-3.5 py-2.5 text-sm leading-relaxed break-words whitespace-pre-wrap bg-zinc-900 border border-zinc-800 text-zinc-50'; const text = data?.message || 'Context usage is getting high.'; note.innerHTML = renderSafeMarkdown(`> ${text}`); _elements.messages.insertBefore(note, placeholder); scrollToBottom(); }); stream.on('run_state', (data) => { if (!data || !data.state) { return; } const labels = { start: 'Run status: working', cancel_requested: 'Run status: cancellation requested', cancelled: 'Run status: cancelled', complete: 'Run status: complete', error: `Run status: error${data.message ? ` (${data.message})` : ''}`, }; statusLine.textContent = labels[data.state] || `Run status: ${data.state}`; statusLine.classList.remove('hidden'); scrollToBottom(); }); const done = await stream.result; const content = done?.content ?? done?.text ?? '(no response)'; const assistantMessage = createMessageEl('assistant', content, Date.now()); placeholder.replaceWith(assistantMessage); } catch (err) { const errorMessage = createMessageEl('error', `Error: ${err.message}`, Date.now()); placeholder.replaceWith(errorMessage); } finally { _sending = false; _cancelling = false; updateSendButton(); clearPendingAttachments(); statusLine.remove(); scrollToBottom(); } } function updateSendButton() { if (!_elements.sendBtn) { return; } if (_sending) { _elements.sendBtn.disabled = _cancelling; _elements.sendBtn.textContent = _cancelling ? 'Stopping...' : 'Stop'; return; } _elements.sendBtn.disabled = false; _elements.sendBtn.textContent = 'Send'; } async function cancelActiveRun(client) { if (!_sending || _cancelling) { return; } _cancelling = true; updateSendButton(); try { await client.call('agent.cancel', {}); } catch (err) { showSystemMessage(`Cancel failed: ${err.message}`); _cancelling = false; updateSendButton(); } } // ── Search SVG Icon ───────────────────────────────────────── const SEARCH_ICON = ''; const ATTACH_ICON = ''; const COPY_ICON = ''; const CHECK_ICON = ''; const EDIT_ICON = ''; // ── Page Export ────────────────────────────────────────────── export const ChatPage = { async render(el, client) { el.innerHTML = `
`; _elements = { sessionSelect: el.querySelector('#chat-session-select'), sessionSort: el.querySelector('#chat-session-sort'), messages: el.querySelector('#chat-messages'), input: el.querySelector('#chat-input'), sendBtn: el.querySelector('#chat-send'), searchBtn: el.querySelector('#chat-search'), attachBtn: el.querySelector('#chat-attach'), fileInput: el.querySelector('#chat-file'), attachments: el.querySelector('#chat-attachments'), slashPopup: el.querySelector('#slash-popup'), }; // Load sessions into dropdown await loadSessions(client); // Event: session change _elements.sessionSelect.addEventListener('change', () => { _currentSession = _elements.sessionSelect.value || null; }); _elements.sessionSort.addEventListener('change', () => { _sessionSort = _elements.sessionSort.value || 'recent'; loadSessions(client); }); // Event: new session el.querySelector('#chat-new-session').addEventListener('click', async () => { try { const result = await client.call('sessions.create'); _currentSession = result.sessionId; await loadSessions(client); _elements.messages.innerHTML = ''; } catch (err) { _elements.messages.innerHTML = `
Failed to create session: ${err.message}
`; } }); // Event: load history el.querySelector('#chat-load-history').addEventListener('click', () => { loadHistory(client); }); // Event: search button toggle _elements.searchBtn.addEventListener('click', () => { setSearchMode(!_searchMode); }); // Event: attach button _elements.attachBtn.addEventListener('click', () => { if (_elements.fileInput) {_elements.fileInput.click();} }); // Event: file input change _elements.fileInput.addEventListener('change', async () => { const files = Array.from(_elements.fileInput.files ?? []); if (files.length === 0) {return;} const MAX_BYTES = 5 * 1024 * 1024; for (const file of files) { if (!isSupportedImageMime(file.type)) { showSystemMessage(`Unsupported file type: ${file.type || file.name}`); continue; } if (file.size > MAX_BYTES) { showSystemMessage(`File too large (max 5MB): ${file.name}`); continue; } try { const data = await readFileAsBase64(file); _pendingAttachments.push({ mimeType: file.type, data, filename: file.name }); } catch (err) { showSystemMessage(`Failed to attach ${file.name}: ${err.message}`); } } renderPendingAttachments(); }); // Event: send message _elements.sendBtn.addEventListener('click', () => { if (_sending) { void cancelActiveRun(client); return; } void sendMessage(client); }); // Event: keyboard in textarea _elements.input.addEventListener('keydown', (e) => { const popup = _elements.slashPopup; const isPopupVisible = popup && !popup.classList.contains('hidden'); // Handle slash popup navigation if (isPopupVisible) { const text = _elements.input.value; const filtered = getFilteredCommands(text); if (e.key === 'ArrowDown') { e.preventDefault(); _slashPopupIndex = Math.min(_slashPopupIndex + 1, filtered.length - 1); updatePopupSelection(filtered); return; } if (e.key === 'ArrowUp') { e.preventDefault(); _slashPopupIndex = Math.max(_slashPopupIndex - 1, 0); updatePopupSelection(filtered); return; } if ((e.key === 'Enter' || e.key === 'Tab') && _slashPopupIndex >= 0 && _slashPopupIndex < filtered.length) { e.preventDefault(); selectSlashCommand(filtered[_slashPopupIndex].name); return; } if (e.key === 'Escape') { e.preventDefault(); hideSlashPopup(); return; } } // Enter to send (Shift+Enter for newline) if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); hideSlashPopup(); if (_sending) { void cancelActiveRun(client); return; } void sendMessage(client); } }); // Event: input changes for slash popup + auto-resize _elements.input.addEventListener('input', () => { // Auto-resize textarea const ta = _elements.input; ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 150) + 'px'; // Slash command popup handleSlashPopupInput(); }); // Dismiss slash popup on outside click el.addEventListener('click', (e) => { if (!e.target.closest('.relative')) { hideSlashPopup(); } }); // If there's a current session, show welcome if (!_currentSession) { _elements.messages.innerHTML = '
Select a session or create a new one to start chatting
'; } updateSendButton(); }, teardown() { _currentSession = null; _sending = false; _cancelling = false; _searchMode = false; _slashPopupIndex = -1; _sessionSort = 'recent'; _elements = {}; _pendingAttachments = []; }, };