// BrainWorkspace — Obsidian-style editor for the Investing Brain vault, with
// Tibeb chat + graphify graph in the side pane and chat-driven file ingest.
//
// Reads /api/vault/tree, /api/vault/note?path=, /api/vault/graphify; writes via
// PUT /api/vault/note and POST /api/vault/ingest. Saves trigger
// /api/agent/reload server-side so Tibeb sees the new note immediately.
//
// When the open note is a persona (path starts with `0-Agents/`), an
// auto-grown-learnings strip shows above the editor + a learnings panel
// below it. Both render the output of the parallel autonomous
// persona-self-review engine. REVIEW NOW / ROLLBACK controls live in
// the strip.
//
// Auth: every `/api/` fetch in this app is intercepted by gateAuth() in
// index.html, which attaches the `Authorization: Bearer <token>` header
// pulled from `localStorage.tibeb.session`. So brain.jsx never has to
// touch auth headers itself — plain `fetch('/api/...')` is enough.
const { useState, useEffect, useMemo, useRef, useCallback } = React;

const VAULT_BASE = '';

async function apiGet(path) {
  const r = await fetch(`${VAULT_BASE}${path}`);
  if (!r.ok) throw new Error(`${path}: ${r.status}`);
  return r.json();
}

async function apiPut(path, body) {
  const r = await fetch(`${VAULT_BASE}${path}`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });
  if (!r.ok) throw new Error(`${path}: ${r.status} — ${await r.text().catch(() => '')}`);
  return r.json();
}

