← back to Ventura Claw

server/public/chat.html

392 lines

<!doctype html>
<html lang="en"><head>
<meta charset="utf-8" /><meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Chat — VenturaClaw</title>
<link rel="stylesheet" href="/static/style.css" />
</head><body>

<header class="topbar">
  <div class="brand">
    <div class="logo-dot"></div>
    <div>
      <div class="brand-name">Ventura<em style="font-style:italic;color:var(--gold)">Claw</em></div>
      <div class="brand-sub">Connected Operations</div>
    </div>
  </div>
  <div style="display:flex;gap:10px;align-items:center">
    <span class="orb-pill"><span class="orb-dot"></span><span id="who">…</span></span>
    <a href="/connections" class="btn btn-ghost">Connections</a>
    <a href="/connections/import" class="btn btn-ghost" title="Import tokens from ~/.claude.json or .env">Import</a>
    <a href="/brand" class="btn btn-ghost">Brand</a>
    <a href="/admin" id="adm-nav" class="btn btn-ghost" style="display:none;border-color:var(--gold);color:var(--gold)">Admin →</a>
    <a href="#" id="logout" class="btn btn-ghost">Sign out</a>
  </div>
</header>

<main style="display:grid;gap:24px;grid-template-columns:340px 1fr;padding:28px;max-width:1480px;margin:0 auto">

  <aside style="display:flex;flex-direction:column;gap:14px;height:calc(100vh - 130px)">
    <div class="glass" style="padding:18px">
      <div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:12px">
        <div class="cat-label" style="border:0;padding:0;color:var(--ink-mute)">Wired</div>
        <div style="font-family:var(--serif);font-style:italic;font-size:24px;line-height:1;color:var(--gold)" id="cc">…</div>
      </div>
      <input id="filter" placeholder="filter connectors…" style="width:100%" />
    </div>
    <div class="glass" id="connector-list" style="padding:6px 10px;overflow-y:auto;flex:1"></div>
  </aside>

  <section class="glass chat-panel" style="display:flex;flex-direction:column;padding:32px;height:calc(100vh - 130px)">
    <div class="chat-head" style="display:flex;justify-content:space-between;align-items:flex-start;padding-bottom:18px;border-bottom:1px solid var(--rule);margin-bottom:18px">
      <div>
        <h1>Ask Ventura<em>Claw</em></h1>
        <div class="sub">Sensitive actions auto-route to the approval queue</div>
      </div>
      <a href="/admin/approvals" id="adm-link" class="btn btn-ghost" style="display:none">Approvals →</a>
    </div>
    <div id="empty-tagline" style="display:none;font-family:var(--serif);font-style:italic;font-size:18px;color:var(--ink-soft);padding:6px 0 14px;letter-spacing:-.005em">
      <span id="conn-count">…</span> connectors. <span style="color:var(--gold)">One command.</span>
    </div>

    <div id="msgs" style="flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:14px;padding-right:6px">

      <!-- COMPONENT 1: Typing indicator — appended here during sendMsg, removed on reply -->
      <div id="typing-indicator" class="bubble assistant typing-indicator" style="display:none" aria-live="polite" aria-label="VenturaClaw is thinking">
        <span class="typing-label">VenturaClaw is thinking</span>
        <span class="typing-dots" aria-hidden="true">
          <span class="typing-dot"></span>
          <span class="typing-dot"></span>
          <span class="typing-dot"></span>
        </span>
      </div>

    </div>

    <!-- COMPONENT 3: Context strip — sits between msgs and input footer -->
    <div id="context-strip" style="display:none" aria-label="Recent message context">
      <span class="ctx-strip-label">Recent Context</span>
      <div id="ctx-chips" class="ctx-chips-row"></div>
    </div>

    <!-- Frequent commands — auto-populated from this user's chat history (top 5 by count). Hidden until populated. -->
    <div id="frequent-commands" class="prompt-chips" style="display:none;margin-top:8px">
      <span style="font-family:var(--mono);font-size:9px;letter-spacing:.16em;text-transform:uppercase;color:var(--ink-mute);margin-right:6px;align-self:center">Your frequent</span>
    </div>

    <div id="prompt-suggestions" class="prompt-chips" style="display:none">
      <button class="prompt-chip" data-prompt="Post our newest product to all 10 social channels">Post newest to all socials</button>
      <button class="prompt-chip" data-prompt="Draft a Mailchimp campaign for our new arrivals">Draft Mailchimp for new arrivals</button>
      <button class="prompt-chip" data-prompt="Pull yesterday's Shopify sales and email me a summary">Yesterday's Shopify sales</button>
      <button class="prompt-chip" data-prompt="Schedule a TikTok post for our top-selling SKU">Schedule TikTok post</button>
      <button class="prompt-chip" data-prompt="Cross-list our top 5 Etsy items to Shopify">Cross-list Etsy → Shopify</button>
    </div>

    <div style="display:flex;gap:10px;margin-top:18px;padding-top:18px;border-top:1px solid var(--rule)">
      <input id="msg" placeholder='Try: "post our newest product to all socials"' style="flex:1" />
      <button id="send" class="btn btn-primary">Send</button>
    </div>
  </section>

