// Kayser v2 "Glass" — main app shell. // Chat-first home with proactive Kayser pushes, ⌘ command palette, // trust gauge, level-up moments, automation training. // Local-time formatter for the bubble timestamp. The server's `t` // field is pre-formatted in UTC (the backend doesn't know the user's // timezone), so we ignore it and re-derive from `ts` (unix-ms) in // the browser's local time. Matches the prototype's mock format // ("8:42 AM"). Falls back to the server-provided `t` if `ts` is missing. function fmtLocalTime(tsMs, fallback) { if (!tsMs) return fallback || ''; try { return new Date(tsMs).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', }); } catch (_) { return fallback || ''; } } // API envelope ({ type, id, t, ts, thread, payload }) -> UI message // shape the components in thread.jsx render. Returns null for envelope // types the chat thread doesn't render (audit, trust, memory, system — // those drive sidebars/banners, not chat bubbles). // // `thread` is carried over so the per-thread filter in // kxv2FilterMessages can route messages to their correct view (live // thread ids aren't in KXV2_THREADS and don't have tag arrays, so the // old tag-substring filter falls back to []). function toUiMessage(env) { const t = fmtLocalTime(env.ts, env.t); const thread = env.thread; const attachments = env.payload && Array.isArray(env.payload.attachments) ? env.payload.attachments : undefined; const toolCalls = env.payload && Array.isArray(env.payload.tool_calls) ? env.payload.tool_calls : undefined; if (env.type === 'kayser-text') { return { id: env.id, kind: 'kayser-text', t, thread, text: env.payload.text, replies: env.payload.replies, attachments, tool_calls: toolCalls, }; } if (env.type === 'user-text') { return { id: env.id, kind: 'user-text', t, thread, text: env.payload.text, attachments }; } if (env.type === 'kayser-card') { return { id: env.id, kind: 'kayser-card', t, thread, card: { type: env.payload.kind, ...env.payload }, }; } return null; } const KXV2_TWEAKS = /*EDITMODE-BEGIN*/{ "trustPct": 58, "watching": 12, "handledToday": 7, "showLevelUp": false, "trustVoice": "calm", "wallpaperHue": "purple", "firstLaunch": false, "showOnboarding": false, "showMorningBrief": true, "offline": false, "pushDemo": false }/*EDITMODE-END*/; function KxV2App() { const [tweaks, setTweak] = useTweaks(KXV2_TWEAKS); // Active thread state — declared early because the message-fetch // useEffect below and the WebSocket handler both depend on it. // Previously this lived ~200 lines further down, but Babel-standalone // hoists `const` like `var`, so the dep arrays `[activeThread, ...]` // evaluated to `[undefined, ...]` on every render → React thought // deps never changed → the fetch never re-fired on thread switch // → user saw stale messages, which the per-thread filter then // stripped out → empty UI on every non-"now" thread. const [activeThread, setActiveThread] = React.useState('now'); // Real chat history: load when the active thread changes. Falls back // to the mock seed if window.KX_TOKEN is absent (so the prototype // still works standalone from the file:// shell). const [messages, setMessages] = React.useState( window.KX_TOKEN ? [] : KXV2_SEED ); React.useEffect(() => { if (!window.KX_TOKEN) { setMessages(tweaks.firstLaunch ? KXV2_ONBOARDING_SEED : KXV2_SEED); return; } // Drafts have no server-side row to fetch; just present an empty // canvas. The first send materializes the thread. if (activeThread === '__draft__') { setMessages([]); return; } let alive = true; window.kxApi(`/thread/${encodeURIComponent(activeThread)}?limit=50`) .then((data) => { if (!alive) return; const ui = (data.messages || []).map(toUiMessage).filter(Boolean); setMessages(ui); }) .catch(() => { if (alive) setMessages([]); }); return () => { alive = false; }; }, [activeThread, tweaks.firstLaunch]); // Live thread id ref — captured fresh inside the WS onmessage // closure so typing-dots / system events end up tagged with the // thread the user is currently viewing. const activeThreadRef = React.useRef(activeThread); React.useEffect(() => { activeThreadRef.current = activeThread; }, [activeThread]); // WebSocket — single persistent connection per session. Server pushes // user-text echo + kayser-text replies + system events. The dedupe // by id (last line of onmessage) means optimistic local renders for // the same id don't get doubled when the server echo arrives. React.useEffect(() => { if (!window.KX_TOKEN) return; // mock mode — no socket let closed = false; let retryMs = 1000; let ws = null; const connect = () => { ws = new WebSocket(window.KX_WS_BASE + '?token=' + encodeURIComponent(window.KX_TOKEN)); ws.onopen = () => { retryMs = 1000; }; ws.onmessage = (e) => { let env; try { env = JSON.parse(e.data); } catch (_) { return; } if (!env || !env.type) return; // ── system events: typing indicator, error fallback ───────── if (env.type === 'system' && env.payload) { const ev = env.payload.event; if (ev === 'agent_busy') { // Drop a typing-dots placeholder, tagged with the thread // the user is currently in so it shows up in the right // chat view (and gets filtered out of others). const tid = activeThreadRef.current; setMessages((m) => m.some((x) => x.id === 'kx_typing') ? m : [...m, { id: 'kx_typing', kind: 'kayser-typing', thread: tid }]); return; } if (ev === 'agent_unreachable') { setMessages((m) => m.filter((x) => x.id !== 'kx_typing')); setUndo({ label: "I'm having trouble reaching my brain. Try again in a minute." }); return; } // Other system events (connected etc.) — no-op. return; } // ── thread_meta: title / folder move / archive notifications ── // Same envelope handles every per-thread sidebar mutation: // payload.title → rename (agent auto-titler OR user PATCH) // payload.folder_id → move (PATCH /threads/{slug}) // payload.archived → delete (DELETE /threads/{slug}) // Multiple fields in one envelope are allowed (PATCH with both). if (env.type === 'thread_meta' && env.payload && env.thread) { const { title, folder_id, archived } = env.payload; if (archived) { setRealThreads((prev) => prev.filter((t) => t.slug !== env.thread)); if (env.thread === activeThreadRef.current) { setActiveThread('now'); } return; } if (title || folder_id !== undefined) { setRealThreads((prev) => prev.map((t) => { if (t.slug !== env.thread) return t; const next = { ...t }; if (title) next.name = title; if (folder_id !== undefined) { next.folder_id = folder_id == null ? null : String(folder_id); } return next; })); } return; } // ── tool_progress: a Hermes MCP tool is running ───────────── if (env.type === 'tool_progress' && env.payload) { // Surface Hermes's preview text alongside the tool name so // the live pill shows e.g. "Looking through past // conversations · foundation" instead of just the bare // tool label. Preview gets passed as-is to kxHumanizeTool // which strips it apart for display. // Phase governs pill style: started (default), failed (red), // thinking (italic — reasoning preview when // ATLAS_SURFACE_REASONING is on). const phase = env.payload.phase || 'started'; let label = env.payload.tool || env.payload.name || 'running tool'; const preview = env.payload.preview; if (preview && typeof preview === 'string') { const trimmed = preview.trim(); if (trimmed) { // Use a separator the humanizer can split on without // mangling MCP names that contain underscores. label = `${label}${trimmed.slice(0, 80)}`; } } setMessages((m) => { // Attach to the streaming kayser-text if one exists, // otherwise to the typing-dots placeholder, otherwise // create a typing placeholder. const idx = m.findIndex( (x) => (x.kind === 'kayser-text' && x.streaming) || x.id === 'kx_typing' ); if (idx !== -1) { const copy = m.slice(); copy[idx] = { ...copy[idx], tool_progress: label, tool_phase: phase }; return copy; } return [...m, { id: 'kx_typing', kind: 'kayser-typing', tool_progress: label, tool_phase: phase, }]; }); return; } // ── kayser-text-chunk: token-by-token streaming ───────────── if (env.type === 'kayser-text-chunk' && env.payload) { const delta = env.payload.delta || ''; if (!delta) return; const streamId = env.id; // stable per-reply id (stream_) setMessages((m) => { const idx = m.findIndex((x) => x.id === streamId); if (idx !== -1) { const copy = m.slice(); copy[idx] = { ...copy[idx], text: (copy[idx].text || '') + delta }; return copy; } // First chunk: replace any typing placeholder with a real // streaming bubble. Carry over any tool_progress label // that was already attached to the typing dots. const typingIdx = m.findIndex((x) => x.id === 'kx_typing'); const carryTool = typingIdx !== -1 ? m[typingIdx].tool_progress : undefined; const t = fmtLocalTime(env.ts || Date.now()); const newBubble = { id: streamId, kind: 'kayser-text', t, text: delta, thread: env.thread, streaming: true, ...(carryTool ? { tool_progress: carryTool } : {}), }; if (typingIdx !== -1) { const copy = m.slice(); copy[typingIdx] = newBubble; return copy; } return [...m, newBubble]; }); return; } // ── kayser-text (final): persist + finalize the streaming bubble ─ if (env.type === 'kayser-text' || env.type === 'user-text' || env.type === 'kayser-card') { const ui = toUiMessage(env); if (!ui) return; // ── draft → real slug swap ─────────────────────────────── // The first user-text echo for a brand-new conversation // carries `payload.draft_id === "__draft__"`. We swap // activeThread to the real slug the server picked, and // optimistically prepend the new thread to the sidebar so // it lands at the top immediately (refreshThreads reconciles // in the background to pull in the auto-title once Hermes // generates it). if (env.type === 'user-text' && env.payload && env.payload.draft_id === '__draft__' && env.thread) { if (activeThreadRef.current === '__draft__') { setActiveThread(env.thread); } setRealThreads((prev) => { if (prev.some((t) => t.slug === env.thread)) return prev; return [ { slug: env.thread, name: 'Untitled', kind: 'topic', summary: '', pinned: false, unread: 0, folder_id: null, }, ...prev, ]; }); setTimeout(() => refreshThreads(), 300); } setMessages((m) => { // Always remove any lingering typing placeholder when a // real Kayser message lands. let next = env.type === 'kayser-text' ? m.filter((x) => x.id !== 'kx_typing') : m; // If the server is finalizing a streaming reply, replace // the streaming bubble in place. The final envelope's // payload.streaming_id matches the stream_ id we // were accumulating chunks into. const streamingId = env.payload && env.payload.streaming_id; if (env.type === 'kayser-text' && streamingId) { const idx = next.findIndex((x) => x.id === streamingId); if (idx !== -1) { const copy = next.slice(); copy[idx] = { ...ui, streaming: false }; return copy; } } // Optimistic-echo replacement for user messages (existing // dedupe pattern via client_id). const clientId = env.payload && env.payload.client_id; if (env.type === 'user-text' && clientId) { const idx = next.findIndex((x) => x.id === clientId); if (idx !== -1) { // If this echo is materializing a draft, retag the // optimistic bubble's thread field to the canonical // slug so kxv2FilterMessages keeps showing it after // we swap activeThread below. const copy = next.slice(); copy[idx] = ui; return copy; } } // Already have this canonical id → no-op if (next.some((x) => x.id === ui.id)) return next; return [...next, ui]; }); return; } if (env.type === 'trust' && env.payload) { setTweak('trustPct', env.payload.overall_pct); } // Phase 5.0 — approval tray. // NOTE (2026-05-30): Hermes 0.15.2 doesn't emit approval events // on session_chat_stream, so this envelope is dormant today. // Path A (atlas-api-side tool gate, when channel tools ship) // will be the one inserting into approval_queue + emitting this // envelope. The handler logic below is correct as-is — only // the producer side changes. See runbooks/approvals-gap.md. // When the envelope arrives: queue it, open the sheet, let the // operator pick Send / Hold / Don't ask again / Skip. if (env.type === 'approval-pending' && env.payload) { setApprovalQueue((q) => { const exists = q.some((a) => a.approval_id === env.payload.approval_id); return exists ? q : [env.payload, ...q]; }); setApprovalOpen(true); return; } if (env.type === 'approval-resolved' && env.payload) { setApprovalQueue((q) => q.filter((a) => a.approval_id !== env.payload.approval_id)); // If the resolved one was the visible one, close the sheet. // The next pending (if any) will pop on its own envelope. setApprovalOpen(false); return; } // `audit`, `memory` envelopes handled in later wiring steps }; ws.onclose = () => { if (closed) return; setTimeout(connect, retryMs); retryMs = Math.min(retryMs * 2, 16000); }; ws.onerror = () => { try { ws.close(); } catch (_) {} }; window.__kxWs = ws; }; connect(); return () => { closed = true; try { ws && ws.close(); } catch (_) {} window.__kxWs = null; }; }, []); // Onboarding overlay (5-screen welcome flow) const [showOnb, setShowOnb] = React.useState(false); React.useEffect(() => { setShowOnb(!!tweaks.showOnboarding); }, [tweaks.showOnboarding]); const [auditOpen, setAuditOpen] = React.useState(false); const [push, setPush] = React.useState(null); const [billing, setBilling] = React.useState(false); const [pauseConfirm, setPauseConfirm] = React.useState(false); const [paused, setPaused] = React.useState(false); const [serviceDetail, setServiceDetail] = React.useState(null); // string id const [palette, setPalette] = React.useState(false); const [trust, setTrust] = React.useState(false); const [status, setStatus] = React.useState(false); const [teach, setTeach] = React.useState(false); const [memory, setMemory] = React.useState(false); const [settings, setSettings] = React.useState(false); const [menu, setMenu] = React.useState(false); const [quickMenu, setQuickMenu] = React.useState(false); const [settingsPage, setSettingsPage] = React.useState(false); const [threadsSheet, setThreadsSheet] = React.useState(false); const menuAnchorRef = React.useRef(null); // (activeThread state moved to the top of the component — see comment above // the message-fetch useEffect for why.) const [approvalOpen, setApprovalOpen] = React.useState(false); // Phase 5.0 — live approval queue. Newest pending approval at index 0, // populated by the `approval-pending` WS envelope + a one-shot // backfill on mount (catches anything that fired while the WS was // disconnected). Cleared per-row on `approval-resolved`. const [approvalQueue, setApprovalQueue] = React.useState([]); const [wrongOpen, setWrongOpen] = React.useState(false); // Backfill the approval tray from /api/v1/approvals/pending once. // Subsequent updates arrive over the WS; this just covers the // reconnect-after-disconnect case + initial page load. React.useEffect(() => { if (!window.KX_TOKEN || !window.kxApi) return; let alive = true; (async () => { try { const list = await window.kxApi('/approvals/pending'); if (alive && Array.isArray(list) && list.length) { setApprovalQueue(list); setApprovalOpen(true); } } catch (_) { /* kxApi handles 401 redirect; otherwise best-effort */ } })(); return () => { alive = false; }; }, []); const [undo, setUndo] = React.useState(null); // { label } const [levelUpDismissed, setLevelUpDismissed] = React.useState(false); // ── Live trust state ─────────────────────────────────────────── // The top bar reads from realTrust when the user is signed in; // falls back to the tweaks defaults so the standalone prototype // still renders sane values. /trust is server-side cached for 60s // so polling from multiple tabs is cheap. const [realTrust, setRealTrust] = React.useState(null); React.useEffect(() => { if (!window.KX_TOKEN) return; let alive = true; const fetchTrust = async () => { try { const t = await window.kxApi('/trust'); if (alive && t && typeof t === 'object') setRealTrust(t); } catch (_) { /* best-effort polling */ } }; fetchTrust(); // 60s matches the server-side cache TTL; visible-tab heuristic // lets dormant tabs sleep without dropping freshness when focused. const id = setInterval(() => { if (document.visibilityState === 'visible') fetchTrust(); }, 60000); const onVis = () => { if (document.visibilityState === 'visible') fetchTrust(); }; document.addEventListener('visibilitychange', onVis); return () => { alive = false; clearInterval(id); document.removeEventListener('visibilitychange', onVis); }; }, []); // Derive the display values. realTrust wins when present; otherwise // fall through to the tweaks defaults for the file:// prototype mode. // // Trust v1 ("count chat tool calls") was misleading — see // docs/trust-v2-plan.md. Until the real task-completion substrate // lands, overall_pct / level / handled_today come back as null from // /trust and we render a "Setting up" copy instead of a fake number. // `watching` stays live (active threads count). const trustReady = realTrust ? realTrust.overall_pct != null : !window.KX_TOKEN; const trustPct = realTrust ? realTrust.overall_pct : tweaks.trustPct; const trustLevel = realTrust ? realTrust.level : (trustPct != null ? (trustPct < 25 ? 1 : trustPct < 50 ? 2 : trustPct < 75 ? 3 : 4) : null); const watchingCount = realTrust ? realTrust.watching : tweaks.watching; const handledCount = realTrust ? realTrust.handled_today : tweaks.handledToday; const tier = trustLevel != null ? KXV2_TRUST_TIERS.find(t => t.lvl === trustLevel) : null; // Wallpaper hue swap (kept tight: purple, indigo, slate) const wallpaper = { purple: KX2_WALLPAPER, indigo: ` radial-gradient(ellipse 80% 60% at 20% 0%, #1e3a8a 0%, transparent 60%), radial-gradient(ellipse 70% 50% at 100% 30%, #3730a3 0%, transparent 55%), radial-gradient(ellipse 90% 60% at 50% 100%, #0f172a 0%, transparent 60%), linear-gradient(180deg, #0f172a 0%, #020617 100%) `, slate: ` radial-gradient(ellipse 80% 60% at 20% 0%, #1e293b 0%, transparent 60%), radial-gradient(ellipse 70% 50% at 100% 30%, #334155 0%, transparent 55%), linear-gradient(180deg, #0b0f17 0%, #050709 100%) `, }[tweaks.wallpaperHue] || KX2_WALLPAPER; const handleSend = (text, opts) => { const attachmentIds = (opts && Array.isArray(opts.attachment_ids)) ? opts.attachment_ids : []; if (!window.KX_TOKEN) { // Mock mode — preserve the canned demo behaviour for the // standalone prototype (no server). const id = Date.now(); setMessages((m) => [...m, { id, kind: 'user-text', t: 'now', text }]); setTimeout(() => { setMessages((m) => [...m, { id: id + 1, kind: 'kayser-text', t: 'now', text: "On it. I'll come back when I have something.", }]); }, 700); return; } // Live mode — optimistic echo. We send the tempId as `client_id` // so the server's WS echo of the same user-text can replace the // optimistic bubble in place rather than appending a duplicate. // Attachment ids ride alongside; the bubble carries placeholder // attachment chips until the server-echo arrives with the real // filename/size/type metadata. const tempId = 'tmp_' + Date.now(); setMessages((m) => [...m, { id: tempId, kind: 'user-text', t: fmtLocalTime(Date.now()), thread: activeThread, text, attachments: attachmentIds.length ? attachmentIds.map((id) => ({ id, filename: 'Uploading…', content_type: '', size_bytes: 0 })) : undefined, }]); window.kxApi('/messages', { method: 'POST', body: JSON.stringify({ thread: activeThread, text, client_id: tempId, attachment_ids: attachmentIds, }), }).catch(() => { setUndo({ label: "Couldn't send that. Try again in a minute." }); }); // The agent reply arrives on the WebSocket — no local timeout. }; const handlePick = (cmd) => { setPalette(false); if (cmd.hint === 'trust' || cmd.hint === 'auto') { setTimeout(() => setTrust(true), 80); return; } if (cmd.hint === 'teach') { setTimeout(() => setTeach(true), 80); return; } if (cmd.hint === 'memory' || cmd.hint === 'forget') { setTimeout(() => setMemory(true), 80); return; } // Skill picks send the skill-aware prompt string the palette // attached to the command rather than the human label. if (cmd.hint && cmd.hint.startsWith('skill:') && cmd.sendText) { handleSend(cmd.sendText); return; } handleSend(cmd.label); }; const handleCardTap = (card) => { if (card.type === 'approval') setApprovalOpen(true); if (card.type === 'auto-handled') setWrongOpen(true); }; const handleCardAction = (card, action) => { if (card.type === 'approval' && action === 'Send') { // Real send flow: replace card with auto-handled receipt + show undo bar setMessages(ms => [ ...ms.filter(m => m.card !== card), { id: Date.now(), kind: 'kayser-card', t: 'now', card: { type: 'auto-handled', title: 'Sent reminder · Garcia · #1031', subtitle: 'Delivered via SMS', meta: 'You said send. I sent it. Undo if you want.', }}, ]); setUndo({ label: 'Sent reminder to Lina Garcia' }); } else if (card.type === 'approval' && action === 'Edit') { setApprovalOpen(true); } else if (card.type === 'approval' && action === 'Skip') { // Remove the card and post a brief Kayser ack setMessages(ms => [ ...ms.filter(m => m.card !== card), { id: Date.now(), kind: 'kayser-text', t: 'now', text: "Skipped. I'll check again in 24 hours." }, ]); } else if (card.type === 'insight') { // Insight CTA: post a Kayser follow-up plan const id = Date.now(); setMessages(ms => [ ...ms, { id, kind: 'user-text', t: 'now', text: action }, { id: id + 1, kind: 'kayser-text', t: 'now', text: "Drafting now. I'll group the 3 overdue invoices, send Garcia the soft nudge first (already approved a tone you like), then a payment-plan offer to Henderson. The third — Reyes — gets nothing yet; she always pays around day 10. Want me to queue it?", replies: ["Yes, queue it", "Skip Reyes anyway", "Show me the drafts"] }, ]); } }; const handleReply = (text) => { handleSend(text); }; const handleAttach = () => { setMessages(ms => [...ms, { id: Date.now(), kind: 'kayser-text', t: 'now', text: 'Attach a photo, voice memo, or file — I\'ll figure out where it goes. (Demo: not wired in this prototype.)', }]); }; const handleSentFromSheet = () => { setApprovalOpen(false); setUndo({ label: 'Sent reminder to Lina Garcia' }); // Replace the approval card with auto-handled receipt setMessages(ms => ms.map(m => m.card?.type === 'approval' && m.card?.title?.includes('Garcia') ? { ...m, card: { type: 'auto-handled', title: 'Sent reminder · Garcia · #1031', subtitle: 'Delivered via SMS', meta: 'You said send. I sent it. Undo if you want.', }} : m )); }; const handleHoldFromSheet = ({ minutes }) => { setApprovalOpen(false); const fmt = minutes < 60 ? `${minutes} min` : minutes < 480 ? `${minutes/60} hr` : minutes < 1440 ? 'tonight' : 'tomorrow'; setUndo({ label: `Holding Garcia reminder for ${fmt}. You can cancel anytime.` }); setMessages(ms => ms.map(m => m.card?.type === 'approval' && m.card?.title?.includes('Garcia') ? { ...m, card: { type: 'auto-handled', title: `Holding reminder · Garcia · ${fmt}`, subtitle: `Will send in ${fmt} unless you cancel`, meta: 'You can review, edit, or cancel in chat anytime in that window.', }} : m )); }; const handleWrongConfirm = () => { setWrongOpen(false); setUndo({ label: "Undid it. I'll be more careful next time." }); }; // Demo push trigger React.useEffect(() => { if (!tweaks.pushDemo) { setPush(null); return; } setPush({ title: 'Kayser', body: 'Garcia just opened the reminder you sent. She replied: "I\'ll have it Friday morning."' }); }, [tweaks.pushDemo]); const showLevelUp = tweaks.showLevelUp && !levelUpDismissed && tier; // When wrapped in DesktopShell at ≥1024, the outer shell renders its own top bar. // We hide ours + the iOS-status-bar padding to avoid double headers. const [isDesktop, setIsDesktop] = React.useState(() => !!window.__kxDesktop); React.useEffect(() => { const check = () => setIsDesktop(!!window.__kxDesktop); check(); const id = setInterval(check, 250); return () => clearInterval(id); }, []); // Expose openers for desktop shell React.useEffect(() => { window.__kxOpenPalette = () => setPalette(true); window.__kxOpenMenu = () => setQuickMenu(q => !q); window.__kxOpenSettings = () => setSettingsPage(true); return () => { window.__kxOpenPalette = null; window.__kxOpenMenu = null; window.__kxOpenSettings = null; }; }, []); // ── Real threads — replace the mock KXV2_THREADS for the live PWA ── // // The threads rail (desktop) and threads sheet (mobile) read from // window.__kxThreads when present, falling back to KXV2_THREADS only // for the standalone/file:// prototype. Refreshed on mount + after // every `done` envelope (handled by the WS branch above for the // title-generation case). const [realThreads, setRealThreads] = React.useState([]); const [realFolders, setRealFolders] = React.useState([]); const refreshThreads = React.useCallback(async () => { if (!window.KX_TOKEN) return; try { const list = await window.kxApi('/threads'); if (!Array.isArray(list)) return; // Adapt server shape → component shape (the components want // `summary` + `pinned` etc. — fill sensible defaults). const mapped = list.map((t) => ({ slug: t.slug, name: t.name || 'Untitled', kind: t.kind || 'topic', summary: t.summary || '', pinned: !!t.pinned, unread: t.unread || 0, folder_id: t.folder_id || null, })); setRealThreads(mapped); } catch (_) { /* best-effort */ } }, []); const refreshFolders = React.useCallback(async () => { if (!window.KX_TOKEN) return; try { const list = await window.kxApi('/folders'); if (!Array.isArray(list)) return; setRealFolders(list.map((f) => ({ id: f.id, name: f.name, position: f.position || 0, }))); } catch (_) { /* best-effort */ } }, []); React.useEffect(() => { refreshThreads(); refreshFolders(); }, [refreshThreads, refreshFolders]); // Synthesise a draft row into the sidebar list while the user is // composing a brand-new conversation. The draft has no server row // until they hit send; this is purely so the rail and sheet show // "New conversation" at the top instead of looking like the user is // still on the previously-selected thread. const displayedThreads = React.useMemo(() => { if (activeThread !== '__draft__') return realThreads; if (realThreads.some((t) => t.slug === '__draft__')) return realThreads; return [ { slug: '__draft__', name: 'New conversation', kind: 'topic', summary: 'Type to start…', pinned: false, unread: 0, folder_id: null, }, ...realThreads, ]; }, [realThreads, activeThread]); React.useEffect(() => { window.__kxThreads = displayedThreads; return () => { window.__kxThreads = null; }; }, [displayedThreads]); React.useEffect(() => { window.__kxFolders = realFolders; return () => { window.__kxFolders = null; }; }, [realFolders]); // ── Real identity (/me) ──────────────────────────────────────────── // // Drives the QuickMenu profile header + Settings "Profile" row hint. // Without this the menu shows hardcoded mock copy ("Wes Kayser / // Kayser Foundation Repair") regardless of who is signed in. Fetched // once on mount because the only things that can change it // mid-session are tenant adds/removes (rare, the operator can // re-sign-in to refresh) and Google profile picture (cosmetic). // Empty object (not null) so child components can destructure safely. const [realMe, setRealMe] = React.useState({}); React.useEffect(() => { if (!window.KX_TOKEN) return; let alive = true; (async () => { try { const me = await window.kxApi('/me'); if (alive && me && typeof me === 'object') setRealMe(me); } catch (_) { /* kxApi handles 401; leave empty otherwise */ } })(); return () => { alive = false; }; }, []); // Refresh threads after each agent reply so titles + last-message // ordering reflect the latest exchange. React.useEffect(() => { const lastK = messages.length ? messages[messages.length - 1] : null; if (lastK && lastK.kind === 'kayser-text' && !lastK.streaming) { refreshThreads(); } }, [messages, refreshThreads]); // ── New thread handler — pure client-side draft ────────────────── // // Switches to the sentinel `__draft__` slug and clears the chat // area. No API call. The first message sent against this slug // materializes the real thread server-side (POST /messages handles // the sentinel + echoes back the real slug; we swap activeThread in // the WS handler when payload.draft_id arrives). // // No-op if the user is already sitting on an empty draft — clicking // "+ New" repeatedly used to pile up empty rows; now it's just inert. const KX_DRAFT_SLUG = '__draft__'; const handleNewThread = React.useCallback(() => { if (!window.KX_TOKEN) { setActiveThread('now'); return; } if (activeThread === KX_DRAFT_SLUG && messages.length === 0) return; setActiveThread(KX_DRAFT_SLUG); setMessages([]); }, [activeThread, messages.length]); React.useEffect(() => { window.__kxNewThread = handleNewThread; return () => { window.__kxNewThread = null; }; }, [handleNewThread]); // ── Folder + thread mutation handlers ──────────────────────────── // All six are best-effort optimistic: update local state immediately // for snappy UI, then reconcile with the server. On failure we show // the undo toast and refresh from server to bounce back to truth. const handleNewFolder = React.useCallback(async () => { if (!window.KX_TOKEN) return; const name = window.prompt('New folder name:', 'Untitled folder'); if (!name || !name.trim()) return; try { const f = await window.kxApi('/folders', { method: 'POST', body: JSON.stringify({ name: name.trim() }), }); if (f && f.id) { setRealFolders((prev) => [...prev, { id: f.id, name: f.name, position: f.position || 0 }]); } } catch (_) { setUndo({ label: "Couldn't create that folder. Try again in a sec." }); } }, []); const handleRenameFolder = React.useCallback(async (folder, name) => { if (!window.KX_TOKEN || !folder || !name) return; setRealFolders((prev) => prev.map((f) => String(f.id) === String(folder.id) ? { ...f, name } : f )); try { await window.kxApi(`/folders/${encodeURIComponent(folder.id)}`, { method: 'PATCH', body: JSON.stringify({ name }), }); } catch (_) { setUndo({ label: "Couldn't rename that folder." }); refreshFolders(); } }, [refreshFolders]); const handleDeleteFolder = React.useCallback(async (folder) => { if (!window.KX_TOKEN || !folder) return; if (!window.confirm(`Delete "${folder.name}"? The conversations inside stay; they just won't be grouped anymore.`)) return; // Optimistic: drop the folder + un-bucket its threads. setRealFolders((prev) => prev.filter((f) => String(f.id) !== String(folder.id))); setRealThreads((prev) => prev.map((t) => String(t.folder_id) === String(folder.id) ? { ...t, folder_id: null } : t )); try { await window.kxApi(`/folders/${encodeURIComponent(folder.id)}`, { method: 'DELETE' }); } catch (_) { setUndo({ label: "Couldn't delete that folder." }); refreshFolders(); refreshThreads(); } }, [refreshFolders, refreshThreads]); const handleRenameThread = React.useCallback(async (thread) => { if (!window.KX_TOKEN || !thread) return; const name = window.prompt('Rename conversation:', thread.name || ''); if (name == null) return; const trimmed = name.trim(); if (!trimmed || trimmed === thread.name) return; setRealThreads((prev) => prev.map((t) => t.slug === thread.slug ? { ...t, name: trimmed } : t )); try { await window.kxApi(`/threads/${encodeURIComponent(thread.slug)}`, { method: 'PATCH', body: JSON.stringify({ title: trimmed }), }); } catch (_) { setUndo({ label: "Couldn't rename that conversation." }); refreshThreads(); } }, [refreshThreads]); const handleMoveThread = React.useCallback(async (thread, folderId) => { if (!window.KX_TOKEN || !thread) return; const targetId = folderId == null ? null : String(folderId); setRealThreads((prev) => prev.map((t) => t.slug === thread.slug ? { ...t, folder_id: targetId } : t )); try { await window.kxApi(`/threads/${encodeURIComponent(thread.slug)}`, { method: 'PATCH', body: JSON.stringify({ folder_id: targetId }), }); } catch (_) { setUndo({ label: "Couldn't move that conversation." }); refreshThreads(); } }, [refreshThreads]); const handleDeleteThread = React.useCallback(async (thread) => { if (!window.KX_TOKEN || !thread) return; if (!window.confirm(`Delete "${thread.name}"? Messages stay in your history but the conversation hides from the sidebar.`)) return; setRealThreads((prev) => prev.filter((t) => t.slug !== thread.slug)); // If you just deleted the open thread, fall back to "now". if (thread.slug === activeThread) setActiveThread('now'); try { await window.kxApi(`/threads/${encodeURIComponent(thread.slug)}`, { method: 'DELETE' }); } catch (_) { setUndo({ label: "Couldn't delete that conversation." }); refreshThreads(); } }, [activeThread, refreshThreads]); // Expose folder + thread mutation handlers for DesktopShell. React.useEffect(() => { window.__kxNewFolder = handleNewFolder; window.__kxRenameFolder = handleRenameFolder; window.__kxDeleteFolder = handleDeleteFolder; window.__kxRenameThread = handleRenameThread; window.__kxMoveThread = handleMoveThread; window.__kxDeleteThread = handleDeleteThread; return () => { window.__kxNewFolder = null; window.__kxRenameFolder = null; window.__kxDeleteFolder = null; window.__kxRenameThread = null; window.__kxMoveThread = null; window.__kxDeleteThread = null; }; }, [handleNewFolder, handleRenameFolder, handleDeleteFolder, handleRenameThread, handleMoveThread, handleDeleteThread]); // Expose thread setter to desktop shell rail React.useEffect(() => { window.__kxSetThread = (slug) => setActiveThread(slug); window.__kxGetThread = () => activeThread; return () => { window.__kxSetThread = null; window.__kxGetThread = null; }; }, [activeThread]); return (
setStatus(true)} onOpenTrust={() => setTrust(true)} onOpenSettings={() => setQuickMenu(q => !q)} onOpenThreads={() => setThreadsSheet(true)} __hidden={isDesktop} /> t.slug === activeThread)} onBack={() => setActiveThread('now')} />
{showLevelUp && (
setLevelUpDismissed(true)} onSeeMore={() => { setLevelUpDismissed(true); setTrust(true); }} />
)} : null} messages={kxv2FilterMessages(messages, activeThread)} onCardTap={handleCardTap} onCardAction={handleCardAction} onReply={handleReply} />
t.slug === activeThread)?.name || 'this'}…`} threadSlug={activeThread} onCmd={() => setPalette(true)} onSend={handleSend} onVoice={(text) => handleSend(text)} /> setPalette(false)} onPick={handlePick} /> setTrust(false)} pct={trustPct} level={trustLevel} onTeach={() => { setTrust(false); setTimeout(() => setTeach(true), 200); }} /> setStatus(false)} watching={watchingCount} handledToday={handledCount} onPick={(ask) => { setStatus(false); setTimeout(() => handleSend(ask), 200); }} /> setTeach(false)} /> setMemory(false)} /> setApprovalOpen(false)} detail={approvalQueue[0]} onSent={async () => { // "Send" maps to upstream `once` (Hermes approves THIS execution). // Live path: POST /api/v1/approvals/{id}/resolve. Falls through // to the legacy mock-card flow when there's no real approval // in the queue (the prototype demo path). const pending = approvalQueue[0]; if (pending) { try { await window.kxApi(`/approvals/${pending.approval_id}/resolve`, { method: 'POST', body: JSON.stringify({ choice: 'send' }), }); // approval-resolved WS envelope will close the sheet // and drop the row; nothing else to do here. } catch (_) { setApprovalOpen(false); } } else if (handleSentFromSheet) { handleSentFromSheet(); } }} onHold={(payload) => { // Hold is operator-side parking for v1 — no upstream call yet. // KxV2HoldForReview captures { minutes, draft } so a future // /api/v1/approvals/{id}/hold endpoint can schedule it. if (handleHoldFromSheet) handleHoldFromSheet(payload); setApprovalOpen(false); }} onWrong={() => { setApprovalOpen(false); setTimeout(() => setWrongOpen(true), 200); }} /> setWrongOpen(false)} onConfirm={async (reason) => { // "Don't ask me again" → upstream `always`. If no live // approval queued, fall through to the legacy mock-handler // (which adjusts trust copy etc.). const pending = approvalQueue[0]; if (pending) { try { await window.kxApi(`/approvals/${pending.approval_id}/resolve`, { method: 'POST', body: JSON.stringify({ choice: 'always', reason }), }); } catch (_) { /* WS resolved envelope is the source of truth */ } setWrongOpen(false); } else if (handleWrongConfirm) { handleWrongConfirm(reason); } }} /> setSettings(false)} onOpenMemory={() => setMemory(true)} onOpenTrust={() => setTrust(true)} onOpenTeach={() => setTeach(true)} /> setMenu(false)} me={realMe} onOpenMemory={() => setMemory(true)} onOpenTrust={() => setTrust(true)} onOpenTeach={() => setTeach(true)} onOpenConversations={() => setThreadsSheet(true)} /> setQuickMenu(false)} anchorRef={menuAnchorRef} me={realMe} onOpenSettings={() => { setQuickMenu(false); setSettingsPage(true); }} onOpenBilling={() => setQuickMenu(false)} onOpenHelp={() => setQuickMenu(false)} onSignOut={() => { setQuickMenu(false); window.kxLogout?.(); }} /> setSettingsPage(false)} me={realMe} onOpen={(id) => { if (id === 'trust') { setSettingsPage(false); setTimeout(() => setTrust(true), 100); } if (id === 'memory') { setSettingsPage(false); setTimeout(() => setMemory(true), 100); } if (id === 'automations') { setSettingsPage(false); setTimeout(() => setTeach(true), 100); } if (id === 'conversations') { setSettingsPage(false); setTimeout(() => setThreadsSheet(true), 100); } if (id === 'billing') { setSettingsPage(false); setTimeout(() => setBilling(true), 100); } if (id === 'pause') { setSettingsPage(false); setTimeout(() => setPauseConfirm(true), 100); } if (['gmail','twilio','quickbooks','calendar','stripe'].includes(id)) { setSettingsPage(false); setTimeout(() => setServiceDetail(id), 100); } }} /> setBilling(false)} /> setPauseConfirm(false)} paused={paused} onTogglePause={(v) => { setPaused(v); setPauseConfirm(false); }} /> setServiceDetail(null)} /> setThreadsSheet(false)} activeSlug={activeThread} threads={displayedThreads} folders={realFolders} onNew={handleNewThread} onPick={(slug) => setActiveThread(slug)} onNewFolder={handleNewFolder} onRenameFolder={handleRenameFolder} onDeleteFolder={handleDeleteFolder} onRenameThread={handleRenameThread} onMoveThread={handleMoveThread} onDeleteThread={handleDeleteThread} /> setUndo(null)} onDismiss={() => setUndo(null)} /> setAuditOpen(false)} /> { setPush(null); setTweak('pushDemo', false); }} onTap={() => { setPush(null); setTweak('pushDemo', false); }} /> {showOnb && ( { setShowOnb(false); setTweak('showOnboarding', false); }} onComplete={(data) => { setShowOnb(false); setTweak('showOnboarding', false); // If user tapped "Show me the draft" on the first-find step, pop the approval sheet if (data?.openApproval) setTimeout(() => setApprovalOpen(true), 250); }} /> )} {/* Dev-only tweaks panel. Hidden in prod (window.KX_DEV false); append ?dev=1 to the URL to opt in for the current tab. The panel previously drove top-bar values (trustPct/watching/ handledToday); those now come from real /trust data. */} {window.KX_DEV && ( setTweak('trustPct', v)} /> { setTweak('showLevelUp', v); setLevelUpDismissed(false); }} /> setTweak('watching', v)} /> setTweak('handledToday', v)} /> setTweak('wallpaperHue', v)} /> setTweak('showMorningBrief', v)} /> setTweak('offline', v)} /> setTweak('pushDemo', true)} /> setAuditOpen(true)} /> setTweak('firstLaunch', v)} /> { setTweak('showOnboarding', v); setShowOnb(v); }} /> { setMessages(tweaks.firstLaunch ? KXV2_ONBOARDING_SEED : KXV2_SEED); setUndo(null); setLevelUpDismissed(false); }} /> )}
); } Object.assign(window, { KxV2App });