diff --git a/client/index.html b/client/index.html index da8836f..7f2ae9f 100644 --- a/client/index.html +++ b/client/index.html @@ -45,7 +45,6 @@ id="input" placeholder="Type a message… (Enter to send, Shift+Enter for newline)" rows="1" - disabled > diff --git a/client/js/app.js b/client/js/app.js index 8b5e659..5ebd091 100644 --- a/client/js/app.js +++ b/client/js/app.js @@ -28,43 +28,65 @@ let currentId = null; let streaming = false; let currentBubble = null; -let currentThinking = null; // { card, pre } during thinking phase -let pendingImages = []; // array of full data URLs (data:image/...;base64,...) +let currentThinking = null; +let pendingImages = []; 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', autoResize); + textarea.addEventListener('input', onTextareaInput); textarea.addEventListener('paste', onPaste); btnSend.addEventListener('click', sendMessage); btnNew.addEventListener('click', newChat); btnAttach.addEventListener('click', () => fileInput.click()); fileInput.addEventListener('change', onFileChange); + profileSelect.addEventListener('change', onProfileChange); [profiles, sessions] = await Promise.all([api.getProfiles(), api.getSessions()]); - sessions = sessions.map(enrichSession); renderProfiles(profileSelect, profiles); - rerenderSidebar(); - // Open session from URL hash, or fall back to most recently active + // Open session from URL hash, or fall back to most recently active overall const hashId = location.hash.slice(1); - const targetId = hashId && sessions.some(s => s.session_id === hashId) + const target = (hashId && sessions.find(s => s.session_id === hashId)) ? hashId : sessions[0]?.session_id ?? null; - if (targetId) { - await openSession(targetId, false); + 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() { @@ -74,7 +96,6 @@ 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; @@ -82,13 +103,20 @@ } 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 s = sessions.find(s => s.session_id === sessionId); const pId = s?.profile_id ?? ''; const pName = s?.profile_name ?? profileName(pId); updateChatHeader(chatHeaderEl, pId, pName); @@ -99,6 +127,7 @@ connectWs(sessionId); setInputEnabled(true); + restoreDraft(); // restore any previously typed (unsent) text } async function loadHistory(sessionId) { @@ -106,7 +135,6 @@ try { const data = await api.getSession(sessionId); - // Build a lookup: tool_call_id → {name, arguments} from assistant tool_calls const toolCallMap = {}; for (const msg of data.messages) { if (msg.role === 'assistant' && msg.tool_calls) { @@ -119,9 +147,6 @@ for (const msg of data.messages) { if (msg.role === 'system') continue; - // is_summary messages exist only in session.messages (display history), - // never in session.context. They are injected when the server loads a - // session whose context was compressed — display them as collapsible cards. if (msg.is_summary) { appendSummaryCard(messagesEl, msg.content ?? ''); continue; @@ -155,6 +180,7 @@ await api.deleteSession(sessionId).catch(console.error); sessions = sessions.filter(s => s.session_id !== sessionId); if (currentId === sessionId) { + abandonStream(); ws.disconnect(); currentId = null; history.replaceState(null, '', location.pathname); @@ -169,7 +195,6 @@ 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(); } @@ -260,24 +285,30 @@ scrollToBottom(messagesEl); } +/** Reset stream state without touching DOM — use when switching sessions mid-stream. */ +function abandonStream() { + streaming = false; + currentBubble = null; + currentThinking = null; +} + // ── Sending ─────────────────────────────────────────────────────────────────── async function sendMessage() { const text = textarea.value.trim(); if ((!text && !pendingImages.length) || !ws.ready || streaming) return; - const imagesToSend = [...pendingImages]; // full data URLs + const imagesToSend = [...pendingImages]; clearImages(); textarea.value = ''; autoResize(); + localStorage.removeItem('navi_draft_' + currentId); setInputEnabled(false); - // Display with full data URLs appendMessage(messagesEl, 'user', text || null, imagesToSend.length ? imagesToSend : null); appendTypingIndicator(messagesEl); scrollToBottom(messagesEl); - // Strip data URI prefix before sending to server (server expects raw base64) const b64List = imagesToSend.map(d => d.split(',', 2)[1]); ws.send(text || ' ', b64List.length ? b64List : null); } @@ -286,10 +317,31 @@ 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() { - renderSessions(sessionListEl, sessions, currentId, { + 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); }, onDelete: deleteSession, onPin: pinSession, @@ -310,13 +362,21 @@ return { ...s, profile_name: profileName(s.profile_id), preview: s.preview || '' }; } +/** + * Only disables the send button — textarea stays enabled so the user can + * keep typing while the model is responding. + */ function setInputEnabled(on) { - textarea.disabled = !on; btnSend.disabled = !on; btnAttach.disabled = !on; if (on) textarea.focus(); } +function onTextareaInput() { + autoResize(); + saveDraft(); +} + function autoResize() { textarea.style.height = 'auto'; textarea.style.height = Math.min(textarea.scrollHeight, 180) + 'px'; @@ -328,7 +388,6 @@ if (!file.type.startsWith('image/')) return; const reader = new FileReader(); reader.onload = (e) => { - // Store the full data URL so we retain mime type for display pendingImages.push(e.target.result); renderPreviewStrip(); }; diff --git a/client/js/sidebar.js b/client/js/sidebar.js index 57915e8..f5a75d0 100644 --- a/client/js/sidebar.js +++ b/client/js/sidebar.js @@ -18,15 +18,13 @@ const active = s.session_id === currentId ? ' active' : ''; const pinned = s.pinned ? ' pinned' : ''; const preview = esc(s.preview || 'No messages yet'); - const name = esc(s.profile_name || s.profile_id); const time = timeLabel(s.last_active); const pinIcon = s.pinned ? '📌' : '📍'; const pinTitle = s.pinned ? 'Unpin' : 'Pin'; return `
-
${s.pinned ? '📌 ' : ''}${name}
-
${preview}
+
${s.pinned ? '📌 ' : ''}${preview}
${time}