async function apiPost(path, body) {
  const r = await fetch(`${VAULT_BASE}${path}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body || {}),
  });
  if (!r.ok) {
    const err = new Error(`${path}: ${r.status} — ${await r.text().catch(() => '')}`);
    err.status = r.status;
    throw err;
  }
  return r.json();
}

// --- Persona path → role helper ---
// Personas live at `0-Agents/<role>.md`. The self-review endpoints want
// the registry's `role:` slug, NOT the filename (e.g. Tibeb.md → jarvis,
// PhD Researcher.md → phd-researcher). Return null when the open path
// isn't a persona — the caller hides the learnings UI in that case.
//
// `registry` (optional): the canonical agent list from
// `/api/agents/registry`. When provided, we look up the role by `file`
// for exact correctness. When the registry hasn't loaded yet, we fall
// back to a derived slug from the filename — good enough for most
// personas where filename ≈ role, breaks for `Tibeb.md → jarvis`.
function personaRoleFromPath(path, registry) {
  if (!path) return null;
  if (!path.startsWith('0-Agents/')) return null;
  // Reject subdirectory paths like `0-Agents/Memory/<role>/<memo>.md` —
  // those are persona memos, not persona definitions. Same for the
  // `_registry.yml` schema file.
  const rel = path.slice('0-Agents/'.length);
  if (rel.includes('/')) return null;
  if (rel.startsWith('_')) return null;
  if (registry && Array.isArray(registry.agents)) {
    const match = registry.agents.find(a => a.file === rel);
    if (match) return match.role;
  }
  // Fallback: legacy slug from filename.
  return rel.replace(/\.md$/i, '').toLowerCase().replace(/\s+/g, '-');
}

function FileTreeNode({ node, depth, expanded, onToggle, onPick, currentPath, filter }) {
  const isDir = node.type === 'dir';
  const isOpen = expanded.has(node.path);
  const matchesFilter = !filter ||
    node.name.toLowerCase().includes(filter) ||
    (node.path || '').toLowerCase().includes(filter);

  if (isDir) {
    const children = (node.children || [])
      .map(c => <FileTreeNode key={c.path || c.name} node={c} depth={depth + 1}
        expanded={expanded} onToggle={onToggle} onPick={onPick}
        currentPath={currentPath} filter={filter}/>)
      .filter(Boolean);
    if (filter && children.length === 0 && !matchesFilter) return null;
    return (
      <div>
        <div className="brain-tree-node dir" style={{ paddingLeft: 16 + depth * 12 }}
             onClick={() => onToggle(node.path)}>
          <span className="brain-tree-caret">{isOpen ? '▾' : '▸'}</span>
          <span>{node.name || 'brain'}</span>
        </div>
        {(isOpen || filter) && (
          <div className="brain-tree-children" style={{ paddingLeft: 0 }}>{children}</div>
        )}
      </div>
    );
  }
  if (!matchesFilter) return null;
  if (!node.name.match(/\.(md|markdown|txt)$/i)) return null;
  return (
    <div className={`brain-tree-node ${currentPath === node.path ? 'active' : ''}`}
         style={{ paddingLeft: 28 + depth * 12 }}
         onClick={() => onPick(node.path)}
         title={node.path}>
      <span style={{ color: 'var(--text-dim)', fontSize: 9 }}>◦</span>
      <span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{node.name.replace(/\.md$/, '')}</span>
    </div>
  );
}

function MarkdownPreview({ content }) {
  const html = useMemo(() => {
    if (!window.marked) return content;
    try {
      return window.marked.parse(content || '', { breaks: true, gfm: true });
    } catch (e) {
      return `<pre>${content}</pre>`;
    }
  }, [content]);
  return <div className="brain-preview" dangerouslySetInnerHTML={{ __html: html }}/>;
}

function BrainGraphView({ onClose, onPickNote }) {
  // Obsidian-style note graph rendered in-page with vis-network.
  //
  // Behavior contract (per user spec):
  //   - Brain tab opens → graph spawns (handled by parent showGraph default)
  //   - SINGLE click a node → highlight its 1-hop neighborhood
  //   - DOUBLE click a node → open that .md file in the editor
  //
  // Sizing strategy: the raw vault has 3500+ nodes and 187k edges
  // (because every note↔ticker creates a co-ticker quadratic blowup).
  // We default to NOTES + WIKILINKS only, hide isolates, and cap to the
  // top 400 by edge degree so the layout stabilizes fast.
  const containerRef = useRef(null);
  const networkRef = useRef(null);
  const dataRef = useRef(null);
  const [stats, setStats] = useState({ visible: 0, totalNotes: 0, totalEdges: 0 });
  const [err, setErr] = useState('');
  const [showTickers, setShowTickers] = useState(false);
  const [showMentions, setShowMentions] = useState(false);
  const [showCoTicker, setShowCoTicker] = useState(false);
  // Focus mode — when set, only the focused entity + its N-hop neighborhood
  // is visible; everything else is hidden. Default depth=1 (direct
  // connections only) because 3-hop fans out into hundreds of nodes on a
  // dense graph. User can crank to 2 or 3 via the depth selector that
  // appears in the toolbar when focus is active.
  const [focus, setFocus] = useState(null); // { type: 'node'|'layer', target }
  const [focusDepth, setFocusDepth] = useState(1);
  const NODE_CAP = 400;
  // Click counter per node — drives the 1/2/3 click behavior:
  //   1 click  → focus this node + 3-hop neighborhood
  //   2 clicks → open the .md file in the editor
  //   3 clicks → unfocus (back to full graph)
  // Counter resets on a different node, after 700ms idle, or on background click.
  const clickStateRef = useRef({ id: null, count: 0, ts: 0 });

  // Map a brain folder path to a layer number. The brain is already
  // numbered (0-Agents, 1-Frameworks, 2-Sectors, ...) so we just use the
  // first character. Tickers and unfoldered notes go to dedicated bands.
  const folderToLevel = (folder, kind) => {
    if (kind === 'ticker') return 9;
    if (!folder) return 8;
    const m = String(folder).trim().match(/^(\d)/);
    return m ? parseInt(m[1], 10) : 8;
  };
  const levelLabels = {
    0: '0 · Agents', 1: '1 · Frameworks', 2: '2 · Sectors',
    3: '3 · Macro', 4: '4 · Stocks', 5: '5 · Newsletters',
    6: '6 · Refresh', 7: '7 · Captures', 8: 'Other', 9: 'Tickers',
  };
  const layerColors = {
    0: '#ff66cc', 1: '#00d4aa', 2: '#3399ff', 3: '#ffaa33',
    4: '#aaff66', 5: '#bb88ff', 6: '#ff7777', 7: '#888899',
    8: '#55556a', 9: '#ffaa33',
  };
  // Layer chip click: toggle focus on this layer (3-hop BFS from all nodes
  // in that layer). Click the same chip again to unfocus.
  const toggleLayer = (lvl) => {
    setFocus(f => (f?.type === 'layer' && f.target === lvl) ? null : { type: 'layer', target: lvl });
  };

  useEffect(() => {
    let cancelled = false;
    setErr('');

    apiGet('/api/ideas/graph').then(raw => {
      if (cancelled || !containerRef.current) return;
      if (!window.vis) {
        setErr('vis-network failed to load (check your network for blocked CDN)');
        return;
      }

      const allNodes = raw.nodes || [];
      const allEdges = raw.edges || [];
      const noteCount = allNodes.filter(n => n.kind === 'note').length;

      // Filter by user's edge-kind toggles
      const wantedKinds = new Set(['wikilink']);
      if (showMentions) wantedKinds.add('mentions');
      if (showCoTicker) wantedKinds.add('co_ticker');

      const filteredEdges = allEdges.filter(e => wantedKinds.has(e.kind));

      // Build degree map from the filtered edges
      const degree = new Map();
      filteredEdges.forEach(e => {
        degree.set(e.source, (degree.get(e.source) || 0) + 1);
        degree.set(e.target, (degree.get(e.target) || 0) + 1);
      });

      // Keep only nodes with at least one edge (drop isolates) and by kind
      // (tickers only if toggled on). Layer-level visibility is now handled
      // by the focus-mode post-effect below — nothing pre-filtered out.
      let candidates = allNodes.filter(n => {
        if (!degree.has(n.id)) return false;
        if (n.kind === 'ticker' && !showTickers && !showMentions) return false;
        return true;
      });

      // Cap to the most connected NODE_CAP nodes
      candidates.sort((a, b) => (degree.get(b.id) || 0) - (degree.get(a.id) || 0));
      const kept = candidates.slice(0, NODE_CAP);
      const keptIds = new Set(kept.map(n => n.id));

      const visNodes = kept.map(n => {
        const level = folderToLevel(n.folder, n.kind);
        const accent = layerColors[level] || '#00d4aa';
        return {
          id: n.id,
          label: n.kind === 'ticker' ? `$${n.label}` : (n.label.length > 32 ? n.label.slice(0, 30) + '…' : n.label),
          title: n.kind === 'note'
            ? `${n.label}\n${n.folder || ''} (${levelLabels[level]})${n.tickers && n.tickers.length ? '\n' + n.tickers.map(t => '$' + t).join(' ') : ''}\n\n1 click: focus 3-hop · 2 clicks: open · 3 clicks: reset`
            : `Ticker: $${n.label}\n\n1 click: focus 3-hop · 3 clicks: reset`,
          // Stash folder + kind on the vis-node so the focus-mode effect can
          // resolve layer membership without re-fetching.
          folder: n.folder,
          shape: n.kind === 'ticker' ? 'box' : 'dot',
          size: n.kind === 'ticker' ? 14 : Math.max(7, Math.min(22, 6 + (degree.get(n.id) || 0) * 0.8)),
          font: n.kind === 'ticker'
            ? { color: accent, size: 11, face: "'JetBrains Mono', monospace" }
            : { color: '#e8e8ed', size: 11, face: "'DM Sans', sans-serif" },
          color: {
            background: n.kind === 'ticker' ? `${accent}22` : '#16161c',
            border: accent,
            highlight: { background: accent, border: accent },
          },
          // Path the double-click handler opens. For a real note this is
          // its actual rel_path; for a ticker/orphan with no backing file,
          // _path is null and the click handler falls through to
          // _suggestedPath instead so the editor can show a stub panel.
          _path: n.rel_path || null,
          _suggestedPath: n.suggested_path || null,
          _referencedBy: n.referenced_by || [],
          _kind: n.kind,
        };
      });

      const edgeColor = {
        wikilink: { color: '#00d4aa', opacity: 0.55 },
        mentions: { color: '#55556a', opacity: 0.4 },
        co_ticker: { color: '#3a3a4a', opacity: 0.25 },
      };
      const visEdges = filteredEdges
        .filter(e => keptIds.has(e.source) && keptIds.has(e.target))
        .map((e, i) => ({
          id: `e${i}`,
          from: e.source,
          to: e.target,
          color: edgeColor[e.kind] || { color: '#444' },
          width: e.kind === 'wikilink' ? 1.4 : (e.kind === 'co_ticker' ? Math.min(3, 0.4 + (e.weight || 1) * 0.4) : 0.7),
          smooth: { type: 'continuous' },
          arrows: e.kind === 'wikilink' ? { to: { enabled: true, scaleFactor: 0.4 } } : undefined,
        }));

      setStats({ visible: visNodes.length, totalNotes: noteCount, totalEdges: visEdges.length });

      const data = { nodes: new window.vis.DataSet(visNodes), edges: new window.vis.DataSet(visEdges) };
      dataRef.current = data;

      const options = {
        nodes: { borderWidth: 1.5 },
        edges: { selectionWidth: 1.8, hoverWidth: 1.5 },
        physics: {
          solver: 'forceAtlas2Based',
          forceAtlas2Based: {
            gravitationalConstant: -55, centralGravity: 0.014,
            springLength: 110, springConstant: 0.05, damping: 0.55, avoidOverlap: 0.5,
          },
          stabilization: { iterations: 180, updateInterval: 25, fit: true },
          maxVelocity: 42,
        },
        interaction: { hover: true, tooltipDelay: 180, navigationButtons: false, keyboard: { enabled: true }, multiselect: false },
        layout: { improvedLayout: visNodes.length <= 250 },
      };

      if (networkRef.current) { try { networkRef.current.destroy(); } catch {} }
      let network;
      try {
        network = new window.vis.Network(containerRef.current, data, options);
      } catch (e) {
        console.error('[brain] vis.Network init failed', e);
        setErr(`vis-network init failed: ${e.message || e}`);
        return;
      }
      networkRef.current = network;

      // Click handler — implements the 1/2/3-click pattern:
      //   1 click  → focus this node + 3-hop neighborhood
      //   2 clicks → open the .md file (or offer to create it for orphans)
      //   3 clicks → reset to full graph
      // Counter is per-node and resets after 700ms idle or on different node.
      network.on('click', (params) => {
        const now = Date.now();
        if (params.nodes.length === 0) {
          // Background click — clear focus + reset counter
          clickStateRef.current = { id: null, count: 0, ts: 0 };
          setFocus(null);
          return;
        }
        const nodeId = params.nodes[0];
        const prev = clickStateRef.current;
        const sameNode = prev.id === nodeId && (now - prev.ts) < 700;
        const count = sameNode ? prev.count + 1 : 1;
        clickStateRef.current = { id: nodeId, count, ts: now };

        if (count === 1) {
          // Focus
          setFocus({ type: 'node', target: nodeId });
        } else if (count === 2) {
          // Phase 6 O — every node click should land somewhere useful,
          // never silently fail. Three branches:
          //   1. Note with backing file → open it (legacy behavior)
          //   2. Ticker/orphan WITH resolved path → open it
          //   3. Ticker/orphan WITHOUT path → open in "stub" mode using
          //      suggested_path; the editor's load handler renders a
          //      "Create stub" panel instead of an error.
          const node = data.nodes.get(nodeId);
          if (!node || !onPickNote) {
            // shouldn't happen; nothing to open
          } else if (node._path) {
            onPickNote(node._path);
          } else if (node._suggestedPath) {
            // Pass the orphan context so the editor can render stub UI
            onPickNote(node._suggestedPath, {
              isOrphan: true,
              label: node.label,
              kind: node._kind,
              referenced_by: node._referencedBy || [],
            });
          }
        } else if (count >= 3) {
          // Reset
          clickStateRef.current = { id: null, count: 0, ts: 0 };
          setFocus(null);
        }
      });

      network.once('stabilizationIterationsDone', () => {
        try { network.fit({ animation: { duration: 600, easingFunction: 'easeOutQuad' } }); } catch {}
      });
    }).catch(e => setErr(e.message));

    return () => {
      cancelled = true;
      if (networkRef.current) { try { networkRef.current.destroy(); } catch {} networkRef.current = null; }
    };
  }, [showTickers, showMentions, showCoTicker, onPickNote]);

  // Focus-mode effect — applies hidden flags to nodes/edges based on focus
  // without rebuilding the network (preserves layout positions).
  useEffect(() => {
    const network = networkRef.current;
    const data = dataRef.current;
    if (!network || !data) return;

    if (!focus) {
      // Show everything
      const allIds = data.nodes.getIds();
      data.nodes.update(allIds.map(id => ({ id, hidden: false })));
      const allEdgeIds = data.edges.getIds();
      data.edges.update(allEdgeIds.map(id => ({ id, hidden: false })));
      return;
    }

    // Compute start set
    let startIds;
    if (focus.type === 'node') {
      startIds = [focus.target];
    } else {
      // type: 'layer' — every node in that layer is a start
      startIds = data.nodes.getIds().filter(id => {
        const n = data.nodes.get(id);
        return n && folderToLevel(n.folder, n._kind) === focus.target;
      });
    }
    if (startIds.length === 0) return;

    // BFS up to focusDepth hops
    const visited = new Set(startIds);
    let frontier = new Set(startIds);
    for (let d = 0; d < focusDepth; d++) {
      const next = new Set();
      data.edges.forEach(e => {
        if (frontier.has(e.from) && !visited.has(e.to)) { next.add(e.to); visited.add(e.to); }
        if (frontier.has(e.to) && !visited.has(e.from)) { next.add(e.from); visited.add(e.from); }
      });
      frontier = next;
      if (frontier.size === 0) break;
    }

    data.nodes.update(data.nodes.getIds().map(id => ({ id, hidden: !visited.has(id) })));
    const edgeUpdates = [];
    data.edges.forEach(e => {
      edgeUpdates.push({ id: e.id, hidden: !(visited.has(e.from) && visited.has(e.to)) });
    });
    data.edges.update(edgeUpdates);
  }, [focus, focusDepth]);

  // Render as a Fragment so the bar + canvas slot directly into the parent
  // .brain-editor's grid rows (44px 1fr). A wrapping div would only take the
  // 44px row and the canvas would have 0 height — that was the blank-graph bug.
  // Help bar adds another row at the top via grid-template-rows override below.
  const Hint = ({ count, what }) => (
    <span style={{
      display: 'inline-flex', alignItems: 'center', gap: 6,
      padding: '4px 10px', borderRadius: 12,
      background: 'var(--bg-3)', border: '1px solid var(--line)',
      fontSize: 10, fontFamily: "'JetBrains Mono', monospace",
      color: 'var(--text-2)', letterSpacing: '0.04em', whiteSpace: 'nowrap',
    }}>
      <span style={{
        background: 'var(--accent-2)', color: 'var(--accent)',
        width: 16, height: 16, borderRadius: '50%',
        display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
        fontWeight: 700, fontSize: 9,
      }}>{count}</span>
      <span>{what}</span>
    </span>
  );
  return (
    <div style={{
      gridRow: '1 / -1',  // span both rows of parent .brain-editor grid
      display: 'flex', flexDirection: 'column',
      height: '100%', overflow: 'hidden', minHeight: 0,
    }}>
      <div style={{
        display: 'flex', alignItems: 'center', gap: 8,
        padding: '8px 20px', borderBottom: '1px solid var(--line)',
        background: 'var(--bg-2)', overflow: 'hidden', flexShrink: 0,
      }}>
        <span style={{
          fontSize: 9, letterSpacing: '0.18em', textTransform: 'uppercase',
          color: 'var(--text-dim)', fontWeight: 700, marginRight: 4,
        }}>Brain graph</span>
        <Hint count="1" what={`click → focus ${focusDepth}-hop`}/>
        <Hint count="2" what="clicks → open .md"/>
        <Hint count="3" what="clicks → reset"/>
        {focus && (
          <span style={{
            display: 'inline-flex', alignItems: 'center', gap: 4,
            marginLeft: 8, padding: '2px 4px',
            background: 'var(--bg-3)', border: '1px solid var(--line)',
            borderRadius: 4, fontSize: 9,
            fontFamily: "'JetBrains Mono', monospace", color: 'var(--text-2)',
          }}>
            <span style={{ marginRight: 4, letterSpacing: '0.1em' }}>DEPTH</span>
            {[1, 2, 3].map(d => (
              <button key={d}
                      onClick={() => setFocusDepth(d)}
                      title={`${d}-hop neighbourhood`}
                      style={{
                        background: focusDepth === d ? 'var(--accent)' : 'transparent',
                        color: focusDepth === d ? 'var(--bg)' : 'var(--text-2)',
                        border: 'none', borderRadius: 2,
                        width: 18, height: 18, lineHeight: 1,
                        fontFamily: 'inherit', fontWeight: 700, fontSize: 10,
                        cursor: 'pointer',
                      }}>{d}</button>
            ))}
          </span>
        )}
        <span style={{ flex: 1 }}/>
        <span className="brain-graph-hint" style={{ fontSize: 10, color: 'var(--text-dim)', fontFamily: "'JetBrains Mono', monospace", whiteSpace: 'nowrap' }}>
          drag · scroll to zoom · click chips below to focus a layer
        </span>
      </div>
      <div className="brain-editor-bar" style={{ flexShrink: 0 }}>
        <div className="brain-editor-path">
          {stats.visible === 0 ? 'graph loading…' : `${stats.visible} nodes · ${stats.totalEdges} edges · 1-click focus · 2-click open · 3-click reset`}
          {focus && focus.type === 'node' && <span style={{ color: 'var(--accent)' }}> · focused on node ({focusDepth}-hop)</span>}
          {focus && focus.type === 'layer' && <span style={{ color: layerColors[focus.target] }}> · focused on {levelLabels[focus.target]} ({focusDepth}-hop)</span>}
          {stats.visible >= NODE_CAP && <span style={{ color: 'var(--catalyst)' }}> · capped at {NODE_CAP} most-connected</span>}
        </div>
        <div className="brain-editor-actions" style={{ flexWrap: 'wrap', gap: 4 }}>
          {/* Per-layer visibility chips. Click to hide/show that brain folder.
              Color matches the node accent so users can map chip → graph color. */}
          {[0, 1, 2, 3, 4, 5, 6, 7].map(lvl => {
            const isFocused = focus?.type === 'layer' && focus.target === lvl;
            const accent = layerColors[lvl];
            // When something else is focused (a node or another layer), dim
            // this chip so the focus chip stands out.
            const dimmed = focus && !isFocused;
            return (
              <button
                key={lvl}
                onClick={() => toggleLayer(lvl)}
                title={`Click: focus ${levelLabels[lvl]} + 3-hop neighbours · click again to unfocus`}
                style={{
                  background: isFocused ? accent : (dimmed ? 'transparent' : accent + '22'),
                  color: isFocused ? 'var(--bg)' : (dimmed ? 'var(--text-dim)' : accent),
                  border: `1px solid ${isFocused ? accent : (dimmed ? 'var(--line)' : accent)}`,
                  padding: '4px 8px',
                  fontSize: 10,
                  fontFamily: "'JetBrains Mono', monospace",
                  fontWeight: 700,
                  letterSpacing: '0.04em',
                  borderRadius: 3,
                  cursor: 'pointer',
                  opacity: dimmed ? 0.5 : 1,
                  boxShadow: isFocused ? `0 0 12px ${accent}66` : 'none',
                }}>
                {lvl}
              </button>
            );
          })}
          {focus && (
            <button onClick={() => setFocus(null)}
                    title="Clear focus — show full graph"
                    style={{
                      background: 'transparent', color: 'var(--text-2)',
                      border: '1px solid var(--line-2)',
                      padding: '4px 8px', fontSize: 10,
                      fontFamily: "'JetBrains Mono', monospace", fontWeight: 700,
                      letterSpacing: '0.04em', borderRadius: 3, cursor: 'pointer',
                    }}>× clear focus</button>
          )}
          <span style={{ width: 8 }}/>
          <button className={`brain-editor-btn ${showTickers ? 'primary' : ''}`}
                  onClick={() => setShowTickers(v => !v)}
                  title="Show ticker nodes">$tickers</button>
          <button className={`brain-editor-btn ${showMentions ? 'primary' : ''}`}
                  onClick={() => setShowMentions(v => !v)}
                  title="Show note→ticker edges">mentions</button>
          <button className={`brain-editor-btn ${showCoTicker ? 'primary' : ''}`}
                  onClick={() => setShowCoTicker(v => !v)}
                  title="Show notes that share a ticker (heavy)">co-ticker</button>
          <button className="brain-editor-btn"
                  title="Re-run graphify on the brain (AST-only, ~5s, no API cost). Picks up any new notes you wrote."
                  onClick={async () => {
                    const r = await fetch('/api/vault/graphify/refresh', { method: 'POST' });
                    if (r.ok) location.reload();
                    else alert('graphify refresh failed: ' + (await r.text()).slice(0, 200));
                  }}>
            ↻ refresh
          </button>
          <button className="brain-editor-btn" onClick={onClose} title="Back to editor">← editor</button>
        </div>
      </div>
      {err
        ? <div className="brain-empty"><div style={{ padding: 24, textAlign: 'center' }}><div className="icon">⚠</div>{err}</div></div>
        : (
          /* flex:1 lets the canvas fill remaining height after the hints bar
             and the editor-bar toolbar are placed. position:relative shell +
             position:absolute canvas keeps vis-network's pixel dimensions
             measurable. minHeight is a safety floor for tiny viewports. */
          <div style={{ position: 'relative', width: '100%', flex: 1, minHeight: 320 }}>
            <div ref={containerRef} style={{ position: 'absolute', inset: 0, background: 'var(--bg)' }}/>
          </div>
        )
      }
    </div>
  );
}

// AutoGrownStatusStrip — thin row that sits above the editor for persona
// notes. Surfaces the learnings counts + REVIEW NOW / ROLLBACK buttons.
// The `unavailable` flag means the backend self-review API hasn't shipped
// yet (404 on GET .../learnings) — we show a graceful notice in that case
// instead of crashing.
function AutoGrownStatusStrip({ role, learnings, unavailable, busy, reviewBanner, onReview, onShowRollback }) {
  if (unavailable) {
    return (
      <div className="auto-grown-status auto-grown-status--unavailable">
        <span className="auto-grown-status-bot">🤖</span>
        Backend self-review API not available yet — the cron will start populating this once the persona-self-review engine ships.
      </div>
    );
  }
  const promotedCount = learnings?.promoted?.length ?? 0;
  const pendingCount = learnings?.pending?.length ?? 0;
  // Last review = newest ts across promoted+pending entries.
  const allTs = [
    ...(learnings?.promoted || []).map(e => e.ts),
    ...(learnings?.pending  || []).map(e => e.ts),
  ].filter(Boolean).sort();
  const lastTs = allTs.length ? allTs[allTs.length - 1] : null;
  const lastDate = lastTs ? lastTs.slice(0, 10) : '—';
  return (
    <div className="auto-grown-status">
      <span className="auto-grown-status-bot">🤖</span>
      <span className="auto-grown-status-stats">
        <strong>{promotedCount}</strong> auto-promoted ·{' '}
        <strong>{pendingCount}</strong> pending · last review <strong>{lastDate}</strong>
        {typeof learnings?.snapshot_versions === 'number' && (
          <span className="auto-grown-status-versions"> · v{learnings.snapshot_versions}</span>
        )}
      </span>
      {reviewBanner && (
        <span className="auto-grown-status-banner">{reviewBanner}</span>
      )}
      <span style={{ flex: 1 }}/>
      <button className="auto-grown-btn"
              onClick={() => onReview(false)}
              disabled={busy}
              title="Run a review now (writes claims, takes a snapshot first). Shift-click for dry-run.">
        {busy ? '…' : 'Review now'}
      </button>
      <button className="auto-grown-btn auto-grown-btn--ghost"
              onClick={() => onReview(true)}
              disabled={busy}
              title="Dry-run: show what the engine would propose without mutating the persona body.">
        Dry-run
      </button>
      <button className="auto-grown-btn"
              onClick={onShowRollback}
              title="View body snapshots and roll back">
        Rollback…
      </button>
    </div>
  );
}

// AutoGrownSection — the larger panel below the editor that lists each
// learning entry as a card.
function AutoGrownSection({ learnings, unavailable }) {
  if (unavailable) return null;
  const promoted = learnings?.promoted || [];
  const pending = learnings?.pending || [];
  const isEmpty = promoted.length === 0 && pending.length === 0;
  return (
    <div className="auto-grown-section">
      <div className="auto-grown-section-header">
        <span className="auto-grown-section-bot">🤖</span>
        <span className="auto-grown-section-title">Auto-grown learnings</span>
        {!isEmpty && (
          <span className="auto-grown-section-meta">
            {promoted.length} auto · {pending.length} pending
          </span>
        )}
      </div>
      {isEmpty ? (
        <div className="auto-grown-empty">
          <div className="auto-grown-empty-icon">∅</div>
          <div>No auto-promoted learnings yet.</div>
          <div className="auto-grown-empty-hint">
            Fire <em>Review now</em> manually or wait for the weekly cron.
          </div>
        </div>
      ) : (
        <>
          {promoted.length > 0 && (
            <LearningList title="LEARNED · AUTO-PROMOTED" tint="accent" entries={promoted}/>
          )}
          {pending.length > 0 && (
            <LearningList title="LEARNED · PENDING" tint="pending" entries={pending} pending/>
          )}
        </>
      )}
    </div>
  );
}

function LearningList({ title, tint, entries, pending }) {
  return (
    <div className="auto-grown-list">
      <div className={`auto-grown-list-title tint-${tint}`}>
        {title} <span className="auto-grown-list-count">{entries.length}</span>
      </div>
      {entries.map((e, i) => (
        <LearningCard key={`${e.ts || ''}|${i}|${(e.text || '').slice(0, 32)}`}
                      entry={e} pending={pending}/>
      ))}
    </div>
  );
}

function LearningCard({ entry, pending }) {
  const prov = entry.provenance || {};
  const memos = prov.evidence_memos || [];
  const conf = typeof prov.confidence === 'number' ? prov.confidence : null;
  const date = prov.date || (entry.ts ? entry.ts.slice(0, 10) : '');
  return (
    <div className={`learning-card ${pending ? 'pending' : 'promoted'}`}>
      <div className="learning-card-text">{entry.text}</div>
      <div className="learning-card-prov">
        {conf != null && (
          <span className="confidence-chip">
            conf {conf} · {memos.length} memo{memos.length === 1 ? '' : 's'}
          </span>
        )}
        {prov.role && <span className="learning-card-role">{prov.role}</span>}
        <span style={{ flex: 1 }}/>
        {date && <span className="learning-card-date">{date}</span>}
        {pending && <span className="learning-card-pending">graduates on review</span>}
      </div>
      {memos.length > 0 && (
        <div className="learning-card-memos">
          {memos.slice(0, 3).map((m, i) => (
            <span key={i} className="learning-card-memo" title={m}>
              {m.split('/').slice(-1)[0]}
            </span>
          ))}
          {memos.length > 3 && <span className="learning-card-memo-more">+{memos.length - 3}</span>}
        </div>
      )}
    </div>
  );
}

// TeamAutonomyDigestButton — small badge in the Brain tab header that
// always displays the team-wide promoted + pending counts. Clicking it
// opens the digest modal listing every persona's current Learned items
// + next scheduled review. Lives in the brain-tree-header so users see
// the autonomy pulse without opening any specific persona file.
function TeamAutonomyDigestButton({ onOpen }) {
  const [summary, setSummary] = React.useState(null);
  const [unavailable, setUnavailable] = React.useState(false);
  React.useEffect(() => {
    let cancelled = false;
    const fetchSummary = () => apiGet('/api/personas/learnings/summary')
      .then(j => { if (!cancelled) { setSummary(j); setUnavailable(false); } })
      .catch(() => { if (!cancelled) { setSummary(null); setUnavailable(true); } });
    fetchSummary();
    // Poll every 60s so the badge stays fresh while the user works.
    const t = setInterval(fetchSummary, 60_000);
    return () => { cancelled = true; clearInterval(t); };
  }, []);
  if (unavailable) return null;
  const total = (summary?.total_promoted || 0) + (summary?.total_pending || 0);
  return (
    <button
      className="team-autonomy-badge"
      onClick={() => onOpen(summary)}
      title={summary
        ? `${summary.total_promoted} auto-promoted · ${summary.total_pending} pending across ${summary.total_personas} agents`
        : 'Team autonomy digest'}
    >
      <span className="team-autonomy-badge-spark">✦</span>
      <span className="team-autonomy-badge-label">AUTONOMY</span>
      <span className="team-autonomy-badge-count">{total}</span>
    </button>
  );
}

// TeamAutonomyDigestModal — one-screen view of every persona's Learned
// items. Each row links into the persona's file so the user can REVIEW
// NOW / ROLLBACK from the in-place strip (or jump back into AgentEditor
// to inspect). Refreshed on open and on the modal-level Refresh button.
function TeamAutonomyDigestModal({ open, onClose, initialSummary, onJumpToRole }) {
  const [summary, setSummary] = React.useState(initialSummary || null);
  const [loading, setLoading] = React.useState(!initialSummary);
  // Pre-flight state: kicks dry-runs across all 14 agents.
  const [preflighting, setPreflighting] = React.useState(false);
  const [preflightResult, setPreflightResult] = React.useState(null);

  React.useEffect(() => {
    if (!open) return;
    setLoading(true);
    apiGet('/api/personas/learnings/summary')
      .then(j => { setSummary(j); setLoading(false); })
      .catch(() => { setLoading(false); });
  }, [open]);

  const runPreflight = async () => {
    if (preflighting) return;
    setPreflighting(true);
    setPreflightResult(null);
    try {
      const r = await apiPost('/api/personas/self-review/preflight', {});
      setPreflightResult(r);
      // Refresh summary in case any pending counts changed (dry-run
      // shouldn't, but safe to re-pull so the user sees fresh counts).
      apiGet('/api/personas/learnings/summary').then(setSummary).catch(() => {});
    } catch (e) {
      setPreflightResult({ ok: false, error: e.message });
    } finally {
      setPreflighting(false);
    }
  };

  if (!open) return null;

  const formatNext = (iso) => {
    if (!iso) return '';
    try {
      const d = new Date(iso);
      return d.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: 'America/New_York' }) + ' ET';
    } catch { return iso; }
  };

  return (
    <div className="rollback-modal-backdrop" onClick={onClose}>
      <div className="rollback-modal team-autonomy-modal" onClick={e => e.stopPropagation()}>
        <div className="rollback-modal-header">
          <span className="rollback-modal-title">✦ TEAM AUTONOMY DIGEST</span>
          <button className="rollback-modal-close" onClick={onClose} title="Close">×</button>
        </div>
        <div className="rollback-modal-hint">
          Every agent with <code>cron_self_review</code> in their frontmatter. ✓ = auto-promoted, ○ = pending. Click a row to jump to the agent.
        </div>
        <div className="team-autonomy-preflight">
          <button
            className="team-autonomy-preflight-btn"
            onClick={runPreflight}
            disabled={preflighting}
            title="Dry-run the self-review pipeline for all 14 agents in parallel. Nothing is written; this is the pre-flight test."
          >
            {preflighting ? 'Pre-flighting… (≈30s)' : '⚡ Pre-flight all 14 agents (dry-run)'}
          </button>
          {preflightResult && (
            <span className="team-autonomy-preflight-result">
              {preflightResult.ok
                ? `${preflightResult.succeeded}/${preflightResult.fired} succeeded · ${preflightResult.elapsed_s}s`
                : `error: ${preflightResult.error || 'unknown'}`}
            </span>
          )}
        </div>
        {loading && !summary ? (
          <div className="rollback-modal-loading">loading team digest…</div>
        ) : !summary ? (
          <div className="rollback-modal-loading" style={{ color: 'var(--danger)' }}>Failed to load digest.</div>
        ) : (
          <>
            <div className="team-autonomy-stats">
              <div className="team-autonomy-stat">
                <div className="team-autonomy-stat-label">PROMOTED</div>
                <div className="team-autonomy-stat-value" style={{ color: 'var(--up, #6fdcb5)' }}>{summary.total_promoted}</div>
              </div>
              <div className="team-autonomy-stat">
                <div className="team-autonomy-stat-label">PENDING</div>
                <div className="team-autonomy-stat-value" style={{ color: 'var(--accent)' }}>{summary.total_pending}</div>
              </div>
              <div className="team-autonomy-stat">
                <div className="team-autonomy-stat-label">AGENTS</div>
                <div className="team-autonomy-stat-value">{summary.total_personas}</div>
              </div>
              <div className="team-autonomy-stat team-autonomy-stat--next">
                <div className="team-autonomy-stat-label">NEXT REVIEW</div>
                <div className="team-autonomy-stat-value team-autonomy-stat-next-time">{summary.next_review_at ? formatNext(summary.next_review_at) : '—'}</div>
              </div>
            </div>
            <div className="team-autonomy-list">
              {(summary.personas || []).map(p => (
                <div key={p.role} className="team-autonomy-row"
                     onClick={() => { onJumpToRole?.(p); }}>
                  <div className="team-autonomy-row-head">
                    <span className="team-autonomy-row-label">{p.label || p.role}</span>
                    <span className="team-autonomy-row-spacer"/>
                    {p.promoted_count > 0 && (
                      <span className="team-autonomy-row-promoted">✓{p.promoted_count}</span>
                    )}
                    {p.pending_count > 0 && (
                      <span className="team-autonomy-row-pending">○{p.pending_count}</span>
                    )}
                    {p.next_review_at && (
                      <span className="team-autonomy-row-next">{formatNext(p.next_review_at)}</span>
                    )}
                  </div>
                  {(p.latest_promoted?.text || p.latest_pending?.text) && (
                    <div className="team-autonomy-row-text">
                      {p.latest_promoted?.text || p.latest_pending?.text}
                    </div>
                  )}
                </div>
              ))}
            </div>
          </>
        )}
      </div>
    </div>
  );
}

// RollbackModal — small sheet listing the last 10 body snapshots. Click
// a row → confirm dialog → POST rollback. Closing via the X or background
// click leaves the editor untouched.
function RollbackModal({ role, snapshots, loading, error, onClose, onPick }) {
  return (
    <div className="rollback-modal-backdrop" onClick={onClose}>
      <div className="rollback-modal" onClick={e => e.stopPropagation()}>
        <div className="rollback-modal-header">
          <span className="rollback-modal-title">Rollback · {role}</span>
          <button className="rollback-modal-close" onClick={onClose} title="Close">×</button>
        </div>
        <div className="rollback-modal-hint">
          Tap a version to restore. The current body is auto-snapshotted before the swap, so this is itself reversible.
        </div>
        {loading ? (
          <div className="rollback-modal-loading">loading snapshots…</div>
        ) : error ? (
          <div className="rollback-modal-error">{error}</div>
        ) : !snapshots || snapshots.length === 0 ? (
          <div className="rollback-modal-empty">
            <div>No snapshots yet.</div>
            <div className="rollback-modal-empty-hint">
              Snapshots are taken automatically before each self-review write.
            </div>
          </div>
        ) : (
          <div className="rollback-modal-list">
            {snapshots.slice(0, 10).map(s => (
              <button key={s.version} className="rollback-modal-row"
                      onClick={() => onPick(s)}>
                <span className="rollback-modal-version">v{s.version}</span>
                <span className="rollback-modal-when">{s.created_at}</span>
                <span style={{ flex: 1 }}/>
                <span className="rollback-modal-size">{s.body_chars} chars</span>
                <span className="rollback-modal-arrow">↩</span>
              </button>
            ))}
          </div>
        )}
      </div>
    </div>
  );
}

function BrainEditor({ path, content, onChange, onSave, dirty, viewMode, setViewMode, onShowGraph, orphanCtx, onCreateStub, personaRole, agentLabel, focusMode, setFocusMode }) {
  const taRef = useRef(null);
  // Cmd/Ctrl-S to save
  useEffect(() => {
    const handler = (e) => {
      if ((e.metaKey || e.ctrlKey) && e.key === 's') {
        e.preventDefault();
        if (dirty) onSave();
      }
    };
    window.addEventListener('keydown', handler);
    return () => window.removeEventListener('keydown', handler);
  }, [dirty, onSave]);

  if (!path) {
    return (
      <div className="brain-empty">
        <div>
          <div className="icon">∅</div>
          Pick a note from the tree to start editing.<br/>
          <span style={{ fontSize: 11, color: 'var(--text-dim)' }}>
            Cmd+S to save · writes hot-reload Tibeb's vault brief
          </span>
        </div>
      </div>
    );
  }

  return (
    <>
      <div className="brain-editor-bar">
        <div className={`brain-editor-path ${dirty ? 'dirty' : ''}`}>
          {/* Persona badge — makes it OBVIOUS the user is editing a
              system agent prompt vs. a research note. Was the #1 friction
              in the brain tab pre-2026-05-24: people would edit a 0-Agents
              file thinking it was their own note. The colored badge is a
              passive signal — visible at a glance, no click needed. */}
          {personaRole && (
            <span style={{
              display: 'inline-block', marginRight: 10, padding: '2px 8px',
              fontSize: 10, fontWeight: 700, letterSpacing: '0.14em',
              fontFamily: "'JetBrains Mono', monospace",
              color: '#00d4aa', background: 'rgba(0,212,170,0.12)',
              border: '1px solid rgba(0,212,170,0.45)', borderRadius: 3,
              textTransform: 'uppercase',
            }} title={`This file is the system prompt for the "${agentLabel || personaRole}" agent. Edits take effect at next persona fire — Tibeb auto-reloads the registry on save.`}>
              AGENT · {agentLabel || personaRole}
            </span>
          )}
          {path}
          {orphanCtx && <span style={{ marginLeft: 10, fontSize: 11, color: '#d9a85f', fontFamily: "'JetBrains Mono', monospace" }}>· orphan · file does not exist</span>}
        </div>
        <div className="brain-editor-actions">
          {orphanCtx && (
            <button className="brain-editor-btn primary"
                    onClick={onCreateStub}
                    title={`Create a properly-frontmattered stub at ${path}`}
                    style={{ background: '#d9a85f', borderColor: '#d9a85f', color: '#000' }}>
              + Create stub
            </button>
          )}
          <button className="brain-editor-btn"
                  onClick={onShowGraph}
                  title="Show the graphify knowledge graph in this pane">
            ⌬ Graph
          </button>
          <button className="brain-editor-btn"
                  onClick={() => setViewMode(viewMode === 'split' ? 'editor' : viewMode === 'editor' ? 'preview' : 'split')}
                  title="Cycle view: split / editor only / preview only">
            {viewMode === 'split' ? '⊟ Split' : viewMode === 'editor' ? '✎ Editor' : '◉ Preview'}
          </button>
          {/* Focus mode — hides both sidebars (file tree + Editor Tibeb
              chat) for a distraction-free Markdown editor. The button
              changes label to "EXIT FOCUS" when active; Esc also exits.
              Survey's #1 friction was "edit affordance hidden" — focus
              mode makes the editor the only thing on screen. */}
          {setFocusMode && (
            <button className={`brain-editor-btn ${focusMode ? 'primary' : ''}`}
                    onClick={() => setFocusMode(!focusMode)}
                    title={focusMode
                      ? "Exit focus mode (Esc) — bring back the sidebars"
                      : "Focus mode — hide sidebars for a clean editor canvas"}
                    style={focusMode ? { background: 'var(--accent)', color: 'var(--bg-0)', borderColor: 'var(--accent)' } : {}}>
              {focusMode ? '◉ Exit focus' : '◉ Focus'}
            </button>
          )}
          <button className="brain-editor-btn primary"
                  onClick={onSave} disabled={!dirty || !!orphanCtx}
                  title={orphanCtx ? "Click 'Create stub' first to materialize this orphan" : "Save (Cmd+S) — also reloads Tibeb's vault brief"}>
            Save
          </button>
        </div>
      </div>
      <div className={`brain-editor-split ${viewMode === 'editor' ? 'editor-only' : viewMode === 'preview' ? 'preview-only' : ''}`}>
        <textarea
          ref={taRef}
          value={content}
          onChange={e => onChange(e.target.value)}
          spellCheck={false}
          placeholder="# Untitled&#10;&#10;Start typing markdown…"
        />
        <MarkdownPreview content={content}/>
      </div>
    </>
  );
}

function BrainChat({ currentPath, onIngestComplete }) {
  const [messages, setMessages] = useState(() => {
    try { return JSON.parse(localStorage.getItem('jarvis.brain.chat') || '[]'); }
    catch { return []; }
  });
  const [input, setInput] = useState('');
  const [busy, setBusy] = useState(false);
  const [ingestStatus, setIngestStatus] = useState('');
  const [recording, setRecording] = useState(false);
  const [transcribing, setTranscribing] = useState(false);
  // Per-Phase 13 v3: voice mode auto-speaks each Tibeb reply via /api/voice/speak.
  // Persisted across reloads. Default OFF — user opts in.
  const [voiceMode, setVoiceMode] = useState(() => {
    try { return localStorage.getItem('jarvis.voiceMode') === '1'; } catch { return false; }
  });
  useEffect(() => {
    try { localStorage.setItem('jarvis.voiceMode', voiceMode ? '1' : '0'); } catch {}
  }, [voiceMode]);
  const logRef = useRef(null);
  const mediaRecRef = useRef(null);
  const audioElRef = useRef(null);

  useEffect(() => {
    // Strip transient streaming flag before persisting — otherwise a reload
    // mid-stream would leave a phantom "streaming" message in localStorage.
    try {
      const persisted = messages.slice(-30).map(m => {
        const { streaming: _s, ...rest } = m;
        return rest;
      });
      localStorage.setItem('jarvis.brain.chat', JSON.stringify(persisted));
    } catch {}
    if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
  }, [messages]);

  const send = useCallback(async () => {
    const text = input.trim();
    if (!text || busy) return;
    const next = [...messages, { role: 'user', content: text }];
    // Append a placeholder assistant message we'll stream into
    const withPlaceholder = [...next, { role: 'jarvis', content: '', streaming: true }];
    setMessages(withPlaceholder);
    setInput('');
    setBusy(true);
    try {
      const noteContext = currentPath ? `\n\n[Currently open in editor: \`${currentPath}\`]` : '';

      // Helper: inline a tool-fire marker into the assistant content so the
      // user sees Editor Tibeb actually using its tools. Emit raw HTML —
      // dangerouslySetInnerHTML + marked.parseInline both pass it through
      // unmodified. The chip styling is in index.html (.brain-tool-chip).
      // Format args tightly — path/title/query is what's interesting; full
      // input objects are noise.
      const escapeHtml = (s) => String(s || '')
        .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;');
      const formatToolMarker = (name, input) => {
        let arg = '';
        if (input?.path) arg = input.path;
        else if (input?.title) arg = input.title;
        else if (input?.query) arg = input.query;
        else if (input?.ticker) arg = input.ticker;
        else if (input?.role) arg = input.role; // consult_specialist
        const argHtml = arg ? `<span class="brain-tool-chip-arg">${escapeHtml(arg)}</span>` : '';
        return `\n\n<span class="brain-tool-chip">↳ <span class="brain-tool-chip-name">${escapeHtml(name)}</span>${argHtml}</span>\n\n`;
      };

      let replyText = '';
      if (typeof window.streamTibebEditor === 'function') {
        replyText = await window.streamTibebEditor({
          prompt: text + noteContext,
          maxTokens: 2048,
          onChunk: (delta, full) => {
            replyText = full;
            setMessages(prev => {
              const arr = [...prev];
              for (let i = arr.length - 1; i >= 0; i--) {
                if (arr[i].role === 'jarvis' && arr[i].streaming) {
                  arr[i] = { ...arr[i], content: full };
                  break;
                }
              }
              return arr;
            });
          },
          onTool: ({ name, input }) => {
            const marker = formatToolMarker(name, input);
            setMessages(prev => {
              const arr = [...prev];
              for (let i = arr.length - 1; i >= 0; i--) {
                if (arr[i].role === 'jarvis' && arr[i].streaming) {
                  arr[i] = { ...arr[i], content: (arr[i].content || '') + marker };
                  break;
                }
              }
              return arr;
            });
            // Don't fold the marker into replyText (we don't want it persisted)
          },
          onToolResult: ({ name, ok }) => {
            // Marker already shown on tool-fire; only annotate failures.
            if (!ok) {
              const failChip = `\n\n<span class="brain-tool-chip failed">✗ <span class="brain-tool-chip-name">${escapeHtml(name)}</span> failed</span>\n\n`;
              setMessages(prev => {
                const arr = [...prev];
                for (let i = arr.length - 1; i >= 0; i--) {
                  if (arr[i].role === 'jarvis' && arr[i].streaming) {
                    arr[i] = { ...arr[i], content: (arr[i].content || '') + failChip };
                    break;
                  }
                }
                return arr;
              });
            }
          },
        });
      } else {
        // Fallback: non-streaming editor endpoint
        const r = await fetch('/api/llm/editor', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ prompt: text + noteContext }),
        });
        const j = await r.json();
        replyText = j.text || '(no reply)';
      }

      // Finalize the placeholder — clear streaming flag
      setMessages(prev => {
        const arr = [...prev];
        for (let i = arr.length - 1; i >= 0; i--) {
          if (arr[i].role === 'jarvis' && arr[i].streaming) {
            arr[i] = { role: 'jarvis', content: arr[i].content || replyText || '(no reply)' };
            break;
          }
        }
        return arr;
      });

      // Phase 13 v3 — auto-speak the reply if voice mode is on
      if (voiceMode && replyText && replyText !== '(no reply)') {
        try {
          const speakResp = await fetch('/api/voice/speak', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ text: replyText.slice(0, 1500) }),
          });
          if (speakResp.ok) {
            const blob = await speakResp.blob();
            const url = URL.createObjectURL(blob);
            if (audioElRef.current) {
              audioElRef.current.src = url;
              audioElRef.current.play().catch(() => {});
            }
          }
        } catch {}
      }
    } catch (e) {
      // Replace the streaming placeholder with the error
      setMessages(prev => {
        const arr = [...prev];
        for (let i = arr.length - 1; i >= 0; i--) {
          if (arr[i].role === 'jarvis' && arr[i].streaming) {
            arr[i] = { role: 'jarvis', content: `error: ${e.message}` };
            return arr;
          }
        }
        // No placeholder — append error
        return [...arr, { role: 'jarvis', content: `error: ${e.message}` }];
      });
    } finally {
      setBusy(false);
    }
  }, [input, busy, messages, currentPath, voiceMode]);

  // Phase 13 v3 — push-to-talk via MediaRecorder + ElevenLabs Scribe.
  // Press to start recording; release/click again to stop, transcribe,
  // and drop the text into the input (user can edit then hit Enter).
  const startRecording = useCallback(async () => {
    if (recording || transcribing) return;
    try {
      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
      const mr = new MediaRecorder(stream, { mimeType: 'audio/webm' });
      const chunks = [];
      mr.ondataavailable = (e) => { if (e.data.size > 0) chunks.push(e.data); };
      mr.onstop = async () => {
        stream.getTracks().forEach(t => t.stop());
        const blob = new Blob(chunks, { type: 'audio/webm' });
        if (blob.size === 0) { setRecording(false); return; }
        setTranscribing(true);
        const fd = new FormData();
        fd.append('file', blob, 'recording.webm');
        try {
          const r = await fetch('/api/voice/transcribe', { method: 'POST', body: fd });
          const j = await r.json();
          if (j.text) setInput(p => (p ? p + ' ' : '') + j.text.trim());
        } catch (e) {
          console.error('transcribe failed', e);
        } finally {
          setTranscribing(false);
          setRecording(false);
        }
      };
      mediaRecRef.current = mr;
      mr.start();
      setRecording(true);
    } catch (e) {
      console.error('mic permission denied', e);
      setRecording(false);
    }
  }, [recording, transcribing]);

  const stopRecording = useCallback(() => {
    if (mediaRecRef.current && mediaRecRef.current.state === 'recording') {
      mediaRecRef.current.stop();
    }
  }, []);

  const onFile = useCallback(async (e) => {
    const file = e.target.files[0];
    e.target.value = '';
    if (!file) return;
    setIngestStatus(`uploading ${file.name}…`);
    const fd = new FormData();
    fd.append('file', file);
    try {
      const r = await fetch('/api/vault/ingest', { method: 'POST', body: fd });
      const j = await r.json();
      if (!r.ok) throw new Error(j.detail || j.error || `${r.status}`);
      setIngestStatus(`✓ ${j.path}`);
      setMessages(m => [...m, {
        role: 'jarvis',
        content: `Ingested **${file.name}** → \`${j.path}\` (${j.kind || 'file'}). Run \`/graphify --update\` in Claude Code on \`brain/\` to add it to the knowledge graph.`,
      }]);
      if (onIngestComplete) onIngestComplete(j.path);
      setTimeout(() => setIngestStatus(''), 4000);
    } catch (e) {
      setIngestStatus(`✗ ${e.message}`);
    }
  }, [onIngestComplete]);

  return (
    <div className="brain-chat-pane">
      <div className="brain-chat-log" ref={logRef}>
        {messages.length === 0 && (
          <div style={{ color: 'var(--text-dim)', fontSize: 11, padding: '8px 0' }}>
            Ask Tibeb anything about your vault. With a note open, it picks up the path as context — useful for "rewrite this paragraph" or "what does my brain say about $TICKER?".
            Drop a file below to ingest into the brain.
          </div>
        )}
        {messages.map((m, i) => {
          const isStreamingEmpty = m.streaming && !(m.content && m.content.trim());
          return (
            <div key={i} className={`brain-chat-msg ${m.role === 'user' ? 'user' : ''}`}>
              <div className="role">
                {m.role === 'user' ? 'You' : 'Tibeb'}
                {m.streaming && (
                  <span style={{
                    marginLeft: 8, fontSize: 9, color: 'var(--accent)',
                    fontWeight: 700, letterSpacing: '0.12em', textTransform: 'uppercase',
                    animation: 'brain-stream-pulse 1.2s ease-in-out infinite',
                  }}>● live</span>
                )}
              </div>
              {isStreamingEmpty ? (
                <em style={{ color: 'var(--text-dim)' }}>thinking…</em>
              ) : (
                <div dangerouslySetInnerHTML={{
                  __html: window.marked ? window.marked.parseInline(m.content || '', { breaks: true }) : (m.content || ''),
                }}/>
              )}
            </div>
          );
        })}
      </div>
      <div className="brain-ingest-zone">
        <label htmlFor="brain-ingest">{ingestStatus || '↓ ingest file (md · txt · pdf · png · jpg)'}</label>
        <input id="brain-ingest" type="file"
               accept=".md,.markdown,.txt,.pdf,.png,.jpg,.jpeg,.gif,.webp"
               onChange={onFile}/>
      </div>
      <audio ref={audioElRef} style={{ display: 'none' }}/>
      <div style={{
        display: 'flex', gap: 8, alignItems: 'center',
        padding: '8px 14px',
        borderTop: '1px dashed var(--line)',
        background: 'rgba(0,0,0,0.15)',
      }}>
        <button type="button"
                onClick={recording ? stopRecording : startRecording}
                disabled={transcribing}
                title={recording ? 'Stop and transcribe' : 'Push to talk · ElevenLabs Scribe'}
                style={{
                  background: recording ? 'var(--danger)' : 'transparent',
                  color: recording ? 'var(--bg)' : 'var(--accent)',
                  border: `1px solid ${recording ? 'var(--danger)' : 'var(--accent)'}`,
                  padding: '6px 10px', fontSize: 11, fontFamily: 'inherit',
                  fontWeight: 700, letterSpacing: '0.08em',
                  borderRadius: 3, cursor: 'pointer',
                  display: 'inline-flex', alignItems: 'center', gap: 6,
                }}>
          <svg width="10" height="10" viewBox="0 0 12 12" fill="currentColor">
            <circle cx="6" cy="6" r="4"/>
          </svg>
          {recording ? 'STOP' : (transcribing ? 'TRANSCRIBING…' : 'PUSH TO TALK')}
        </button>
        <button type="button"
                onClick={() => setVoiceMode(v => !v)}
                title={voiceMode ? 'Voice replies on (Brian via Flash v2.5)' : 'Voice replies off'}
                style={{
                  background: voiceMode ? 'var(--accent-2)' : 'transparent',
                  color: voiceMode ? 'var(--accent)' : 'var(--text-dim)',
                  border: `1px solid ${voiceMode ? 'var(--accent)' : 'var(--line)'}`,
                  padding: '6px 10px', fontSize: 10, fontFamily: 'inherit',
                  fontWeight: 700, letterSpacing: '0.08em',
                  borderRadius: 3, cursor: 'pointer',
                }}>
          {voiceMode ? '🔊 SPEAK ON' : '🔇 SPEAK OFF'}
        </button>
        {recording && <span style={{ fontSize: 10, color: 'var(--danger)', fontFamily: "'JetBrains Mono', monospace" }}>● recording</span>}
      </div>
      <form className="brain-chat-form" onSubmit={e => { e.preventDefault(); send(); }}>
        <textarea
          value={input}
          onChange={e => setInput(e.target.value)}
          onKeyDown={e => {
            if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
          }}
          placeholder={busy ? 'thinking…' : 'Ask about your vault…'}
          disabled={busy}
        />
        <button type="submit" className="send-btn" disabled={busy || !input.trim()}>↗</button>
      </form>
    </div>
  );
}

