Files
flynn/src/gateway/ui/pages/chat.js
T
2026-02-15 23:14:21 -08:00

809 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 _searchMode = false;
let _slashPopupIndex = -1;
let _elements = {};
let _pendingAttachments = [];
// ── 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' },
];
// ── 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 createMessageEl(role, content) {
const wrapper = document.createElement('div');
wrapper.className = 'message-wrapper';
const div = document.createElement('div');
div.className = `message ${role}`;
if (role === 'assistant' || role === 'system') {
div.innerHTML = renderSafeMarkdown(content);
setTimeout(highlightCode, 0);
} else {
div.textContent = content;
}
wrapper.appendChild(div);
// 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 = 'tool-event-group';
const header = document.createElement('div');
header.className = 'tool-event-header';
if (event === 'tool_start') {
header.innerHTML = `<span class="spinner"></span> <strong>${escapeHtml(data.tool)}</strong>`;
} else if (event === 'tool_end') {
const icon = data.result?.success ? '&#10003;' : '&#10007;';
const cls = data.result?.success ? 'status-ok' : 'status-error';
header.innerHTML = `<span class="${cls}">${icon}</span> <strong>${escapeHtml(data.tool)}</strong>`;
}
header.addEventListener('click', () => {
body.classList.toggle('open');
});
const body = document.createElement('div');
body.className = 'tool-event-body';
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:<mime>;base64,<payload>
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 = 'attachment-chip';
const name = document.createElement('span');
name.className = 'attachment-name';
name.textContent = att.filename || 'attachment';
const rm = document.createElement('button');
rm.className = 'attachment-remove';
rm.type = 'button';
rm.title = 'Remove attachment';
rm.textContent = '×';
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) {
// For user messages, textContent is the raw text.
// For assistant/system messages rendered as markdown, extract plain text.
return (el.textContent || '').trim();
}
function createMessageActions(role) {
const bar = document.createElement('div');
bar.className = 'message-actions';
// Copy button — all messages
const copyBtn = document.createElement('button');
copyBtn.className = 'msg-action-btn';
copyBtn.title = 'Copy';
copyBtn.innerHTML = COPY_ICON;
copyBtn.addEventListener('click', () => {
const msg = bar.closest('.message');
if (!msg) {return;}
const text = getMessageText(msg);
navigator.clipboard.writeText(text).then(() => {
copyBtn.innerHTML = CHECK_ICON;
copyBtn.classList.add('copied');
setTimeout(() => {
copyBtn.innerHTML = COPY_ICON;
copyBtn.classList.remove('copied');
}, 1500);
});
});
bar.appendChild(copyBtn);
// Edit button — user messages only
if (role === 'user') {
const editBtn = document.createElement('button');
editBtn.className = 'msg-action-btn';
editBtn.title = 'Edit';
editBtn.innerHTML = EDIT_ICON;
editBtn.addEventListener('click', () => {
const msg = bar.closest('.message');
if (!msg) {return;}
const text = getMessageText(msg);
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 = 'slash-popup-item' + (i === _slashPopupIndex ? ' selected' : '');
item.innerHTML = `<span class="cmd-name">${escapeHtml(filtered[i].name)}</span><span class="cmd-desc">${escapeHtml(filtered[i].desc)}</span>`;
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.querySelectorAll('.slash-popup-item');
items.forEach((el, i) => {
el.classList.toggle('selected', i === _slashPopupIndex);
});
}
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 };
default: return null;
}
}
function showSystemMessage(content) {
_elements.messages.appendChild(createMessageEl('system', content));
scrollToBottom();
}
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` | Show current model info |',
'',
'Type `/` to see autocomplete suggestions.',
];
showSystemMessage(lines.join('\n'));
return true;
}
case 'reset': {
try {
// Send reset command via metadata
const stream = client.stream('agent.send', {
message: '/reset',
metadata: { isCommand: true, command: 'reset' },
});
await stream.result;
_elements.messages.innerHTML = '';
showSystemMessage('Session reset.');
} catch (err) {
showSystemMessage(`Failed to reset: ${err.message}`);
}
return true;
}
case 'compact': {
// Send as a regular message — the agent will interpret the request
showSystemMessage('Requesting context compaction...');
return false; // Let it pass through as a normal message
}
case 'usage': {
try {
const result = await client.call('system.tokenUsage');
const sessions = result.sessions ?? [];
if (sessions.length === 0) {
showSystemMessage('No usage data available.');
} else {
const lines = ['**Token Usage**', ''];
let totalIn = 0, totalOut = 0, totalCalls = 0;
for (const s of sessions) {
totalIn += s.total?.inputTokens ?? 0;
totalOut += s.total?.outputTokens ?? 0;
totalCalls += s.total?.calls ?? 0;
}
lines.push(`**Input:** ${totalIn.toLocaleString()} tokens`);
lines.push(`**Output:** ${totalOut.toLocaleString()} tokens`);
lines.push(`**API Calls:** ${totalCalls}`);
if (sessions.length > 1) {
lines.push(`**Sessions:** ${sessions.length}`);
}
showSystemMessage(lines.join('\n'));
}
} catch (err) {
showSystemMessage(`Failed to fetch usage: ${err.message}`);
}
return true;
}
case 'status': {
try {
const result = await client.call('system.health');
const lines = [
'**System Status**',
'',
`**Uptime:** ${result.uptime ?? 'unknown'}`,
`**Status:** ${result.status ?? 'unknown'}`,
];
if (result.channels) {
lines.push('', '**Channels:**');
for (const ch of result.channels) {
const dot = ch.status === 'connected' ? '\\*' : '-';
lines.push(` ${dot} ${ch.name}: ${ch.status}`);
}
}
if (result.model) {
lines.push('', `**Model:** ${result.model}`);
}
showSystemMessage(lines.join('\n'));
} catch (err) {
showSystemMessage(`Failed to fetch status: ${err.message}`);
}
return true;
}
case 'model': {
try {
const result = await client.call('system.health');
const model = result.model ?? result.config?.model ?? 'unknown';
showSystemMessage(`**Current Model:** ${model}`);
} catch (err) {
showSystemMessage(`Failed to fetch model info: ${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 = result.sessions ?? [];
// 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;
opt.textContent = `${s.id} (${s.messageCount} msgs)`;
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));
}
scrollToBottom();
} catch {
msgs.innerHTML = '<div class="empty-state">Could not load history</div>';
}
}
// ── 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;
_elements.sendBtn.disabled = true;
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));
scrollToBottom();
// Create placeholder for assistant response
const placeholder = document.createElement('div');
placeholder.className = 'message assistant streaming-cursor';
placeholder.innerHTML = '<span class="text-muted">Thinking...</span>';
_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('.tool-event-group');
const last = events[events.length - 1];
if (last) {
const header = last.querySelector('.tool-event-header');
if (header && data.tool) {
const icon = data.result?.success !== false ? '&#10003;' : '&#10007;';
const cls = data.result?.success !== false ? 'status-ok' : 'status-error';
header.innerHTML = `<span class="${cls}">${icon}</span> <strong>${escapeHtml(data.tool)}</strong>`;
}
// Add result body
const body = last.querySelector('.tool-event-body');
if (body && data.result) {
body.textContent = data.result.output || data.result.error || '(no output)';
}
}
scrollToBottom();
});
const done = await stream.result;
// Replace placeholder with actual response
placeholder.classList.remove('streaming-cursor');
const content = done?.content ?? done?.text ?? '(no response)';
placeholder.innerHTML = renderSafeMarkdown(content);
placeholder.appendChild(createMessageActions('assistant'));
setTimeout(highlightCode, 0);
} catch (err) {
placeholder.classList.remove('streaming-cursor');
placeholder.className = 'message error';
placeholder.textContent = `Error: ${err.message}`;
} finally {
_sending = false;
if (_elements.sendBtn) {_elements.sendBtn.disabled = false;}
clearPendingAttachments();
scrollToBottom();
}
}
// ── Search SVG Icon ─────────────────────────────────────────
const SEARCH_ICON = '<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M8.5 3a5.5 5.5 0 0 1 4.38 8.82l4.15 4.15a.75.75 0 0 1-1.06 1.06l-4.15-4.15A5.5 5.5 0 1 1 8.5 3zm0 1.5a4 4 0 1 0 0 8 4 4 0 0 0 0-8z" fill="currentColor"/></svg>';
const ATTACH_ICON = '<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M7.5 4.75A3.75 3.75 0 0 1 11.25 1h.5A3.25 3.25 0 0 1 15 4.25V12a4.5 4.5 0 0 1-9 0V6.75a2.75 2.75 0 0 1 5.5 0V12a1.25 1.25 0 0 1-2.5 0V6.75a.75.75 0 0 0-1.5 0V12a2.75 2.75 0 0 0 5.5 0V6.75A4.25 4.25 0 0 0 5 6.75V12a6 6 0 0 0 12 0V4.25A4.75 4.75 0 0 0 11.75-.5h-.5A5.25 5.25 0 0 0 6 4.75v8a.75.75 0 0 0 1.5 0z" fill="currentColor"/></svg>';
const COPY_ICON = '<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z" fill="currentColor"/><path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z" fill="currentColor"/></svg>';
const CHECK_ICON = '<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.75.75 0 0 1 1.06-1.06L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z" fill="currentColor"/></svg>';
const EDIT_ICON = '<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm1.414 1.06a.25.25 0 0 0-.354 0L3.463 11.1l-.47 1.64 1.64-.47 8.61-8.61a.25.25 0 0 0 0-.354Z" fill="currentColor"/></svg>';
// ── Page Export ──────────────────────────────────────────────
export const ChatPage = {
async render(el, client) {
el.innerHTML = `
<div class="chat-layout">
<div class="chat-header">
<select id="chat-session-select"></select>
<button id="chat-new-session" class="btn btn-secondary">+ New</button>
<button id="chat-load-history" class="btn btn-secondary">History</button>
</div>
<div class="chat-messages" id="chat-messages"></div>
<div class="chat-actions">
<button id="chat-search" class="btn-action" title="Search the web">
${SEARCH_ICON}
<span class="btn-action-label">Search</span>
</button>
<button id="chat-attach" class="btn-action" title="Attach image">
${ATTACH_ICON}
<span class="btn-action-label">Attach</span>
</button>
<input id="chat-file" type="file" accept="image/png,image/jpeg,image/gif,image/webp" multiple class="hidden" />
<div id="chat-attachments" class="chat-attachments hidden"></div>
</div>
<div class="chat-input-wrapper">
<div id="slash-popup" class="slash-popup hidden"></div>
<div class="chat-input">
<textarea id="chat-input" placeholder="Type a message..." rows="1"></textarea>
<button id="chat-send" class="btn btn-primary">Send</button>
</div>
</div>
</div>
`;
_elements = {
sessionSelect: el.querySelector('#chat-session-select'),
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;
});
// 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 = `<div class="empty-state">Failed to create session: ${err.message}</div>`;
}
});
// 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', () => 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();
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('.chat-input-wrapper')) {
hideSlashPopup();
}
});
// If there's a current session, show welcome
if (!_currentSession) {
_elements.messages.innerHTML = '<div class="empty-state">Select a session or create a new one to start chatting</div>';
}
},
teardown() {
_currentSession = null;
_sending = false;
_searchMode = false;
_slashPopupIndex = -1;
_elements = {};
_pendingAttachments = [];
},
};