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 = `${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.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 = '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;
_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 = '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('.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 ? '✓' : '✗';
const cls = data.result?.success !== false ? 'status-ok' : 'status-error';
header.innerHTML = `${icon} ${escapeHtml(data.tool)}`;
}
// 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 = '';
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'),
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 = `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', () => 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 = 'Select a session or create a new one to start chatting
';
}
},
teardown() {
_currentSession = null;
_sending = false;
_searchMode = false;
_slashPopupIndex = -1;
_elements = {};
_pendingAttachments = [];
},
};