function BrainGraphPane() {
  const [stats, setStats] = useState(null);
  const [err, setErr] = useState('');
  useEffect(() => {
    apiGet('/api/vault/graphify')
      .then(g => {
        const nodes = g.nodes || [];
        const edges = g.links || g.edges || [];
        // Compute god nodes (top by degree) for a quick at-a-glance summary
        const degree = {};
        edges.forEach(e => {
          degree[e.source] = (degree[e.source] || 0) + 1;
          degree[e.target] = (degree[e.target] || 0) + 1;
        });
        const nodeById = Object.fromEntries(nodes.map(n => [n.id, n]));
        const top = Object.entries(degree)
          .sort((a, b) => b[1] - a[1])
          .slice(0, 12)
          .map(([id, deg]) => ({ id, deg, label: nodeById[id]?.label || id }));
        const communities = new Set(nodes.map(n => n.community).filter(c => c != null));
        setStats({
          nodes: nodes.length,
          edges: edges.length,
          communities: communities.size,
          godNodes: top,
        });
      })
      .catch(e => setErr(e.message));
  }, []);

  if (err) return (
    <div className="brain-empty">
      <div style={{ padding: 24, fontSize: 12 }}>
        <div className="icon">∅</div>
        Graph not built yet.<br/>
        <code style={{ fontSize: 10 }}>graphify update brain/</code><br/>
        <span style={{ color: 'var(--text-dim)' }}>then refresh</span>
      </div>
    </div>
  );
  if (!stats) return <div className="brain-empty">loading graph…</div>;
  return (
    <div style={{ padding: 16, fontSize: 12, overflowY: 'auto', height: '100%' }}>
      <div style={{ fontSize: 10, letterSpacing: '0.16em', textTransform: 'uppercase', color: 'var(--text-dim)', marginBottom: 6 }}>Graph Snapshot</div>
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8, marginBottom: 16 }}>
        <div><div style={{ fontSize: 22, fontWeight: 700, color: 'var(--accent)' }}>{stats.nodes}</div><div style={{ fontSize: 10, color: 'var(--text-dim)' }}>NODES</div></div>
        <div><div style={{ fontSize: 22, fontWeight: 700, color: 'var(--accent)' }}>{stats.edges}</div><div style={{ fontSize: 10, color: 'var(--text-dim)' }}>EDGES</div></div>
        <div><div style={{ fontSize: 22, fontWeight: 700, color: 'var(--accent)' }}>{stats.communities}</div><div style={{ fontSize: 10, color: 'var(--text-dim)' }}>COMMUNITIES</div></div>
      </div>
      <div style={{ fontSize: 10, letterSpacing: '0.16em', textTransform: 'uppercase', color: 'var(--text-dim)', marginBottom: 8 }}>God Nodes (most connected)</div>
      {stats.godNodes.map(n => (
        <div key={n.id} style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0', borderBottom: '1px solid var(--line)', fontSize: 11 }}>
          <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{n.label}</span>
          <span style={{ color: 'var(--text-dim)', fontFamily: "'JetBrains Mono', monospace", fontSize: 10 }}>{n.deg}</span>
        </div>
      ))}
      <div style={{ marginTop: 16, fontSize: 10, color: 'var(--text-dim)' }}>
        For full markdown concept extraction, run <code>/graphify --update</code> in Claude Code on <code>brain/</code>.
      </div>
    </div>
  );
}

