// Main app — state machine + screen switching. const { useState, useEffect, useRef, useMemo, useCallback } = React; const TWEAK_DEFAULTS = { theme: "studio", showChart: true, showTable: true, showCitations: true, showDrills: true, showCost: true, showHonesty: true, accent: "#2A2A6E", }; const useLocalTweaks = (defaults) => { const [t, setT] = useState(defaults); const setTweak = (k, v) => setT(prev => ({ ...prev, [k]: v })); return [t, setTweak]; }; // ─── localStorage thread persistence ──────────────────────────────────────── // Schema: { version, threads: [{ id, title, createdAt, updatedAt, agent, questions: [], responses: [] }] } const STORAGE_KEY = "omnibuddy.threads.v1"; const SCHEMA_VERSION = 1; const loadThreads = () => { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return []; const data = JSON.parse(raw); if (data.version !== SCHEMA_VERSION) return []; return Array.isArray(data.threads) ? data.threads : []; } catch (e) { console.warn("Failed to load threads from localStorage:", e); return []; } }; const saveThreads = (threads) => { try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ version: SCHEMA_VERSION, threads })); } catch (e) { console.warn("Failed to save threads:", e); } }; const threadTitle = (firstQ) => { if (!firstQ) return "New thread"; const t = firstQ.trim(); return t.length > 60 ? t.slice(0, 60) + "…" : t; }; const relTime = (ts) => { const diff = Date.now() - ts; const min = Math.floor(diff / 60000); if (min < 1) return "Just now"; if (min < 60) return min + "m ago"; const hr = Math.floor(min / 60); if (hr < 24) return hr + "h ago"; const d = Math.floor(hr / 24); if (d < 7) return d + "d ago"; return new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric" }); }; // ─── Top-level Topbar (NOT defined inside App, to avoid re-creation per render) ─ const Topbar = ({ title, sbOpen, setSbOpen, persona, activeSources, routedTo, setModal }) => (
{title}
{routedTo ? "routed to " + routedTo : "routed via auto"} {activeSources}/8 sources
{/* Pitch Deck Studio — external app, opens in same window */} Pitch Deck Studio
); // ─── Replaces the hardcoded LiveTrailCard. Clean, honest "thinking…" state. ──── const ThinkingIndicator = () => (
OmniBuddy is thinking…
); function App() { const [t, setTweak] = useLocalTweaks(TWEAK_DEFAULTS); const D = window.OMNI_DATA; // Theme useEffect(() => { document.documentElement.dataset.theme = t.theme; }, [t.theme]); useEffect(() => { document.documentElement.style.setProperty("--accent", t.accent); }, [t.accent]); // Sidebar const [sbOpen, setSbOpen] = useState(window.innerWidth > 900); const [isMobile, setIsMobile] = useState(window.innerWidth <= 900); useEffect(() => { const r = () => setIsMobile(window.innerWidth <= 900); window.addEventListener("resize", r); return () => window.removeEventListener("resize", r); }, []); // Close sidebar with Esc on mobile useEffect(() => { const onKey = (e) => { if (e.key === "Escape" && sbOpen && isMobile) setSbOpen(false); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [sbOpen, isMobile]); // ─── Threads (persisted) ──────────────────────────────────────────────── const [threads, setThreads] = useState(loadThreads); const [activeThreadId, setActiveThreadId] = useState(() => { const t0 = loadThreads(); return t0.length > 0 ? t0[0].id : null; }); useEffect(() => { saveThreads(threads); }, [threads]); const activeThread = threads.find(th => th.id === activeThreadId); const questions = activeThread?.questions || []; const responses = activeThread?.responses || []; const errors = activeThread?.errors || []; // ─── Conversation state ───────────────────────────────────────────────── const [mode, setMode] = useState(() => loadThreads().length > 0 ? "answered" : "empty"); const [composerValue, setComposerValue] = useState(""); const [chatMode, setChatMode] = useState("work"); const [speed, setSpeed] = useState("fast"); const [persona, setPersona] = useState("Executive"); // Active modal const [modal, setModal] = useState(null); const [activeSources, setActiveSources] = useState(D.AGENTS.map(a => a.id)); const [autoRoute, setAutoRoute] = useState(true); const feedRef = useRef(); const scrollToBottom = () => { requestAnimationFrame(() => { if (feedRef.current) feedRef.current.scrollTo({ top: feedRef.current.scrollHeight, behavior: "smooth" }); }); }; // ─── Thread mutations ────────────────────────────────────────────────── const upsertThread = (id, updater) => { setThreads(prev => { const idx = prev.findIndex(th => th.id === id); if (idx === -1) return prev; const next = [...prev]; next[idx] = { ...next[idx], ...updater(next[idx]), updatedAt: Date.now() }; // Move active thread to top const [moved] = next.splice(idx, 1); return [moved, ...next]; }); }; const createThread = (firstQ) => { const id = "th_" + Date.now() + "_" + Math.random().toString(36).slice(2, 7); const th = { id, title: threadTitle(firstQ), createdAt: Date.now(), updatedAt: Date.now(), agent: null, questions: [firstQ], responses: [null], errors: [null], }; setThreads(prev => [th, ...prev]); setActiveThreadId(id); return id; }; const newThread = () => { setMode("empty"); setActiveThreadId(null); setComposerValue(""); }; const selectThread = (id) => { setActiveThreadId(id); setMode("answered"); if (isMobile) setSbOpen(false); }; const deleteThread = (id) => { setThreads(prev => prev.filter(th => th.id !== id)); if (activeThreadId === id) { setActiveThreadId(null); setMode("empty"); } }; // ─── Ask backend ─────────────────────────────────────────────────────── const askQuestion = async (q) => { if (!q || !q.trim()) return; const trimmed = q.trim(); setComposerValue(""); setMode("thinking"); // Create a new thread if there isn't one, else append to active let threadId = activeThreadId; let priorQs = []; let priorRs = []; if (!threadId) { threadId = createThread(trimmed); } else { const th = threads.find(x => x.id === threadId); priorQs = th?.questions || []; priorRs = th?.responses || []; upsertThread(threadId, (th) => ({ questions: [...th.questions, trimmed], responses: [...th.responses, null], errors: [...th.errors, null], })); } scrollToBottom(); // Build history from prior pairs in this thread const history = []; priorQs.forEach((qq, i) => { history.push({ role: "user", content: qq }); const r = priorRs[i]; if (r && r.answer) history.push({ role: "assistant", content: r.answer }); }); try { const data = await window.OmniAPI.ask({ query: trimmed, history, mode: chatMode, complexity: speed, }); upsertThread(threadId, (th) => { const responses = [...th.responses]; responses[responses.length - 1] = data; return { responses, agent: data.routed_to || th.agent }; }); setMode("answered"); scrollToBottom(); } catch (e) { console.error("OmniAPI.ask failed:", e); upsertThread(threadId, (th) => { const errs = [...th.errors]; errs[errs.length - 1] = e.message || String(e); return { errors: errs }; }); setMode("answered"); scrollToBottom(); } }; // ─── Topbar derived props ────────────────────────────────────────────── const topbarTitle = mode === "empty" ? "New thread" : (activeThread?.title || threadTitle(questions[0])); const latestRouted = responses.length > 0 ? responses[responses.length - 1]?.routed_to : null; return ( <>
{(sbOpen && isMobile) &&
setSbOpen(false)}/>} {sbOpen && ( setSbOpen(false)} onSelect={selectThread} onNew={() => { newThread(); if (isMobile) setSbOpen(false); }} onDelete={deleteThread} onOpenSaved={() => { setModal("saved"); if (isMobile) setSbOpen(false); }} onOpenSettings={() => { setModal("settings"); if (isMobile) setSbOpen(false); }} onOpenSources={() => { setModal("sources"); if (isMobile) setSbOpen(false); }} /> )}
{mode === "empty" && ( askQuestion(q)} onOpenSources={() => setModal("sources")}/> )} {mode !== "empty" && (
{questions.map((q, i) => { const isLast = i === questions.length - 1; const realResponse = responses[i]; const error = errors[i]; return (
{q}
{isLast && mode === "thinking" && } {(!isLast || mode === "answered") && error && (
Couldn't reach the backend.
{error}
)} {(!isLast || mode === "answered") && !error && ( setModal("trail")} realResponse={realResponse} /> )}
); })}
)}
askQuestion(composerValue.trim())} mode={chatMode} setMode={setChatMode} speed={speed} setSpeed={setSpeed} onOpenSources={() => setModal("sources")} sourcesActive={activeSources.length} />
{modal === "trail" && setModal(null)}/>} {modal === "sources" && setModal(null)} active={activeSources} onToggle={(id) => setActiveSources(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])} autoRoute={autoRoute} setAutoRoute={setAutoRoute}/>} {modal === "saved" && setModal(null)} onSchedule={() => setModal("schedule")}/>} {modal === "schedule" && setModal(null)}/>} {modal === "settings" && setModal(null)} tweaks={t} setTweak={setTweak}/>} ); } ReactDOM.createRoot(document.getElementById("root")).render();