// 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();