// AgentPromptsShortcut — compact horizontal-scroll row of agent pills
// at the top of the Brain editor pane. Mirrors the visionOS shortcut
// in `Views/Intel/AgentPromptsShortcut.swift`. One pill per persona;
// click → opens that agent's prompt body via the parent's `onPick`
// (which is `pickNote` in the workspace).
//
// Sources from `/api/agents/registry`. The parent already loads this
// for the persona-role lookup, so we accept the registry as a prop
// to avoid a duplicate fetch.
function AgentPromptsShortcut({ registry, currentPath, onPick }) {
  if (!registry || !Array.isArray(registry.agents) || registry.agents.length === 0) {
    return null;
  }
  const modelTint = (model) => {
    const m = (model || '').toLowerCase();
    if (m.includes('opus'))   return '#00d4aa'; // accent teal
    if (m.includes('sonnet')) return '#4cd964'; // green
    if (m.includes('haiku'))  return '#999fad'; // dim
    return '#888d9a';
  };
  const modelShort = (model) => {
    const m = (model || '').toLowerCase();
    if (m.includes('opus'))   return 'OPUS';
    if (m.includes('sonnet')) return 'SONNET';
    if (m.includes('haiku'))  return 'HAIKU';
    return (model || '').slice(0, 6).toUpperCase();
  };
  return (
    <div style={{
      flexShrink: 0,
      padding: '6px 12px',
      borderBottom: '1px solid var(--line)',
      background: 'rgba(0, 0, 0, 0.18)',
      display: 'flex',
      flexDirection: 'column',
      gap: 4,
    }}>
      <div style={{
        display: 'flex', alignItems: 'center', gap: 6,
        fontSize: 9, fontFamily: "'JetBrains Mono', monospace",
        letterSpacing: 1.2, fontWeight: 700,
        color: 'var(--text-dim)', textTransform: 'uppercase',
      }}>
        <span style={{ color: '#00d4aa' }}>●</span>
        <span>AGENT PROMPTS</span>
        <span style={{ color: 'var(--text-dim)', opacity: 0.7, fontWeight: 500 }}>
          · {registry.agents.length}
        </span>
      </div>
      <div style={{
        display: 'flex', gap: 6, overflowX: 'auto', overflowY: 'hidden',
        paddingBottom: 2, scrollbarWidth: 'thin',
      }}>
        {registry.agents.map(agent => {
          const tint = modelTint(agent.model);
          const path = `0-Agents/${agent.file}`;
          const active = currentPath === path;
          return (
            <button
              key={agent.role || agent.file}
              onClick={() => onPick && onPick(path)}
              title={`Open ${agent.label} prompt · ${agent.description || ''}`}
              style={{
                flexShrink: 0,
                display: 'flex', alignItems: 'center', gap: 5,
                padding: '5px 10px',
                background: active ? `${tint}22` : 'rgba(255,255,255,0.04)',
                border: `1px solid ${active ? tint : tint + '55'}`,
                borderRadius: 999,
                cursor: 'pointer',
                fontFamily: 'inherit',
                color: 'var(--text)',
                fontSize: 11,
                whiteSpace: 'nowrap',
                transition: 'background 120ms, border-color 120ms',
              }}
              onMouseEnter={e => { e.currentTarget.style.background = `${tint}1a`; }}
              onMouseLeave={e => { e.currentTarget.style.background = active ? `${tint}22` : 'rgba(255,255,255,0.04)'; }}
            >
              <span style={{ width: 5, height: 5, borderRadius: 999, background: tint, flexShrink: 0 }}/>
              <span style={{ fontWeight: 600 }}>{agent.label}</span>
              <span style={{
                fontSize: 8, fontWeight: 700, letterSpacing: 0.6,
                fontFamily: "'JetBrains Mono', monospace",
                color: tint, background: `${tint}1f`,
                padding: '1px 5px', borderRadius: 999,
              }}>
                {modelShort(agent.model)}
              </span>
            </button>
          );
        })}
      </div>
    </div>
  );
}

