Files
flynn/src/gateway/ui/pages/chat.js
T
2026-02-25 10:22:44 -08:00

1045 lines
38 KiB
JavaScript

/**
* 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 = `<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 ? 'text-green-500' : 'text-red-500';
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 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:<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 = '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 = `<span class="text-blue-500 font-semibold text-sm min-w-[80px]">${escapeHtml(filtered[i].name)}</span><span class="text-zinc-500 text-xs">${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.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 = '<div class="text-center py-12 px-6 text-zinc-500 text-sm">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;
_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 = '<span class="text-zinc-500">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('.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 ? '&#10003;' : '&#10007;';
const cls = data.result?.success !== false ? 'text-green-500' : 'text-red-500';
header.innerHTML = `<span class="${cls}">${icon}</span> <strong>${escapeHtml(data.tool)}</strong>`;
}
// 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 = '<svg class="w-3.5 h-3.5 fill-current shrink-0" 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 class="w-3.5 h-3.5 fill-current shrink-0" 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 class="w-3.5 h-3.5 fill-current" 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 class="w-3.5 h-3.5 fill-current" 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 class="w-3.5 h-3.5 fill-current" 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="flex flex-col h-[calc(100vh-6rem)] md:h-[calc(100vh-3rem)] max-w-3xl">
<div class="flex items-center gap-3 pb-3 border-b border-zinc-800 mb-3 flex-wrap">
<select id="chat-session-select" class="bg-zinc-900 text-zinc-50 border border-zinc-800 rounded-lg px-3 py-1.5 text-sm outline-none focus:border-blue-500"></select>
<select id="chat-session-sort" class="bg-zinc-900 text-zinc-50 border border-zinc-800 rounded-lg px-3 py-1.5 text-sm outline-none focus:border-blue-500" title="Sort sessions">
<option value="recent">Sort: Recent</option>
<option value="messages">Sort: Most messages</option>
<option value="name">Sort: Name</option>
</select>
<button id="chat-new-session" class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors">+ New</button>
<button id="chat-load-history" class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors">History</button>
</div>
<div class="flex-1 overflow-y-auto flex flex-col gap-3 py-3" id="chat-messages"></div>
<div class="flex items-center gap-2 py-2 flex-wrap">
<button id="chat-search" class="btn-action inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium bg-zinc-800 text-zinc-400 border border-zinc-700 hover:text-zinc-50 hover:border-zinc-600 transition-colors cursor-pointer select-none" title="Search the web">
${SEARCH_ICON}
<span>Search</span>
</button>
<button id="chat-attach" class="btn-action inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium bg-zinc-800 text-zinc-400 border border-zinc-700 hover:text-zinc-50 hover:border-zinc-600 transition-colors cursor-pointer select-none" title="Attach image">
${ATTACH_ICON}
<span>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="inline-flex items-center gap-1.5 flex-wrap hidden"></div>
</div>
<div class="relative">
<div id="slash-popup" class="absolute bottom-full left-0 right-0 mb-1 bg-zinc-900 border border-zinc-800 rounded-lg max-h-60 overflow-y-auto z-50 shadow-lg shadow-black/30 hidden"></div>
<div class="flex gap-2 pt-3 border-t border-zinc-800">
<textarea id="chat-input" class="flex-1 bg-zinc-900 text-zinc-50 border border-zinc-800 rounded-lg px-3 py-2.5 text-sm outline-none resize-none min-h-[42px] max-h-[150px] leading-relaxed focus:border-blue-500 transition-colors" placeholder="Type a message..." rows="1"></textarea>
<button id="chat-send" class="px-4 py-2.5 bg-blue-500 text-zinc-950 font-semibold text-sm rounded-lg hover:opacity-85 disabled:opacity-40 disabled:cursor-not-allowed transition-opacity self-end">Send</button>
</div>
</div>
</div>
`;
_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 = `<div class="text-center py-12 px-6 text-zinc-500 text-sm">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', () => {
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 = '<div class="text-center py-12 px-6 text-zinc-500 text-sm">Select a session or create a new one to start chatting</div>';
}
updateSendButton();
},
teardown() {
_currentSession = null;
_sending = false;
_cancelling = false;
_searchMode = false;
_slashPopupIndex = -1;
_sessionSort = 'recent';
_elements = {};
_pendingAttachments = [];
},
};