import { api } from './api.js';
import { WsClient } from './ws.js';
import { appendMessage, appendStreamBubble, finalizeStreamBubble,
appendToolCall, appendTypingIndicator, removeTypingIndicator,
appendError, showEmptyState, scrollToBottom } 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 sessionListEl = document.getElementById('session-list');
const chatHeaderEl = document.getElementById('chat-header');
const messagesEl = document.getElementById('messages');
const textarea = document.getElementById('input');
const btnSend = document.getElementById('btn-send');
// ── State ─────────────────────────────────────────────────────────────────────
const STORAGE_KEY = 'navi_current_session';
let profiles = [];
let sessions = [];
let currentId = localStorage.getItem(STORAGE_KEY) ?? null;
let streaming = false;
let currentBubble = null;
const ws = new WsClient();
// ── Boot ──────────────────────────────────────────────────────────────────────
async function init() {
textarea.addEventListener('keydown', onKey);
textarea.addEventListener('input', autoResize);
btnSend.addEventListener('click', sendMessage);
btnNew.addEventListener('click', newChat);
[profiles, sessions] = await Promise.all([api.getProfiles(), api.getSessions()]);
// Attach preview text from message history
sessions = await Promise.all(sessions.map(enrichSession));
renderProfiles(profileSelect, profiles);
rerenderSidebar();
// Restore last active session
if (currentId && sessions.some(s => s.session_id === currentId)) {
await openSession(currentId, false);
} else {
currentId = null;
showEmptyState(messagesEl);
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) });
rerenderSidebar();
await openSession(session.session_id, false);
} finally {
btnNew.disabled = false;
}
}
async function openSession(sessionId, skipLoad = false) {
ws.disconnect();
currentId = sessionId;
localStorage.setItem(STORAGE_KEY, sessionId);
rerenderSidebar();
const s = sessions.find(s => s.session_id === sessionId);
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);
}
async function loadHistory(sessionId) {
messagesEl.innerHTML = '';
try {
const data = await api.getSession(sessionId);
for (const msg of data.messages) {
if (msg.role === 'system') continue;
if (msg.role === 'user' || (msg.role === 'assistant' && msg.content)) {
appendMessage(messagesEl, msg.role, msg.content);
}
}
scrollToBottom(messagesEl);
} catch (e) {
console.error('loadHistory', e);
}
}
async function deleteSession(sessionId) {
await api.deleteSession(sessionId).catch(console.error);
sessions = sessions.filter(s => s.session_id !== sessionId);
if (currentId === sessionId) {
ws.disconnect();
currentId = null;
localStorage.removeItem(STORAGE_KEY);
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;
// Re-sort: pinned first
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;
removeTypingIndicator(messagesEl);
currentBubble = appendStreamBubble(messagesEl);
break;
case 'stream_delta':
if (currentBubble) {
currentBubble.textContent += event.delta;
scrollToBottom(messagesEl);
}
break;
case 'tool_call':
appendToolCall(messagesEl, event);
scrollToBottom(messagesEl);
break;
case 'stream_end':
finishStream(event.content);
setInputEnabled(true);
break;
case 'error':
finishStream();
appendError(messagesEl, event.message);
setInputEnabled(true);
break;
}
}
function finishStream(finalContent) {
streaming = false;
removeTypingIndicator(messagesEl);
if (currentBubble) {
if (finalContent !== undefined) {
finalizeStreamBubble(currentBubble, finalContent);
updatePreview(currentId, finalContent);
} else {
currentBubble.classList.remove('cursor');
}
currentBubble = null;
}
scrollToBottom(messagesEl);
}
// ── Sending ───────────────────────────────────────────────────────────────────
async function sendMessage() {
const text = textarea.value.trim();
if (!text || !ws.ready || streaming) return;
textarea.value = '';
autoResize();
setInputEnabled(false);
appendMessage(messagesEl, 'user', text);
appendTypingIndicator(messagesEl);
scrollToBottom(messagesEl);
ws.send(text);
}
function onKey(e) {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function rerenderSidebar() {
renderSessions(sessionListEl, sessions, currentId, {
onSelect: (id) => { if (id !== currentId) openSession(id); },
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;
}
async function enrichSession(s) {
return { ...s, profile_name: profileName(s.profile_id), preview: s.preview || '' };
}
function setInputEnabled(on) {
textarea.disabled = !on;
btnSend.disabled = !on;
if (on) textarea.focus();
}
function autoResize() {
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 180) + 'px';
}
// ── Start ─────────────────────────────────────────────────────────────────────
init();