function BrainWorkspace() {
  const [tree, setTree] = useState(null);
  const [treeFilter, setTreeFilter] = useState('');
  const [expanded, setExpanded] = useState(() => new Set(['', '4-Stocks', '4-Stocks/Deep Dives', '7-Tibeb Captures']));
  const [currentPath, setCurrentPath] = useState(null);
  const [originalContent, setOriginalContent] = useState('');
  const [content, setContent] = useState('');
  const [viewMode, setViewMode] = useState('split');
  const [sideTab, setSideTab] = useState('chat');
  const [reloadKey, setReloadKey] = useState(0);
  const [saveStatus, setSaveStatus] = useState('');
  // Graph is the default view when entering the Brain tab — picking a note
  // from the tree dismisses it and reveals the editor.
  const [showGraph, setShowGraph] = useState(true);
  // Editor Tibeb side-panel collapse — same UX as the Research-tab TibebChat.
  // Persisted across reloads so the user's preference sticks.
  const [sideCollapsed, setSideCollapsed] = useState(() => {
    try {
      const stored = localStorage.getItem('jarvis.brain.sideCollapsed');
      if (stored != null) return stored === '1';
    } catch {}
    // On phones the editor needs the full screen — collapse the Editor Tibeb
    // chat by default so the markdown isn't squeezed.
    return typeof window !== 'undefined' && window.innerWidth < 900;
  });
  useEffect(() => {
    try { localStorage.setItem('jarvis.brain.sideCollapsed', sideCollapsed ? '1' : '0'); } catch {}
  }, [sideCollapsed]);

  // Focus mode — single toggle that hides BOTH the file-tree sidebar AND
  // the Editor Tibeb chat pane, giving the user a centered Markdown
  // editor with zero chrome. Addresses the brain-tab survey's
  // "edit affordance hidden" friction by making the editor the only
  // thing on screen. Persisted via localStorage so the choice sticks.
  //
  // Exit: a small "EXIT FOCUS" pill renders on the editor toolbar
  // when active. Esc keypress also exits (handled in BrainEditor).
  const [focusMode, setFocusMode] = useState(() => {
    try {
      const stored = localStorage.getItem('jarvis.brain.focusMode');
      return stored === '1';
    } catch { return false; }
  });
  useEffect(() => {
    try { localStorage.setItem('jarvis.brain.focusMode', focusMode ? '1' : '0'); } catch {}
  }, [focusMode]);
  // Esc key globally exits focus mode — fast bailout if the user
  // typed themselves into a corner with no visible sidebar buttons.
  useEffect(() => {
    if (!focusMode) return;
    const onKey = (e) => { if (e.key === 'Escape') setFocusMode(false); };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [focusMode]);

  // orphanCtx is set when the user opened an orphan node in the graph.
  // The load effect uses it to render a "Create stub" panel instead of
  // showing a generic error when /api/vault/note returns 404.
  const [orphanCtx, setOrphanCtx] = useState(null);

  const pickNote = useCallback((path, orphanInfo = null) => {
    setOrphanCtx(orphanInfo);
    setCurrentPath(path);
    setShowGraph(false);
  }, []);

  const refreshTree = useCallback(() => {
    apiGet('/api/vault/tree').then(setTree).catch(e => console.error('tree', e));
  }, []);
  useEffect(() => { refreshTree(); }, [refreshTree, reloadKey]);

  // Library Search hand-off: when the user picks a result there, it sets
  // localStorage.jarvis.brain.openPath and switches tabs. We pop the path
  // here on mount and clear the flag so the next reopen of Brain doesn't
  // re-load the same file.
  useEffect(() => {
    try {
      const requested = localStorage.getItem('jarvis.brain.openPath');
      if (requested) {
        localStorage.removeItem('jarvis.brain.openPath');
        setCurrentPath(requested);
        setShowGraph(false);
      }
    } catch {}
  }, []);

  // Load note content when path changes. If the file doesn't exist AND
  // we came from an orphan node click, render a "stub create" panel
  // inline (so no click in the brain ever lands on "nothing").
  useEffect(() => {
    if (!currentPath) return;
    apiGet(`/api/vault/note?path=${encodeURIComponent(currentPath)}`)
      .then(j => {
        setContent(j.content || '');
        setOriginalContent(j.content || '');
        // File loaded normally — clear any stale orphan context
        setOrphanCtx(null);
      })
      .catch(e => {
        const msg = String(e.message || e);
        const is404 = /not found|404/i.test(msg);
        if (is404 && orphanCtx) {
          // Render orphan stub UI: shows label + inbound references +
          // a one-click "Create stub" pre-filled markdown banner.
          const refsList = (orphanCtx.referenced_by || []).slice(0, 12)
            .map(r => `- [[${r.replace(/\.md$/, '').split('/').pop()}]] · ${r}`)
            .join('\n');
          const stubPreview = (
            `# ${orphanCtx.label || currentPath}\n\n` +
            `> ⚠️ **No file at \`${currentPath}\` yet.**\n` +
            `> This node showed up in the graph because notes link to it,\n` +
            `> but no markdown file was ever written for it.\n\n` +
            `## Inbound references (${(orphanCtx.referenced_by || []).length})\n\n` +
            (refsList || '_(no inbound references recorded)_') + `\n\n` +
            `## Action\n\n` +
            `Click **Create stub** below to write a properly-frontmattered\n` +
            `placeholder at \`${currentPath}\` so this node opens directly\n` +
            `next time. The stub is editable; come back and fill it in when\n` +
            `the thesis matures.\n`
          );
          setContent(stubPreview);
          setOriginalContent(stubPreview);
        } else {
          setContent(`# Error\n\n${msg}`);
          setOriginalContent('');
        }
      });
  }, [currentPath, orphanCtx]);

  const createStub = useCallback(async () => {
    if (!orphanCtx || !currentPath) return;
    try {
      const r = await fetch('/api/vault/note/stub', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          path: currentPath,
          label: orphanCtx.label,
          kind: orphanCtx.kind,
          referenced_by: orphanCtx.referenced_by || [],
        }),
      });
      if (!r.ok) {
        const t = await r.text();
        alert(`Stub create failed: ${t}`);
        return;
      }
      const j = await r.json();
      setOrphanCtx(null);
      setContent(j.content || '');
      setOriginalContent(j.content || '');
      // Trigger a tree refresh so the new file shows up in the sidebar
      apiGet('/api/vault/tree').then(setTree).catch(() => {});
    } catch (e) {
      alert(`Stub create failed: ${e}`);
    }
  }, [orphanCtx, currentPath]);

  const dirty = content !== originalContent;

  // --- Autonomous persona-self-review state ---
  // The open path is a persona iff personaRoleFromPath returns a slug.
  // For any other path (memos, frameworks, tickers, etc.) we skip the
  // learnings UI entirely.
  //
  // The agent registry (loaded once on mount) provides the file→role
  // mapping so legacy-named files like `Tibeb.md → jarvis` resolve to
  // the correct role for the self-review endpoints.
  const [agentRegistry, setAgentRegistry] = useState(null);
  useEffect(() => {
    let cancelled = false;
    apiGet('/api/agents/registry')
      .then(j => { if (!cancelled) setAgentRegistry(j); })
      .catch(() => {}); // legacy backend without registry — fallback path handles it
    return () => { cancelled = true; };
  }, []);
  const personaRole = personaRoleFromPath(currentPath, agentRegistry);
  const [learnings, setLearnings] = useState(null);
  const [learningsUnavailable, setLearningsUnavailable] = useState(false);
  const [reviewBusy, setReviewBusy] = useState(false);
  const [reviewBanner, setReviewBanner] = useState('');
  const [showRollback, setShowRollback] = useState(false);
  const [snapshots, setSnapshots] = useState(null);
  const [snapshotsLoading, setSnapshotsLoading] = useState(false);
  const [snapshotsError, setSnapshotsError] = useState('');
  const [learningsReloadKey, setLearningsReloadKey] = useState(0);

  // --- Team Autonomy digest (cross-persona view) ---
  // Independent of the per-persona strip above: shows the whole team's
  // promoted+pending counts in one modal. Wired off a small badge in
  // the brain-tree-header.
  const [showTeamDigest, setShowTeamDigest] = useState(false);
  const [seedDigest, setSeedDigest] = useState(null);
  const openTeamDigest = useCallback((summary) => {
    setSeedDigest(summary || null);
    setShowTeamDigest(true);
  }, []);
  const jumpToRoleFromDigest = useCallback((p) => {
    if (!p?.path) return;
    pickNote(p.path);
    setShowTeamDigest(false);
  }, [pickNote]);

  useEffect(() => {
    if (!personaRole) {
      setLearnings(null);
      setLearningsUnavailable(false);
      return;
    }
    let cancelled = false;
    apiGet(`/api/personas/${encodeURIComponent(personaRole)}/learnings`)
      .then(j => {
        if (cancelled) return;
        setLearnings(j || { promoted: [], pending: [], snapshot_versions: 0 });
        setLearningsUnavailable(false);
      })
      .catch(() => {
        if (cancelled) return;
        // 404 or anything else — the parallel agent's endpoints aren't
        // deployed yet. Mark unavailable so the strip renders a notice.
        setLearnings(null);
        setLearningsUnavailable(true);
      });
    return () => { cancelled = true; };
  }, [personaRole, learningsReloadKey]);

  const runReview = useCallback(async (dryRun) => {
    if (!personaRole || reviewBusy) return;
    setReviewBusy(true);
    setReviewBanner(dryRun ? 'Dry-run starting…' : 'Review running…');
    try {
      const r = await apiPost(`/api/personas/${encodeURIComponent(personaRole)}/self-review`,
                              { dry_run: !!dryRun });
      // Build a 1-line summary tolerant of partial field set.
      const parts = [];
      if (typeof r.memos_considered === 'number') parts.push(`${r.memos_considered} memos considered`);
      if (typeof r.claims_proposed === 'number') parts.push(`${r.claims_proposed} claims proposed`);
      if (typeof r.promoted_to_pending === 'number') parts.push(`${r.promoted_to_pending} promoted to pending`);
      if (typeof r.graduated_to_auto === 'number' && r.graduated_to_auto > 0) parts.push(`${r.graduated_to_auto} graduated`);
      const summary = parts.length ? parts.join(' · ') : (r.summary || 'Review complete.');
      setReviewBanner((dryRun ? 'DRY-RUN · ' : '') + summary);
      if (!dryRun) setLearningsReloadKey(k => k + 1);
    } catch (e) {
      if (e.status === 404) {
        setLearningsUnavailable(true);
        setReviewBanner('Backend self-review API not available');
      } else {
        setReviewBanner(`Review failed: ${e.message}`);
      }
    } finally {
      setReviewBusy(false);
      setTimeout(() => setReviewBanner(''), 6000);
    }
  }, [personaRole, reviewBusy]);

  const openRollback = useCallback(async () => {
    if (!personaRole) return;
    setShowRollback(true);
    setSnapshotsLoading(true);
    setSnapshotsError('');
    try {
      const list = await apiGet(`/api/personas/${encodeURIComponent(personaRole)}/snapshots`);
      // Sort newest first defensively in case the backend returns oldest-first.
      const sorted = Array.isArray(list) ? [...list].sort((a, b) => (b.version || 0) - (a.version || 0)) : [];
      setSnapshots(sorted);
    } catch (e) {
      setSnapshotsError(e.status === 404
        ? 'Backend self-review API not available.'
        : `${e.message}`);
    } finally {
      setSnapshotsLoading(false);
    }
  }, [personaRole]);

  const performRollback = useCallback(async (snap) => {
    if (!personaRole || !snap) return;
    const ok = window.confirm(
      `Roll back ${personaRole} to v${snap.version}?\n\n` +
      `Snapshot from ${snap.created_at} (${snap.body_chars} chars).\n` +
      `The current body will be auto-snapshotted first, so this is reversible.`
    );
    if (!ok) return;
    setShowRollback(false);
    setReviewBanner(`Rolling back to v${snap.version}…`);
    try {
      await apiPost(
        `/api/personas/${encodeURIComponent(personaRole)}/rollback?to=v${snap.version}`,
        {}
      );
      // Reload the note body so the editor reflects the restored version.
      const refreshed = await apiGet(`/api/vault/note?path=${encodeURIComponent(currentPath)}`);
      setContent(refreshed.content || '');
      setOriginalContent(refreshed.content || '');
      setReviewBanner(`Rolled back to v${snap.version}`);
      setLearningsReloadKey(k => k + 1);
    } catch (e) {
      setReviewBanner(`Rollback failed: ${e.message}`);
    } finally {
      setTimeout(() => setReviewBanner(''), 5000);
    }
  }, [personaRole, currentPath]);

  const onToggle = useCallback((path) => {
    setExpanded(prev => {
      const next = new Set(prev);
      if (next.has(path)) next.delete(path); else next.add(path);
      return next;
    });
  }, []);

  const onSave = useCallback(async () => {
    if (!currentPath || !dirty) return;
    setSaveStatus('saving…');
    try {
      const r = await apiPut('/api/vault/note', { path: currentPath, content });
      setOriginalContent(content);
      setSaveStatus(`✓ saved · ${r.size_bytes}b · brain reloaded`);
      setTimeout(() => setSaveStatus(''), 3500);
    } catch (e) {
      setSaveStatus(`✗ ${e.message}`);
    }
  }, [currentPath, dirty, content]);

  const filterLower = treeFilter.trim().toLowerCase();

  return (
    <div className={`brain-workspace${sideCollapsed ? ' brain-workspace--side-collapsed' : ''}${focusMode ? ' brain-workspace--focus' : ''}`}>
      <div className="brain-tree">
        <div className="brain-tree-header">
          <span className="brain-tree-header-title">Brain · vault</span>
          <TeamAutonomyDigestButton onOpen={openTeamDigest}/>
          <button className="brain-editor-btn" onClick={refreshTree} title="Refresh tree">↻</button>
        </div>
        <input className="brain-tree-search" placeholder="filter notes…"
               value={treeFilter} onChange={e => setTreeFilter(e.target.value)}/>
        {tree
          ? <FileTreeNode node={tree} depth={0} expanded={expanded}
              onToggle={onToggle} onPick={pickNote}
              currentPath={currentPath} filter={filterLower}/>
          : <div style={{ padding: 16, color: 'var(--text-dim)', fontSize: 11 }}>loading tree…</div>}
      </div>

      <div className={`brain-editor${personaRole && !showGraph ? ' brain-editor--with-auto-grown' : ''}`}>
        {/* Agent-prompt shortcut row — moved here from the Research tab
            per user request. Always visible at the top of the editor
            pane (above the graph + above any persona learnings strip)
            so the user can jump to any of the 18 personas in one tap.
            Mirrors the visionOS shortcut at the top of the Brain window. */}
        <AgentPromptsShortcut
          registry={agentRegistry}
          currentPath={currentPath}
          onPick={pickNote}
        />
        {showGraph ? (
          <BrainGraphView onClose={() => setShowGraph(false)} onPickNote={pickNote}/>
        ) : (
          <>
            {personaRole && (
              <AutoGrownStatusStrip
                role={personaRole}
                learnings={learnings}
                unavailable={learningsUnavailable}
                busy={reviewBusy}
                reviewBanner={reviewBanner}
                onReview={runReview}
                onShowRollback={openRollback}
              />
            )}
            <BrainEditor
              path={currentPath} content={content} dirty={dirty}
              onChange={setContent} onSave={onSave}
              viewMode={viewMode} setViewMode={setViewMode}
              onShowGraph={() => setShowGraph(true)}
              orphanCtx={orphanCtx} onCreateStub={createStub}
              personaRole={personaRole}
              agentLabel={(agentRegistry?.agents || []).find(a => a.role === personaRole)?.label}
              focusMode={focusMode}
              setFocusMode={setFocusMode}
            />
            {personaRole && (
              <AutoGrownSection learnings={learnings} unavailable={learningsUnavailable}/>
            )}
          </>
        )}
        {saveStatus && (
          <div style={{ position: 'absolute', bottom: 12, right: 16, fontSize: 11,
                        color: saveStatus.startsWith('✗') ? 'var(--danger)' : 'var(--accent)',
                        background: 'var(--bg-3)', padding: '4px 10px', borderRadius: 3,
                        border: '1px solid var(--line)' }}>
            {saveStatus}
          </div>
        )}
        {showRollback && personaRole && (
          <RollbackModal
            role={personaRole}
            snapshots={snapshots}
            loading={snapshotsLoading}
            error={snapshotsError}
            onClose={() => setShowRollback(false)}
            onPick={performRollback}
          />
        )}
        <TeamAutonomyDigestModal
          open={showTeamDigest}
          onClose={() => setShowTeamDigest(false)}
          initialSummary={seedDigest}
          onJumpToRole={jumpToRoleFromDigest}
        />
      </div>

      {sideCollapsed ? (
        <aside className="brain-side brain-side-collapsed">
          <button className="brain-side-collapse-btn"
                  onClick={() => setSideCollapsed(false)}
                  title="Expand Editor Tibeb">
            <svg width="14" height="14" viewBox="0 0 14 14"><path d="M9 3l-4 4 4 4" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round"/></svg>
          </button>
          <div className="brain-side-collapsed-spine">
            <div className="brain-side-collapsed-avatar">
              <svg viewBox="0 0 24 24" width="14" height="14">
                <circle cx="12" cy="12" r="3" fill="currentColor"/>
                <circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" strokeWidth="1.5"/>
              </svg>
            </div>
            <div className="brain-side-collapsed-label">EDITOR</div>
          </div>
        </aside>
      ) : (
        <div className="brain-side">
          {/* Side panel is Editor Tibeb chat only. The graph lives in the
              center pane (toggled via the editor toolbar's ⌬ Graph button). */}
          <div className="brain-side-tabs">
            <button className="active" disabled style={{ flex: 1 }}>Editor Tibeb</button>
            <button className="brain-side-mini-btn"
                    onClick={() => setSideCollapsed(true)}
                    title="Minimize Editor Tibeb">
              <svg width="12" height="12" viewBox="0 0 14 14" fill="none">
                <path d="M5 3l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
              </svg>
            </button>
          </div>
          <div className="brain-side-pane">
            <BrainChat currentPath={currentPath} onIngestComplete={() => setReloadKey(k => k + 1)}/>
          </div>
        </div>
      )}
    </div>
  );
}

window.BrainWorkspace = BrainWorkspace;