</main>

<script>
/* ─── DOM refs ─────────────────────────────────────────────────────────── */
const filter   = document.getElementById('filter'),
      list     = document.getElementById('connector-list'),
      msgs     = document.getElementById('msgs'),
      msg      = document.getElementById('msg'),
      send     = document.getElementById('send'),
      who      = document.getElementById('who'),
      cc       = document.getElementById('cc'),
      sugg     = document.getElementById('prompt-suggestions'),
      typingEl = document.getElementById('typing-indicator'),
      ctxStrip = document.getElementById('context-strip'),
      ctxRow   = document.getElementById('ctx-chips');

let CONNECTORS = [];

/* ─── Boot ──────────────────────────────────────────────────────────────── */
(async () => {
  const me = await (await fetch('/api/me')).json();
  who.textContent = me.user.email + (me.user.role === 'admin' ? ' · admin' : '');
  if (me.user.role === 'admin') {
    document.getElementById('adm-link').style.display = 'inline-flex';
    document.getElementById('adm-nav').style.display = 'inline-flex';
  }
  const d = await (await fetch('/api/connectors')).json();
  CONNECTORS = d.connectors;
  cc.textContent = CONNECTORS.length;
  document.getElementById('conn-count').textContent = CONNECTORS.length;
  drawConnectors();
  // Frequent commands strip — auto-loaded from history. Click to re-fire.
  fetch('/api/chat/frequent').then(r => r.json()).then(f => {
    if (!f.top || !f.top.length) return;
    const host = document.getElementById('frequent-commands');
    for (const item of f.top) {
      const b = document.createElement('button');
      b.className = 'prompt-chip';
      b.dataset.prompt = item.text;
      b.title = `used ${item.count}×`;
      b.textContent = item.text.length > 50 ? item.text.slice(0, 47) + '…' : item.text;
      b.addEventListener('click', () => sendMsg(item.text));
      host.appendChild(b);
    }
    host.style.display = 'flex';
  }).catch(() => {});

  // Restore prior conversation if any
  try {
    const h = await (await fetch('/api/chat/history?limit=80')).json();
    if (h.messages && h.messages.length) {
      for (const m of h.messages) {
        pushMsg(m.role, m.text);
        if (m.role === 'assistant' && m.toolCalls) {
          for (const c of m.toolCalls) pushMsg('tool', `→ ${c.name||c.connector}.${c.action} ${c.queued?'(queued for approval)':'(executed)'}`);
        }
      }
    } else {
      pushMsg('assistant', `Hello ${me.user.name.split(' ')[0]}. I'm wired into all ${CONNECTORS.length} of your connectors. Pick a starting point below — or just tell me what you'd like to do.`);
      document.getElementById('empty-tagline').style.display = 'block';
      sugg.style.display = 'flex';
    }
  } catch {
    pushMsg('assistant', `Hello ${me.user.name.split(' ')[0]}. ${CONNECTORS.length} connectors ready.`);
    document.getElementById('empty-tagline').style.display = 'block';
    sugg.style.display = 'flex';
  }
})();

/* ─── Connector sidebar ─────────────────────────────────────────────────── */
// Brands not on simpleicons CDN — skip remote fetch entirely, render letter-fallback.
const NO_LOGO = new Set(['klaviyo','monday','mondaydotcom']);
function logoHTML(c) {
  if (c.logo && !NO_LOGO.has(c.logo)) {
    const url = `https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/${c.logo}.svg`;
    return `<div class="logo-wrap"><img src="${url}" alt="${c.name}" loading="lazy" onerror="this.outerHTML='<div class=\\'logo-fb\\' style=\\'background:${c.tint}\\'>${c.name[0]}</div>'"></div>`;
  }
  return `<div class="logo-wrap"><div class="logo-fb" style="background:${c.tint}">${c.name[0]}</div></div>`;
}

