// 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 (
    <div className={`hypothesis-banner ${empty ? 'empty' : ''} ${collapsed ? 'collapsed' : ''}`}>
      <div className="hypo-row">
        <div className="hypo-eyebrow">
          <span className="dot" style={{ background: statusColor }}/>
          Hypothesis · <span style={{ color: statusColor, textTransform: 'uppercase', letterSpacing: '0.12em' }}>{statusLabel}</span>
          {hasProb && (
            <span style={{ marginLeft: 14, color: statusColor, fontWeight: 700 }}>
              {h.probability}% YES
            </span>
          )}
          {(sup + con) > 0 && (
            <span style={{ marginLeft: 14, color: 'var(--text-dim)', fontFamily: "'JetBrains Mono', monospace", fontSize: 11 }}>
              {sup} supporting · {con} contradicting
            </span>
          )}
        </div>
        <div className="hypo-actions">
          <select
            className="hypo-status-pick"
            value={h.status || 'open'}
            onChange={e => setHypothesis({ status: e.target.value })}
            title="Override the hypothesis state"
          >
            <option value="open">open</option>
            <option value="leaning_yes">leaning YES</option>
            <option value="leaning_no">leaning NO</option>
            <option value="undecided">undecided</option>
            <option value="yes">resolved · YES</option>
            <option value="no">resolved · NO</option>
          </select>
          <button
            className={`hypo-seed ${seedOpen ? 'active' : ''}`}
            onClick={() => setSeedOpen(o => !o)}
            disabled={inventing}
            title="Type a rough idea and Tibeb refines it into a research-grade hypothesis"
          >
            {seedOpen ? 'Cancel' : '✎ Seed'}
          </button>
          <button
            className={`hypo-invent ${inventing ? 'running' : ''}`}
            onClick={() => onInvent()}
            disabled={inventing}
            title="Have Tibeb propose a sharp binary, event-based hypothesis from your vault"
          >
            {inventing ? 'Inventing…' : (empty ? '✦ Invent hypothesis' : '✦ Re-invent')}
          </button>
          <button
            className="hypo-toggle"
            onClick={() => setCollapsed(c => !c)}
            title={collapsed ? 'Expand hypothesis' : 'Minimize hypothesis'}
            aria-label={collapsed ? 'Expand hypothesis' : 'Minimize hypothesis'}
          >
            <svg width="11" height="11" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
              {collapsed
                ? <path d="M3 4.5l3 3 3-3"/>   /* down arrow → click to expand */
                : <path d="M3 7.5l3-3 3 3"/>   /* up arrow → click to minimize */
              }
            </svg>
          </button>
        </div>
      </div>

      {!collapsed && seedOpen && (
        <div className="hypo-seed-row">
          {empty && (
            <div className="hypo-onboard">
              <div className="hypo-onboard-eyebrow">START HERE · STEP 1 OF 4</div>
              <div className="hypo-onboard-title">What's your investing idea?</div>
              <div className="hypo-onboard-sub">
                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.
              </div>
              <div className="hypo-onboard-chips">
                {[
                  '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) => (
                  <button key={i} className="hypo-chip" onClick={() => setSeedText(s)}>{s}</button>
                ))}
              </div>
            </div>
          )}
          <textarea
            ref={seedRef}
            className="hypo-seed-input"
            placeholder={empty
              ? 'Type your idea here — or pick a prompt above. Tibeb refines it into a binary, event-anchored hypothesis with vault-cited yes/no scenarios.'
              : 'Sharpen this hypothesis. e.g. "Token-based LLM pricing — who benefits?"'}
            value={seedText}
            onChange={e => setSeedText(e.target.value)}
            onKeyDown={e => {
              if (e.key === 'Escape') { e.preventDefault(); setSeedOpen(false); setSeedText(''); }
              if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); submitSeed(); }
            }}
          />
          <div className="hypo-seed-actions">
            <span className="hypo-seed-hint"><kbd>⌘ ↵</kbd> refine · <kbd>esc</kbd> cancel</span>
            <button
              className="hypo-seed-submit"
              onClick={submitSeed}
              disabled={inventing || !seedText.trim()}
            >
              {inventing ? 'Refining…' : 'Refine →'}
            </button>
          </div>
        </div>
      )}

      {!collapsed && (
        <>
          <window.EditableField
            className="hypo-question"
            value={h.question}
            onSave={v => 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"
          />

          <div className="hypo-grid">
            <div className="hypo-cell">
              <div className="hypo-label">If <span style={{ color: '#00d4aa' }}>YES</span></div>
              <window.EditableField
                className="hypo-scenario"
                value={h.yesScenario}
                onSave={v => setHypothesis({ yesScenario: v })}
                placeholder="What does the world look like? What's the trade?"
                label="Yes scenario"
                multiline
              />
            </div>
            <div className="hypo-cell">
              <div className="hypo-label">If <span style={{ color: '#ff4466' }}>NO</span></div>
              <window.EditableField
                className="hypo-scenario"
                value={h.noScenario}
                onSave={v => setHypothesis({ noScenario: v })}
                placeholder="What does the world look like? What's the trade?"
                label="No scenario"
                multiline
              />
            </div>
            <div className="hypo-cell hypo-cell--narrow">
              <div className="hypo-label">Resolves</div>
              <window.EditableField
                className="hypo-resolve"
                value={h.resolveBy}
                onSave={v => setHypothesis({ resolveBy: v })}
                placeholder="YYYY-MM-DD or event"
                label="Resolve by"
              />
            </div>
          </div>
        </>
      )}
    </div>
  );
}
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 (
    <span
      title={`${data.one_line_read || ''}\n\nLast updated: ${data.last_updated || '?'} (${data.days_since_update}d ago)${stale ? ' · NOTE STALE >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,
      }}
    >
      <span style={{ fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>{tone}</span>
      {stale && <span title="Regime note >30 days old" style={{ color: '#d96a6a' }}>⚠</span>}
    </span>
  );
}
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 (
    <button
      onClick={toggle}
      disabled={saving}
      title={err ? `Error: ${err}` : tooltip}
      className={`autonomy-pill ${isOn ? 'on' : 'off'}`}
      style={{
        marginLeft: 8,
        padding: '6px 14px',
        fontSize: 11,
        fontFamily: "'JetBrains Mono', monospace",
        fontWeight: 700,
        letterSpacing: '0.12em',
        textTransform: 'uppercase',
        borderRadius: 4,
        cursor: saving ? 'wait' : 'pointer',
        background: isOn
          ? 'linear-gradient(180deg, rgba(0,212,170,0.28), rgba(0,212,170,0.18))'
          : 'rgba(40,40,52,0.65)',
        color: isOn ? '#00d4aa' : 'var(--text-dim)',
        border: `1.5px solid ${isOn ? 'rgba(0,212,170,0.65)' : 'var(--line-2)'}`,
        boxShadow: isOn ? '0 0 14px rgba(0,212,170,0.28)' : 'none',
        display: 'inline-flex',
        alignItems: 'center',
        gap: 8,
        flexShrink: 0,
        opacity: saving ? 0.6 : 1,
        transition: 'all 0.18s ease',
      }}
    >
      <span style={{
        width: 8, height: 8, borderRadius: '50%',
        background: isOn ? '#00d4aa' : '#5a5a6a',
        boxShadow: isOn ? '0 0 8px #00d4aa' : 'none',
        animation: isOn ? 'pf-live-pulse 1.4s ease-in-out infinite' : 'none',
      }}/>
      {saving ? 'SAVING…' : (isOn ? 'AUTONOMOUS · 24/7' : 'AUTONOMY · OFF')}
      {err && <span title={err} style={{ color: '#d96a6a', marginLeft: 4 }}>⚠</span>}
    </button>
  );
}
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 (
    <div className={`stock-docs-panel${collapsed ? ' collapsed' : ''}`}>
      <div
        className="stock-docs-head"
        onClick={() => 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'}
      >
        <div style={{ flex: 1 }}>
          <div className="stock-docs-eyebrow">SEC Filings · live from EDGAR</div>
          {!collapsed && <div className="stock-docs-title">Documents for tickers on canvas</div>}
          {!collapsed && <div className="stock-docs-sub">Real working links. Click a row to open the filing on sec.gov · 10-K · 10-Q · 8-K · proxy &amp; more.</div>}
          {collapsed && (
            <div className="stock-docs-sub" style={{ marginTop: 0 }}>
              {tickers.length} ticker{tickers.length === 1 ? '' : 's'} · click to expand
            </div>
          )}
        </div>
        <span style={{
          color: 'var(--text-dim)', fontSize: 18, lineHeight: 1,
          fontFamily: "'JetBrains Mono', monospace", userSelect: 'none',
        }}>
          {collapsed ? '▸' : '▾'}
        </span>
      </div>
      {!collapsed && (
      <div className="stock-docs-list">
        {tickers.map(t => {
          const isOpen = openTicker === t;
          const data = docsByTicker[t];
          const isLoading = loadingTicker === t;
          const err = errByTicker[t];
          const filingCount = data?.filings?.length || 0;
          return (
            <div key={t} className={`stock-docs-row ${isOpen ? 'open' : ''}`}>
              <button className="stock-docs-toggle" onClick={() => toggleTicker(t)}>
                <span className="stock-docs-ticker">${t}</span>
                {data && <span className="stock-docs-company">{data.company}</span>}
                <span className="stock-docs-count">
                  {isLoading ? 'loading…' : data ? `${filingCount} filings` : 'click to load'}
                </span>
                <span className="stock-docs-caret">{isOpen ? '▾' : '▸'}</span>
              </button>
              {isOpen && err && (
                <div className="stock-docs-err">⚠ {err}</div>
              )}
              {isOpen && data && (() => {
                const stored = storedByTicker[t]?.documents || { filings: [], transcripts: [], letters: [] };
                const pullStatus = pullStatusByTicker[t];
                const isPulling = pullingTicker === t;
                const totalStored = stored.filings.length + stored.transcripts.length + stored.letters.length;
                return (
                  <>
                    {/* Pull-corpus action bar — downloads SEC filings as MD into
                        the brain so the chat can ground answers in them. */}
                    <div className="stock-docs-pull-bar">
                      <span className="stock-docs-pull-stat">
                        {totalStored === 0
                          ? 'No documents pulled into the brain yet.'
                          : `${totalStored} document${totalStored === 1 ? '' : 's'} pulled into the brain — chat can cite them.`}
                      </span>
                      <button
                        className="stock-docs-pull-btn"
                        onClick={() => pullCorpus(t)}
                        disabled={isPulling}
                        title="Download the latest SEC filings (10-K, 10-Q, 8-K) AND earnings call transcripts (last 4 quarters) as Markdown into brain/4-Stocks/Documents/. The chat agent reads + searches them when answering ticker-specific questions. Transcripts require FMP_API_KEY on the server."
                      >
                        {isPulling ? '⟳ pulling filings + transcripts…' : totalStored > 0 ? '↻ refresh corpus' : '⬇ pull SEC + transcripts'}
                      </button>
                    </div>
                    {pullStatus?.phase === 'done' && (
                      <div className="stock-docs-pull-result">
                        ✓ Saved {pullStatus.saved.length} new doc{pullStatus.saved.length === 1 ? '' : 's'}
                        {pullStatus.skipped > 0 && `, skipped ${pullStatus.skipped} already-stored`}
                        {pullStatus.errors.length > 0 && `, ${pullStatus.errors.length} error${pullStatus.errors.length === 1 ? '' : 's'}`}
                        .
                        {pullStatus.transcriptsConfigured === false && (
                          <span style={{ display: 'block', marginTop: 4, opacity: 0.75 }}>
                            (Earnings transcripts skipped — set <code>FMP_API_KEY</code> on the server to enable.)
                          </span>
                        )}
                      </div>
                    )}
                    {pullStatus?.phase === 'error' && (
                      <div className="stock-docs-err">⚠ Pull failed — {pullStatus.error}</div>
                    )}

                    {/* Stored corpus — what's actually in the brain right now */}
                    {totalStored > 0 && (
                      <div className="stock-docs-stored">
                        <div className="stock-docs-stored-eyebrow">In your brain · searchable in chat</div>
                        {['filings', 'transcripts', 'letters'].map(kind => (
                          stored[kind].length > 0 && (
                            <div key={kind} className="stock-docs-stored-group">
                              <div className="stock-docs-stored-kind">{kind} ({stored[kind].length})</div>
                              {stored[kind].map((d, i) => (
                                <div key={`${d.vault_path}-${i}`} className="stock-docs-stored-row">
                                  <span className="stock-docs-form">{d.form}</span>
                                  <span className="stock-docs-date">{d.date}</span>
                                  <span className="stock-docs-desc" title={d.vault_path}>{d.vault_path.split('/').pop()}</span>
                                  <span className="stock-docs-links">
                                    {d.source_url && (
                                      <a href={d.source_url} target="_blank" rel="noopener noreferrer" title="Open original on sec.gov">orig ↗</a>
                                    )}
                                  </span>
                                </div>
                              ))}
                            </div>
                          )
                        ))}
                      </div>
                    )}

                    {/* Live SEC EDGAR listing — what's available to pull */}
                    {data.filings && data.filings.length > 0 && (
                      <div className="stock-docs-filings">
                        <div className="stock-docs-stored-eyebrow" style={{ padding: '8px 14px 0', borderTop: totalStored > 0 ? '1px solid var(--line)' : 'none' }}>
                          Live · SEC EDGAR
                        </div>
                        {data.filings.map((f, i) => (
                          <div key={`${f.accession}-${i}`} className="stock-docs-filing">
                            <span className="stock-docs-form">{f.form}</span>
                            <span className="stock-docs-date">{f.date}</span>
                            <span className="stock-docs-desc">{f.description || ''}</span>
                            <span className="stock-docs-links">
                              {f.primary_document_url && (
                                <a href={f.primary_document_url} target="_blank" rel="noopener noreferrer" title="Open primary document on sec.gov">doc ↗</a>
                              )}
                              {f.filing_index_url && (
                                <a href={f.filing_index_url} target="_blank" rel="noopener noreferrer" title="Open filing index on sec.gov">index ↗</a>
                              )}
                            </span>
                          </div>
                        ))}
                      </div>
                    )}
                  </>
                );
              })()}
            </div>
          );
        })}
      </div>
      )}
    </div>
  );
}
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/<slug>.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
    // <!DOCTYPE> tags in their "Deep Research" stream.
    const sanitizeLogText = (txt) => {
      if (typeof txt !== 'string') return txt;
      // Wall-of-HTML — replace with one short line.
      if (/<!doctype|<html|<body|<head>/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(`<!doctype html><meta charset="utf-8"><title>${activeWeb.name} — Research Brief</title>
<style>body{font-family:Helvetica,Arial,sans-serif;padding:60px;color:#222;background:#fafaf7}.spin{display:inline-block;width:14px;height:14px;border:2px solid #ccc;border-top-color:#222;border-radius:50%;animation:s 0.8s linear infinite;vertical-align:middle;margin-right:10px}@keyframes s{to{transform:rotate(360deg)}}h1{font-size:18pt;margin:0 0 6px;font-family:Georgia,serif}.sub{color:#888;font-size:11pt;letter-spacing:0.05em;text-transform:uppercase}</style>
<div class="sub">Tibeb · Synthesizing</div><h1>${activeWeb.name} — Research Brief</h1><p style="color:#555"><span class="spin"></span>Generating full brief… this typically takes 20–60 seconds while Claude synthesizes per-source analyses.</p>`);
    }
    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 => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[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, '</p><p>').replace(/\n/g, '<br/>');
    const bullets = (s) => safe(s)
      .split(/\n+/)
      .map(line => line.trim().replace(/^[•\-\*]\s*/, ''))
      .filter(Boolean)
      .map(line => `<li>${line}</li>`).join('');

    // ── Build the brief document ──
    const tp = activeWeb.tradeProposal;
    const tradeBlock = tp ? `
<section class="trade">
  <div class="kicker">PROPOSED TRADE</div>
  <h2>${safe(tp.title)}</h2>
  ${tp.subtitle ? `<div class="trade-sub">${safe(tp.subtitle)}</div>` : ''}
  ${tp.rationale ? `<p class="trade-rat">${safe(tp.rationale)}</p>` : ''}
  ${tp.legs?.length ? `<table class="legs"><thead><tr><th>Side</th><th>Instrument</th><th>Size</th><th>Rationale</th></tr></thead><tbody>${tp.legs.map(l => `<tr><td class="${(l.side||'').toLowerCase()}">${safe((l.side||'').toUpperCase())}</td><td><b>${safe(l.instrument||'')}</b></td><td>${safe(l.size||'')}</td><td>${safe(l.rationale||'')}</td></tr>`).join('')}</tbody></table>` : ''}
  ${tp.rails?.length ? `<div class="rails">${tp.rails.map(r => `<div class="rail"><div class="rail-l">${safe(r.label)}</div><div class="rail-v">${safe(r.value)}</div></div>`).join('')}</div>` : ''}
</section>` : '';

    const thesisBlock = theses.length ? `
<section>
  <div class="kicker">THESES · ${theses.length}</div>
  ${theses.map((t, i) => {
    const links = edgesOf(t.id);
    const linkList = links.length ? `<div class="edges">${links.map(e => {
      const other = e.from === t.id ? e.to : e.from;
      const dir = e.from === t.id ? '→' : '←';
      return `<span class="edge"><span class="ek">${safe(e.kind)}</span> ${dir} ${safe(labelOf(other))}</span>`;
    }).join('')}</div>` : '';
    return `<article class="thesis"><h3>${safe(t.label)} <span class="id">[${t.id}]</span></h3>${t.summary ? `<div class="lede">${safe(t.summary)}</div>` : ''}<div class="note"><p>${para(thesisNotes[i] || '_Synthesis unavailable_')}</p></div>${linkList}</article>`;
  }).join('')}
</section>` : '';

    const catalystBlock = catalysts.length ? `
<section>
  <div class="kicker">CATALYST TIMELINE · ${catalysts.length}</div>
  <ol class="timeline">${catalysts.map(c => `<li class="cat ${c.impact || 'med'}"><div class="cat-date">${safe(c.date || 'TBD')}</div><div class="cat-body"><div class="cat-label">${safe(c.label)} <span class="impact ${c.impact||'med'}">${safe((c.impact||'med').toUpperCase())}</span></div>${c.summary ? `<div class="cat-sum">${safe(c.summary)}</div>` : ''}</div></li>`).join('')}</ol>
</section>` : '';

    const tickerBlock = tickers.length ? `
<section>
  <div class="kicker">TICKER BOARD · ${tickers.length}</div>
  <table class="tickers"><thead><tr><th>Symbol</th><th>Name</th><th class="num">Price</th><th class="num">Δ</th><th>Sentiment</th></tr></thead><tbody>${tickers.map(t => `<tr><td><b>${safe(t.label)}</b></td><td>${safe(t.name||'')}</td><td class="num">${t.price != null ? '$'+t.price : '—'}</td><td class="num ${(t.change||0) >= 0 ? 'pos' : 'neg'}">${t.change != null ? (t.change > 0 ? '+' : '') + t.change + '%' : '—'}</td><td>${safe(t.sentiment||'')}</td></tr>`).join('')}</tbody></table>
</section>` : '';

    const sourceBlock = sources.length ? `
<section>
  <div class="kicker">SOURCES · ${sources.length}</div>
  ${sources.map((s, i) => `<article class="source"><h4>${safe(s.label)} <span class="id">[${s.id}]</span></h4><div class="src-meta">${safe(s.publisher||'')}${s.date ? ' · ' + safe(s.date) : ''}</div>${s.url ? `<div class="src-url"><a href="${safe(s.url)}">${safe(s.url)}</a></div>` : ''}${s.excerpt ? `<blockquote>${safe(s.excerpt)}</blockquote>` : ''}<div class="src-digest"><div class="src-digest-l">Key points</div><ul>${bullets(sourceDigests[i] || '')}</ul></div></article>`).join('')}
</section>` : '';

    const html = `<!doctype html>
<html><head><meta charset="utf-8"/><title>${safe(activeWeb.name)} — Research Brief</title>
<style>
  @page { size: letter; margin: 0.6in; }
  * { box-sizing: border-box; }
  body { font-family: 'Helvetica Neue', Arial, sans-serif; color: #1a1a1a; line-height: 1.55; font-size: 10.5pt; max-width: 7.4in; margin: 0 auto; padding: 0.4in 0; background: #fafaf7; }
  .head { border-bottom: 2px solid #1a1a1a; padding-bottom: 14px; margin-bottom: 22px; }
  .head .meta { color: #666; font-size: 9pt; letter-spacing: 0.1em; text-transform: uppercase; margin-bottom: 6px; }
  .head h1 { font-family: 'Times New Roman', Georgia, serif; font-size: 26pt; line-height: 1.1; margin: 0 0 6px; letter-spacing: -0.01em; }
  .head .tagline { color: #444; font-size: 12pt; font-style: italic; }
  .head .stamp { color: #888; font-size: 9pt; margin-top: 10px; display: flex; gap: 16px; flex-wrap: wrap; }
  .head .stamp b { color: #1a1a1a; font-weight: 600; }
  section { margin: 26px 0; page-break-inside: avoid; }
  .kicker { font-size: 9pt; letter-spacing: 0.15em; text-transform: uppercase; color: #888; border-bottom: 1px solid #ddd; padding-bottom: 4px; margin-bottom: 12px; font-weight: 600; }
  h2 { font-family: 'Times New Roman', Georgia, serif; font-size: 18pt; margin: 0 0 4px; line-height: 1.2; }
  h3 { font-family: 'Times New Roman', Georgia, serif; font-size: 13.5pt; margin: 18px 0 4px; line-height: 1.25; }
  h4 { font-size: 11.5pt; margin: 16px 0 2px; font-weight: 600; }
  .id { color: #aaa; font-family: 'Courier New', monospace; font-size: 9pt; font-weight: normal; }
  .exec { background: #fff; border-left: 3px solid #1a1a1a; padding: 14px 18px; margin-bottom: 22px; font-size: 11pt; }
  .exec p { margin: 0 0 10px; }
  .exec p:last-child { margin-bottom: 0; }
  /* Trade */
  .trade { background: #fff; border: 1px solid #1a1a1a; padding: 18px 20px; }
  .trade-sub { color: #555; font-size: 11pt; margin-bottom: 8px; font-style: italic; }
  .trade-rat { margin: 8px 0 14px; color: #333; }
  table.legs { width: 100%; border-collapse: collapse; margin-top: 8px; font-size: 10pt; }
  table.legs th { text-align: left; font-weight: 600; padding: 6px 8px; border-bottom: 1px solid #1a1a1a; font-size: 9pt; letter-spacing: 0.08em; text-transform: uppercase; color: #666; }
  table.legs td { padding: 8px; border-bottom: 1px solid #eee; vertical-align: top; }
  table.legs td.long { color: #0a7d2e; font-weight: 700; font-size: 9pt; letter-spacing: 0.08em; }
  table.legs td.short { color: #c43d1f; font-weight: 700; font-size: 9pt; letter-spacing: 0.08em; }
  .rails { display: flex; gap: 18px; margin-top: 14px; padding-top: 12px; border-top: 1px solid #ddd; flex-wrap: wrap; }
  .rail-l { font-size: 8.5pt; color: #888; letter-spacing: 0.08em; text-transform: uppercase; }
  .rail-v { font-size: 12pt; font-weight: 600; margin-top: 2px; }
  /* Theses */
  .thesis { padding: 12px 0 14px; border-bottom: 1px solid #e5e5e0; }
  .thesis:last-child { border-bottom: none; }
  .lede { color: #444; font-style: italic; font-size: 10.5pt; margin-bottom: 8px; }
  .note p { margin: 0 0 8px; }
  .edges { margin-top: 10px; display: flex; flex-wrap: wrap; gap: 8px; font-size: 9pt; }
  .edge { background: #f0eee8; padding: 3px 8px; border-radius: 2px; color: #555; }
  .edge .ek { color: #888; font-style: italic; }
  /* Timeline */
  ol.timeline { list-style: none; padding: 0; margin: 0; }
  li.cat { display: flex; gap: 16px; padding: 10px 0; border-bottom: 1px solid #e5e5e0; }
  li.cat:last-child { border-bottom: none; }
  .cat-date { flex: 0 0 110px; font-family: 'Courier New', monospace; font-size: 10pt; color: #1a1a1a; font-weight: 600; padding-top: 1px; }
  .cat-body { flex: 1; }
  .cat-label { font-weight: 600; font-size: 10.5pt; }
  .cat-sum { color: #555; font-size: 10pt; margin-top: 2px; }
  .impact { font-size: 8pt; padding: 2px 6px; border-radius: 2px; letter-spacing: 0.08em; margin-left: 6px; vertical-align: 2px; }
  .impact.high { background: #c43d1f; color: #fff; }
  .impact.med { background: #d4b800; color: #1a1a1a; }
  .impact.low { background: #ddd; color: #555; }
  /* Tickers */
  table.tickers { width: 100%; border-collapse: collapse; font-size: 10pt; }
  table.tickers th { text-align: left; font-weight: 600; padding: 6px 8px; border-bottom: 2px solid #1a1a1a; font-size: 9pt; letter-spacing: 0.08em; text-transform: uppercase; color: #666; }
  table.tickers th.num, table.tickers td.num { text-align: right; font-family: 'Courier New', monospace; }
  table.tickers td { padding: 7px 8px; border-bottom: 1px solid #eee; }
  table.tickers td.pos { color: #0a7d2e; }
  table.tickers td.neg { color: #c43d1f; }
  /* Sources */
  .source { padding: 12px 0 14px; border-bottom: 1px solid #e5e5e0; page-break-inside: avoid; }
  .source:last-child { border-bottom: none; }
  .src-meta { color: #888; font-size: 9.5pt; letter-spacing: 0.05em; text-transform: uppercase; margin-bottom: 4px; }
  .src-url { font-size: 9.5pt; word-break: break-all; margin-bottom: 8px; }
  .src-url a { color: #1a4dde; }
  blockquote { font-style: italic; color: #444; border-left: 2px solid #aaa; padding-left: 12px; margin: 8px 0; font-size: 10.5pt; }
  .src-digest-l { font-size: 8.5pt; letter-spacing: 0.1em; text-transform: uppercase; color: #888; margin-top: 8px; margin-bottom: 4px; }
  .source ul { margin: 0; padding-left: 18px; font-size: 10pt; }
  .source li { margin-bottom: 3px; }
  .footer { margin-top: 40px; padding-top: 14px; border-top: 1px solid #1a1a1a; color: #888; font-size: 9pt; display: flex; justify-content: space-between; }
  .warn { background: #fff7d6; padding: 10px 12px; font-size: 9.5pt; color: #6b5d00; margin-top: 18px; border-left: 3px solid #d4b800; }
</style></head>
<body>
  <div class="head">
    <div class="meta">TIBEB · INVESTING BRAIN · RESEARCH BRIEF</div>
    <h1>${safe(activeWeb.name)}</h1>
    ${activeWeb.tagline ? `<div class="tagline">${safe(activeWeb.tagline)}</div>` : ''}
    <div class="stamp">
      <span><b>Generated</b> ${today}</span>
      <span><b>${nodes.length}</b> nodes</span>
      <span><b>${edges.length}</b> edges</span>
      <span><b>${theses.length}</b> theses · <b>${tickers.length}</b> tickers · <b>${catalysts.length}</b> catalysts · <b>${sources.length}</b> sources</span>
    </div>
  </div>

  ${execSummary ? `<section><div class="kicker">EXECUTIVE SUMMARY</div><div class="exec"><p>${para(execSummary)}</p></div></section>` : ''}

  ${tradeBlock}
  ${thesisBlock}
  ${catalystBlock}
  ${tickerBlock}
  ${sourceBlock}

  <div class="warn">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.</div>

  <div class="footer">
    <span>Generated ${today} · Tibeb · Investing Brain</span>
    <span>${safe(activeWeb.name)}</span>
  </div>
  <script>setTimeout(() => window.print(), 600);</script>
</body></html>`;

    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/<slug>.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 (
    <div className="app">
      <header className="topbar">
        {/* 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. */}
        <div className="brand-row" style={{
          display: 'inline-flex', alignItems: 'center', gap: 10,
          fontSize: 13, letterSpacing: '0.04em', flexShrink: 0,
        }}>
          <span style={{
            display: 'inline-block', width: 8, height: 8, borderRadius: '50%',
            background: 'var(--accent)', boxShadow: '0 0 6px var(--accent)',
          }}/>
          <span style={{ fontFamily: 'Instrument Serif, serif', fontStyle: 'italic',
                         color: 'var(--text)', fontSize: 18 }}>
            Tibeb
          </span>
        </div>
        {/* 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. */}
        <AutonomyPill />
        <div className="topbar-meta">
          <span><b>{nodes.filter(n=>n.type==='thesis').length}</b> theses</span>
          <span><b>{nodes.filter(n=>n.type==='ticker').length}</b> watching</span>
          <span><b>{nodes.length}</b> nodes · <b>{edges.length}</b> edges</span>
          <span className="topbar-status"><span className="dot"/>Live · paper</span>
          {/* 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. */}
          <RegimeChip />
          {/* 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 && (
            <span style={{ position: 'relative', display: 'inline-block', marginLeft: 4 }}>
              <span
                onClick={() => 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,
                }}
              >
                <span style={{
                  width: 7, height: 7, borderRadius: '50%', background: '#ff9c5e',
                  animation: 'pf-live-pulse 1.4s ease-in-out infinite',
                }}/>
                MAD MAX · {mmRunningCount}
              </span>
              {mmPopoverOpen && (
                <>
                  {/* Click-outside backdrop */}
                  <div
                    onClick={() => setMmPopoverOpen(false)}
                    style={{ position: 'fixed', inset: 0, zIndex: 90, background: 'transparent' }}
                  />
                  <div style={{
                    position: 'absolute', top: 'calc(100% + 6px)', right: 0,
                    minWidth: 320, maxWidth: 460, zIndex: 91,
                    background: 'var(--bg)',
                    border: '1px solid rgba(255,128,64,0.5)',
                    borderRadius: 4, padding: '8px 6px',
                    boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
                    fontFamily: 'var(--font-body)',
                  }}>
                    <div style={{
                      fontSize: 9, letterSpacing: '0.18em', textTransform: 'uppercase',
                      color: '#ff9c5e', fontWeight: 700, padding: '4px 8px 8px',
                      borderBottom: '1px dashed var(--line)',
                    }}>
                      {mmRunningCount} run{mmRunningCount === 1 ? '' : 's'} live
                    </div>
                    {Object.entries(mmByWeb)
                      .filter(([, s]) => s.running)
                      .map(([webId, s]) => {
                        const w = webs.find(x => x.id === webId);
                        const name = w?.name || webId;
                        const iter = s.progress?.iter ?? 0;
                        const max = s.progress?.max ?? 24;
                        const nodes = s.progress?.nodeCount ?? 0;
                        const lastLog = (s.log && s.log.length)
                          ? s.log[s.log.length - 1]?.text || ''
                          : '';
                        const pct = max > 0 ? Math.min(100, Math.round((iter / max) * 100)) : 0;
                        const isActive = webId === activeWebId;
                        return (
                          <div
                            key={webId}
                            onClick={() => {
                              if (webId !== activeWebId) setActiveWebId(webId);
                              setMmOpen(true);
                              setMmPopoverOpen(false);
                            }}
                            style={{
                              padding: '8px 8px',
                              borderRadius: 3,
                              cursor: 'pointer',
                              background: isActive ? 'rgba(255,128,64,0.08)' : 'transparent',
                              transition: 'background 0.12s ease',
                            }}
                            onMouseEnter={e => e.currentTarget.style.background = 'rgba(255,128,64,0.14)'}
                            onMouseLeave={e => e.currentTarget.style.background = isActive ? 'rgba(255,128,64,0.08)' : 'transparent'}
                          >
                            <div style={{
                              display: 'flex', justifyContent: 'space-between', alignItems: 'baseline',
                              gap: 8, marginBottom: 4,
                            }}>
                              <span style={{
                                fontSize: 12, color: 'var(--text)',
                                overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
                                fontWeight: isActive ? 600 : 400,
                              }}>
                                {name}
                              </span>
                              <span style={{
                                fontSize: 10, fontFamily: "'JetBrains Mono', monospace",
                                color: '#ff9c5e', whiteSpace: 'nowrap',
                              }}>
                                {iter}/{max} · {nodes} nodes
                              </span>
                            </div>
                            {/* Progress bar */}
                            <div style={{
                              height: 3, background: 'rgba(255,255,255,0.05)',
                              borderRadius: 2, overflow: 'hidden', marginBottom: 4,
                            }}>
                              <div style={{
                                width: `${pct}%`, height: '100%', background: '#ff9c5e',
                                transition: 'width 0.4s ease',
                              }}/>
                            </div>
                            {lastLog && (
                              <div style={{
                                fontSize: 10, color: 'var(--text-dim)',
                                fontFamily: "'JetBrains Mono', monospace",
                                overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
                              }}>
                                {lastLog.slice(0, 80)}{lastLog.length > 80 ? '…' : ''}
                              </div>
                            )}
                          </div>
                        );
                      })}
                  </div>
                </>
              )}
            </span>
          )}

          {isPersistedUser && (
            <span
              className={`web-save-chip web-save-${webSaveStatus}`}
              onClick={webSaveStatus === 'failed' ? forceSaveActive : undefined}
              title={webSaveStatus === 'failed'
                ? `Save failed: ${webSaveError || 'unknown'} — click to retry`
                : webSaveStatus === 'saved' && lastSavedAt
                  ? `Last saved at ${new Date(lastSavedAt).toLocaleTimeString()}`
                  : webSaveStatus === 'saving' ? 'Saving to server…'
                  : (activeWeb && activeWeb._dirty) ? 'Unsaved changes — auto-saves 800ms after you stop'
                  : 'All changes synced to the server'}
              style={{
                marginLeft: 4, padding: '2px 8px', fontSize: 10,
                fontFamily: "'JetBrains Mono', monospace",
                borderRadius: 3,
                cursor: webSaveStatus === 'failed' ? 'pointer' : 'default',
                background: webSaveStatus === 'failed' ? '#5e2424'
                          : webSaveStatus === 'saving' ? '#2a3a4a'
                          : webSaveStatus === 'saved'  ? '#2a4a35'
                          : (activeWeb && activeWeb._dirty) ? '#3a3328'
                          : '#1c1c24',
                color: webSaveStatus === 'failed' ? '#ff8888'
                     : webSaveStatus === 'saving' ? '#88ccff'
                     : webSaveStatus === 'saved'  ? '#88ddaa'
                     : (activeWeb && activeWeb._dirty) ? '#d9b878'
                     : 'var(--text-dim)',
                border: `1px solid ${webSaveStatus === 'failed' ? '#a04444' : 'var(--line)'}`,
              }}
            >
              {webSaveStatus === 'saving' ? 'saving…'
                : webSaveStatus === 'saved'  ? 'saved ✓'
                : webSaveStatus === 'failed' ? 'save failed · retry'
                : (activeWeb && activeWeb._dirty) ? 'unsaved'
                : 'synced'}
            </span>
          )}
          {/* 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 && (
            <window.LiveFeedBadge onClick={() => 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. */}
          <button className="agents-btn" onClick={() => setToolsMenuOpen(v => !v)}
                  title="Open quick tools menu (Export, Team, Brief, Health, Watchlist, Agents, Autonomy, Memos, Asymm, Tests).">
            Tools ▾
          </button>
          {toolsMenuOpen && (
            <div className="tools-menu">
              <button onClick={() => { exportWeb(); setToolsMenuOpen(false); }}>Export ({nodes.length}n)</button>
              <button onClick={() => { setTeamOpen(true); setToolsMenuOpen(false); }}>Team</button>
              <button onClick={() => { window.open('/api/morning-brief/preview', '_blank', 'noopener'); setToolsMenuOpen(false); }}>Brief</button>
              <button onClick={() => { setHealthOpen(true); setToolsMenuOpen(false); }}>Health</button>
              <button onClick={() => { setWatchlistOpen(true); setToolsMenuOpen(false); }}>Watchlist</button>
              <button onClick={() => { setAgentsOpen(true); setToolsMenuOpen(false); }}>Agents</button>
              <button onClick={() => { setAutonomyOpen(true); setToolsMenuOpen(false); }}>Autonomy</button>
              <button onClick={() => { setMemosOpen(true); setToolsMenuOpen(false); }}>Memos</button>
              <button onClick={() => { setAsymmOpen(true); setToolsMenuOpen(false); }}>Asymm</button>
              <button onClick={() => { setTestsOpen(true); setToolsMenuOpen(false); }}>Tests</button>
            </div>
          )}
          {/* 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 && <window.PushControl/>}
          <button
            className={`invent-btn ${inventing ? 'running' : ''}`}
            onClick={inventHypothesis}
            disabled={inventing}
            title="Invent a sharp binary, event-based hypothesis grounded in your Investing Brain"
          >
            <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
              <path d="M8 1.5l1.6 4.4L14 7l-3.4 2.5.7 4.5L8 12l-3.3 2 .7-4.5L2 7l4.4-1.1L8 1.5z" strokeLinejoin="round"/>
            </svg>
            {inventing ? 'Inventing…' : 'Invent'}
          </button>
          {/* 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). */}
          <button className={`madmax-btn ${mmRunning ? 'running' : ''}`} onClick={startMadMax} title="Iterative deep research — builds 100 nodes for/against the hypothesis then proposes a verdict. Memos feed Sunday self-review for durable truth promotion.">
            <svg className="skull" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
              <path d="M8 1L2 4v6l6 4 6-4V4L8 1z"/>
              <path d="M8 6l-2 2 2 2 2-2-2-2z" fill="currentColor"/>
            </svg>
            {mmRunning ? 'Mad Max digging…' : 'Mad Max'}
          </button>
          {/* 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 && (
            <div className="user-chip" title={`${currentUser.email}${currentUser.role !== 'viewer' ? ` · ${currentUser.role}` : ''}`}>
              <span className="user-chip-email">{currentUser.email.split('@')[0]}</span>
              <button
                className="user-chip-logout"
                onClick={() => {
                  if (window.confirm('Sign out?')) {
                    if (window.tibebLogout) window.tibebLogout();
                    else { try { localStorage.removeItem('tibeb.session'); } catch {}; location.replace('/login.html'); }
                  }
                }}
                title="Sign out"
              >⏻</button>
            </div>
          )}
        </div>
      </header>

      <div className="tab-bar">
        <button className={activeTab === 'research' ? 'active' : ''}
                onClick={() => setActiveTab('research')}
                title="Start here. Type an idea, get research, place a trade.">
          <span className="tab-num">1</span>Idea & Research
        </button>
        <button className={activeTab === 'portfolio' ? 'active' : ''}
                onClick={() => setActiveTab('portfolio')}
                title="Your live Alpaca paper book — execute recs, track positions.">
          <span className="tab-num">2</span>Trades & Book
        </button>
        <button className={activeTab === 'brain' ? 'active' : ''}
                onClick={() => setActiveTab('brain')}
                title="The full Markdown vault — every thesis, framework, and position note.">
          <span className="tab-num">3</span>Brain & Notes
        </button>
        <button className={activeTab === 'today' ? 'active' : ''}
                onClick={() => setActiveTab('today')}
                title="Today's brief + each persona's same-day memos. Replaces email as the morning surface.">
          <span className="tab-num">4</span>Today
        </button>
      </div>

      {activeTab === 'brain' && window.BrainWorkspace && <window.BrainWorkspace/>}
      {activeTab === 'portfolio' && window.PortfolioWorkspace && <window.PortfolioWorkspace/>}
      {activeTab === 'today' && window.TodayWorkspace && <window.TodayWorkspace/>}

      {activeTab === 'research' && (
      <div className={`workspace${chatCollapsed ? ' workspace--chat-collapsed' : ''}`}>
      <LeftRail
        collapsed={collapsed} setCollapsed={setCollapsed}
        webs={webs}
        activeWebId={activeWebId}
        setActiveWebId={setActiveWebId}
        onCreateWeb={onCreateWeb}
        onRenameWeb={onRenameWeb}
        onDeleteWeb={onDeleteWeb}
      />

      <main className="canvas-area">
        <div className="canvas-bar">
          <div className="bar-left">
            <span className="bar-eyebrow">Workspace</span>
            <span className="bar-title">{activeWeb.name} · <em>{activeWeb.tagline}</em></span>
          </div>
          <div className="bar-right">
            <button className="bar-btn" onClick={verifySources} disabled={verifying}
                    title="Backfill: run web_search on every Mad Max source in this web that hasn't been verified yet. ~$0.02-0.04 per source. Won't re-verify already-verified ones.">
              <svg width="11" height="11" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5">
                <path d="M3 6l2 2 4-4" strokeLinecap="round" strokeLinejoin="round"/>
              </svg>
              {verifying ? `Verifying ${verifyProgress.done}/${verifyProgress.total}…` : 'Verify sources'}
            </button>
            <button className="bar-btn" onClick={() => setLibrarySearchOpen(true)}
                    title="Library Search — find anything in the brain (frameworks, sectors, stocks, captures, agents, SEC filings).">
              <svg width="11" height="11" viewBox="0 0 12 12"><circle cx="5" cy="5" r="3.2" stroke="currentColor" strokeWidth="1.5" fill="none"/><path d="M7.5 7.5 L10.5 10.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/></svg>
              Search <kbd>⌘K</kbd>
            </button>
            <button className="bar-btn" onClick={() => setQuickOpen(true)}
                    title="Quick add — drop a node onto the canvas (thesis, ticker, catalyst, source).">
              <svg width="11" height="11" viewBox="0 0 12 12"><path d="M6 2v8M2 6h8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/></svg>
              Quick add <kbd>⌘I</kbd>
            </button>
          </div>
        </div>

        <HypothesisBanner
          hypothesis={activeWeb.hypothesis}
          setHypothesis={setHypothesis}
          onInvent={inventHypothesis}
          inventing={inventing}
        />

        <ThoughtGraph
          nodes={nodes}
          edges={edges}
          webId={activeWebId}
          selected={selected} setSelected={setSelected}
          focused={focused} setFocused={setFocused}
          onShortcut={onShortcut}
          onConnect={(fromId, toId) => {
            // Obsidian-style shift-drag connect — derives a default edge
            // `kind` from the node-type pair so the user doesn't have to
            // pick one. They can rename via the right-click menu later.
            const a = nodes.find(n => n.id === fromId);
            const b = nodes.find(n => n.id === toId);
            if (!a || !b) return;
            // Don't double-connect the same pair in the same direction
            if (edges.some(e => e.from === fromId && e.to === toId)) return;
            let kind = 'mentions';
            if (a.type === 'source' && b.type === 'thesis') kind = 'supports';
            else if (a.type === 'thesis' && b.type === 'source') kind = 'mentions';
            else if (a.type === 'thesis' && b.type === 'thesis') kind = 'supports';
            else if (a.type === 'catalyst' && b.type === 'ticker') kind = 'triggers';
            else if (a.type === 'catalyst' || b.type === 'catalyst') kind = 'triggers';
            else if (a.type === 'ticker' || b.type === 'ticker') kind = 'mentions';
            setEdges(es => [...es, { from: fromId, to: toId, kind }]);
            showToast(`Connected · ${a.label} ${kind} → ${b.label}`);
          }}
        />

        {/* Don't pop the NodeDetail drawer for the hypothesis node — the
            HypothesisBanner above already shows the same text. Showing
            both creates a "jumbled mess" with duplicated 28px serif
            italic copies of the question wrapping in two columns. The
            user can edit the hypothesis directly in the banner. */}
        {focusedNode && !focusedNode.isHypothesis && (
          <NodeDetail
            node={focusedNode}
            allNodes={nodes}
            edges={edges}
            news={activeWeb.news}
            setFocused={setFocused}
            onClose={() => setFocused(null)}
            onNewThesis={onNewThesis}
            onDeleteNode={(id) => {
              setNodes(ns => ns.filter(n => n.id !== id));
              setEdges(es => es.filter(e => e.from !== id && e.to !== id));
              setFocused(null);
              setSelected(s => s.filter(x => x !== id));
              showToast('Node deleted.');
            }}
            onUpdateNode={(id, patch) => {
              setNodes(ns => ns.map(n => n.id === id ? { ...n, ...patch } : n));
              const target = nodes.find(n => n.id === id);
              if (target && target.isHypothesis && patch.label !== undefined) {
                setHypothesis({ question: patch.label });
              }
            }}
          />
        )}

        {/* Stock Documents — pulls live SEC EDGAR filings for every ticker
            on the canvas. Real working sec.gov links, no hallucination.
            Lazy-loaded per ticker on first expand to keep boot fast. */}
        {window.StockDocumentsPanel && (
          <window.StockDocumentsPanel
            tickers={Array.from(new Set(nodes.filter(n => n.type === 'ticker').map(n => n.label))).slice(0, 12)}
          />
        )}
      </main>

      <TibebChat
        allNodes={nodes}
        edges={edges}
        activeWeb={activeWeb}
        tweaks={tweaks}
        selected={selected}
        setFocused={setFocused}
        onConnectTrade={onConnectTrade}
        tradeProposal={activeWeb.tradeProposal}
        onAcceptTrade={onAcceptTrade}
        onDismissTrade={onDismissTrade}
        onRefineTrade={onRefineTrade}
        tradeAccepted={tradeAccepted}
        tradeDismissed={tradeDismissed}
        collapsed={chatCollapsed}
        setCollapsed={setChatCollapsed}
      />

      </div>
      )}
      <QuickAdd open={quickOpen} onClose={() => setQuickOpen(false)} onAdd={onAddNode}/>
      <ContextMenu menu={contextMenu} onClose={() => setContextMenu(null)} onAction={onContextAction}/>
      <Toast toast={toast}/>
      <TibebTweaks tweaks={tweaks} setTweak={setTweak}/>
      <MadMaxOverlay
        open={mmOpen}
        /* Phase 6 S — onClose now backgrounds (keeps run alive). The
           overlay's explicit Stop button calls onHalt for the
           old-style abort. */
        onClose={backgroundMadMax}
        onHalt={stopMadMax}
        running={mmRunning}
        log={mmLog}
        progress={mmProgress}
        result={mmResult}
        onAcceptTrade={acceptMadMaxTrade}
        /* Phase 2A.3 — passed so the canonical-math button can extract
           hypothesis + edges from the active web's React state. */
        activeWeb={activeWeb}
      />
      {window.TeamOverlay && (
        <window.TeamOverlay
          open={teamOpen}
          onClose={() => setTeamOpen(false)}
          topic={(activeWeb?.hypothesis?.question || '').trim() || activeWeb?.name}
          hypothesis={activeWeb?.hypothesis}
        />
      )}
      {window.HealthPanel && (
        <window.HealthPanel
          open={healthOpen}
          onClose={() => setHealthOpen(false)}
        />
      )}
      {window.AutonomyPanel && (
        <window.AutonomyPanel
          open={autonomyOpen}
          onClose={() => setAutonomyOpen(false)}
        />
      )}
      {window.MemoTimeline && (
        <window.MemoTimeline
          open={memosOpen}
          onClose={() => setMemosOpen(false)}
        />
      )}
      {window.LiveFeed && (
        <window.LiveFeed
          open={liveFeedOpen}
          onClose={() => setLiveFeedOpen(false)}
        />
      )}
      {window.AsymmetricTheses && (
        <window.AsymmetricTheses
          open={asymmOpen}
          onClose={() => setAsymmOpen(false)}
        />
      )}
      {window.AgentTests && (
        <window.AgentTests
          open={testsOpen}
          onClose={() => setTestsOpen(false)}
        />
      )}
      {window.TickerContext && activeTicker && (
        <window.TickerContext
          ticker={activeTicker}
          onClose={() => setActiveTicker(null)}
        />
      )}
      {window.AgentsPanel && (
        <window.AgentsPanel
          open={agentsOpen}
          onClose={() => setAgentsOpen(false)}
        />
      )}
      {window.LibrarySearch && (
        <window.LibrarySearch
          open={librarySearchOpen}
          onClose={() => setLibrarySearchOpen(false)}
          onOpenNote={(path) => {
            // Hand off to the Brain tab editor by switching tabs + setting
            // localStorage. The Brain tab boot picks up the path and opens it.
            try { localStorage.setItem('jarvis.brain.openPath', path); } catch {}
            setActiveTab('brain');
            setLibrarySearchOpen(false);
          }}
        />
      )}
      {window.WatchlistPanel && (
        <window.WatchlistPanel
          open={watchlistOpen}
          onClose={() => setWatchlistOpen(false)}
        />
      )}
    </div>
  );
}

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App/>);
