Newer
Older
navi-1 / client / js / app.js
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();