function drawConnectors() {
  const q = filter.value.toLowerCase();
  const cats = [...new Set(CONNECTORS.map(c => c.category))];
  list.innerHTML = cats.map(cat => {
    const items = CONNECTORS.filter(c => c.category === cat && (!q || c.name.toLowerCase().includes(q)));
    if (!items.length) return '';
    return `<div class="cat-label">${escHtml(cat)}</div>` +
      items.map(c => `<div class="connector-row">
        ${logoHTML(c)}
        <div style="min-width:0">
          <div class="name">${escHtml(c.name)}</div>
          <div class="meta">${escHtml(c.triggers)}t · ${escHtml(c.actions)}a · ${escHtml(c.auth)}</div>
        </div>
        <span class="orb-dot"></span>
      </div>`).join('');
  }).join('');
}
filter.addEventListener('input', drawConnectors);

/* ─── COMPONENT 1: Typing indicator ────────────────────────────────────── */
function showTyping() {
  // Always keep typing indicator as the last child so it appears after bubbles
  msgs.appendChild(typingEl);
  typingEl.style.display = 'inline-flex';
  msgs.scrollTop = msgs.scrollHeight;
}

function hideTyping() {
  typingEl.style.display = 'none';
}

/* ─── COMPONENT 2: Tool-call rows ───────────────────────────────────────── */
// toolCall shape: { name, action, queued, error, tint, logo, payload }
function renderToolRow(toolCall) {
  const { name, action, queued, error, tint = '#888', logo, payload } = toolCall;
  const status  = error ? 'error' : queued ? 'queued' : 'executed';
  const statusLabel = error ? 'Error' : queued ? 'Queued for Approval' : 'Executed';

  const slug = String(logo || '').replace(/[^a-z0-9-]/gi,'');
  const tintClean = String(tint || '#888').replace(/[^#0-9a-fA-F]/g,'').slice(0,7);
  const letter = escHtml((name || '?')[0]);
  const iconUrl = slug ? `https://cdn.simpleicons.org/${slug}/${tintClean.replace('#','')}` : null;
  const iconHTML = iconUrl
    ? `<img src="${escHtml(iconUrl)}" alt="${escHtml(name)}" width="16" height="16" style="object-fit:contain;display:block" data-letter="${letter}" onerror="this.outerHTML='<span style=&quot;font-size:13px;font-style:italic;font-family:var(--serif);color:var(--ink-mute)&quot;>'+this.dataset.letter+'</span>'">`
    : `<span style="font-size:13px;font-style:italic;font-family:var(--serif);color:var(--ink-mute)">${letter}</span>`;

  const payloadText = payload ? JSON.stringify(payload, null, 2) : null;

  const row = document.createElement('div');
  row.className = 'tool-row';
  row.setAttribute('data-status', status);
  row.setAttribute('data-tint', tint);
  row.style.setProperty('--tool-tint', tint);

  row.innerHTML = `
    <div class="tool-row-header" role="button" tabindex="0" aria-expanded="false">
      <div class="tool-row-left">
        <div class="tool-icon-wrap">${iconHTML}</div>
        <span class="tool-connector-name">${escHtml(name)}</span>
        <span class="tool-sep">·</span>
        <span class="tool-action-name">${escHtml(action)}</span>
      </div>
      <div class="tool-row-right">
        <span class="tool-status" data-status="${escHtml(status)}">${escHtml(statusLabel)}</span>
        ${payloadText ? `<span class="tool-toggle-label" aria-hidden="true">▸ payload</span>` : ''}
      </div>
    </div>
    ${payloadText ? `<div class="tool-payload" style="display:none"><pre>${escHtml(payloadText)}</pre></div>` : ''}
  `;

  if (payloadText) {
    const header  = row.querySelector('.tool-row-header');
    const payload_el = row.querySelector('.tool-payload');
    const toggle  = row.querySelector('.tool-toggle-label');

    header.addEventListener('click', () => {
      const open = payload_el.style.display !== 'none';
      payload_el.style.display = open ? 'none' : 'block';
      toggle.textContent = open ? '▸ payload' : '▾ payload';
      header.setAttribute('aria-expanded', String(!open));
    });
    header.addEventListener('keydown', e => {
      if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); header.click(); }
    });
  }

  msgs.appendChild(row);
  msgs.scrollTop = msgs.scrollHeight;
  return row;
}

