← 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="font-size:13px;font-style:italic;font-family:var(--serif);color:var(--ink-mute)">'+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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
}
/* ─── 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>