// 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 ` 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/.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//.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 => ) .filter(Boolean); if (filter && children.length === 0 && !matchesFilter) return null; return (
onToggle(node.path)}> {isOpen ? '▾' : '▸'} {node.name || 'brain'}
{(isOpen || filter) && (
{children}
)}
); } if (!matchesFilter) return null; if (!node.name.match(/\.(md|markdown|txt)$/i)) return null; return (
onPick(node.path)} title={node.path}> {node.name.replace(/\.md$/, '')}
); } function MarkdownPreview({ content }) { const html = useMemo(() => { if (!window.marked) return content; try { return window.marked.parse(content || '', { breaks: true, gfm: true }); } catch (e) { return `
${content}
`; } }, [content]); return
; } 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 }) => ( {count} {what} ); return (
Brain graph {focus && ( DEPTH {[1, 2, 3].map(d => ( ))} )} drag · scroll to zoom · click chips below to focus a layer
{stats.visible === 0 ? 'graph loading…' : `${stats.visible} nodes · ${stats.totalEdges} edges · 1-click focus · 2-click open · 3-click reset`} {focus && focus.type === 'node' && · focused on node ({focusDepth}-hop)} {focus && focus.type === 'layer' && · focused on {levelLabels[focus.target]} ({focusDepth}-hop)} {stats.visible >= NODE_CAP && · capped at {NODE_CAP} most-connected}
{/* 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 ( ); })} {focus && ( )}
{err ?
{err}
: ( /* 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. */
) }
); } // 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 (
🤖 Backend self-review API not available yet — the cron will start populating this once the persona-self-review engine ships.
); } 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 (
🤖 {promotedCount} auto-promoted ·{' '} {pendingCount} pending · last review {lastDate} {typeof learnings?.snapshot_versions === 'number' && ( · v{learnings.snapshot_versions} )} {reviewBanner && ( {reviewBanner} )}
); } // 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 (
🤖 Auto-grown learnings {!isEmpty && ( {promoted.length} auto · {pending.length} pending )}
{isEmpty ? (
No auto-promoted learnings yet.
Fire Review now manually or wait for the weekly cron.
) : ( <> {promoted.length > 0 && ( )} {pending.length > 0 && ( )} )}
); } function LearningList({ title, tint, entries, pending }) { return (
{title} {entries.length}
{entries.map((e, i) => ( ))}
); } 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 (
{entry.text}
{conf != null && ( conf {conf} · {memos.length} memo{memos.length === 1 ? '' : 's'} )} {prov.role && {prov.role}} {date && {date}} {pending && graduates on review}
{memos.length > 0 && (
{memos.slice(0, 3).map((m, i) => ( {m.split('/').slice(-1)[0]} ))} {memos.length > 3 && +{memos.length - 3}}
)}
); } // 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 ( ); } // 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 (
e.stopPropagation()}>
✦ TEAM AUTONOMY DIGEST
Every agent with cron_self_review in their frontmatter. ✓ = auto-promoted, ○ = pending. Click a row to jump to the agent.
{preflightResult && ( {preflightResult.ok ? `${preflightResult.succeeded}/${preflightResult.fired} succeeded · ${preflightResult.elapsed_s}s` : `error: ${preflightResult.error || 'unknown'}`} )}
{loading && !summary ? (
loading team digest…
) : !summary ? (
Failed to load digest.
) : ( <>
PROMOTED
{summary.total_promoted}
PENDING
{summary.total_pending}
AGENTS
{summary.total_personas}
NEXT REVIEW
{summary.next_review_at ? formatNext(summary.next_review_at) : '—'}
{(summary.personas || []).map(p => (
{ onJumpToRole?.(p); }}>
{p.label || p.role} {p.promoted_count > 0 && ( ✓{p.promoted_count} )} {p.pending_count > 0 && ( ○{p.pending_count} )} {p.next_review_at && ( {formatNext(p.next_review_at)} )}
{(p.latest_promoted?.text || p.latest_pending?.text) && (
{p.latest_promoted?.text || p.latest_pending?.text}
)}
))}
)}
); } // 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 (
e.stopPropagation()}>
Rollback · {role}
Tap a version to restore. The current body is auto-snapshotted before the swap, so this is itself reversible.
{loading ? (
loading snapshots…
) : error ? (
{error}
) : !snapshots || snapshots.length === 0 ? (
No snapshots yet.
Snapshots are taken automatically before each self-review write.
) : (
{snapshots.slice(0, 10).map(s => ( ))}
)}
); } 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 (
Pick a note from the tree to start editing.
Cmd+S to save · writes hot-reload Tibeb's vault brief
); } return ( <>
{/* 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 && ( AGENT · {agentLabel || personaRole} )} {path} {orphanCtx && · orphan · file does not exist}
{orphanCtx && ( )} {/* 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 && ( )}