/* ─── COMPONENT 3: Context strip ───────────────────────────────────────── */
// Keeps at most 2 chips. Oldest is evicted when a 3rd is added.
function updateContextStrip(role, text) {
  const chips = ctxRow.querySelectorAll('.ctx-chip');
  if (chips.length >= 2) chips[0].remove(); // evict oldest

  const truncated = text.length > 52 ? text.slice(0, 52) + '…' : text;
  const roleLabel = role === 'user' ? 'YOU' : 'CLAW';

  const chip = document.createElement('div');
  chip.className = 'ctx-chip';
  chip.setAttribute('role', 'button');
  chip.setAttribute('tabindex', '0');
  chip.dataset.role = role;
  chip.dataset.fullText = text;

  chip.innerHTML = `
    <span class="ctx-role ${role === 'user' ? 'ctx-role-user' : 'ctx-role-claw'}">${roleLabel}</span>
    <span class="ctx-excerpt">${escHtml(truncated)}</span>
    <button class="ctx-dismiss" aria-label="Dismiss" tabindex="0">×</button>
  `;

  chip.querySelector('.ctx-dismiss').addEventListener('click', e => {
    e.stopPropagation();
    chip.remove();
    syncContextStrip();
  });

  chip.addEventListener('click', e => {
    if (e.target.classList.contains('ctx-dismiss')) return;
    chip.dispatchEvent(new CustomEvent('ctx-chip-click', {
      bubbles: true,
      detail: { role, fullText: text }
    }));
  });

  chip.addEventListener('keydown', e => {
    if (e.key === 'Enter') chip.click();
  });

  ctxRow.appendChild(chip);
  syncContextStrip();
}

function syncContextStrip() {
  const hasChips = ctxRow.querySelectorAll('.ctx-chip').length > 0;
  ctxStrip.style.display = hasChips ? 'flex' : 'none';
}

/* ─── Utility ────────────────────────────────────────────────────────────── */
function escHtml(s) {
  return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}

/* ─── Core message push ──────────────────────────────────────────────────── */
function pushMsg(role, text) {
  const div = document.createElement('div');
  div.className = `bubble ${role}`;
  div.textContent = text;
  // Insert before typing indicator so it stays last
  msgs.insertBefore(div, typingEl);
  msgs.scrollTop = msgs.scrollHeight;
  return div;
}

/* ─── Send flow ─────────────────────────────────────────────────────────── */
async function sendMsg(prefilled) {
  const v = (prefilled || msg.value).trim();
  if (!v) return;
  msg.value = '';
  pushMsg('user', v);
  updateContextStrip('user', v);
  sugg.style.display = 'none';
  document.getElementById('empty-tagline').style.display = 'none';
  send.disabled = true;
  showTyping();

  let d;
  try {
    const r = await fetch('/api/chat', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ message: v })
    });
    d = await r.json();
  } catch (err) {
    hideTyping();
    pushMsg('assistant', 'Connection error — please try again.');
    send.disabled = false;
    msg.focus();
    return;
  }

  hideTyping();
  pushMsg('assistant', d.reply);
  updateContextStrip('assistant', d.reply);

  for (const c of d.toolCalls || []) {
    // Find matching connector for tint/logo
    const connector = CONNECTORS.find(cx =>
      cx.name.toLowerCase() === (c.name || '').toLowerCase()
    );
    renderToolRow({
      name:    c.name,
      action:  c.action,
      queued:  !!c.queued,
      error:   !!c.error,
      tint:    connector ? connector.tint : '#888888',
      logo:    connector ? connector.logo : null,
      payload: c.payload || null
    });
  }

  send.disabled = false;
  msg.focus();
}

send.addEventListener('click', () => sendMsg());
msg.addEventListener('keydown', e => { if (e.key === 'Enter') sendMsg(); });

document.querySelectorAll('.prompt-chip').forEach(b => {
  b.addEventListener('click', () => sendMsg(b.dataset.prompt));
});

document.getElementById('logout').addEventListener('click', async e => {
  e.preventDefault();
  await fetch('/api/auth/logout', { method: 'POST' });
  location.href = '/login';
});
</script>
</body></html>