import { api } from './api.js';
import { WsClient } from './ws.js';
import { formatBytes } from './utils.js';
import { appendMessage, appendStreamBubble, finalizeStreamBubble,
appendToolCall, appendPendingToolCard, finalizeToolCard,
appendSubagentStep, finalizeSubagentStep,
appendTurnThinkingCard, appendSubagentThinking,
appendThinkingCard, finalizeThinkingCard,
appendPlanCard,
appendTypingIndicator, removeTypingIndicator,
appendError, showEmptyState, scrollToBottom,
appendSummaryCard, appendCompressionNotice } from './chat.js';
import { renderProfiles, renderSessions, updateChatHeader } from './sidebar.js';
// ── DOM refs ─────────────────────────────────────────────────────────────────
const profileSelect = document.getElementById('profile-select');
const btnNew = document.getElementById('btn-new');
const btnSidebarToggle = document.getElementById('btn-sidebar-toggle');
const sidebarEl = document.querySelector('.sidebar');
const sidebarBackdrop = document.getElementById('sidebar-backdrop');
const sessionListEl = document.getElementById('session-list');
const chatHeaderEl = document.getElementById('chat-header');
const tokenCounterEl = document.getElementById('token-counter');
const messagesEl = document.getElementById('messages');
const textarea = document.getElementById('input');
const btnSend = document.getElementById('btn-send');
const btnAttach = document.getElementById('btn-attach');
const fileInput = document.getElementById('file-input');
const previewStrip = document.getElementById('image-preview-strip');
const uploadProgressBar = document.getElementById('upload-progress-bar');
const uploadProgressFill = document.getElementById('upload-progress-fill');
const uploadProgressLabel = document.getElementById('upload-progress-label');
// ── State ─────────────────────────────────────────────────────────────────────
let profiles = [];
let sessions = [];
let currentId = null;
let streaming = false;
let inputAllowed = false; // true when a session is open and ready for input
let currentBubble = null;
let currentThinking = null;
let pendingImages = []; // {dataUrl} — images to send as base64
let pendingFiles = []; // {name, size, path, contentType} — uploaded non-image files
let uploadCount = 0; // number of in-progress uploads
let pendingToolCard = null; // current main-level tool card awaiting result
let pendingSubStep = null; // current sub-agent step inside pendingToolCard
const ws = new WsClient();
// ── Boot ──────────────────────────────────────────────────────────────────────
async function init() {
textarea.disabled = false; // always enabled; send button guards actual sending
textarea.addEventListener('keydown', onKey);
textarea.addEventListener('input', onTextareaInput);
textarea.addEventListener('paste', onPaste);
btnSend.addEventListener('click', () => streaming ? stopGeneration() : sendMessage());
btnNew.addEventListener('click', newChat);
btnAttach.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', onFileChange);
profileSelect.addEventListener('change', onProfileChange);
btnSidebarToggle.addEventListener('click', toggleSidebar);
sidebarBackdrop.addEventListener('click', closeSidebar);
sessionListEl.innerHTML = '<div class="sidebar-spinner"><div class="spinner"></div></div>';
[profiles, sessions] = await Promise.all([api.getProfiles(), api.getSessions()]);
sessions = sessions.map(enrichSession);
renderProfiles(profileSelect, profiles);
// Open session from URL hash, or fall back to most recently active overall
const hashId = location.hash.slice(1);
const target = (hashId && sessions.find(s => s.session_id === hashId))
? hashId
: sessions[0]?.session_id ?? null;
if (target) {
await openSession(target, false);
} else {
rerenderSidebar();
showEmptyState(messagesEl);
setInputEnabled(false);
}
}
// ── Profile selector ──────────────────────────────────────────────────────────
function onProfileChange() {
rerenderSidebar();
// Switch to the most recent session of the newly selected profile, or empty state
const profileId = profileSelect.value;
const first = sessions.find(s => s.profile_id === profileId)?.session_id ?? null;
if (first) {
openSession(first);
} else {
abandonStream();
ws.disconnect();
currentId = null;
history.replaceState(null, '', location.pathname);
showEmptyState(messagesEl);
updateChatHeader(chatHeaderEl, null);
setInputEnabled(false);
}
}
// ── Sessions ──────────────────────────────────────────────────────────────────
async function newChat() {
const profileId = profileSelect.value;
if (!profileId) return;
btnNew.disabled = true;
try {
const session = await api.createSession(profileId);
sessions.unshift({ ...session, preview: '', profile_name: profileName(profileId) });
await openSession(session.session_id, false);
} finally {
btnNew.disabled = false;
}
}
async function openSession(sessionId, skipLoad = false) {
saveDraft(); // persist typed text for the session we're leaving
abandonStream(); // reset stream state before WS disconnect to suppress finishStream on onClose
ws.disconnect();
currentId = sessionId;
history.replaceState(null, '', '#' + sessionId);
tokenCounterEl.hidden = true;
// Sync profile selector to match the opened session
const s = sessions.find(s => s.session_id === sessionId);
if (s) profileSelect.value = s.profile_id;
rerenderSidebar();
const pId = s?.profile_id ?? '';
const pName = s?.profile_name ?? profileName(pId);
updateChatHeader(chatHeaderEl, pId, pName);
if (!skipLoad) {
await loadHistory(sessionId);
}
connectWs(sessionId);
setInputEnabled(true);
restoreDraft(); // restore any previously typed (unsent) text
}
async function loadHistory(sessionId) {
messagesEl.innerHTML = '<div class="chat-spinner"><div class="spinner"></div></div>';
try {
const data = await api.getSession(sessionId);
messagesEl.innerHTML = '';
const toolCallMap = {};
for (const msg of data.messages) {
if (msg.role === 'assistant' && msg.tool_calls) {
for (const tc of msg.tool_calls) {
toolCallMap[tc.id] = { name: tc.name, args: tc.arguments ?? {} };
}
}
}
for (const msg of data.messages) {
if (msg.role === 'system') continue;
if (msg.is_summary) {
appendSummaryCard(messagesEl, msg.content ?? '');
continue;
}
if (msg.role === 'tool') {
const tc = toolCallMap[msg.tool_call_id] ?? { name: msg.name ?? '?', args: {} };
const success = !msg.content?.startsWith('Error:');
appendToolCall(messagesEl, {
tool: tc.name,
args: tc.args,
result: msg.content ?? '',
success,
});
continue;
}
if (msg.role === 'user' || (msg.role === 'assistant' && msg.content && !msg.tool_calls)) {
const imgs = msg.images?.map(b => b.startsWith('data:') ? b : `data:image/jpeg;base64,${b}`) ?? null;
appendMessage(messagesEl, msg.role, msg.content, imgs, msg.created_at ?? null);
}
}
scrollToBottom(messagesEl);
} catch (e) {
console.error('loadHistory', e);
messagesEl.innerHTML = '';
}
}
async function deleteSession(sessionId) {
await api.deleteSession(sessionId).catch(console.error);
localStorage.removeItem('navi_draft_' + sessionId);
sessions = sessions.filter(s => s.session_id !== sessionId);
if (currentId === sessionId) {
abandonStream();
ws.disconnect();
currentId = null;
history.replaceState(null, '', location.pathname);
showEmptyState(messagesEl);
updateChatHeader(chatHeaderEl, null);
setInputEnabled(false);
}
rerenderSidebar();
}
async function pinSession(sessionId, pinned) {
await api.pinSession(sessionId, pinned).catch(console.error);
const s = sessions.find(s => s.session_id === sessionId);
if (s) s.pinned = pinned;
sessions.sort((a, b) => (b.pinned - a.pinned) || (b.last_active > a.last_active ? 1 : -1));
rerenderSidebar();
}
// ── WebSocket ─────────────────────────────────────────────────────────────────
function connectWs(sessionId) {
ws.connect(sessionId, {
onClose: () => { if (streaming) finishStream(); },
onMessage: handleWsEvent,
});
}
function handleWsEvent(event) {
switch (event.type) {
case 'stream_start':
streaming = true;
currentBubble = null;
currentThinking = null;
updateInputUI();
appendTypingIndicator(messagesEl);
scrollToBottom(messagesEl);
break;
case 'thinking_delta':
if (!currentThinking) {
removeTypingIndicator(messagesEl);
currentThinking = appendThinkingCard(messagesEl);
}
currentThinking.pre.textContent += event.delta;
scrollToBottom(messagesEl);
break;
case 'thinking_end':
if (currentThinking) {
finalizeThinkingCard(currentThinking.card);
currentThinking = null;
}
break;
case 'stream_delta':
if (!currentBubble) {
removeTypingIndicator(messagesEl);
currentBubble = appendStreamBubble(messagesEl);
}
currentBubble.textContent += event.delta;
scrollToBottom(messagesEl);
break;
case 'turn_thinking':
removeTypingIndicator(messagesEl);
if (event.is_subagent) {
appendSubagentThinking(pendingToolCard, event.thinking);
} else {
appendTurnThinkingCard(messagesEl, event.thinking);
}
scrollToBottom(messagesEl);
break;
case 'tool_started':
removeTypingIndicator(messagesEl);
if (event.is_subagent) {
// Sub-agent step — attach to current spawn_agent card
pendingSubStep = appendSubagentStep(pendingToolCard, event);
} else {
// Main-level tool — create new pending card
pendingToolCard = appendPendingToolCard(messagesEl, event);
}
scrollToBottom(messagesEl);
break;
case 'tool_call':
if (event.is_subagent) {
// Complete the current sub-agent step
finalizeSubagentStep(pendingSubStep, event);
pendingSubStep = null;
} else {
// Complete the main-level tool card
finalizeToolCard(pendingToolCard, event);
pendingToolCard = null;
}
scrollToBottom(messagesEl);
break;
case 'stream_end':
finishStream(event.content);
updateTokenCounter(event.context_tokens, event.max_context_tokens);
setInputEnabled(true);
break;
case 'stream_stopped':
finishStream();
setInputEnabled(true);
break;
case 'profile_switched': {
// Update local session record so sidebar and header stay in sync.
const idx = sessions.findIndex(s => s.session_id === currentId);
if (idx !== -1) {
sessions[idx].profile_id = event.profile_id;
sessions[idx].profile_name = event.profile_name;
}
profileSelect.value = event.profile_id;
updateChatHeader(chatHeaderEl, event.profile_id, event.profile_name);
rerenderSidebar();
break;
}
case 'plan_ready':
removeTypingIndicator(messagesEl);
appendPlanCard(messagesEl, event.plan);
appendTypingIndicator(messagesEl);
scrollToBottom(messagesEl);
break;
case 'context_compressed':
appendCompressionNotice(messagesEl, event.messages_before, event.messages_after, event.summary);
scrollToBottom(messagesEl);
break;
case 'error':
finishStream();
appendError(messagesEl, event.message);
setInputEnabled(true);
break;
}
}
function finishStream(finalContent) {
streaming = false;
removeTypingIndicator(messagesEl);
if (currentThinking) {
finalizeThinkingCard(currentThinking.card);
currentThinking = null;
}
if (finalContent !== undefined) {
if (!currentBubble) {
currentBubble = appendStreamBubble(messagesEl);
}
finalizeStreamBubble(currentBubble, finalContent);
updatePreview(currentId, finalContent);
} else if (currentBubble) {
currentBubble.classList.remove('cursor');
}
currentBubble = null;
scrollToBottom(messagesEl);
}
/** Reset stream state without touching DOM — use when switching sessions mid-stream. */
function abandonStream() {
streaming = false;
currentBubble = null;
currentThinking = null;
pendingToolCard = null;
pendingSubStep = null;
// Don't clear pendingImages/pendingFiles — those belong to the user's draft
}
// ── Sidebar (mobile) ──────────────────────────────────────────────────────────
function toggleSidebar() {
sidebarEl.classList.toggle('open');
sidebarBackdrop.classList.toggle('visible');
}
function closeSidebar() {
sidebarEl.classList.remove('open');
sidebarBackdrop.classList.remove('visible');
}
// ── Sending ───────────────────────────────────────────────────────────────────
async function sendMessage() {
const text = textarea.value.trim();
if ((!text && !pendingImages.length && !pendingFiles.length) || !ws.ready || streaming) return;
if (uploadCount > 0) return; // wait for uploads to finish
const imagesToSend = [...pendingImages];
const filesToSend = [...pendingFiles];
clearAttachments();
textarea.value = '';
autoResize();
localStorage.removeItem('navi_draft_' + currentId);
setInputEnabled(false);
appendMessage(messagesEl, 'user', text || null, imagesToSend.length ? imagesToSend : null, null, filesToSend.length ? filesToSend : null);
appendTypingIndicator(messagesEl);
scrollToBottom(messagesEl);
const b64List = imagesToSend.map(d => d.split(',', 2)[1]);
ws.send(
text || ' ',
b64List.length ? b64List : null,
filesToSend.length ? filesToSend : null,
);
}
function onKey(e) {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
}
// ── Draft persistence ─────────────────────────────────────────────────────────
function saveDraft() {
if (!currentId) return;
const text = textarea.value;
if (text) {
localStorage.setItem('navi_draft_' + currentId, text);
} else {
localStorage.removeItem('navi_draft_' + currentId);
}
}
function restoreDraft() {
textarea.value = (currentId && localStorage.getItem('navi_draft_' + currentId)) ?? '';
autoResize();
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function rerenderSidebar() {
const profileId = profileSelect.value;
const visible = profileId
? sessions.filter(s => s.profile_id === profileId)
: sessions;
renderSessions(sessionListEl, visible, currentId, {
onSelect: (id) => { if (id !== currentId) openSession(id); closeSidebar(); },
onDelete: deleteSession,
onPin: pinSession,
});
}
function updatePreview(sessionId, text) {
const s = sessions.find(s => s.session_id === sessionId);
if (s) s.preview = text.slice(0, 60);
rerenderSidebar();
}
function profileName(profileId) {
return profiles.find(p => p.id === profileId)?.name ?? profileId;
}
function enrichSession(s) {
return { ...s, profile_name: profileName(s.profile_id), preview: s.preview || '' };
}
/**
* Single source of truth for input bar state.
* Derives button appearance from: streaming, inputAllowed, uploadCount.
*/
function updateInputUI() {
if (streaming) {
btnSend.disabled = false;
btnSend.textContent = '■';
btnSend.title = 'Stop generation';
btnSend.classList.add('stop-mode');
btnAttach.disabled = true;
} else {
btnSend.disabled = !inputAllowed || uploadCount > 0;
btnSend.textContent = '↑';
btnSend.title = 'Send (Enter)';
btnSend.classList.remove('stop-mode');
btnAttach.disabled = !inputAllowed;
if (inputAllowed) textarea.focus();
}
}
function setInputEnabled(on) {
inputAllowed = on;
updateInputUI();
}
function stopGeneration() {
btnSend.disabled = true; // prevent double-click while waiting for stream_stopped
fetch(`/sessions/${currentId}/stop`, { method: 'POST' }).catch(console.error);
}
function onTextareaInput() {
autoResize();
saveDraft();
}
function autoResize() {
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 180) + 'px';
}
// ── File / Image handling ─────────────────────────────────────────────────────
function addImageFile(file) {
const reader = new FileReader();
reader.onload = (e) => {
pendingImages.push(e.target.result);
renderPreviewStrip();
};
reader.readAsDataURL(file);
}
function addArbitraryFile(file) {
if (!currentId) return;
uploadCount++;
updateInputUI();
// Show upload progress
uploadProgressBar.hidden = false;
uploadProgressFill.style.width = '0%';
uploadProgressLabel.textContent = `Uploading ${file.name}…`;
const xhr = new XMLHttpRequest();
const form = new FormData();
form.append('file', file, file.name);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
uploadProgressFill.style.width = pct + '%';
uploadProgressLabel.textContent = `${file.name} — ${pct}%`;
}
};
xhr.onload = () => {
uploadCount--;
if (uploadCount === 0) uploadProgressBar.hidden = true;
updateInputUI();
if (xhr.status === 201) {
const info = JSON.parse(xhr.responseText);
pendingFiles.push(info);
renderPreviewStrip();
} else {
let msg = `Upload failed (${xhr.status})`;
try { msg = JSON.parse(xhr.responseText).detail || msg; } catch (_) {}
alert(msg);
}
};
xhr.onerror = () => {
uploadCount--;
if (uploadCount === 0) uploadProgressBar.hidden = true;
updateInputUI();
alert(`Upload failed: network error`);
};
xhr.open('POST', `/sessions/${currentId}/files`);
xhr.send(form);
}
function onFileChange(e) {
for (const file of e.target.files) {
if (file.type.startsWith('image/') && file.type !== 'image/svg+xml') {
addImageFile(file);
} else {
addArbitraryFile(file);
}
}
fileInput.value = '';
}
function onPaste(e) {
for (const item of e.clipboardData?.items ?? []) {
if (item.kind === 'file' && item.type.startsWith('image/') && item.type !== 'image/svg+xml') {
e.preventDefault();
addImageFile(item.getAsFile());
}
}
}
function clearAttachments() {
pendingImages = [];
pendingFiles = [];
previewStrip.innerHTML = '';
}
function renderPreviewStrip() {
previewStrip.innerHTML = '';
// Image thumbnails
pendingImages.forEach((dataUrl) => {
const wrap = document.createElement('div');
wrap.className = 'img-thumb-wrap';
const img = document.createElement('img');
img.src = dataUrl;
img.className = 'img-thumb';
const btn = document.createElement('button');
btn.className = 'img-thumb-remove';
btn.textContent = '×';
btn.addEventListener('click', () => {
pendingImages.splice(pendingImages.indexOf(dataUrl), 1);
renderPreviewStrip();
});
wrap.append(img, btn);
previewStrip.appendChild(wrap);
});
// File badges
pendingFiles.forEach((info) => {
const badge = document.createElement('div');
badge.className = 'file-badge';
const icon = document.createElement('span');
icon.className = 'file-badge-icon';
icon.textContent = fileIcon(info.content_type || info.contentType || '');
const infoEl = document.createElement('div');
infoEl.className = 'file-badge-info';
const nameEl = document.createElement('div');
nameEl.className = 'file-badge-name';
nameEl.title = info.name;
nameEl.textContent = info.name;
const sizeEl = document.createElement('div');
sizeEl.className = 'file-badge-size';
sizeEl.textContent = formatBytes(info.size);
infoEl.append(nameEl, sizeEl);
const btn = document.createElement('button');
btn.className = 'file-badge-remove';
btn.textContent = '×';
btn.addEventListener('click', () => {
pendingFiles.splice(pendingFiles.indexOf(info), 1);
renderPreviewStrip();
updateInputUI();
});
badge.append(icon, infoEl, btn);
previewStrip.appendChild(badge);
});
}
function fileIcon(contentType) {
if (contentType === 'image/svg+xml') return '🎨';
if (contentType.startsWith('image/')) return '🖼️';
if (contentType.startsWith('video/')) return '🎬';
if (contentType.startsWith('audio/')) return '🎵';
if (contentType.includes('pdf')) return '📄';
if (contentType.includes('zip') || contentType.includes('tar') || contentType.includes('gz')) return '🗜️';
if (contentType.includes('json') || contentType.includes('xml') || contentType.includes('text')) return '📝';
return '📎';
}
function updateTokenCounter(used, max) {
if (!used || !max) { tokenCounterEl.hidden = true; return; }
const pct = Math.round((used / max) * 100);
tokenCounterEl.textContent = `${used.toLocaleString()}/${max.toLocaleString()} (${pct}%) tokens`;
tokenCounterEl.classList.toggle('warn', pct >= 50 && pct < 80);
tokenCounterEl.classList.toggle('danger', pct >= 80);
tokenCounterEl.hidden = false;
}
// ── Start ─────────────────────────────────────────────────────────────────────
init();