diff --git a/client/app.js b/client/app.js deleted file mode 100644 index ea593ec..0000000 --- a/client/app.js +++ /dev/null @@ -1,362 +0,0 @@ -'use strict'; - -const API = ''; // same origin - -// ── State ──────────────────────────────────────────────────────────────────── - -let profiles = []; -let sessions = []; // [{id, profile_id, last_active, preview}] -let currentSession = null; // session id -let ws = null; -let streaming = false; -let currentBubble = null; // DOM element being streamed into - -// ── DOM refs ───────────────────────────────────────────────────────────────── - -const profileSelect = document.getElementById('profile-select'); -const btnNew = document.getElementById('btn-new'); -const sessionList = document.getElementById('session-list'); -const chatHeader = document.getElementById('chat-header'); -const messagesEl = document.getElementById('messages'); -const textarea = document.getElementById('input'); -const btnSend = document.getElementById('btn-send'); - -// ── Init ───────────────────────────────────────────────────────────────────── - -async function init() { - await loadProfiles(); - await loadSessions(); - textarea.addEventListener('keydown', onKey); - btnSend.addEventListener('click', sendMessage); - btnNew.addEventListener('click', createSession); - textarea.addEventListener('input', autoResize); -} - -// ── Profiles ───────────────────────────────────────────────────────────────── - -async function loadProfiles() { - try { - const res = await fetch(`${API}/agents/profiles`); - profiles = await res.json(); - profileSelect.innerHTML = profiles.map(p => - `` - ).join(''); - } catch (e) { - console.error('Failed to load profiles', e); - } -} - -// ── Sessions ───────────────────────────────────────────────────────────────── - -async function loadSessions() { - try { - const res = await fetch(`${API}/sessions`); - const data = await res.json(); - sessions = data.map(s => ({ ...s, preview: '' })); - renderSessionList(); - } catch (e) { - console.error('Failed to load sessions', e); - } -} - -async function createSession() { - const profileId = profileSelect.value; - if (!profileId) return; - btnNew.disabled = true; - try { - const res = await fetch(`${API}/sessions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ profile_id: profileId }), - }); - const session = await res.json(); - sessions.unshift({ ...session, preview: 'New conversation' }); - renderSessionList(); - await switchSession(session.session_id); - } catch (e) { - console.error('Failed to create session', e); - } finally { - btnNew.disabled = false; - } -} - -async function switchSession(sessionId) { - if (currentSession === sessionId) return; - disconnectWS(); - currentSession = sessionId; - renderSessionList(); - await loadHistory(sessionId); - connectWS(sessionId); - setInputEnabled(true); -} - -async function loadHistory(sessionId) { - messagesEl.innerHTML = ''; - try { - const res = await fetch(`${API}/sessions/${sessionId}`); - const data = await res.json(); - updateChatHeader(data.profile_id); - - const profile = profiles.find(p => p.id === data.profile_id); - - for (const msg of data.messages) { - if (msg.role === 'system') continue; - if (msg.role === 'user') { - appendMessage('user', msg.content); - } else if (msg.role === 'assistant' && msg.content) { - appendMessage('assistant', msg.content); - } - } - scrollToBottom(); - } catch (e) { - console.error('Failed to load history', e); - } -} - -function renderSessionList() { - if (sessions.length === 0) { - sessionList.innerHTML = '
No conversations yet
'; - return; - } - sessionList.innerHTML = sessions.map(s => { - const profile = profiles.find(p => p.id === s.profile_id); - const name = profile ? profile.name : s.profile_id; - const time = formatTime(s.last_active); - const preview = s.preview || 'No messages yet'; - const active = s.session_id === currentSession ? ' active' : ''; - return ` -
-
-
${escHtml(name)}
-
${escHtml(preview)}
-
${time}
-
- -
`; - }).join(''); - - sessionList.querySelectorAll('.session-item').forEach(el => { - el.addEventListener('click', () => switchSession(el.dataset.id)); - }); - sessionList.querySelectorAll('.btn-delete').forEach(btn => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - deleteSession(btn.dataset.id); - }); - }); -} - -async function deleteSession(sessionId) { - try { - await fetch(`${API}/sessions/${sessionId}`, { method: 'DELETE' }); - } catch (e) { - console.error('Failed to delete session', e); - } - sessions = sessions.filter(s => s.session_id !== sessionId); - - if (currentSession === sessionId) { - disconnectWS(); - currentSession = null; - messagesEl.innerHTML = '
💬

Start a new conversation

'; - chatHeader.textContent = 'Select a profile and start a new chat'; - setInputEnabled(false); - } - renderSessionList(); -} - -function updateSessionPreview(sessionId, text) { - const s = sessions.find(s => s.session_id === sessionId); - if (s) s.preview = text.slice(0, 60); - renderSessionList(); -} - -// ── WebSocket ───────────────────────────────────────────────────────────────── - -function connectWS(sessionId) { - const proto = location.protocol === 'https:' ? 'wss' : 'ws'; - const url = `${proto}://${location.host}/ws/sessions/${sessionId}`; - ws = new WebSocket(url); - - ws.onopen = () => console.log('WS connected', sessionId); - ws.onclose = (e) => { - console.log('WS closed', e.code); - if (streaming) finishStream(); - }; - ws.onerror = (e) => console.error('WS error', e); - ws.onmessage = (e) => handleWsEvent(JSON.parse(e.data)); -} - -function disconnectWS() { - if (ws) { ws.close(); ws = null; } -} - -function handleWsEvent(event) { - switch (event.type) { - case 'stream_start': - streaming = true; - removeTypingIndicator(); - currentBubble = appendMessage('assistant', ''); - currentBubble.classList.add('cursor'); - break; - - case 'stream_delta': - if (currentBubble) { - currentBubble.textContent += event.delta; - scrollToBottom(); - } - break; - - case 'tool_call': - appendToolCall(event); - scrollToBottom(); - break; - - case 'stream_end': - finishStream(event.content); - setInputEnabled(true); - break; - - case 'error': - finishStream(); - appendError(event.message); - setInputEnabled(true); - break; - } -} - -function finishStream(finalContent) { - streaming = false; - removeTypingIndicator(); - if (currentBubble) { - currentBubble.classList.remove('cursor'); - if (finalContent !== undefined) { - currentBubble.textContent = finalContent; - updateSessionPreview(currentSession, finalContent); - } - currentBubble = null; - } - scrollToBottom(); -} - -// ── Sending ─────────────────────────────────────────────────────────────────── - -async function sendMessage() { - const text = textarea.value.trim(); - if (!text || !ws || ws.readyState !== WebSocket.OPEN || streaming) return; - - textarea.value = ''; - autoResize(); - setInputEnabled(false); - - appendMessage('user', text); - appendTypingIndicator(); - scrollToBottom(); - - ws.send(JSON.stringify({ type: 'message', content: text })); -} - -function onKey(e) { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - sendMessage(); - } -} - -// ── DOM helpers ─────────────────────────────────────────────────────────────── - -function appendMessage(role, content) { - const msg = document.createElement('div'); - msg.className = `msg ${role}`; - - const bubble = document.createElement('div'); - bubble.className = 'bubble'; - bubble.textContent = content; - - const time = document.createElement('div'); - time.className = 'msg-time'; - time.textContent = formatTime(new Date().toISOString()); - - msg.appendChild(bubble); - msg.appendChild(time); - messagesEl.appendChild(msg); - return bubble; -} - -function appendToolCall(event) { - const icons = { web_search: '🔍', filesystem: '📁', http_request: '🌐', code_exec: '⚙️', terminal: '💻' }; - const icon = icons[event.tool] || '🔧'; - const card = document.createElement('div'); - card.className = `tool-card${event.success ? '' : ' error'}`; - card.innerHTML = ` -
- ${icon} - ${escHtml(event.tool)} -
-
${escHtml(event.result)}
`; - messagesEl.appendChild(card); -} - -function appendTypingIndicator() { - removeTypingIndicator(); - const el = document.createElement('div'); - el.className = 'typing'; - el.id = 'typing'; - el.innerHTML = ''; - messagesEl.appendChild(el); -} - -function removeTypingIndicator() { - document.getElementById('typing')?.remove(); -} - -function appendError(message) { - const el = document.createElement('div'); - el.className = 'msg-error'; - el.textContent = `Error: ${message}`; - messagesEl.appendChild(el); -} - -function updateChatHeader(profileId) { - const profile = profiles.find(p => p.id === profileId); - const name = profile ? profile.name : profileId; - chatHeader.innerHTML = `${escHtml(profileId)} ${escHtml(name)}`; -} - -function setInputEnabled(enabled) { - textarea.disabled = !enabled; - btnSend.disabled = !enabled; - if (enabled) textarea.focus(); -} - -function scrollToBottom() { - messagesEl.scrollTop = messagesEl.scrollHeight; -} - -function autoResize() { - textarea.style.height = 'auto'; - textarea.style.height = Math.min(textarea.scrollHeight, 180) + 'px'; -} - -function escHtml(str) { - return String(str) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} - -function formatTime(iso) { - if (!iso) return ''; - const d = new Date(iso); - if (isNaN(d)) return ''; - const now = new Date(); - const diff = now - d; - if (diff < 60_000) return 'just now'; - if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`; - if (diff < 86_400_000) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - return d.toLocaleDateString(); -} - -// ── Start ───────────────────────────────────────────────────────────────────── - -init(); diff --git a/client/js/app.js b/client/js/app.js index 445f63c..3522ee2 100644 --- a/client/js/app.js +++ b/client/js/app.js @@ -34,6 +34,7 @@ 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 @@ -193,6 +194,7 @@ 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(); @@ -229,8 +231,7 @@ streaming = true; currentBubble = null; currentThinking = null; - // Switch send button to stop mode; disable attach. - setStreamMode(true); + updateInputUI(); appendTypingIndicator(messagesEl); scrollToBottom(messagesEl); break; @@ -298,13 +299,11 @@ case 'stream_end': finishStream(event.content); updateTokenCounter(event.context_tokens, event.max_context_tokens); - setStreamMode(false); setInputEnabled(true); break; case 'stream_stopped': finishStream(); - setStreamMode(false); setInputEnabled(true); break; @@ -328,7 +327,6 @@ case 'error': finishStream(); - setStreamMode(false); appendError(messagesEl, event.message); setInputEnabled(true); break; @@ -362,7 +360,6 @@ currentThinking = null; pendingToolCard = null; pendingSubStep = null; - setStreamMode(false); // Don't clear pendingImages/pendingFiles — those belong to the user's draft } @@ -443,44 +440,36 @@ } /** - * Only disables the send button — textarea stays enabled so the user can - * keep typing while the model is responding. + * Single source of truth for input bar state. + * Derives button appearance from: streaming, inputAllowed, uploadCount. */ -function setInputEnabled(on) { - btnSend.disabled = !on || uploadCount > 0; - btnAttach.disabled = !on; - if (on) textarea.focus(); -} - -/** Switch the send button between send (↑) and stop (■) modes. */ -function setStreamMode(active) { - if (active) { +function updateInputUI() { + if (streaming) { + btnSend.disabled = false; btnSend.textContent = '■'; btnSend.title = 'Stop generation'; - btnSend.disabled = false; 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() { ws.stop(); btnSend.disabled = true; // prevent double-click while waiting for stream_stopped } -/** Re-evaluate send button state based on current upload activity (called from upload callbacks). */ -function syncSendButton() { - // Only touch send — don't change btnAttach (that's controlled by streaming state). - // If streaming, send stays disabled regardless of upload state. - if (!streaming) { - btnSend.disabled = uploadCount > 0; - } -} - function onTextareaInput() { autoResize(); saveDraft(); @@ -505,7 +494,7 @@ function addArbitraryFile(file) { if (!currentId) return; uploadCount++; - syncSendButton(); + updateInputUI(); // Show upload progress uploadProgressBar.hidden = false; @@ -527,7 +516,7 @@ xhr.onload = () => { uploadCount--; if (uploadCount === 0) uploadProgressBar.hidden = true; - syncSendButton(); + updateInputUI(); if (xhr.status === 201) { const info = JSON.parse(xhr.responseText); @@ -543,7 +532,7 @@ xhr.onerror = () => { uploadCount--; if (uploadCount === 0) uploadProgressBar.hidden = true; - syncSendButton(); + updateInputUI(); alert(`Upload failed: network error`); }; @@ -630,7 +619,7 @@ btn.addEventListener('click', () => { pendingFiles.splice(pendingFiles.indexOf(info), 1); renderPreviewStrip(); - syncSendButton(); + updateInputUI(); }); badge.append(icon, infoEl, btn);