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 = `
-
- ${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);