// Main App: composes all panels, owns shared state, wires shortcuts
const { useState, useEffect, useCallback, useMemo } = React;
// Default hypothesis shape — every web has one. Binary, event-based, with a
// resolveBy date and clear yes/no scenarios. Webs without one get this stub
// when the user opens them.
const DEFAULT_HYPOTHESIS = () => ({
question: '',
resolveBy: '',
yesScenario: '',
noScenario: '',
status: 'open', // open | yes | no | undecided
});
// Migrate older saved webs (which had no `hypothesis`) into the new shape so
// hooks reading `web.hypothesis` don't crash on legacy state.
function migrateWeb(w) {
if (!w) return w;
return { ...w, hypothesis: { ...DEFAULT_HYPOTHESIS(), ...(w.hypothesis || {}) } };
}
// Editable banner sitting above the canvas. Shows the active web's hypothesis
// (the binary, event-based question the entire web exists to answer) plus
// the yes/no scenarios and resolveBy date. All fields click-to-edit.
function HypothesisBanner({ hypothesis, setHypothesis, onInvent, inventing }) {
// Auto-open the seed textarea when there's no hypothesis yet, so a new
// user lands on the "type your idea" prompt instead of an empty banner
// they have to figure out how to interact with.
const _initialEmpty = !((hypothesis || {}).question || '').trim();
const [seedOpen, setSeedOpen] = useState(_initialEmpty);
const [seedText, setSeedText] = useState(() => {
// Restore in-flight seed across page reloads (e.g. user clicks Invent,
// it fails, they refresh — without this, their typed text is gone).
try { return localStorage.getItem('tibeb.seedDraft') || ''; } catch { return ''; }
});
const seedRef = useRef(null);
useEffect(() => {
if (seedOpen) setTimeout(() => seedRef.current && seedRef.current.focus(), 50);
}, [seedOpen]);
// Persist the seed draft on every keystroke so refreshes don't blow it away.
useEffect(() => {
try { localStorage.setItem('tibeb.seedDraft', seedText); } catch {}
}, [seedText]);
// Watch for hypothesis transitions: when invent succeeds, the parent's
// hypothesis prop gets a real question. THAT's the safe moment to clear
// the seed draft. If invent fails, hypothesis stays empty and the user's
// text remains so they can edit + retry.
const prevHadHypothesisRef = useRef(!_initialEmpty);
useEffect(() => {
const hasNow = !!((hypothesis || {}).question || '').trim();
if (hasNow && !prevHadHypothesisRef.current && seedText) {
// Hypothesis just became non-empty — the prior submit must have landed.
setSeedText('');
try { localStorage.removeItem('tibeb.seedDraft'); } catch {}
}
prevHadHypothesisRef.current = hasNow;
}, [hypothesis && hypothesis.question]);
// If inventing flips false WITHOUT a hypothesis appearing, the request
// failed. Re-open the seed pane so the user sees their text + a path to
// retry instead of staring at an empty banner.
const prevInventingRef = useRef(inventing);
useEffect(() => {
const wasInventing = prevInventingRef.current;
const stillEmpty = !((hypothesis || {}).question || '').trim();
if (wasInventing && !inventing && stillEmpty && seedText) {
setSeedOpen(true);
}
prevInventingRef.current = inventing;
}, [inventing]);
const submitSeed = () => {
const t = seedText.trim();
if (!t) return;
onInvent({ seed: t });
// CRITICAL: keep seedText populated until the hypothesis prop confirms
// the invent landed. If invent fails, the user's text stays in the box
// so they can edit it and retry instead of losing it forever.
setSeedOpen(false);
};
// Minimize toggle — same pattern as the LeftRail / chat collapses.
// Persists across reloads so the user's preference sticks.
const [collapsed, setCollapsed] = useState(() => {
try { return localStorage.getItem('jarvis.hypoCollapsed') === '1'; } catch { return false; }
});
useEffect(() => {
try { localStorage.setItem('jarvis.hypoCollapsed', collapsed ? '1' : '0'); } catch {}
}, [collapsed]);
const h = hypothesis || DEFAULT_HYPOTHESIS();
const empty = !(h.question || '').trim();
const statusColor = {
open: 'var(--text-2)',
yes: '#00d4aa',
no: '#ff4466',
leaning_yes: '#00d4aa',
leaning_no: '#ff4466',
undecided: '#d4b800',
}[h.status || 'open'];
const statusLabel = {
open: 'open',
yes: 'resolved · YES',
no: 'resolved · NO',
leaning_yes: 'leaning YES',
leaning_no: 'leaning NO',
undecided: 'undecided',
}[h.status || 'open'] || (h.status || 'open');
// Probability + source split (set after a Mad Max sweep finishes)
const hasProb = typeof h.probability === 'number';
const sup = h.supportingCount || 0;
const con = h.contradictingCount || 0;
return (
Type a rough thought. Tibeb sharpens it into a research-grade hypothesis,
runs Mad Max for sources, and offers a sized paper trade — one click, end-to-end.
{[
'AI compute is overrated — find one mispriced semi short',
'Hedge my tech book through earnings season',
'What\'s the next leg of the data-center power story?',
'Is there a misread on inventory cyclicality in DRAM?',
].map((s, i) => (
))}
)}
)}
{!collapsed && (
<>
setHypothesis({ question: v.trim() })}
placeholder="Click Invent or write a binary, event-based question — e.g. 'Will NVDA Q1 FY26 EPS beat consensus by >10% on May 21?'"
label="Hypothesis question"
/>
If YES
setHypothesis({ yesScenario: v })}
placeholder="What does the world look like? What's the trade?"
label="Yes scenario"
multiline
/>
If NO
setHypothesis({ noScenario: v })}
placeholder="What does the world look like? What's the trade?"
label="No scenario"
multiline
/>
Resolves
setHypothesis({ resolveBy: v })}
placeholder="YYYY-MM-DD or event"
label="Resolve by"
/>
>
)}
);
}
window.HypothesisBanner = HypothesisBanner;
// Phase 6 R · #8 — Regime chip in the topbar. Persistent always-visible
// summary of the current macro regime (read from
// brain/3-Hedging & Macro/Current Regime/Regime Monitor.md). Auto-polls
// every 5 minutes so a regime update during the day shows up promptly.
// Color tone:
// risk-on → green
// late-cycle → amber
// risk-off → red
// unknown → neutral grey
// Stale-warning dot appears when the file hasn't been updated in 30+
// days (matches the Hedging Specialist's date-check rule).
function RegimeChip() {
const [data, setData] = useState(null);
const [err, setErr] = useState(null);
const load = useCallback(async () => {
try {
const r = await fetch('/api/regime/current');
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const d = await r.json();
setData(d); setErr(null);
} catch (e) { setErr(String(e.message || e)); }
}, []);
useEffect(() => {
load();
const t = setInterval(load, 5 * 60 * 1000);
return () => clearInterval(t);
}, [load]);
if (!data || !data.available) return null;
const tone = data.tone || 'unknown';
const colorMap = {
'risk-on': { bg: 'rgba(95,179,124,0.15)', fg: '#5fb37c', bord: 'rgba(95,179,124,0.4)' },
'late-cycle': { bg: 'rgba(217,168,95,0.15)', fg: '#d9a85f', bord: 'rgba(217,168,95,0.4)' },
'risk-off': { bg: 'rgba(217,106,106,0.15)', fg: '#d96a6a', bord: 'rgba(217,106,106,0.4)' },
'unknown': { bg: 'rgba(120,120,140,0.10)', fg: 'var(--text-dim)', bord: 'var(--line)' },
};
const c = colorMap[tone];
const stale = !!data.stale;
// Compact pill — just the tag. Full one-liner in tooltip on hover.
// Earlier version included ~90 chars of read text inline which was
// pushing Mad Max + Inventing buttons off-screen on standard viewports.
return (
30d' : ''}\n\nClick → open Regime Monitor in Brain tab.`}
onClick={() => {
try {
localStorage.setItem('jarvis.brain.openPath', '3-Hedging & Macro/Current Regime/Regime Monitor.md');
localStorage.setItem('jarvis.activeTab', 'brain');
location.reload();
} catch {}
}}
style={{
marginLeft: 4, padding: '2px 8px', fontSize: 10, lineHeight: '14px',
fontFamily: "'JetBrains Mono', monospace",
borderRadius: 3, cursor: 'pointer',
background: c.bg, color: c.fg, border: `1px solid ${c.bord}`,
display: 'inline-flex', alignItems: 'center', gap: 4,
flexShrink: 0,
}}
>
{tone}
{stale && ⚠}
);
}
window.RegimeChip = RegimeChip;
// AutonomyPill — the ONE BUTTON for full 24/7 autonomy. Sits prominently
// in the topbar right after the brand mark.
//
// Two states:
// OFF (mode != "autonomous"): gray pill, label "AUTONOMY · OFF". The 5
// cron schedulers (morning_brief, agent_cron, self_review, event,
// twitter) tick but no-op via _cron_autonomy_active() in the
// backend. AutonomyExecutor also waits for verdicts to manually-
// accept as TradeRecs.
// ON (mode == "autonomous"): green pill with pulsing dot, label
// "AUTONOMOUS · 24/7". Schedulers fire on their cron schedule;
// TradeVerdictMemos auto-execute (subject to the 9 hard caps in
// backend/tibeb/autonomy.py).
//
// Click → POST /api/autonomy/mode to flip between "autonomous" and
// "copilot". Confirmation modal on the ON→OFF transition would be nice
// later, but for v1 a direct toggle wins the speed contest the user
// asked for ("ONE BUTTON").
//
// Polls /api/autonomy/status every 30s so multi-tab / multi-client
// sessions stay in sync. No optimistic update — we wait for the POST
// to return before flipping the pill so a server-side reject doesn't
// leave the UI lying.
function AutonomyPill() {
const [mode, setMode] = useState('copilot');
const [saving, setSaving] = useState(false);
const [err, setErr] = useState(null);
const load = useCallback(async () => {
try {
const r = await fetch('/api/autonomy/status');
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const d = await r.json();
setMode(String(d.mode || 'copilot'));
setErr(null);
} catch (e) { setErr(String(e.message || e)); }
}, []);
useEffect(() => {
load();
const t = setInterval(load, 30 * 1000);
return () => clearInterval(t);
}, [load]);
const toggle = useCallback(async () => {
if (saving) return;
const next = mode === 'autonomous' ? 'copilot' : 'autonomous';
// Confirm on ON→OFF flip when transitioning from autonomous. The
// OFF→ON path is one tap (user just clicked the big green button)
// because spinning up autonomy is the whole point of the button.
if (mode === 'autonomous' && !window.confirm(
'Turn autonomy OFF? The 5 cron schedulers will stop firing on '
+ 'their next tick and verdicts will queue as TradeRecs for '
+ 'manual review instead of auto-executing.'
)) return;
setSaving(true);
try {
const r = await fetch('/api/autonomy/mode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: next }),
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const d = await r.json();
setMode(String(d.mode || next));
setErr(null);
} catch (e) {
setErr(String(e.message || e));
} finally {
setSaving(false);
}
}, [mode, saving]);
const isOn = mode === 'autonomous';
const tooltip = isOn
? 'Autonomy ON · 24/7. Personas fire on their cron schedule; '
+ 'TradeVerdictMemos auto-execute subject to the 9 risk caps. '
+ 'Click to turn OFF.'
: `Autonomy OFF (mode: ${mode}). Cron schedulers tick but no-op; `
+ 'verdicts queue as TradeRecs for manual review. Click to '
+ 'turn ON full 24/7 autonomy.';
return (
);
}
window.AutonomyPill = AutonomyPill;
// Stock Documents panel — pulls SEC EDGAR filings (10-K, 10-Q, 8-K, etc) for
// every ticker on the canvas with REAL working links. Phase 1 of the research
// corpus build (see docs/research-corpus-plan.md). Earnings transcripts and
// shareholder letters are tracked there as Phase 2 work.
function StockDocumentsPanel({ tickers }) {
const [openTicker, setOpenTicker] = useState(null);
const [docsByTicker, setDocsByTicker] = useState({});
const [storedByTicker, setStoredByTicker] = useState({});
const [loadingTicker, setLoadingTicker] = useState(null);
const [pullingTicker, setPullingTicker] = useState(null);
const [errByTicker, setErrByTicker] = useState({});
const [pullStatusByTicker, setPullStatusByTicker] = useState({});
// Collapse state — persisted across sessions so the user's choice
// sticks. Defaults to expanded so first-time users see the panel.
const [collapsed, setCollapsed] = useState(() => {
try { return localStorage.getItem('jarvis.stockDocsCollapsed') === '1'; }
catch { return false; }
});
useEffect(() => {
try { localStorage.setItem('jarvis.stockDocsCollapsed', collapsed ? '1' : '0'); }
catch {}
}, [collapsed]);
const loadDocs = async (ticker) => {
if (docsByTicker[ticker] || loadingTicker === ticker) return;
setLoadingTicker(ticker);
setErrByTicker(e => ({ ...e, [ticker]: null }));
try {
// Two parallel fetches: live SEC EDGAR listing + the per-ticker corpus
// already stored in brain/4-Stocks/Documents/{TICKER}/. Stored docs
// mean the chat can ground answers in them via search_stock_documents.
const [liveR, storedR] = await Promise.all([
fetch(`/api/research/edgar-filings?ticker=${encodeURIComponent(ticker)}&limit=12`),
fetch(`/api/research/list-stock-documents?ticker=${encodeURIComponent(ticker)}`),
]);
if (!liveR.ok) {
const t = await liveR.text().catch(() => '');
throw new Error(`HTTP ${liveR.status} ${t.slice(0, 80)}`);
}
const live = await liveR.json();
setDocsByTicker(d => ({ ...d, [ticker]: live }));
if (storedR.ok) {
const stored = await storedR.json();
setStoredByTicker(s => ({ ...s, [ticker]: stored }));
}
} catch (e) {
setErrByTicker(err => ({ ...err, [ticker]: e.message || 'fetch failed' }));
} finally {
setLoadingTicker(t => t === ticker ? null : t);
}
};
const pullCorpus = async (ticker) => {
if (pullingTicker === ticker) return;
setPullingTicker(ticker);
setPullStatusByTicker(s => ({ ...s, [ticker]: { phase: 'pulling' } }));
// Phase 3.A — pull both SEC filings AND earnings transcripts in parallel.
// Each is independent: filings always work (SEC is free + public),
// transcripts may 503 silently if FMP_API_KEY isn't set on the server.
try {
const [filingsRes, transcriptsRes] = await Promise.allSettled([
fetch('/api/research/pull-corpus', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ticker, forms: ['10-K', '10-Q', '8-K'], per_form: 3 }),
}),
fetch('/api/research/pull-transcripts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ticker, count: 4 }),
}),
]);
// Filings — required to succeed
if (filingsRes.status === 'rejected' || !filingsRes.value?.ok) {
const txt = filingsRes.status === 'fulfilled'
? await filingsRes.value.text().catch(() => '')
: String(filingsRes.reason || '');
throw new Error(`Filings: ${txt.slice(0, 80) || filingsRes.reason}`);
}
const filings = await filingsRes.value.json();
// Transcripts — best-effort, capture status separately
let transcripts = { saved: [], errors: [], skipped_existing: 0, configured: true };
if (transcriptsRes.status === 'fulfilled') {
if (transcriptsRes.value.ok) {
transcripts = await transcriptsRes.value.json();
} else if (transcriptsRes.value.status === 503) {
transcripts = { saved: [], errors: [], skipped_existing: 0, configured: false };
} else {
const t = await transcriptsRes.value.text().catch(() => '');
transcripts = { saved: [], errors: [{ error: `HTTP ${transcriptsRes.value.status} ${t.slice(0, 60)}` }], skipped_existing: 0, configured: true };
}
}
setPullStatusByTicker(s => ({
...s,
[ticker]: {
phase: 'done',
saved: [...(filings.saved || []), ...(transcripts.saved || [])],
skipped: (filings.skipped_existing || 0) + (transcripts.skipped_existing || 0),
errors: [...(filings.errors || []), ...(transcripts.errors || [])],
transcriptsConfigured: transcripts.configured !== false,
},
}));
// Refresh stored list
try {
const sr = await fetch(`/api/research/list-stock-documents?ticker=${encodeURIComponent(ticker)}`);
if (sr.ok) {
const stored = await sr.json();
setStoredByTicker(s => ({ ...s, [ticker]: stored }));
}
} catch {}
} catch (e) {
setPullStatusByTicker(s => ({ ...s, [ticker]: { phase: 'error', error: e.message || 'pull failed' } }));
} finally {
setPullingTicker(t => t === ticker ? null : t);
}
};
const toggleTicker = (t) => {
if (openTicker === t) { setOpenTicker(null); return; }
setOpenTicker(t);
loadDocs(t);
};
if (!tickers || tickers.length === 0) return null;
return (
setCollapsed(c => !c)}
style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 12 }}
title={collapsed ? 'Expand SEC filings panel' : 'Collapse to see the canvas behind it'}
>
SEC Filings · live from EDGAR
{!collapsed &&
Documents for tickers on canvas
}
{!collapsed &&
Real working links. Click a row to open the filing on sec.gov · 10-K · 10-Q · 8-K · proxy & more.
);
}
window.StockDocumentsPanel = StockDocumentsPanel;
function App() {
const seed = window.TIBEB_SEED;
// Top-level tab — 'research' (the existing Mad Max workspace) or 'brain'
// (the new Obsidian-style editor over brain/). Persisted across reloads.
const [activeTab, setActiveTab] = useState(() => {
try { return localStorage.getItem('jarvis.activeTab') || 'research'; }
catch { return 'research'; }
});
useEffect(() => {
try { localStorage.setItem('jarvis.activeTab', activeTab); } catch {}
}, [activeTab]);
// Tweakable defaults
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"persona": "analyst",
"riskTolerance": 50,
"horizon": "swing",
"jargon": "plain",
"accent": "#00d4aa",
"showSentiment": true,
"liveDrift": true,
"useAI": true
}/*EDITMODE-END*/;
const [tweaks, setTweak] = useTweaks(TWEAK_DEFAULTS);
// Apply accent live
useEffect(() => {
document.documentElement.style.setProperty('--accent', tweaks.accent);
}, [tweaks.accent]);
// Multiple webs — each is its own thought graph
const [webs, setWebs] = useState(() => {
try {
const saved = localStorage.getItem('jarvis.webs.v2');
if (saved) return JSON.parse(saved).map(migrateWeb);
} catch (_) {}
return seed.webs.map(w => migrateWeb({ ...w }));
});
const [activeWebId, setActiveWebId] = useState(() => {
try { return localStorage.getItem('jarvis.activeWeb.v2') || seed.webs[0].id; }
catch(_) { return seed.webs[0].id; }
});
const activeWeb = webs.find(w => w.id === activeWebId) || webs[0];
// _dirty marks webs that have edits not yet pushed to the server.
// Hydrate refuses to overwrite a dirty web with a stale server snapshot,
// and the save status chip shows "unsaved changes" until performWebSave
// succeeds (which clears _dirty).
const setNodes = (updater) => setWebs(ws => ws.map(w => w.id === activeWebId
? { ...w, nodes: typeof updater === 'function' ? updater(w.nodes) : updater,
lastEdit: 'just now', _dirty: true }
: w));
const setEdges = (updater) => setWebs(ws => ws.map(w => w.id === activeWebId
? { ...w, edges: typeof updater === 'function' ? updater(w.edges) : updater,
lastEdit: 'just now', _dirty: true }
: w));
// Hypothesis question IS the web's name AND the central thesis node on the
// canvas — they're ONE concept stored in three places that stay synced.
// - web.name → the title shown in the rail / canvas-bar
// - web.hypothesis → the structured form (question, scenarios, resolveBy, status)
// - the t_hypo node → a flagged thesis node at the center of the graph that
// other nodes connect to. It's created on first invent
// (or first manual question entry) and updated when the
// question/scenarios change.
const HYPO_NODE_ID = 't_hypo';
const setHypothesis = (patch) => setWebs(ws => ws.map(w => {
if (w.id !== activeWebId) return w;
const nextH = { ...DEFAULT_HYPOTHESIS(), ...(w.hypothesis || {}), ...patch };
const question = (nextH.question || '').trim();
const nameUpdate = question ? { name: question } : {};
// Upsert the central hypothesis thesis node so the graph reflects the
// question. Only create it once the user has a non-empty question.
let nextNodes = w.nodes || [];
const existing = nextNodes.find(n => n.id === HYPO_NODE_ID || n.isHypothesis);
if (question) {
const summary = [
nextH.yesScenario && `IF YES — ${nextH.yesScenario}`,
nextH.noScenario && `IF NO — ${nextH.noScenario}`,
].filter(Boolean).join('\n');
if (existing) {
nextNodes = nextNodes.map(n => (n.id === existing.id)
? { ...n, type: 'thesis', label: question, summary, isHypothesis: true,
hypothesisStatus: nextH.status, hypothesisResolveBy: nextH.resolveBy }
: n
);
} else {
nextNodes = [
{ id: HYPO_NODE_ID, type: 'thesis', label: question, summary,
isHypothesis: true, hypothesisStatus: nextH.status,
hypothesisResolveBy: nextH.resolveBy,
x: 0, y: 0, claims: [] },
...nextNodes,
];
}
}
return { ...w, hypothesis: nextH, nodes: nextNodes, ...nameUpdate, lastEdit: 'just now', _dirty: true };
}));
const nodes = activeWeb.nodes;
const edges = activeWeb.edges;
// ─── Phase 2C.3 — server-side persistence ──────────────────────────
// localStorage stays as the always-on local cache. When the user is
// logged in (auth_configured + non-local), we ALSO hydrate from the
// server on boot and PUT updates back. Both layers are eventually
// consistent: localStorage gives instant load, the server keeps your
// webs across devices.
// Phase 6 R · webs-recovery — persist server-side whenever the
// server has Supabase auth configured, even for LOCAL_DEFAULT
// (the soft-fallback identity used when JWT verification fails).
// Backend's list_webs treats local-default as 'see everything' so
// the user's existing webs come back. Was previously gated on
// !is_local which silently disabled persistence for soft-fallback
// users → seed webs replaced their real history.
const isPersistedUser = !!(currentUser && currentUser.auth_configured);
const hydratedFromServerRef = useRef(false);
// Boot hydrate. After currentUser arrives, fetch /api/webs once. If the
// server has any webs, overlay them onto the in-memory state (server
// wins on conflict by id; new local-only webs are kept and pushed up
// on the next save).
//
// Phase 4.A — fresh-user onboarding: if the user is logged in AND has no
// server webs AND we're showing the seed.js demos (NVDA capex, Fed cuts —
// these are Nathaniel's demo theses, not the new user's), replace them
// with a single empty starter web. Local-default mode (single-user dev)
// keeps the seeds for the original demo experience.
useEffect(() => {
if (!isPersistedUser || hydratedFromServerRef.current) return;
hydratedFromServerRef.current = true;
(async () => {
try {
const r = await fetch('/api/webs');
if (!r.ok) return;
const j = await r.json();
const serverList = j.webs || [];
if (!serverList.length) {
// First-time invited user. Wipe the demo seed in localStorage and
// start them on a blank web. Their first Invent / Seed creates
// their actual content.
const seedWebIds = new Set((window.TIBEB_SEED?.webs || []).map(w => w.id));
const allLocalAreSeeds = webs.every(w => seedWebIds.has(w.id));
if (allLocalAreSeeds) {
const newId = `web-${Date.now().toString(36)}`;
const starter = migrateWeb({
id: newId,
name: 'Untitled hypothesis · click Invent to start',
tagline: '',
nodes: [],
edges: [],
hypothesis: DEFAULT_HYPOTHESIS(),
news: [],
});
setWebs([starter]);
setActiveWebId(newId);
try {
localStorage.removeItem('jarvis.webs.v2');
localStorage.removeItem('jarvis.activeWeb.v2');
// Also nuke any seed-web chat threads (those reference NVDA/Fed
// discussions the new user has nothing to do with).
localStorage.removeItem('jarvis.chat.v3');
} catch {}
// The next save will push the starter web to the server.
}
return;
}
// Pull full state for each in parallel — small N (≤20 typically)
const fulls = await Promise.all(serverList.map(async w => {
try {
const fr = await fetch(`/api/webs/${encodeURIComponent(w.id)}`);
if (!fr.ok) return null;
return await fr.json();
} catch { return null; }
}));
const valid = fulls.filter(Boolean).map(w => migrateWeb({
id: w.id,
name: w.name,
tagline: w.tagline,
nodes: w.nodes || [],
edges: w.edges || [],
hypothesis: w.hypothesis,
tradeProposal: w.tradeProposal,
news: w.news || [],
}));
if (!valid.length) return;
setWebs(prev => {
// Merge with conflict resolution that PROTECTS UNSAVED LOCAL WORK.
//
// For each id, choose between server's version and local's version:
// - Local is _dirty (edited but never saved successfully) → keep local.
// This was the bug that lost a user's research session: hydrate
// blindly overwrote unsaved-but-edited webs with a stale server
// snapshot, then localStorage saved over the local copy too.
// - Otherwise → server wins (it's the canonical source across devices).
//
// Pure-local webs (no server twin) always kept. Pure-server webs
// (no local twin) always kept.
const byId = new Map();
const serverById = new Map(valid.map(w => [w.id, w]));
const localById = new Map(prev.map(w => [w.id, w]));
const allIds = new Set([...serverById.keys(), ...localById.keys()]);
let protectedCount = 0;
for (const id of allIds) {
const s = serverById.get(id);
const l = localById.get(id);
if (l && s && l._dirty) {
byId.set(id, l);
protectedCount++;
} else if (s) {
byId.set(id, s);
} else if (l) {
byId.set(id, l);
}
}
if (protectedCount) {
console.warn(`[webs hydrate] protected ${protectedCount} dirty local web(s) from server overwrite`);
}
return Array.from(byId.values());
});
// Switch to the most-recently-updated server web on first hydrate
if (serverList[0]?.id) setActiveWebId(serverList[0].id);
} catch (e) {
// Non-fatal — localStorage cache still works
console.warn('[webs hydrate] failed:', e);
}
})();
}, [isPersistedUser, currentUser]);
// Debounced save: 800ms after the last edit, push the active web's full
// state to the server. Only runs for persisted users; local-default
// mode stays purely localStorage.
//
// Save status is surfaced in the topbar so the user always knows
// whether their work is on the server. Goes through three states:
// idle — no recent edit
// saving — PUT in flight
// saved — last save succeeded (timestamp tracked per-web)
// failed — last save errored or returned !ok; persists until next try
// After a 'saved' state, fades back to idle after 2.5s.
const [webSaveStatus, setWebSaveStatus] = useState('idle');
const [webSaveError, setWebSaveError] = useState(null);
const [lastSavedAt, setLastSavedAt] = useState(null);
const saveTimerRef = useRef(null);
const performWebSave = useCallback(async (w) => {
if (!w) return false;
setWebSaveStatus('saving'); setWebSaveError(null);
try {
const r = await fetch(`/api/webs/${encodeURIComponent(w.id)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: w.name,
tagline: w.tagline,
nodes: w.nodes,
edges: w.edges,
hypothesis: w.hypothesis,
tradeProposal: w.tradeProposal,
selected: undefined,
focused: undefined,
news: w.news,
}),
});
if (!r.ok) {
const txt = await r.text().catch(() => '');
throw new Error(`HTTP ${r.status} ${txt.slice(0, 120)}`);
}
const now = new Date().toISOString();
setWebSaveStatus('saved');
setLastSavedAt(now);
// Mark this web as cleanly synced so the hydrate guard knows it's
// safe to be overwritten by a NEWER server snapshot.
setWebs(ws => ws.map(x => x.id === w.id
? { ...x, _serverSavedAt: now, _dirty: false }
: x));
// Fade back to idle after a moment so the chip isn't permanently green
setTimeout(() => setWebSaveStatus(s => s === 'saved' ? 'idle' : s), 2500);
return true;
} catch (e) {
console.warn('[webs save] failed:', e);
setWebSaveStatus('failed');
setWebSaveError(String(e.message || e));
return false;
}
}, []);
useEffect(() => {
if (!isPersistedUser) return;
const w = webs.find(x => x.id === activeWebId);
if (!w) return;
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(() => { performWebSave(w); }, 800);
return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current); };
}, [webs, activeWebId, isPersistedUser, performWebSave]);
// Manual force-save handler — exposed on the save status chip so the
// user can retry after a failure without waiting for the next edit.
const forceSaveActive = useCallback(() => {
const w = webs.find(x => x.id === activeWebId);
if (w) performWebSave(w);
}, [webs, activeWebId, performWebSave]);
// Expose globally so any panel (LeftRail, brain hand-off, etc.) can
// trigger a manual save.
window.tibebForceSaveWeb = forceSaveActive;
// Persist webs (always to localStorage as a local cache)
useEffect(() => {
try { localStorage.setItem('jarvis.webs.v2', JSON.stringify(webs)); } catch(_) {}
}, [webs]);
useEffect(() => {
try { localStorage.setItem('jarvis.activeWeb.v2', activeWebId); } catch(_) {}
}, [activeWebId]);
// Live ticker hydration — fetch real prices + 5-day spark from Massive on
// web switch and every 60s. The seed.js values were placeholders; this is
// what makes the prices accurate.
// Uses a ref so the interval always sees the latest nodes (Mad Max may
// have added new tickers since the effect mounted).
const websRef = React.useRef(webs);
websRef.current = webs;
useEffect(() => {
if (!window.hydrateQuotes) return; // lib/marketdata.js may not have loaded
let cancelled = false;
const refresh = async () => {
if (cancelled) return;
const w = (websRef.current || []).find(x => x.id === activeWebId);
if (!w || !w.nodes || !w.nodes.length) return;
await window.hydrateQuotes(w.nodes); // mutates ticker nodes in place
if (cancelled) return;
// Force React to re-render with the now-updated prices/spark by handing
// it a new nodes array.
setWebs(ws => ws.map(x => x.id === activeWebId
? { ...x, nodes: x.nodes.map(n => ({ ...n })) }
: x
));
};
refresh();
const id = setInterval(refresh, 60_000);
return () => { cancelled = true; clearInterval(id); };
}, [activeWebId]);
// Auto-persist the active web into Investing Brain/7-Tibeb Captures/Webs/.md
// — debounced 1.5s so rapid edits don't hammer the backend.
useEffect(() => {
if (!activeWeb) return;
const t = setTimeout(() => {
const base = (window.TIBEB_PROXY && window.TIBEB_PROXY.baseUrl) || '';
fetch(`${base}/api/web/persist`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: activeWeb.id,
name: activeWeb.name,
tagline: activeWeb.tagline || '',
nodes: activeWeb.nodes || [],
edges: activeWeb.edges || [],
}),
}).catch(() => {/* offline / no backend — localStorage still has it */});
}, 1500);
return () => clearTimeout(t);
}, [activeWeb && activeWeb.nodes, activeWeb && activeWeb.edges, activeWeb && activeWeb.name, activeWeb && activeWeb.tagline]);
const [selected, setSelected] = useState([]);
const [focused, setFocused] = useState('t1');
// On phones/small tablets the canvas needs the full screen — default both
// rails collapsed so the graph is usable. The user can still tap the spine
// mini-button to expand either one (which goes full-screen on mobile).
const _isMobile = typeof window !== 'undefined' && window.innerWidth < 900;
const [collapsed, setCollapsed] = useState(_isMobile);
const [chatCollapsed, setChatCollapsed] = useState(() => {
try {
const stored = localStorage.getItem('jarvis.chatCollapsed');
if (stored != null) return stored === '1';
} catch {}
return _isMobile;
});
useEffect(() => {
try { localStorage.setItem('jarvis.chatCollapsed', chatCollapsed ? '1' : '0'); } catch {}
}, [chatCollapsed]);
const [quickOpen, setQuickOpen] = useState(false);
const [contextMenu, setContextMenu] = useState(null);
const [tradeAccepted, setTradeAccepted] = useState(false);
const [tradeDismissed, setTradeDismissed] = useState(false);
const [toast, setToast] = useState(null);
// Mad Max research state — Phase 6 S: per-web Map so multiple
// webs can have concurrent runs. Each entry: { running, log,
// progress, result, stopRef }. The overlay reads/writes the
// ACTIVE web's slot; switching webs surfaces that web's MM state
// (or nothing if that web has no run going).
const [mmOpen, setMmOpen] = useState(false);
const [mmByWeb, setMmByWeb] = useState({});
const mmStopRefs = React.useRef({}); // webId → {current: bool}
// Helpers for the active web's slot
const getMm = useCallback((webId) => mmByWeb[webId] || {
running: false, log: [], progress: { iter: 0, max: 24, nodeCount: 0, newNodes: 0 },
result: null,
}, [mmByWeb]);
const setMm = useCallback((webId, patch) => {
setMmByWeb(prev => {
const cur = prev[webId] || { running: false, log: [], progress: { iter: 0, max: 24, nodeCount: 0, newNodes: 0 }, result: null };
const next = typeof patch === 'function' ? patch(cur) : { ...cur, ...patch };
return { ...prev, [webId]: next };
});
}, []);
// Backwards-compat shims so existing call sites still work
const activeMm = mmByWeb[activeWebId] || { running: false, log: [], progress: { iter: 0, max: 24, nodeCount: 0, newNodes: 0 }, result: null };
const mmRunning = activeMm.running;
const setMmRunning = (val) => setMm(activeWebId, { running: !!val });
// Count of webs with running MM — for the topbar pill
const mmRunningCount = Object.values(mmByWeb).filter(s => s.running).length;
// Click-popover state: list every running web with iter/max + latest log line
const [mmPopoverOpen, setMmPopoverOpen] = useState(false);
// Auto-close popover when no webs are running
React.useEffect(() => {
if (mmRunningCount === 0) setMmPopoverOpen(false);
}, [mmRunningCount]);
const [teamOpen, setTeamOpen] = useState(false);
const [agentsOpen, setAgentsOpen] = useState(false);
const [librarySearchOpen, setLibrarySearchOpen] = useState(false);
const [watchlistOpen, setWatchlistOpen] = useState(false);
const [healthOpen, setHealthOpen] = useState(false);
const [autonomyOpen, setAutonomyOpen] = useState(false);
const [memosOpen, setMemosOpen] = useState(false);
const [liveFeedOpen, setLiveFeedOpen] = useState(false);
const [asymmOpen, setAsymmOpen] = useState(false);
const [testsOpen, setTestsOpen] = useState(false);
const [toolsMenuOpen, setToolsMenuOpen] = useState(false);
// Pro/Focus toolbar variant deleted 2026-05-18 — the 10 "pro" buttons
// (Export/Team/Brief/Agents/Watchlist/Health/Autonomy/Memos/Asymm/Tests)
// were always duplicated inside the Tools ▾ dropdown right next to them,
// so pro mode added visual clutter without adding capability. Single home
// for secondary actions = the dropdown. Stale localStorage key
// `jarvis.toolbarVariant` is left in place (inert, ~5 bytes).
// Phase 14 — ticker context drilldown. Any chip in any panel can fire
// window.openTickerContext("NVDA") to surface team verdicts + signals
// + position info for that name. Listens once at the app root.
const [activeTicker, setActiveTicker] = useState(null);
React.useEffect(() => {
const onTickerEvent = (e) => setActiveTicker(e?.detail?.ticker || null);
window.addEventListener('tibeb:ticker', onTickerEvent);
return () => window.removeEventListener('tibeb:ticker', onTickerEvent);
}, []);
// Phase 2C.1 — current user identity. The boot gate at index.html fetches
// /api/me and stashes the result on window.TIBEB_USER. We poll a few times
// in case the gate's fetch is still in flight when this component mounts.
const [currentUser, setCurrentUser] = useState(() => window.TIBEB_USER || null);
useEffect(() => {
if (currentUser) return;
let tries = 0;
const id = setInterval(() => {
if (window.TIBEB_USER) {
setCurrentUser(window.TIBEB_USER);
clearInterval(id);
} else if (++tries > 30) {
clearInterval(id);
}
}, 100);
return () => clearInterval(id);
}, [currentUser]);
// Phase 12 — backfill state for the "Verify sources" button
const [verifying, setVerifying] = useState(false);
const [verifyProgress, setVerifyProgress] = useState({ done: 0, total: 0 });
// Backfill: run /api/madmax/verify-source on every unverified Mad Max
// source in the active web. Updates node.url + url_verified_by_search.
const verifySources = useCallback(async () => {
if (verifying) return;
const targets = (activeWeb?.nodes || []).filter(n =>
n.type === 'source'
&& !n.url_verified_by_user
&& !n.url_verified_by_search
);
if (targets.length === 0) {
showToast('All sources already verified or pending Google fallback.');
return;
}
const estCost = (targets.length * 0.025).toFixed(2);
const ok = window.confirm(
`${targets.length} unverified sources in "${activeWeb.name}".\n\n`
+ `Estimated cost: ~$${estCost} (Anthropic web_search × ${targets.length}).\n\n`
+ `Each call browses the web for the real article URL. Continue?`
);
if (!ok) return;
setVerifying(true);
setVerifyProgress({ done: 0, total: targets.length });
let matched = 0;
let removed = 0;
// Sequential to keep API rate predictable; could batch later.
for (let i = 0; i < targets.length; i++) {
const node = targets[i];
let didMatch = false;
try {
const r = await fetch('/api/madmax/verify-source', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
label: node.label,
publisher: node.publisher,
date: node.date,
excerpt: node.excerpt,
}),
});
const j = await r.json();
if (j.matched && j.url) {
// Update the node in place with the real URL + verified flag
setNodes(ns => ns.map(n => n.id === node.id ? {
...n,
url: j.url,
url_verified_by_search: true,
publisher: n.publisher && n.publisher !== 'unknown' ? n.publisher : (j.publisher_inferred || n.publisher),
} : n));
matched += 1;
didMatch = true;
}
} catch (e) {
// network/timeout failure — treat as unmatched
}
// STRICT no-Google policy: if no real URL came back, REMOVE the node
// and its edges. Past sources without verifiable URLs don't earn a
// place on the canvas.
if (!didMatch) {
setNodes(ns => ns.filter(n => n.id !== node.id));
setEdges(es => es.filter(e => e.from !== node.id && e.to !== node.id));
removed += 1;
}
setVerifyProgress({ done: i + 1, total: targets.length });
}
setVerifying(false);
showToast(
`Verified ${matched}/${targets.length} · removed ${removed} unverifiable`,
);
}, [activeWeb, verifying, setNodes, setEdges]);
// Phase 6 S — these read/write the ACTIVE web's slot in mmByWeb.
// Backwards-compat shims for call sites that used the flat state
// before per-web tracking landed.
const mmLog = activeMm.log;
const mmProgress = activeMm.progress;
const mmResult = activeMm.result;
const setMmLog = (updater) => setMm(activeWebId, (cur) => ({
...cur, log: typeof updater === 'function' ? updater(cur.log || []) : updater,
}));
const setMmProgress = (updater) => setMm(activeWebId, (cur) => ({
...cur, progress: typeof updater === 'function' ? updater(cur.progress || {}) : updater,
}));
const setMmResult = (val) => setMm(activeWebId, { result: val });
// Each web has its own stop-ref so abort signals don't bleed.
const mmStopRef = (() => {
if (!mmStopRefs.current[activeWebId]) {
mmStopRefs.current[activeWebId] = { current: false };
}
return mmStopRefs.current[activeWebId];
})();
// Hypothesis-invent state — calls /api/hypothesis/invent which has Claude
// propose a binary, event-based question grounded in the user's vault.
// The same endpoint accepts a `seed` parameter for the Seed flow: the user
// types a rough idea, we send it through, and Claude sharpens it instead
// of generating from scratch.
const [inventing, setInventing] = useState(false);
const inventHypothesis = async (opts = {}) => {
const seed = (opts.seed || '').trim();
if (inventing) return;
setInventing(true);
try {
const base = (window.TIBEB_PROXY && window.TIBEB_PROXY.baseUrl) || '';
const res = await fetch(`${base}/api/hypothesis/invent`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
web_name: activeWeb.name,
web_tagline: activeWeb.tagline || '',
// Hint Claude with what's already on the canvas so the question is targeted
tickers: nodes.filter(n => n.type === 'ticker').map(n => n.label).slice(0, 12),
existing_question: (activeWeb.hypothesis && activeWeb.hypothesis.question) || '',
seed, // when set, Claude refines the user's idea
}),
});
if (!res.ok) {
// Phase 4.C — backend now returns humanized 503 messages on
// upstream failures (credits/rate-limit/etc). Use the body detail
// so the toast tells the user what to actually do.
let detail = '';
try {
const errBody = await res.json();
detail = errBody.detail || '';
} catch { detail = `HTTP ${res.status}`; }
throw new Error(detail || `HTTP ${res.status}`);
}
const j = await res.json();
if (j.hypothesis && j.hypothesis.question) {
setHypothesis(j.hypothesis); // also syncs web.name to the question
const ev = j.events_considered || 0;
const msg = (seed ? 'Hypothesis refined' : 'Hypothesis invented')
+ ` · ${ev} recent event${ev === 1 ? '' : 's'} considered.`;
showToast(msg);
// Drop the result into the persistent chat thread so the user has a
// record after the toast fades. Auto-Mad-Max removed per user feedback
// (2026-05-05) — kicking off a 30s research sweep without consent
// hijacked the canvas. User clicks Mad Max / Team explicitly when ready.
if (window.tibebAppendChat && activeWeb?.id) {
window.tibebAppendChat(activeWeb.id, {
role: 'jarvis',
text: `✦ ${msg}\n\n**${j.hypothesis.question}**\n\nClick **Mad Max** above for deep research, or **Team** to stress-test with all 9 analyst lenses.`,
});
}
} else {
const msg = (seed ? 'Refine' : 'Invent') + ' returned no hypothesis. Try again with more detail.';
showToast(msg);
if (window.tibebAppendChat && activeWeb?.id) {
window.tibebAppendChat(activeWeb.id, { role: 'jarvis', text: `⚠ ${msg}` });
}
}
} catch (e) {
const msg = (seed ? 'Seed' : 'Invent') + ' failed: ' + (e.message || 'unknown');
showToast(msg);
// Persist failures too — they're easy to miss as a 4-second toast and
// a beginner is left wondering why nothing happened.
if (window.tibebAppendChat && activeWeb?.id) {
window.tibebAppendChat(activeWeb.id, { role: 'jarvis', text: `✕ ${msg}` });
}
} finally {
setInventing(false);
}
};
const startMadMax = async () => {
if (mmRunning) { setMmOpen(true); return; }
setMmOpen(true);
setMmRunning(true);
setMmLog([]);
setMmResult(null);
setMmProgress({ iter: 0, max: 24, nodeCount: nodes.length, newNodes: 0 });
mmStopRef.current = false;
const stamp = () => new Date().toLocaleTimeString('en-US', { hour12: false }).slice(3);
// Sanitize log rows before they hit the UI. Mad Max's iterations
// sometimes pull back raw HTML 5xx pages or developer-flavored stack
// traces — that's fine for the dev console but a beginner shouldn't see
// tags in their "Deep Research" stream.
const sanitizeLogText = (txt) => {
if (typeof txt !== 'string') return txt;
// Wall-of-HTML — replace with one short line.
if (//i.test(txt)) {
return 'Engine returned an error — retrying with a different angle.';
}
// Long error response with HTTP status — extract the status, drop body.
const httpMatch = txt.match(/(?:^|[^a-z])(\d{3})\b.*?(?:Error|HTTPStatus|html)/i);
if (httpMatch && /\d{3}/.test(httpMatch[1])) {
return `Engine ${httpMatch[1]} — retrying.`;
}
// "model is not returning parsable JSON" — translate.
if (/parsable json|aborting sweep/i.test(txt)) {
return 'Hit a rough patch reading the model output. Continuing with what we have.';
}
// Trim absurdly long lines (anything > 240 chars is almost always a stack/HTML).
if (txt.length > 240) return txt.slice(0, 200) + '…';
return txt;
};
const onLog = (row) => setMmLog(l => [...l, {
...row,
text: sanitizeLogText(row.text),
time: stamp(),
}]);
const onProgress = (p) => setMmProgress(prev => ({ ...prev, ...p }));
const onResult = (r) => {
setMmResult(r);
setMmRunning(false);
// Auto-engage the Hedge Fund Team to stress-test the Mad Max verdict.
// The team works WITH Tibeb on Mad Max — see brain/0-Agents/Tibeb.md.
// Open after a short delay so the user sees the Mad Max verdict first.
if (r && r.trade) {
setTimeout(() => setTeamOpen(true), 800);
}
// When Mad Max returns a probabilistic verdict, update the hypothesis
// status + probability so the banner reflects the debate outcome.
const t = r && r.trade;
if (t && typeof t.probability === 'number') {
const p = t.probability;
const newStatus =
p >= 80 ? 'yes'
: p <= 20 ? 'no'
: (p >= 40 && p <= 60) ? 'undecided'
: (p > 60 ? 'leaning_yes' : 'leaning_no');
setHypothesis({
status: newStatus,
probability: p,
supportingCount: t.supportingCount,
contradictingCount: t.contradictingCount,
});
}
};
try {
await window.runMadMax({
initialNodes: nodes,
initialEdges: edges,
activeWeb,
tweaks,
setNodes, setEdges,
onLog, onProgress, onResult,
shouldStop: () => mmStopRef.current,
});
} catch (e) {
onLog({ tag: 'warn', text: 'Mad Max crashed: ' + (e.message || e) });
setMmRunning(false);
}
};
// Phase 6 S — split "background" from "halt". Background closes
// the overlay but the run keeps going (per-web stopRef stays
// false). Halt actually aborts the run. The MadMaxOverlay now
// exposes both — close-X = background, explicit Stop button = halt.
const stopMadMax = () => {
if (mmRunning) {
mmStopRef.current = true;
showToast('Mad Max halted.');
}
setMmOpen(false);
};
const backgroundMadMax = () => {
setMmOpen(false);
if (mmRunning) {
showToast('Mad Max still running in background — click the pill in the topbar to reopen.');
}
};
const acceptMadMaxTrade = () => {
if (!mmResult?.trade) return;
// Convert into the activeWeb.tradeProposal so the existing trade card renders it.
const tp = mmResult.trade;
setWebs(ws => ws.map(w => w.id === activeWebId ? {
...w,
tradeProposal: {
title: tp.name,
subtitle: 'Mad Max research · ' + (tp.horizon || tweaks.horizon),
rationale: tp.thesis,
legs: (tp.legs || []).map(l => ({
side: l.side, instrument: l.instrument, size: l.size, rationale: l.rationale,
})),
rails: [
{ label: 'Expected', value: tp.expectedReturn || '+?%', tone: 'up' },
{ label: 'Max loss', value: tp.maxLoss || '-?%', tone: 'dn' },
{ label: 'Conviction', value: (tp.conviction || tp.convictionScore || '?') + '/100', tone: 'up' },
],
cites: tp.supports || tp.supportingNodes || [],
}
} : w));
setMmOpen(false);
setTradeDismissed(false);
setTradeAccepted(false);
showToast('Mad Max trade staged in chat →');
};
// Reset focus when switching webs
useEffect(() => {
setSelected([]);
setFocused(activeWeb.nodes[0]?.id || null);
setTradeAccepted(false);
setTradeDismissed(false);
}, [activeWebId]);
const onCreateWeb = () => {
const id = 'w_' + Math.random().toString(36).slice(2,7);
const newWeb = {
id,
// The web's name IS the hypothesis question. Both empty until the user
// hits Invent or types one in.
name: 'Untitled hypothesis · click Invent to start',
tagline: '',
hypothesis: DEFAULT_HYPOTHESIS(),
nodes: [],
edges: [], news: {}, tradeProposal: null, lastEdit: 'just now',
};
setWebs(ws => [...ws, newWeb]);
setActiveWebId(id);
showToast('New web · click Invent to seed the hypothesis.');
};
// Renaming a web ALSO updates hypothesis.question — they're synced.
const onRenameWeb = (id, name) => {
setWebs(ws => ws.map(w => w.id === id
? { ...w, name, hypothesis: { ...DEFAULT_HYPOTHESIS(), ...(w.hypothesis || {}), question: name } }
: w
));
};
const onDeleteWeb = (id) => {
if (webs.length <= 1) { showToast('Need at least one web.'); return; }
setWebs(ws => ws.filter(w => w.id !== id));
if (activeWebId === id) setActiveWebId(webs.find(w => w.id !== id).id);
showToast('Web deleted.');
// Phase 2C.3 — also delete server-side for persisted users. Fire-and-forget;
// a failed delete just leaves a stale server-side row that we'll overwrite
// on the next save anyway. localStorage is the immediate authority.
if (isPersistedUser) {
fetch(`/api/webs/${encodeURIComponent(id)}`, { method: 'DELETE' }).catch(() => {});
}
};
const showToast = (t) => {
setToast(t);
setTimeout(() => setToast(null), 2400);
};
const exportWeb = async () => {
if (!activeWeb) return;
const slug = (activeWeb.name || 'web').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'web';
const today = new Date().toISOString().slice(0, 10);
const theses = nodes.filter(n => n.type === 'thesis');
const tickers = nodes.filter(n => n.type === 'ticker');
const catalysts = nodes.filter(n => n.type === 'catalyst');
const sources = nodes.filter(n => n.type === 'source');
// Open the brief shell IMMEDIATELY in a new tab (synchronously, inside the click handler)
// so popup blockers don't kill it. We'll stream content into it as Claude finishes.
const briefWin = window.open('', '_blank');
if (briefWin) {
briefWin.document.write(`${activeWeb.name} — Research Brief
Tibeb · Synthesizing
${activeWeb.name} — Research Brief
Generating full brief… this typically takes 20–60 seconds while Claude synthesizes per-source analyses.
`);
}
showToast(`Generating full research brief — ${sources.length} sources to synthesize…`);
const labelOf = id => nodes.find(n => n.id === id)?.label || id;
const edgesOf = id => edges.filter(e => e.from === id || e.to === id);
const safe = (s) => String(s || '').replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'}[c]));
// ── Synthesize an executive summary + per-source mini-briefs in parallel ──
const webContext = `WEB: ${activeWeb.name}\nTAGLINE: ${activeWeb.tagline || ''}\nNODES: ${nodes.length} (${theses.length} theses, ${tickers.length} tickers, ${catalysts.length} catalysts, ${sources.length} sources)\nTHESES:\n${theses.map(t => `- [${t.id}] ${t.label}: ${t.summary || ''}`).join('\n')}\nKEY CATALYSTS:\n${catalysts.slice(0, 8).map(c => `- ${c.date || 'TBD'} · ${c.label} (${c.impact || 'med'} impact)`).join('\n')}`;
const pCall = async (p) => {
try { return await window.claude.complete(p); }
catch { return ''; }
};
const execPromise = pCall(`You are a sell-side analyst. Write a tight EXECUTIVE SUMMARY for this research web. Plain text, no markdown. 3-4 short paragraphs covering: (1) the central thesis, (2) the key drivers and supporting evidence, (3) the trade structure and risk/reward, (4) what would invalidate it. Be specific. ≤300 words.\n\n${webContext}${activeWeb.tradeProposal ? `\nTRADE: ${activeWeb.tradeProposal.title} — ${activeWeb.tradeProposal.subtitle || ''}\nLEGS: ${(activeWeb.tradeProposal.legs||[]).map(l => `${l.side} ${l.instrument} ${l.size}`).join(' / ')}` : ''}`);
const thesisPromises = theses.map(t => pCall(`Expand this thesis into a 2-paragraph analyst note. Plain text, no markdown. First paragraph: the core argument and mechanism. Second paragraph: supporting evidence and key data points. Be specific and concrete. ≤180 words.\n\nTHESIS: ${t.label}\nSUMMARY: ${t.summary || ''}\nWEB CONTEXT: ${activeWeb.name} — ${activeWeb.tagline || ''}`));
const sourcePromises = sources.map(s => pCall(`Produce a 4-bullet KEY POINTS digest of this source. Plain text bullets starting with "•". Each bullet ≤20 words. Be specific.\n\nSOURCE: ${s.label}\nPUBLISHER: ${s.publisher || ''}\nDATE: ${s.date || ''}\nEXCERPT: ${s.excerpt || ''}\nCONTEXT: ${activeWeb.name} — ${activeWeb.tagline || ''}`));
const [execSummary, thesisNotes, sourceDigests] = await Promise.all([
execPromise,
Promise.all(thesisPromises),
Promise.all(sourcePromises),
]);
const para = (s) => safe(s).replace(/\n\n+/g, '
This brief synthesizes node metadata, excerpts, and Claude analysis. Per-source digests are summaries — refer to each source URL for the primary document. Trade structures are illustrative and not investment advice.
`;
if (briefWin) {
briefWin.document.open();
briefWin.document.write(html);
briefWin.document.close();
} else {
// Popup blocked — fall back to download
const blob = new Blob([html], { type: 'text/html' });
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = `${slug}-brief-${today}.html`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(blobUrl), 60000);
}
showToast(`"${activeWeb.name}" brief ready · ${sources.length} sources synthesized`);
};
// Live price drift
useEffect(() => {
if (!tweaks.liveDrift) return;
const id = setInterval(() => {
setNodes(ns => ns.map(n => {
if (n.type !== 'ticker') return n;
const drift = (Math.random() - 0.5) * 0.4;
const newPrice = +(n.price * (1 + drift / 100)).toFixed(2);
const newChange = +(n.change + drift * 0.3).toFixed(2);
const newSpark = [...n.spark.slice(1), newPrice];
return { ...n, price: newPrice, change: newChange, spark: newSpark };
}));
}, 3500);
return () => clearInterval(id);
}, [tweaks.liveDrift]);
// Keyboard shortcuts
useEffect(() => {
const onKey = (e) => {
const inField = ['INPUT','TEXTAREA'].includes(document.activeElement?.tagName);
if (e.key === 'Escape') {
setQuickOpen(false);
setLibrarySearchOpen(false);
setContextMenu(null);
if (!inField) setFocused(null);
}
// Cmd/Ctrl+K → Library Search (global brain search). Phase 2D.
// Quick Add moved to Cmd/Ctrl+I (Insert).
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
setLibrarySearchOpen(true);
}
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'i') {
e.preventDefault();
setQuickOpen(true);
}
if (!inField && e.key === '/') {
e.preventDefault();
window.__focusTibeb?.();
}
// Phase 6 R · #H — Cmd+Shift+H opens the Health panel.
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'h') {
e.preventDefault();
setHealthOpen(true);
}
// Phase 14 — Cmd+Shift+L opens the Live Feed.
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'l') {
e.preventDefault();
setLiveFeedOpen(true);
}
// Phase 14b — Cmd+Shift+A opens Asymmetric Theses.
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'a') {
e.preventDefault();
setAsymmOpen(true);
}
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 't') {
e.preventDefault();
if (selected.length >= 2) {
setTradeDismissed(false);
setTradeAccepted(false);
showToast(`Trade re-proposed from ${selected.length} selected nodes.`);
} else {
showToast('Select 2+ nodes (⌘-click) first.');
}
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [selected]);
const onConnectTrade = () => {
if (selected.length < 2) { showToast('Select 2+ nodes first.'); return; }
setTradeDismissed(false);
setTradeAccepted(false);
showToast(`Trade proposed from ${selected.length} nodes.`);
};
const onAcceptTrade = () => {
setTradeAccepted(true);
showToast('Trade logged to paper journal.');
};
const onDismissTrade = () => {
setTradeDismissed(true);
showToast('Trade dismissed.');
};
const onRefineTrade = () => {
showToast('Tibeb is refining the trade…');
};
const onShortcut = (kind, payload) => {
if (kind === 'contextmenu') {
setContextMenu(payload);
}
};
const onContextAction = (id, node) => {
if (id === 'thesis') {
showToast(`"${node.label}" marked as thesis.`);
} else if (id === 'archive') {
showToast(`"${node.label}" archived.`);
} else if (id === 'copy') {
showToast(`Reference to "${node.label}" copied.`);
} else if (id === 'link') {
if (selected.length === 0) { showToast('Nothing selected to link to.'); return; }
showToast(`Linked "${node.label}" to ${selected.length} node(s).`);
} else if (id === 'branch') {
// BFS from `node` along outgoing edges; remove the node + every
// descendant. Refuse if the user picked the central hypothesis —
// there's exactly one, and deleting its branch would nuke the entire
// web.
if (node.isHypothesis || node.id === 't_hypo') {
showToast('Cannot delete the hypothesis branch — that is the entire web.');
return;
}
const reachable = new Set([node.id]);
const queue = [node.id];
while (queue.length) {
const cur = queue.shift();
for (const e of edges) {
if (e.from === cur && !reachable.has(e.to)) {
reachable.add(e.to);
queue.push(e.to);
}
}
}
const removedNodes = reachable.size;
const removedEdges = edges.filter(e => reachable.has(e.from) || reachable.has(e.to)).length;
setNodes(ns => ns.filter(n => !reachable.has(n.id)));
setEdges(es => es.filter(e => !reachable.has(e.from) && !reachable.has(e.to)));
if (focused && reachable.has(focused)) setFocused(null);
setSelected(s => s.filter(x => !reachable.has(x)));
showToast(`Branch deleted · ${removedNodes} node${removedNodes === 1 ? '' : 's'} + ${removedEdges} edge${removedEdges === 1 ? '' : 's'} removed.`);
}
};
// Generalized add: accepts a partial node, fills in id + position + type-defaults.
// Phase 6 R · #10 — Global QuickAdd. When the user fires Cmd+I from
// a non-Research tab (Trades / Brain / Today), they almost certainly
// want to capture something to the brain — NOT mutate whichever web
// happened to be selected last. Route to /api/ideas (save_idea) so
// the capture lands at brain/7-Jarvis Captures/.md.
const onQuickCaptureToBrain = useCallback(async (payload) => {
try {
const type = payload.type || 'source';
// Build a sensible note shape from whatever shape QuickAdd emits.
let title = (payload.label || payload.text || '').trim();
if (!title) title = `Quick capture · ${new Date().toLocaleString()}`;
const bodyParts = [];
if (payload.summary) bodyParts.push(payload.summary);
if (payload.publisher) bodyParts.push(`**Publisher:** ${payload.publisher}`);
if (payload.date) bodyParts.push(`**Date:** ${payload.date}`);
if (payload.url) bodyParts.push(`**URL:** ${payload.url}`);
if (payload.text) bodyParts.push(payload.text);
const body = bodyParts.join('\n\n') || '_(no body)_';
const tickers = payload.label && /^[A-Z$.\-]{1,8}$/.test(payload.label)
? [payload.label.replace(/^\$/, '').toUpperCase()]
: [];
const r = await fetch('/api/ideas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
body,
tickers,
tags: ['quick-capture', `quick-${type}`],
folder: 'quick',
}),
});
if (r.ok) {
const j = await r.json();
try {
// Light toast: re-use existing toast machinery if present
if (window.__tibebToast) {
window.__tibebToast(`✓ Saved to brain · ${j.path || 'Quick Captures'}`);
}
} catch {}
}
} catch (e) {
console.warn('[quick capture] failed:', e);
}
}, []);
// Used by QuickAdd for thesis/ticker/catalyst/source — and by anything else
// that wants to drop a node into the active web programmatically.
// When activeTab is NOT research, route the capture to the brain
// instead (via onQuickCaptureToBrain) so the user can capture from
// anywhere without the surprise of "I'm on Trades, why did this end
// up in some random web's canvas?"
const onAddNode = (payload) => {
if (activeTab !== 'research') {
onQuickCaptureToBrain(payload);
return;
}
const type = payload.type || 'source';
const idPrefix = { thesis: 't_', ticker: 'k_', catalyst: 'c_', source: 's_' }[type] || 'n_';
let id = payload.id || (idPrefix + Math.random().toString(36).slice(2, 7));
// ensure id collision-free
if (nodes.some(n => n.id === id)) id = id + '_' + Math.random().toString(36).slice(2, 4);
const base = {
id, type,
label: (payload.label || '').trim() || `New ${type}`,
x: payload.x ?? (Math.random() - 0.5) * 200,
y: payload.y ?? (Math.random() - 0.5) * 200,
};
let newNode;
if (type === 'thesis') {
newNode = { ...base, summary: payload.summary || '', claims: payload.claims || [] };
} else if (type === 'ticker') {
// Strip $ and uppercase the symbol
const sym = base.label.replace(/^\$/, '').toUpperCase();
newNode = {
...base,
label: sym,
name: payload.name || '',
price: typeof payload.price === 'number' ? payload.price : null,
change: typeof payload.change === 'number' ? payload.change : 0,
sentiment: typeof payload.sentiment === 'number' ? payload.sentiment : 0,
spark: payload.spark || [],
};
} else if (type === 'catalyst') {
newNode = {
...base,
date: payload.date || '',
impact: payload.impact || 'med',
summary: payload.summary || '',
};
} else {
// source — keep the legacy URL-extraction behavior so paste-a-link still works
const trimmed = (payload.text || payload.label || '').trim();
const isUrl = /^https?:\/\/\S+$/i.test(trimmed);
let url = payload.url || (isUrl ? trimmed : '');
let publisher = payload.publisher || (isUrl ? (() => { try { return new URL(trimmed).hostname.replace(/^www\./, ''); } catch { return 'User · pasted'; } })() : 'User · pasted');
let label = payload.label && payload.label.trim()
? payload.label.trim()
: (isUrl ? (() => { try { return new URL(trimmed).hostname.replace(/^www\./, '') + new URL(trimmed).pathname; } catch { return trimmed.slice(0, 48); } })() : trimmed.slice(0, 48) + (trimmed.length > 48 ? '…' : ''));
newNode = {
...base,
label,
publisher,
date: payload.date || 'just now',
url,
excerpt: payload.excerpt || (isUrl ? '' : trimmed.slice(0, 220)),
};
}
setNodes(ns => [...ns, newNode]);
setFocused(id);
const niceType = type[0].toUpperCase() + type.slice(1);
showToast(`${niceType} added · "${newNode.label}"`);
};
// Backwards-compat shim — anything old that still calls onAddSource(text)
const onAddSource = (text) => onAddNode({ type: 'source', text });
const onNewThesis = (node) => {
const id = 't_' + Math.random().toString(36).slice(2,7);
const newNode = {
id, type: 'thesis',
label: 'New thesis from ' + node.label,
summary: 'Draft thesis seeded from ' + node.label + '.',
claims: [],
x: node.x + 80, y: node.y - 40,
};
setNodes(ns => [...ns, newNode]);
setEdges(es => [...es, { from: id, to: node.id, kind: 'mentions' }]);
setFocused(id);
showToast('New thesis created.');
};
const focusedNode = useMemo(() => nodes.find(n => n.id === focused), [nodes, focused]);
// Aggregate sentiment across watched tickers
const aggSentiment = useMemo(() => {
const ts = nodes.filter(n => n.type === 'ticker');
if (!ts.length) return 0;
return ts.reduce((s,t) => s + t.sentiment, 0) / ts.length;
}, [nodes]);
return (
{/* Phase 6 R · UI fix — replaced the boxy logo+wordmark combo
with a single inline mark. The old 28px teal-border box +
uppercase TIBEB + serif italic "Investing Brain" stack was
visually disconnected from the rest of the topbar and ate
~280px of horizontal space. Now it's just the mark dot +
"Tibeb" — same identity cue, integrates with the chip row. */}
Tibeb
{/* AUTONOMY 24/7 toggle — the ONE BUTTON. Sits right after the
brand mark so it's the most prominent thing in the topbar
after the wordmark. Click to flip between full-autonomous
(5 cron schedulers fire + verdicts auto-execute) and
copilot (everything queues for manual review).
See AutonomyPill component definition for full behavior. */}
{nodes.filter(n=>n.type==='thesis').length} theses{nodes.filter(n=>n.type==='ticker').length} watching{nodes.length} nodes · {edges.length} edgesLive · paper
{/* Phase 6 R · #8 — Regime chip. Always visible across all
tabs so the trader has persistent awareness of "what
market are we in?" before placing any trade. */}
{/* Phase 6 S — Running Mad Max pill. Click opens a popover that
lists every running web with iter/max progress + latest log
line. Click a row → jump to that web's overlay. Pure-click
UX (no hover) so it survives touch + scroll. */}
{mmRunningCount > 0 && (
setMmPopoverOpen(o => !o)}
title={`${mmRunningCount} Mad Max running · click for breakdown`}
style={{
padding: '2px 8px', fontSize: 10, lineHeight: '14px',
fontFamily: "'JetBrains Mono', monospace", borderRadius: 3,
cursor: 'pointer',
background: 'rgba(255,128,64,0.15)', color: '#ff9c5e',
border: '1px solid rgba(255,128,64,0.4)',
display: 'inline-flex', alignItems: 'center', gap: 6,
}}
>
MAD MAX · {mmRunningCount}
{mmPopoverOpen && (
<>
{/* Click-outside backdrop */}
>
)}
)}
{isPersistedUser && (
{webSaveStatus === 'saving' ? 'saving…'
: webSaveStatus === 'saved' ? 'saved ✓'
: webSaveStatus === 'failed' ? 'save failed · retry'
: (activeWeb && activeWeb._dirty) ? 'unsaved'
: 'synced'}
)}
{/* Phase 14 — Live Feed: chronological stream of every autonomous
signal (memos + raw twitter signals + order fills). The "99+"
counter badge is too signal-rich to bury in the Tools menu,
so this stays top-level. Cmd+Shift+L also opens it. */}
{window.LiveFeedBadge && (
setLiveFeedOpen(true)}/>
)}
{/* The 10 secondary buttons that used to live here (Export, Team,
Brief, Agents, Watchlist, Health, Autonomy, Memos, Asymm, Tests)
were deleted 2026-05-18 — they were all duplicated inside the
Tools ▾ dropdown directly below this comment, so they only
added visual noise to the topbar. Tools ▾ is now the single
home for secondary actions. */}
{toolsMenuOpen && (
)}
{/* Phase 14 — Push notification subscribe pill. Shows when the
server has VAPID configured AND the browser supports push.
Self-hides otherwise. Click to enable / disable; shift+click
to fire a test push. */}
{window.PushControl && }
{/* Team button removed — Tibeb is the leader; the team auto-engages */}
{/* with Mad Max and Invent flows. Direct invocation lives at WS /ws/team */}
{/* and via Tibeb tools (consult_team / consult_specialist). */}
{/* Phase 2C.1 — user identity chip. Only renders when we have a user
AND auth is configured (i.e. NOT in single-user local-default
mode where the email "local@jarvis.dev" is meaningless). */}
{currentUser && currentUser.auth_configured && !currentUser.is_local && (