// Kayser v2 — Bottom composer + ⌘ command palette + Trust sheet + Reference sheet. // Pending-attachment row used inside the composer. Shows a real // thumbnail for image uploads, a file card for everything else, plus // an upload-status indicator (uploading / ready / failed). function KxV2PendingAttachmentRow({ att, onRemove, fmtBytes }) { // Free the object-URL when the row unmounts. Otherwise blob memory // sticks around until the page is reloaded. React.useEffect(() => { return () => { if (att.localPreview) { try { URL.revokeObjectURL(att.localPreview); } catch (_) {} } }; }, [att.localPreview]); const failed = att.status === 'failed'; const pending = att.status === 'uploading'; const ready = att.status === 'ready'; const isImage = (att.type || '').startsWith('image/'); // Status pill in the top-right corner of the row. const statusPill = ( {failed ? 'FAILED' : (pending ? 'UPLOADING' : 'READY')} ); // Remove (×) button — same in every variant. const removeBtn = ( ); if (isImage && att.localPreview) { return (
{att.name}
{statusPill} {removeBtn}
{att.name} {failed ? att.error : fmtBytes(att.size)}
); } // Non-image (and image without a local preview — shouldn't happen, // but render gracefully): file-card shape. const badge = kxAttachmentBadge ? kxAttachmentBadge(att.type) : { label: 'FILE', bg: 'rgba(255,255,255,.10)', fg: KX2.ink2 }; return (
{badge.label}
{att.name}
{failed ? att.error : fmtBytes(att.size)} · {statusPill}
{removeBtn}
); } // ============ COMPOSER ============ // // Attachments // ----------- // The composer maintains a local queue of attachment objects, each // shaped { id?, name, size, type, status, file?, error? }. Files added // via the +button / drag-drop / paste are uploaded eagerly to // POST /uploads — the server returns an attachment id; the queue chip // flips from "uploading" to "ready". On send, attachment ids ride // alongside the text via the onSend signature // onSend(text, { attachment_ids: number[] }). // // All UI is debounce-free: attachments upload as you pick them, send is // disabled while any attachment is still uploading. function KxV2Composer({ onCmd, onSend, onVoice, placeholder, threadSlug }) { const [val, setVal] = React.useState(''); const [listening, setListening] = React.useState(false); const [waveTick, setWaveTick] = React.useState(0); const [attachments, setAttachments] = React.useState([]); // {tmpId, id?, name, size, type, status, error?} const [dragHover, setDragHover] = React.useState(false); const fileInputRef = React.useRef(null); React.useEffect(() => { if (!listening) return; let lastVoice = Date.now(); let stopped = false; const finish = () => { if (stopped) return; stopped = true; setListening(false); onVoice?.("What's on for tomorrow?"); }; const id = setInterval(() => { setWaveTick(t => t + 1); // Mock voice activity — assume 70% of ticks are "speech" if (Math.random() < 0.7) lastVoice = Date.now(); // Auto-stop on 10s of silence if (Date.now() - lastVoice > 10000) finish(); }, 120); // Hard cap at 30s for the demo const cap = setTimeout(finish, 30000); return () => { clearInterval(id); clearTimeout(cap); }; }, [listening, onVoice]); // Mirror server-side limit (atlas-api MAX_FILE_BYTES). Avoids round- // tripping a 25MB blob just to get a 413 back. const MAX_FILE_BYTES = 25 * 1024 * 1024; const fmtBytes = (n) => { if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`; return `${(n / 1024 / 1024).toFixed(1)} MB`; }; // Upload one file. Pushes the chip immediately (status='uploading'), // then flips to 'ready' with the server-assigned id or 'failed' with // the error. const uploadFile = React.useCallback(async (file) => { const tmpId = 'a_' + Math.random().toString(36).slice(2, 10); // For images we generate a local object-URL so the preview shows // instantly, before the upload completes. Revoked when the // attachment is removed (see KxV2PendingAttachmentRow's effect). const localPreview = (file.type || '').startsWith('image/') ? URL.createObjectURL(file) : null; const stub = { tmpId, name: file.name, size: file.size, type: file.type, file, localPreview, status: file.size > MAX_FILE_BYTES ? 'failed' : 'uploading', error: file.size > MAX_FILE_BYTES ? `Over ${fmtBytes(MAX_FILE_BYTES)}` : undefined, }; setAttachments((prev) => [...prev, stub]); if (stub.status === 'failed') return; if (!window.kxApi) { // Mock mode — no real upload available. setAttachments((prev) => prev.map((a) => a.tmpId === tmpId ? { ...a, status: 'failed', error: 'Demo mode — uploads disabled.' } : a, )); return; } try { const fd = new FormData(); fd.append('file', file, file.name); const path = '/uploads' + (threadSlug ? `?thread=${encodeURIComponent(threadSlug)}` : ''); const resp = await window.kxApi(path, { method: 'POST', body: fd }); if (resp && resp.id != null) { setAttachments((prev) => prev.map((a) => a.tmpId === tmpId ? { ...a, id: resp.id, status: 'ready' } : a, )); } else { setAttachments((prev) => prev.map((a) => a.tmpId === tmpId ? { ...a, status: 'failed', error: 'Upload failed.' } : a, )); } } catch (e) { setAttachments((prev) => prev.map((a) => a.tmpId === tmpId ? { ...a, status: 'failed', error: 'Upload failed.' } : a, )); } }, [threadSlug]); const handleFiles = React.useCallback((fileList) => { if (!fileList || !fileList.length) return; [...fileList].forEach((f) => uploadFile(f)); }, [uploadFile]); // Paste handler — attach files dropped from clipboard (screenshots // pasted from the OS, files copied from another app, etc.). const onPaste = React.useCallback((e) => { const files = e.clipboardData?.files; if (files && files.length) { e.preventDefault(); handleFiles(files); } }, [handleFiles]); const removeAttachment = (tmpId) => { setAttachments((prev) => prev.filter((a) => a.tmpId !== tmpId)); }; const hasPending = attachments.some((a) => a.status === 'uploading'); const hasReady = attachments.some((a) => a.status === 'ready'); const canSend = val.trim().length > 0 || hasReady; const sendBlocked = hasPending; const doSend = () => { if (sendBlocked || !canSend) return; const ids = attachments.filter((a) => a.status === 'ready' && a.id != null).map((a) => a.id); onSend?.(val, { attachment_ids: ids }); setVal(''); setAttachments([]); }; // Drag-and-drop on the whole composer container. const onDragOver = (e) => { e.preventDefault(); setDragHover(true); }; const onDragLeave = (e) => { // Only clear when the drag actually leaves the container (not when // crossing internal child boundaries). if (e.currentTarget.contains(e.relatedTarget)) return; setDragHover(false); }; const onDrop = (e) => { e.preventDefault(); setDragHover(false); handleFiles(e.dataTransfer?.files); }; return (
{/* Drag overlay — appears whenever a file is being dragged over the composer (or its parent thread area when we lift this higher in a later iteration). */} {dragHover && (
Drop to attach
)} {/* Hidden file input — clicked by the paperclip button below. */} { handleFiles(e.target.files); // Reset so picking the same file twice still fires onChange. e.target.value = ''; }} /> {/* Attachment queue — one row per pending attachment, richer than a name-only chip so the user can see what they're sending. The thumbnail uses the local File object via ObjectURL (no server round-trip needed for the just-picked image). For non-images we render the same file-card shape we use in chat bubbles. */} {attachments.length > 0 && (
{attachments.map((a) => ( removeAttachment(a.tmpId)} fmtBytes={fmtBytes} /> ))}
)} {listening ? (
{[...Array(18)].map((_, i) => { const phase = (waveTick + i) * 0.5; const h = 4 + Math.abs(Math.sin(phase)) * 18; return
; })}
LISTENING…
) : (
{/* Commands — palette */} setVal(e.target.value)} placeholder={placeholder || "Ask Kayser, or tap Commands"} onPaste={onPaste} style={{ flex: 1, background: 'transparent', border: 'none', outline: 'none', color: KX2.ink, fontSize: 14, fontFamily: KX2.fontUI, padding: '0 4px', minWidth: 0, }} onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey && canSend && !sendBlocked) { e.preventDefault(); doSend(); } }} />
)}
); } // ============ SHEET WRAPPER ============ function KxV2Sheet({ open, onClose, title, children, peek }) { if (!open) return null; const isDesktop = !!window.__kxDesktop; // On desktop, render as a right-side drawer that floats above the whole window. if (isDesktop) { return (
e.stopPropagation()} style={{ ...kx2Glass({ borderRadius: '20px 0 0 20px' }), background: KX2.glassSheet, width: 'min(480px, 50vw)', height: '100%', display: 'flex', flexDirection: 'column', animation: 'kxv2SlideInRight 220ms cubic-bezier(.2,.8,.2,1)', borderLeft: `1px solid ${KX2.glassLine}`, borderRight: 'none', borderTop: 'none', borderBottom: 'none', }}>
{title}
{children}
); } // Mobile / tablet: slide up from bottom (original behavior) return (
e.stopPropagation()} style={{ ...kx2Glass({ borderRadius: '20px 20px 0 0' }), background: KX2.glassSheet, width: '100%', maxHeight: peek ? '54%' : '88%', display: 'flex', flexDirection: 'column', animation: 'kxv2SlideUp 220ms cubic-bezier(.2,.8,.2,1)', }}>
{title}
{children}
); } // ============ COMMAND PALETTE ============ const KXV2_COMMANDS = [ { group: 'Today', icon: '◷', label: "Show today's schedule", hint: 'today' }, { group: 'Today', icon: '◷', label: 'Show overdue invoices', hint: 'overdue' }, { group: 'Today', icon: '◷', label: 'What needs my approval?', hint: 'queue' }, { group: 'Money', icon: '$', label: 'Cashflow this month', hint: 'cashflow' }, { group: 'Money', icon: '$', label: 'Outstanding by customer', hint: 'aging' }, { group: 'Money', icon: '$', label: 'Send a payment reminder…', hint: 'remind' }, { group: 'People', icon: '◉', label: 'Find a customer…', hint: 'find' }, { group: 'People', icon: '◉', label: 'Pipeline & recent estimates', hint: 'pipeline' }, { group: 'Train', icon: '↗', label: 'Teach Kayser a new automation', hint: 'teach' }, { group: 'Train', icon: '↗', label: 'Review what Kayser auto-handled', hint: 'auto' }, { group: 'Train', icon: '↗', label: 'Adjust trust thresholds', hint: 'trust' }, { group: 'Memory', icon: '◈', label: 'What I know about you', hint: 'memory' }, { group: 'Memory', icon: '◈', label: 'Forget something', hint: 'forget' }, ]; function KxV2Palette({ open, onClose, onPick }) { const [q, setQ] = React.useState(''); // Hermes-side skills are fetched on first open and cached in state. // Each one becomes a real palette command — name + description — that // sends a prompt asking the agent to use the skill. In live mode they // appear under a "Skills" group above the built-in command set. const [skills, setSkills] = React.useState([]); React.useEffect(() => { if (open) setQ(''); }, [open]); React.useEffect(() => { if (!open) return; if (!window.KX_TOKEN || !window.kxApi) return; let alive = true; window.kxApi('/skills').then((data) => { if (alive && Array.isArray(data)) setSkills(data); }).catch(() => { /* palette still works without skills */ }); return () => { alive = false; }; }, [open]); // Adapt server-shaped skills into palette commands. The ``hint`` is // the actual prompt sent to the agent on pick — we ask Hermes to // load + use the skill, with a placeholder the user can edit // mid-conversation. The label/description come straight from // /v1/skills so the agent's own ``skill_view`` description doesn't // drift from what we render. const skillCommands = skills.map((s) => ({ group: 'Skills', icon: '▣', label: (s.name || 'unnamed-skill').replace(/[-_]/g, ' '), sub: s.description || '', hint: 'skill:' + (s.name || ''), sendText: `Use your ${s.name} skill for: `, })); const allCommands = [...skillCommands, ...KXV2_COMMANDS]; const filt = allCommands.filter(c => !q || c.label.toLowerCase().includes(q.toLowerCase()) || (c.sub || '').toLowerCase().includes(q.toLowerCase()) || c.hint.includes(q.toLowerCase())); const groups = ['Skills', 'Today', 'Money', 'People', 'Train', 'Memory']; return (
setQ(e.target.value)} placeholder="Type a command, skill, or task…" style={{ flex: 1, background: 'transparent', border: 'none', outline: 'none', color: KX2.ink, fontSize: 14, fontFamily: KX2.fontUI, }} />
{groups.map(g => { const items = filt.filter(c => c.group === g); if (!items.length) return null; return (
{g}{g === 'Skills' && skills.length > 0 ? ` (${skills.length})` : ''}
{items.map(c => ( ))}
); })}
); } // ============ TRUST SHEET ============ const KXV2_TRUST_TIERS = [ { lvl: 1, name: 'Apprentice', range: [0, 25], blurb: 'Asks before everything.' }, { lvl: 2, name: 'Assistant', range: [25, 50], blurb: 'Handles routine reschedules and reminders on its own.' }, { lvl: 3, name: 'Operator', range: [50, 75], blurb: 'Sends quotes, books appointments, manages collections autonomously.' }, { lvl: 4, name: 'Partner', range: [75, 100], blurb: 'Acts as a true co-pilot. You review the day, not every action.' }, ]; const KXV2_TRUST_DOMAINS = [ { label: 'Scheduling', pct: 82, auto: true, did: 'Reschedules, confirmations, calendar holds' }, { label: 'Reminders', pct: 64, auto: true, did: 'Invoice nudges, follow-ups under $5k' }, { label: 'Quotes', pct: 38, auto: false, did: 'Drafts only — you approve every send' }, { label: 'Collections', pct: 22, auto: false, did: 'Drafts only — phone calls require you' }, { label: 'New leads', pct: 56, auto: true, did: 'Auto-replies to inbound under 4 hours' }, ]; function KxV2TrustSheet({ open, onClose, pct, level, onTeach }) { const tier = KXV2_TRUST_TIERS.find(t => t.lvl === level) || KXV2_TRUST_TIERS[0]; const next = KXV2_TRUST_TIERS.find(t => t.lvl === level + 1); const toNext = next ? next.range[0] - pct : 0; return (
{/* Big gauge */}
{pct}%
LEVEL {level}
{tier.name}
{tier.blurb}
{next && (
{toNext}% to {next.name}
)}
{/* Tier ladder */}
The journey
{KXV2_TRUST_TIERS.map(t => { const reached = pct >= t.range[0]; const current = level === t.lvl; return (
L{t.lvl}
{t.name}
{t.blurb}
{t.range[0]}–{t.range[1]}%
); })}
{/* Domain breakdown */}
By area · tap to train
{KXV2_TRUST_DOMAINS.map(d => ( ))}
); } // ============ STATUS PEEK SHEET ============ function KxV2StatusSheet({ open, onClose, watching, handledToday, onPick }) { const items = [ { kind: 'watch', label: '3 invoices · cashflow', meta: '$11,200 outstanding', ask: "Show me the cashflow plan for those 3 invoices." }, { kind: 'watch', label: 'Williams estimate · 2 days quiet', meta: 'Will follow up tomorrow AM', ask: "What's the Williams estimate situation?" }, { kind: 'watch', label: 'Patel weather window', meta: 'Re-checking forecast hourly', ask: "When does the Patel weather window open?" }, { kind: 'done', label: 'Rescheduled Patel · Wed → Thu', meta: '6 min ago', ask: "Why did you reschedule Patel?" }, { kind: 'done', label: 'Sent reminder · Garcia · #1031', meta: '14 min ago · you approved', ask: "Show me the Garcia reminder you sent." }, { kind: 'done', label: 'Logged Sherwin Williams receipt', meta: '1 hr ago · matched job 1822', ask: "What did you log for the Sherwin Williams receipt?" }, ]; return (
WATCHING
{watching}
HANDLED TODAY
{handledToday}
{items.map((it, i) => ( ))}
); } Object.assign(window, { KxV2Composer, KxV2Sheet, KxV2Palette, KxV2TrustSheet, KxV2StatusSheet });