<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SAME — Vault Dashboard</title>
<style>
:root {
--bg: #1a1a2e;
--bg-card: #16213e;
--bg-input: #0f3460;
--text: #e0e0e0;
--text-dim: #8892b0;
--accent: #5ea4eb;
--accent-hover: #7bb8f0;
--border: #233554;
--green: #64ffda;
--yellow: #ffd166;
--red: #ff6b6b;
--purple: #c084fc;
--orange: #fb923c;
--focus: #36cfc9;
--badge-decision: #3b82f6;
--badge-handoff: #22c55e;
--badge-entity: #a855f7;
--badge-hub: #f97316;
--badge-note: #6b7280;
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, sans-serif;
--mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
--radius: 8px;
--shadow: 0 2px 8px rgba(0,0,0,0.3);
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f5f5f5;
--bg-card: #ffffff;
--bg-input: #e8e8e8;
--text: #1a1a2e;
--text-dim: #6b7280;
--border: #d1d5db;
--shadow: 0 2px 8px rgba(0,0,0,0.1);
}
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: var(--font);
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
}
/* Sidebar */
.sidebar {
width: 220px;
background: var(--bg-card);
border-right: 1px solid var(--border);
padding: 20px 0;
display: flex;
flex-direction: column;
position: fixed;
top: 0;
left: 0;
height: 100vh;
z-index: 10;
}
.sidebar-brand {
padding: 0 20px 20px;
border-bottom: 1px solid var(--border);
margin-bottom: 12px;
}
.sidebar-brand h1 {
font-size: 20px;
font-weight: 700;
color: var(--accent);
letter-spacing: 2px;
}
.sidebar-brand small {
font-size: 11px;
color: var(--text-dim);
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 20px;
color: var(--text-dim);
text-decoration: none;
font-size: 14px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
border-left: 3px solid transparent;
}
.nav-item:hover { background: var(--bg-input); color: var(--text); }
.nav-item.active {
color: var(--accent);
background: var(--bg-input);
border-left-color: var(--accent);
}
.nav-item:focus-visible {
outline: 2px solid var(--focus);
outline-offset: -2px;
}
.nav-item svg { width: 18px; height: 18px; flex-shrink: 0; }
.sidebar-footer {
margin-top: auto;
padding: 12px 20px;
border-top: 1px solid var(--border);
font-size: 11px;
color: var(--text-dim);
}
.search-mode-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.search-mode-badge.semantic { background: rgba(94,164,235,0.2); color: var(--accent); }
.search-mode-badge.keyword { background: rgba(255,209,102,0.2); color: var(--yellow); }
/* Main content */
.main {
margin-left: 220px;
flex: 1;
padding: 24px 32px;
min-height: 100vh;
}
.page { display: none; }
.page.active { display: block; }
.page-title {
font-size: 22px;
font-weight: 600;
margin-bottom: 20px;
}
/* Stat cards */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
box-shadow: var(--shadow);
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: var(--accent);
}
.stat-label {
font-size: 12px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 4px;
}
/* Section headers */
.section-header {
font-size: 16px;
font-weight: 600;
margin: 24px 0 12px;
color: var(--text);
}
/* Note cards */
.note-list { display: flex; flex-direction: column; gap: 8px; }
.note-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 18px;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
box-shadow: var(--shadow);
}
.note-card:hover {
border-color: var(--accent);
box-shadow: 0 2px 12px rgba(94,164,235,0.15);
}
.note-card:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 1px;
}
.note-card.search-active {
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(94,164,235,0.2);
}
.note-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 4px;
}
.note-path {
font-size: 11px;
font-family: var(--mono);
color: var(--text-dim);
margin-bottom: 6px;
}
.note-snippet {
font-size: 13px;
color: var(--text-dim);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.note-meta {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
flex-wrap: wrap;
}
/* Tags */
.tag {
display: inline-block;
padding: 2px 8px;
background: var(--bg-input);
border-radius: 10px;
font-size: 11px;
color: var(--text-dim);
}
.agent-badge {
background: rgba(94,164,235,0.18);
color: var(--accent);
font-weight: 600;
}
/* Content type badges */
.type-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #fff;
}
.type-badge.decision { background: var(--badge-decision); }
.type-badge.handoff { background: var(--badge-handoff); }
.type-badge.entity { background: var(--badge-entity); }
.type-badge.hub { background: var(--badge-hub); }
.type-badge.note { background: var(--badge-note); }
/* Search */
.search-container {
position: relative;
margin-bottom: 20px;
}
.search-input {
width: 100%;
padding: 12px 16px 12px 42px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-size: 15px;
font-family: var(--font);
outline: none;
transition: border-color 0.15s;
}
.search-input:focus { border-color: var(--accent); }
.search-input::placeholder { color: var(--text-dim); }
.search-input:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
.search-icon {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
color: var(--text-dim);
}
.search-icon svg { width: 18px; height: 18px; }
/* Score bar */
.score-bar-container {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
}
.score-bar {
flex: 0 0 80px;
height: 6px;
background: var(--bg-input);
border-radius: 3px;
overflow: hidden;
}
.score-bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s ease;
}
.score-bar-fill.high { background: var(--green); }
.score-bar-fill.medium { background: var(--yellow); }
.score-bar-fill.low { background: var(--text-dim); }
.score-label {
font-size: 11px;
color: var(--text-dim);
font-family: var(--mono);
}
/* Note viewer */
.note-viewer {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px;
box-shadow: var(--shadow);
}
.note-viewer-title {
font-size: 20px;
font-weight: 700;
margin-bottom: 8px;
}
.note-viewer-path {
font-size: 12px;
font-family: var(--mono);
color: var(--text-dim);
margin-bottom: 16px;
}
.note-viewer-meta {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border);
}
.note-viewer-content {
font-size: 14px;
line-height: 1.7;
word-break: break-word;
font-family: var(--font);
}
.note-viewer-content h1,
.note-viewer-content h2,
.note-viewer-content h3,
.note-viewer-content h4 {
margin: 20px 0 8px;
font-weight: 600;
line-height: 1.3;
}
.note-viewer-content h1 { font-size: 20px; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
.note-viewer-content h2 { font-size: 17px; border-bottom: 1px solid var(--border); padding-bottom: 4px; }
.note-viewer-content h3 { font-size: 15px; }
.note-viewer-content h4 { font-size: 14px; color: var(--text-dim); }
.note-viewer-content p { margin: 8px 0; }
.note-viewer-content code {
background: var(--bg-input);
padding: 2px 6px;
border-radius: 4px;
font-family: var(--mono);
font-size: 13px;
}
.note-viewer-content pre {
background: var(--bg-input);
padding: 14px;
border-radius: var(--radius);
overflow-x: auto;
margin: 12px 0;
line-height: 1.5;
}
.note-viewer-content pre code {
background: none;
padding: 0;
}
.note-viewer-content ul, .note-viewer-content ol {
margin: 8px 0;
padding-left: 24px;
}
.note-viewer-content li { margin: 3px 0; }
.note-viewer-content li.task-item { list-style: none; margin-left: -20px; }
.note-viewer-content li.task-item input { margin-right: 6px; vertical-align: middle; }
.note-viewer-content blockquote {
border-left: 3px solid var(--accent);
padding: 8px 12px;
color: var(--text-dim);
margin: 12px 0;
background: var(--bg-input);
border-radius: 0 var(--radius) var(--radius) 0;
}
.note-viewer-content hr {
border: none;
border-top: 1px solid var(--border);
margin: 16px 0;
}
.note-viewer-content table {
border-collapse: collapse;
width: 100%;
margin: 12px 0;
font-size: 13px;
}
.note-viewer-content th, .note-viewer-content td {
border: 1px solid var(--border);
padding: 8px 12px;
text-align: left;
}
.note-viewer-content th {
background: var(--bg-input);
font-weight: 600;
}
.note-viewer-content a {
color: var(--accent);
text-decoration: none;
}
.note-viewer-content a:hover { text-decoration: underline; }
.note-viewer-content img { max-width: 100%; border-radius: var(--radius); }
.note-viewer-content .frontmatter {
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 16px;
margin-bottom: 16px;
font-size: 13px;
line-height: 1.6;
}
.note-viewer-content .frontmatter strong { color: var(--accent); }
.back-link {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--accent);
text-decoration: none;
font-size: 13px;
margin-bottom: 16px;
cursor: pointer;
}
.back-link:hover { text-decoration: underline; }
/* Browse filter */
.browse-controls {
display: flex;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.browse-controls input, .browse-controls select {
padding: 8px 12px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-size: 13px;
font-family: var(--font);
outline: none;
}
.browse-controls input:focus, .browse-controls select:focus {
border-color: var(--accent);
}
.browse-controls select { cursor: pointer; }
.browse-controls select option { background: var(--bg-card); }
.note-count-label {
font-size: 12px;
color: var(--text-dim);
margin-bottom: 12px;
}
.search-help {
margin-top: -8px;
margin-bottom: 12px;
font-size: 12px;
color: var(--text-dim);
}
.search-help kbd {
border: 1px solid var(--border);
border-radius: 4px;
padding: 1px 5px;
font-family: var(--mono);
font-size: 11px;
background: var(--bg-card);
color: var(--text);
}
.snippet-mark {
background: rgba(255, 209, 102, 0.33);
color: inherit;
padding: 0 2px;
border-radius: 2px;
}
/* Composition breakdown */
.composition-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 24px; }
.composition-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
transition: border-color 0.15s;
}
.composition-row:hover { border-color: var(--accent); }
.composition-bar {
flex: 1;
height: 6px;
background: var(--bg-input);
border-radius: 3px;
overflow: hidden;
}
.composition-bar-fill { height: 100%; border-radius: 3px; }
.composition-label { font-size: 12px; min-width: 80px; }
.composition-count { font-size: 12px; color: var(--text-dim); font-family: var(--mono); min-width: 30px; text-align: right; }
/* CLI copy button */
.cli-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-dim);
font-size: 11px;
font-family: var(--mono);
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
white-space: nowrap;
}
.cli-btn:hover { border-color: var(--accent); color: var(--accent); }
.cli-btn:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 1px;
}
.cli-btn svg { width: 12px; height: 12px; }
.cli-actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
/* Related notes */
.related-section {
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid var(--border);
}
.related-section .section-header { margin-top: 0; }
/* Insights */
.insights-list { display: flex; flex-direction: column; gap: 8px; margin-bottom: 24px; }
.insight-card {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 16px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 13px;
line-height: 1.5;
}
.insight-card.tip { border-left: 3px solid var(--accent); }
.insight-card.warn { border-left: 3px solid var(--yellow); }
.insight-card.good { border-left: 3px solid var(--green); }
.insight-icon { font-size: 16px; flex-shrink: 0; margin-top: 1px; }
.insight-body { flex: 1; }
.insight-body strong { font-weight: 600; }
.insight-body .cli-btn { margin-top: 6px; }
/* Empty state */
.empty-state {
text-align: center;
padding: 48px 20px;
color: var(--text-dim);
}
.empty-state svg { width: 48px; height: 48px; margin-bottom: 16px; opacity: 0.5; }
.empty-state p { font-size: 14px; line-height: 1.6; }
.empty-state .hint { font-size: 12px; margin-top: 8px; font-family: var(--mono); }
.error-state {
border: 1px solid rgba(255,107,107,0.35);
border-radius: var(--radius);
background: rgba(255,107,107,0.08);
padding: 16px;
}
/* Loading */
.loading {
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
color: var(--text-dim);
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-right: 8px;
}
@keyframes spin { to { transform: rotate(360deg); } }
.skeleton-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.skeleton-card {
background: linear-gradient(90deg, rgba(136,146,176,0.12), rgba(136,146,176,0.22), rgba(136,146,176,0.12));
background-size: 240px 100%;
border: 1px solid var(--border);
border-radius: var(--radius);
height: 92px;
animation: skeleton 1.2s ease-in-out infinite;
}
@keyframes skeleton {
0% { background-position: -240px 0; }
100% { background-position: calc(100% + 240px) 0; }
}
/* Responsive */
@media (max-width: 1024px) {
.sidebar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: auto;
width: 100%;
flex-direction: row;
align-items: center;
padding: 8px 10px;
border-right: none;
border-bottom: 1px solid var(--border);
overflow-x: auto;
gap: 4px;
}
.sidebar-brand {
border-bottom: none;
margin-bottom: 0;
padding: 0 10px 0 4px;
min-width: 160px;
}
.sidebar-footer {
border-top: none;
margin-top: 0;
margin-left: auto;
white-space: nowrap;
padding: 0 6px;
}
.nav-item {
border-left: none;
border-bottom: 2px solid transparent;
padding: 8px 12px;
}
.nav-item.active { border-bottom-color: var(--accent); }
.main {
margin-left: 0;
margin-top: 72px;
padding: 18px;
}
}
@media (max-width: 768px) {
.sidebar-brand { min-width: 90px; }
.sidebar-brand h1 { font-size: 14px; }
.sidebar-brand small,
#vault-label,
.sidebar-footer div { display: none; }
.nav-item span { display: none; }
.nav-item { padding: 10px; justify-content: center; }
.main { margin-top: 56px; padding: 14px; }
.stats-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 480px) {
.stats-grid { grid-template-columns: 1fr; }
.browse-controls { flex-direction: column; }
.browse-controls input, .browse-controls select { width: 100%; }
.page-title { font-size: 20px; }
.note-viewer { padding: 16px; }
}
@media print {
body { background: #fff; color: #000; display: block; }
.sidebar, .back-link, .related-section, .cli-actions, .search-help { display: none !important; }
.main { margin: 0; padding: 0; }
.page { display: none !important; }
#page-note { display: block !important; }
.note-viewer {
border: none;
box-shadow: none;
padding: 0;
}
.note-viewer-content {
font-size: 12pt;
line-height: 1.5;
}
.note-viewer-content a {
color: #000;
text-decoration: underline;
}
}
</style>
</head>
<body>
<nav class="sidebar" aria-label="Primary">
<div class="sidebar-brand">
<h1>SAME</h1>
<small style="display:block;font-size:10px;color:var(--text-dim);margin-top:2px">Persistent memory for AI agents</small>
<small id="version-label">v0.0.0</small>
<div id="vault-label" style="font-size:11px;color:var(--text-dim);margin-top:4px;word-break:break-all;cursor:help" title=""></div>
</div>
<a class="nav-item active" data-page="dashboard" onclick="navigate('dashboard')" title="Vault stats, health insights, and recent activity" aria-label="Open dashboard">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
<span>Dashboard</span>
</a>
<a class="nav-item" data-page="search" onclick="navigate('search')" title="Semantic or keyword search across your notes" aria-label="Open search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<span>Search</span>
</a>
<a class="nav-item" data-page="browse" onclick="navigate('browse')" title="Browse and filter all indexed notes" aria-label="Open browse">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
<span>Browse</span>
</a>
<a class="nav-item" data-page="decisions" onclick="navigate('decisions')" title="Key choices captured from your AI sessions" aria-label="Open decisions">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
<span>Decisions</span>
</a>
<a class="nav-item" data-page="handoffs" onclick="navigate('handoffs')" title="Session summaries for continuity across sessions" aria-label="Open handoffs">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
<span>Handoffs</span>
</a>
<div class="sidebar-footer">
<span id="search-mode-indicator"></span>
<div style="margin-top:8px"><a href="https://statelessagent.com" target="_blank" rel="noopener" style="font-size:11px;color:var(--text-dim);text-decoration:none">Docs & Help</a></div>
</div>
</nav>
<main class="main" id="main-content">
<!-- Dashboard -->
<div id="page-dashboard" class="page active" role="region" aria-label="Dashboard view">
<h2 class="page-title" tabindex="-1">Dashboard</h2>
<div class="stats-grid" id="stats-grid">
<div class="stat-card" title="Markdown files indexed in your vault"><div class="stat-value" id="stat-notes">-</div><div class="stat-label">Notes</div></div>
<div class="stat-card" title="Notes split into sections for embedding and search"><div class="stat-value" id="stat-chunks">-</div><div class="stat-label">Chunks</div></div>
<div class="stat-card" title="SQLite database size on disk"><div class="stat-value" id="stat-dbsize">-</div><div class="stat-label">DB Size</div></div>
<div class="stat-card" title="Semantic: finds notes by meaning via Ollama embeddings. Keyword: matches exact terms."><div class="stat-value" id="stat-search">-</div><div class="stat-label">Search Mode</div></div>
</div>
<div id="insights-section" style="display:none">
<h3 class="section-header">Insights</h3>
<div class="insights-list" id="insights-list"></div>
</div>
<div id="composition-section" style="display:none">
<h3 class="section-header">Vault Composition</h3>
<div class="composition-list" id="composition-list"></div>
</div>
<div id="pinned-section" style="display:none">
<h3 class="section-header">Pinned Notes</h3>
<div class="note-list" id="pinned-notes"></div>
</div>
<h3 class="section-header">Recent Notes</h3>
<div class="note-list" id="recent-notes">
<div class="loading"><div class="spinner"></div> Loading...</div>
</div>
</div>
<!-- Search -->
<div id="page-search" class="page" role="region" aria-label="Search view">
<h2 class="page-title" tabindex="-1">Search</h2>
<div class="search-container">
<div class="search-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
</div>
<input type="text" class="search-input" id="search-input" placeholder="Search by concept or keyword..." autocomplete="off" aria-label="Search notes" aria-controls="search-results">
</div>
<div class="search-help"><kbd>/</kbd> focus search · <kbd>Esc</kbd> clear · <kbd>↑</kbd>/<kbd>↓</kbd> navigate results</div>
<div id="search-results" aria-live="polite">
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<p>Search your vault by meaning or keywords</p>
<p class="hint">Try: "authentication approach" or "error handling patterns"</p>
</div>
</div>
</div>
<!-- Browse -->
<div id="page-browse" class="page" role="region" aria-label="Browse view">
<h2 class="page-title" tabindex="-1">Browse</h2>
<div class="browse-controls">
<input type="text" id="browse-filter" placeholder="Filter notes..." style="flex:1;min-width:200px" aria-label="Filter notes">
<select id="browse-type-filter" title="Filter by content type" aria-label="Filter by note type">
<option value="">All types</option>
<option value="note">Notes</option>
<option value="decision">Decisions (key choices)</option>
<option value="handoff">Handoffs (session summaries)</option>
<option value="entity">Entities (people, APIs, configs)</option>
<option value="hub">Hubs (index notes linking related content)</option>
</select>
<select id="browse-sort" aria-label="Sort notes">
<option value="modified">Modified (newest)</option>
<option value="modified-asc">Modified (oldest)</option>
<option value="title">Title (A-Z)</option>
<option value="title-desc">Title (Z-A)</option>
</select>
</div>
<div class="note-count-label" id="browse-count"></div>
<div class="note-list" id="browse-notes">
<div class="loading"><div class="spinner"></div> Loading...</div>
</div>
</div>
<!-- Decisions -->
<div id="page-decisions" class="page" role="region" aria-label="Decisions view">
<h2 class="page-title" tabindex="-1">Decisions</h2>
<div class="note-count-label" id="decisions-count"></div>
<div class="note-list" id="decisions-notes">
<div class="loading"><div class="spinner"></div> Loading...</div>
</div>
</div>
<!-- Handoffs -->
<div id="page-handoffs" class="page" role="region" aria-label="Handoffs view">
<h2 class="page-title" tabindex="-1">Session Handoffs</h2>
<div class="note-count-label" id="handoffs-count"></div>
<div class="note-list" id="handoffs-notes">
<div class="loading"><div class="spinner"></div> Loading...</div>
</div>
</div>
<!-- Note Viewer -->
<div id="page-note" class="page" role="region" aria-label="Note details view">
<a class="back-link" id="back-link" onclick="goBack()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
Back
</a>
<div class="note-viewer" id="note-viewer">
<div class="loading"><div class="spinner"></div> Loading note...</div>
</div>
<div class="related-section" id="related-section" style="display:none">
<h3 class="section-header">Related Notes</h3>
<div class="note-list" id="related-notes"></div>
</div>
</div>
</main>
<script>
// SAME Web UI — All data is local, served from localhost only.
// All user content is escaped via textContent before DOM insertion.
(function() {
'use strict';
// State
var allNotes = [];
var searchMode = 'unknown';
var lastPage = 'dashboard';
var debounceTimer = null;
var activeSearchIndex = -1;
var searchResultCards = [];
var lastSearchTerms = [];
// API helpers
function friendlyErrorMessage(err) {
var msg = (err && err.message ? err.message : '').toLowerCase();
if (msg.indexOf('failed to fetch') !== -1 || msg.indexOf('network') !== -1) {
return 'Could not reach the local SAME server. Run `same web` and refresh this page.';
}
if (msg.indexOf('forbidden') !== -1 || msg.indexOf('403') !== -1) {
return 'This dashboard only works from localhost. Open the exact URL printed by `same web`.';
}
if (msg.indexOf('404') !== -1) {
return 'The requested note or endpoint was not found.';
}
return 'Something went wrong while loading local data. Try refreshing, then run `same doctor` if it persists.';
}
function api(path) {
return fetch(path).then(function(res) {
if (!res.ok) {
return res.text().then(function(body) {
var details = body ? body.replace(/\s+/g, ' ').trim() : '';
if (details.length > 140) details = details.slice(0, 140) + '...';
throw new Error(details || ('HTTP ' + res.status));
});
}
return res.json().catch(function() {
throw new Error('Malformed API response');
});
});
}
// Safe text escaping via DOM
function escapeHTML(str) {
if (!str) return '';
var div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// Safe DOM builder helpers
function el(tag, attrs, children) {
var node = document.createElement(tag);
if (attrs) {
Object.keys(attrs).forEach(function(k) {
if (k === 'className') node.className = attrs[k];
else if (k === 'textContent') node.textContent = attrs[k];
else if (k === 'onclick') node.onclick = attrs[k];
else if (k === 'style') node.style.cssText = attrs[k];
else node.setAttribute(k, attrs[k]);
});
}
if (children) {
children.forEach(function(c) {
if (typeof c === 'string') node.appendChild(document.createTextNode(c));
else if (c) node.appendChild(c);
});
}
return node;
}
// Copy to clipboard helper
function copyToClipboard(text, btn) {
navigator.clipboard.writeText(text).then(function() {
var orig = btn.textContent;
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = orig; }, 1200);
});
}
// Build a CLI copy button
function buildCliBtn(label, command) {
var btn = el('button', {className: 'cli-btn', textContent: label});
btn.onclick = function(e) {
e.stopPropagation();
copyToClipboard(command, btn);
};
return btn;
}
// Navigation
window.navigate = function(page, push) {
if (push !== false) {
window.location.hash = '#' + page;
}
document.querySelectorAll('.page').forEach(function(p) { p.classList.remove('active'); });
document.querySelectorAll('.nav-item').forEach(function(n) { n.classList.remove('active'); });
var pageEl = document.getElementById('page-' + page);
if (pageEl) pageEl.classList.add('active');
var nav = document.querySelector('[data-page="' + page + '"]');
if (nav) nav.classList.add('active');
if (page !== 'note') lastPage = page;
if (page === 'decisions') loadDecisions();
if (page === 'handoffs') loadHandoffs();
if (page === 'search') {
var input = document.getElementById('search-input');
if (input) setTimeout(function() { input.focus(); }, 50);
}
var heading = pageEl ? pageEl.querySelector('.page-title') : null;
if (heading) setTimeout(function() { heading.focus(); }, 0);
};
window.goBack = function() {
navigate(lastPage);
};
// Hash router
function handleHash() {
var hash = window.location.hash.slice(1) || 'dashboard';
if (hash.startsWith('note/')) {
var path = decodeURIComponent(hash.slice(5));
navigate('note', false);
loadNote(path);
} else {
navigate(hash, false);
}
}
window.addEventListener('hashchange', handleHash);
// Format helpers
function formatBytes(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
function formatDate(ts) {
if (!ts) return '';
var d = new Date(ts * 1000);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}
function parseTags(tagsJSON) {
if (!tagsJSON) return [];
try { return JSON.parse(tagsJSON) || []; } catch(e) { return []; }
}
function extractSearchTerms(query) {
return (query || '')
.toLowerCase()
.split(/\s+/)
.map(function(t) { return t.replace(/[^a-z0-9_-]/g, ''); })
.filter(function(t) { return t.length >= 2; });
}
function buildHighlightedText(text, terms) {
var frag = document.createDocumentFragment();
var source = text || '';
if (!source || !terms || terms.length === 0) {
frag.appendChild(document.createTextNode(source));
return frag;
}
var lower = source.toLowerCase();
var ranges = [];
terms.forEach(function(term) {
var idx = 0;
while (idx < lower.length) {
var found = lower.indexOf(term, idx);
if (found === -1) break;
ranges.push({ start: found, end: found + term.length });
idx = found + term.length;
}
});
if (ranges.length === 0) {
frag.appendChild(document.createTextNode(source));
return frag;
}
ranges.sort(function(a, b) { return a.start - b.start; });
var merged = [ranges[0]];
for (var i = 1; i < ranges.length; i++) {
var prev = merged[merged.length - 1];
var cur = ranges[i];
if (cur.start <= prev.end) {
prev.end = Math.max(prev.end, cur.end);
} else {
merged.push(cur);
}
}
var cursor = 0;
merged.forEach(function(r) {
if (cursor < r.start) {
frag.appendChild(document.createTextNode(source.slice(cursor, r.start)));
}
var mark = document.createElement('mark');
mark.className = 'snippet-mark';
mark.textContent = source.slice(r.start, r.end);
frag.appendChild(mark);
cursor = r.end;
});
if (cursor < source.length) {
frag.appendChild(document.createTextNode(source.slice(cursor)));
}
return frag;
}
// Build a type badge DOM node
function buildTypeBadge(type) {
var t = type || 'note';
var span = document.createElement('span');
span.className = 'type-badge ' + t;
span.textContent = t;
return span;
}
// Build a score bar DOM fragment
function buildScoreBar(score) {
var pct = Math.round(score * 100);
var cls = 'low';
if (pct >= 70) cls = 'high';
else if (pct >= 40) cls = 'medium';
var container = el('div', {className: 'score-bar-container'}, [
el('div', {className: 'score-bar'}, [
el('div', {className: 'score-bar-fill ' + cls, style: 'width:' + pct + '%'})
]),
el('span', {className: 'score-label', textContent: pct + '%'})
]);
return container;
}
// Build a note card DOM node
function buildNoteCard(note, showScore, highlightTerms) {
var tags = parseTags(note.tags || note.Tags);
var snippet = (note.snippet || note.text || note.Text || '').slice(0, 200).replace(/\n/g, ' ');
var title = note.title || note.Title || note.path || note.Path;
var path = note.path || note.Path;
var type = note.content_type || note.ContentType || 'note';
var agent = note.agent || note.Agent || '';
var modified = note.modified || note.Modified;
var metaChildren = [buildTypeBadge(type)];
if (agent) {
metaChildren.push(el('span', {className: 'tag agent-badge', textContent: '@' + agent}));
}
tags.forEach(function(t) {
metaChildren.push(el('span', {className: 'tag', textContent: t}));
});
if (modified) {
metaChildren.push(el('span', {className: 'tag', textContent: formatDate(modified)}));
}
var cardChildren = [
el('div', {className: 'note-title', textContent: title}),
el('div', {className: 'note-path', textContent: path})
];
if (snippet) {
var snippetEl = el('div', {className: 'note-snippet'});
snippetEl.appendChild(buildHighlightedText(snippet, highlightTerms || []));
cardChildren.push(snippetEl);
}
if (showScore && note.score !== undefined) {
cardChildren.push(buildScoreBar(note.score));
}
cardChildren.push(el('div', {className: 'note-meta'}, metaChildren));
// CLI action buttons
var actions = el('div', {className: 'cli-actions'}, [
buildCliBtn('same related "' + path + '"', 'same related "' + path + '"'),
buildCliBtn('same pin "' + path + '"', 'same pin "' + path + '"')
]);
cardChildren.push(actions);
var card = el('div', {
className: 'note-card',
tabindex: '0',
role: 'button',
'aria-label': 'Open note ' + title,
onclick: function(e) {
if (e.target.closest('.cli-btn')) return;
window.location.hash = '#note/' + encodeURIComponent(path);
},
onkeydown: function(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
window.location.hash = '#note/' + encodeURIComponent(path);
}
}
}, cardChildren);
return card;
}
// Clear and set children on an element
function setChildren(parent, children) {
parent.textContent = '';
children.forEach(function(c) {
if (typeof c === 'string') parent.appendChild(document.createTextNode(c));
else if (c) parent.appendChild(c);
});
}
// Set loading state
function setLoading(parent, mode) {
parent.textContent = '';
if (mode === 'cards') {
var list = el('div', {className: 'skeleton-list'});
for (var i = 0; i < 4; i++) list.appendChild(el('div', {className: 'skeleton-card'}));
parent.appendChild(list);
return;
}
parent.appendChild(el('div', {className: 'loading'}, [
el('div', {className: 'spinner'}),
' Loading...'
]));
}
// Set empty state
function setEmpty(parent, msg, hint) {
parent.textContent = '';
var children = [el('p', {textContent: msg})];
if (hint) children.push(el('p', {className: 'hint', textContent: hint}));
parent.appendChild(el('div', {className: 'empty-state'}, children));
}
function setError(parent, title, err) {
parent.textContent = '';
var detail = friendlyErrorMessage(err);
parent.appendChild(el('div', {className: 'empty-state error-state'}, [
el('p', {textContent: title || 'Unable to load data'}),
el('p', {className: 'hint', textContent: detail})
]));
}
// Render markdown — returns safe HTML string
// All content is escaped via escapeHTML() first, then formatting applied.
// Capped at 100KB to prevent ReDoS on pathological inputs.
var MAX_RENDER_SIZE = 100 * 1024;
function renderMarkdown(text) {
if (!text) return '';
if (text.length > MAX_RENDER_SIZE) {
text = text.slice(0, MAX_RENDER_SIZE) + '\n\n[Content truncated — ' + text.length.toLocaleString() + ' chars total]';
}
// Escape all content first
var escaped = escapeHTML(text);
// Extract and protect code blocks before other processing.
// Placeholders use a pattern that cannot appear in escaped HTML:
// escapeHTML converts & to &, < to <, etc., so raw angle brackets
// never appear in escaped text, making «CODEBLOCK_N» and «ICODE_N» safe.
var codeBlocks = [];
escaped = escaped.replace(/```(\w*)\n([\s\S]*?)```/g, function(m, lang, code) {
var idx = codeBlocks.length;
codeBlocks.push('<pre><code>' + code + '</code></pre>');
return '\u00abCODEBLOCK_' + idx + '\u00bb';
});
// Inline code (protect from further processing)
var inlineCode = [];
escaped = escaped.replace(/`([^`]+)`/g, function(m, code) {
var idx = inlineCode.length;
inlineCode.push('<code>' + code + '</code>');
return '\u00abICODE_' + idx + '\u00bb';
});
// Process line by line for block elements
var lines = escaped.split('\n');
var out = [];
var inList = false;
var listType = '';
var inBlockquote = false;
var inTable = false;
var tableRows = [];
function closeList() {
if (inList) { out.push('</' + listType + '>'); inList = false; }
}
function closeBlockquote() {
if (inBlockquote) { out.push('</blockquote>'); inBlockquote = false; }
}
function closeTable() {
if (inTable && tableRows.length > 0) {
var html = '<table>';
tableRows.forEach(function(row, i) {
var cells = row.split('|').filter(function(c) { return c.trim() !== ''; });
// Skip separator rows (---, :--:, etc.)
if (cells.length > 0 && /^[\s:|-]+$/.test(cells[0])) return;
var tag = i === 0 ? 'th' : 'td';
html += '<tr>';
cells.forEach(function(c) { html += '<' + tag + '>' + c.trim() + '</' + tag + '>'; });
html += '</tr>';
});
html += '</table>';
out.push(html);
}
inTable = false;
tableRows = [];
}
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
// Horizontal rule
if (/^(-{3,}|\*{3,}|_{3,})$/.test(line.trim())) {
closeList(); closeBlockquote(); closeTable();
out.push('<hr>');
continue;
}
// Table rows (lines with |)
if (line.indexOf('|') !== -1 && line.trim().charAt(0) === '|') {
closeList(); closeBlockquote();
if (!inTable) { inTable = true; tableRows = []; }
tableRows.push(line);
continue;
} else if (inTable) {
closeTable();
}
// Headers
var hMatch = line.match(/^(#{1,4}) (.+)$/);
if (hMatch) {
closeList(); closeBlockquote();
var level = hMatch[1].length;
out.push('<h' + level + '>' + applyInline(hMatch[2]) + '</h' + level + '>');
continue;
}
// Blockquote
var bqMatch = line.match(/^> ?(.*)$/);
if (bqMatch) {
closeList();
if (!inBlockquote) { out.push('<blockquote>'); inBlockquote = true; }
out.push(applyInline(bqMatch[1]));
continue;
} else if (inBlockquote) {
closeBlockquote();
}
// Task list items
var taskMatch = line.match(/^- \[([ xX])\] (.+)$/);
if (taskMatch) {
if (!inList || listType !== 'ul') { closeList(); out.push('<ul>'); inList = true; listType = 'ul'; }
var checked = taskMatch[1] !== ' ' ? ' checked disabled' : ' disabled';
out.push('<li class="task-item"><input type="checkbox"' + checked + '>' + applyInline(taskMatch[2]) + '</li>');
continue;
}
// Unordered list
var ulMatch = line.match(/^[-*+] (.+)$/);
if (ulMatch) {
closeBlockquote();
if (!inList || listType !== 'ul') { closeList(); out.push('<ul>'); inList = true; listType = 'ul'; }
out.push('<li>' + applyInline(ulMatch[1]) + '</li>');
continue;
}
// Ordered list
var olMatch = line.match(/^\d+\. (.+)$/);
if (olMatch) {
closeBlockquote();
if (!inList || listType !== 'ol') { closeList(); out.push('<ol>'); inList = true; listType = 'ol'; }
out.push('<li>' + applyInline(olMatch[1]) + '</li>');
continue;
}
// Close list if we hit a non-list line
if (inList) closeList();
// Empty line = paragraph break
if (line.trim() === '') {
out.push('');
continue;
}
// Regular paragraph line
out.push('<p>' + applyInline(line) + '</p>');
}
closeList(); closeBlockquote(); closeTable();
var html = out.join('\n');
// Clean up: merge consecutive empty lines, remove empty <p> tags
html = html.replace(/(<p><\/p>\s*)+/g, '');
html = html.replace(/\n{3,}/g, '\n\n');
// Restore code blocks and inline code
html = html.replace(/\u00abCODEBLOCK_(\d+)\u00bb/g, function(m, idx) { return codeBlocks[parseInt(idx)]; });
html = html.replace(/\u00abICODE_(\d+)\u00bb/g, function(m, idx) { return inlineCode[parseInt(idx)]; });
return html;
}
// Apply inline formatting (bold, italic, links, etc.)
// Called on already-escaped text
function applyInline(text) {
// Bold + italic
text = text.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
// Bold
text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
// Italic
text = text.replace(/\*(.+?)\*/g, '<em>$1</em>');
// Strikethrough
text = text.replace(/~~(.+?)~~/g, '<del>$1</del>');
// Links [text](url) — URL was escaped, unescape & back for href
// Block dangerous URL schemes (javascript:, data:, vbscript:)
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(m, label, href) {
href = href.replace(/&/g, '&');
var scheme = href.trim().toLowerCase().replace(/[\x00-\x1f\x7f]/g, '');
if (/^(javascript|data|vbscript)\s*:/i.test(scheme)) {
return label;
}
return '<a href="' + href + '" target="_blank" rel="noopener">' + label + '</a>';
});
// Restore inline code placeholders (pass through)
return text;
}
// Load dashboard
function loadDashboard() {
setLoading(document.getElementById('recent-notes'), 'cards');
Promise.all([
api('/api/status'),
api('/api/pinned'),
api('/api/notes/recent?limit=15')
]).then(function(data) {
var status = data[0], pinned = data[1], recent = data[2];
document.getElementById('stat-notes').textContent = status.note_count.toLocaleString();
document.getElementById('stat-chunks').textContent = status.chunk_count.toLocaleString();
document.getElementById('stat-dbsize').textContent = formatBytes(status.db_size);
document.getElementById('stat-search').textContent = status.search_mode;
document.getElementById('version-label').textContent = 'v' + status.version;
searchMode = status.search_mode;
// Vault identity
var vaultLabel = document.getElementById('vault-label');
if (status.vault_name) {
vaultLabel.textContent = status.vault_name;
vaultLabel.title = status.vault_path || '';
}
var indicator = document.getElementById('search-mode-indicator');
indicator.textContent = '';
var badge = document.createElement('span');
badge.className = 'search-mode-badge ' + (searchMode === 'semantic' ? 'semantic' : 'keyword');
badge.textContent = searchMode === 'semantic' ? 'Semantic' : 'Keyword';
indicator.appendChild(badge);
// Pinned
var pinnedEl = document.getElementById('pinned-notes');
var pinnedSection = document.getElementById('pinned-section');
if (pinned && pinned.length > 0) {
pinnedSection.style.display = 'block';
pinnedEl.textContent = '';
pinned.forEach(function(n) { pinnedEl.appendChild(buildNoteCard(n, false)); });
} else {
pinnedSection.style.display = 'none';
}
// Recent
var recentEl = document.getElementById('recent-notes');
if (recent && recent.length > 0) {
recentEl.textContent = '';
recent.forEach(function(n) { recentEl.appendChild(buildNoteCard(n, false)); });
} else {
setEmpty(recentEl, 'No notes indexed yet.', 'Run same reindex to index your notes, or same seed list to explore pre-built seeds');
}
// Vault composition + insights (computed from allNotes after browse loads)
ensureAllNotes(function(ok) {
if (ok === false) return;
renderComposition();
renderInsights(status, pinned);
});
}).catch(function(err) {
setError(document.getElementById('recent-notes'), 'Failed to load dashboard', err);
});
}
// Build an insight card
function buildInsight(type, text, cliCmd) {
var icon = type === 'good' ? '\u2705' : type === 'warn' ? '\u26a0\ufe0f' : '\u2139\ufe0f';
var children = [
el('span', {className: 'insight-icon', textContent: icon}),
];
var body = el('div', {className: 'insight-body'});
body.innerHTML = text; // safe: built from static strings below, no user content
if (cliCmd) body.appendChild(buildCliBtn(cliCmd, cliCmd));
children.push(body);
return el('div', {className: 'insight-card ' + type}, children);
}
// Compute and render insights based on vault state
function renderInsights(status, pinned) {
var insights = [];
// Index staleness
if (status.index_age) {
var age = parseDuration(status.index_age);
if (age > 86400) {
var days = Math.floor(age / 86400);
insights.push(buildInsight('warn',
'<strong>Index is ' + days + ' day' + (days > 1 ? 's' : '') + ' old.</strong> Run a reindex to pick up any new or changed notes.',
'same reindex'));
}
}
// Search mode
if (status.search_mode === 'keyword') {
insights.push(buildInsight('tip',
'<strong>Running in keyword mode.</strong> Install <a href="https://ollama.com" target="_blank" rel="noopener">Ollama</a> for semantic search — it finds conceptually related notes, not just keyword matches.',
'same doctor'));
} else {
insights.push(buildInsight('good',
'<strong>Semantic search active.</strong> SAME uses embeddings to find conceptually related content.'));
}
// No pinned notes
if (!pinned || pinned.length === 0) {
insights.push(buildInsight('tip',
'<strong>No pinned notes.</strong> Pin your most important notes so they surface at the start of every AI session.',
'same pin "path/to/note.md"'));
}
// Content type diversity
var types = {};
allNotes.forEach(function(n) {
var t = n.content_type || n.ContentType || 'note';
types[t] = (types[t] || 0) + 1;
});
if (!types.decision) {
insights.push(buildInsight('tip',
'<strong>No decisions captured yet.</strong> Decisions are logged automatically from your AI conversations via hooks, or you can add them manually.',
'same save-decision "Use JWT for auth"'));
}
if (!types.handoff) {
insights.push(buildInsight('tip',
'<strong>No session handoffs.</strong> Handoffs capture what was done, what\'s pending, and blockers — so your next session picks up where you left off.',
'same create-handoff'));
}
// Vault size tips
if (status.note_count > 0 && status.note_count < 10) {
insights.push(buildInsight('tip',
'<strong>Small vault (' + status.note_count + ' notes).</strong> SAME works best with 20+ notes. Try installing a seed for ready-made content.',
'same seed list'));
} else if (status.note_count >= 100) {
insights.push(buildInsight('good',
'<strong>Well-stocked vault (' + status.note_count + ' notes).</strong> Your AI agent has rich context to draw from.'));
}
// Doctor suggestion if low note count or keyword mode
if (status.note_count === 0) {
insights.push(buildInsight('warn',
'<strong>No notes indexed.</strong> Create a vault and index your markdown notes, or install a pre-built seed.',
'same init'));
}
// Render
var section = document.getElementById('insights-section');
var list = document.getElementById('insights-list');
if (insights.length > 0) {
section.style.display = 'block';
list.textContent = '';
insights.forEach(function(card) { list.appendChild(card); });
} else {
section.style.display = 'none';
}
}
// Parse Go duration string (e.g. "72h30m0s") to seconds
function parseDuration(str) {
var total = 0;
var h = str.match(/(\d+)h/);
var m = str.match(/(\d+)m/);
var s = str.match(/([\d.]+)s/);
if (h) total += parseInt(h[1]) * 3600;
if (m) total += parseInt(m[1]) * 60;
if (s) total += parseFloat(s[1]);
return total;
}
// Search
function doSearch(query) {
var resultsEl = document.getElementById('search-results');
searchResultCards = [];
activeSearchIndex = -1;
lastSearchTerms = extractSearchTerms(query);
if (!query.trim()) {
setEmpty(resultsEl, 'Type to search your vault', 'Supports semantic search when Ollama is running');
return;
}
setLoading(resultsEl, 'cards');
api('/api/search?q=' + encodeURIComponent(query) + '&top_k=20').then(function(data) {
if (!data.results || data.results.length === 0) {
setEmpty(resultsEl, 'No results for "' + query + '"', 'Try different phrasing, broader terms, or run same reindex to pick up new notes');
return;
}
resultsEl.textContent = '';
var countLabel = el('div', {className: 'note-count-label'});
countLabel.textContent = data.results.length + ' result(s)';
if (data.mode) {
countLabel.appendChild(document.createTextNode(' \u00B7 '));
var modeBadge = document.createElement('span');
modeBadge.className = 'search-mode-badge ' + data.mode;
modeBadge.textContent = data.mode;
countLabel.appendChild(modeBadge);
}
resultsEl.appendChild(countLabel);
var list = el('div', {className: 'note-list'});
data.results.forEach(function(r) { list.appendChild(buildNoteCard(r, true, lastSearchTerms)); });
resultsEl.appendChild(list);
searchResultCards = Array.prototype.slice.call(list.querySelectorAll('.note-card'));
if (searchResultCards.length > 0) {
activeSearchIndex = 0;
updateActiveSearchCard();
}
}).catch(function(err) {
setError(resultsEl, 'Search failed', err);
});
}
function updateActiveSearchCard() {
searchResultCards.forEach(function(card, idx) {
card.classList.toggle('search-active', idx === activeSearchIndex);
});
}
function moveSearchSelection(delta) {
if (searchResultCards.length === 0) return;
if (activeSearchIndex < 0) activeSearchIndex = 0;
else activeSearchIndex = (activeSearchIndex + delta + searchResultCards.length) % searchResultCards.length;
updateActiveSearchCard();
var card = searchResultCards[activeSearchIndex];
if (card) card.focus();
}
function openActiveSearchCard() {
if (searchResultCards.length === 0 || activeSearchIndex < 0) return;
var card = searchResultCards[activeSearchIndex];
if (card) card.click();
}
document.getElementById('search-input').addEventListener('input', function(e) {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(function() { doSearch(e.target.value); }, 300);
});
document.getElementById('search-input').addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
e.preventDefault();
e.target.value = '';
doSearch('');
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
moveSearchSelection(1);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
moveSearchSelection(-1);
return;
}
if (e.key === 'Enter') {
clearTimeout(debounceTimer);
if (activeSearchIndex >= 0) {
e.preventDefault();
openActiveSearchCard();
} else {
doSearch(e.target.value);
}
}
});
// Browse
function loadBrowse() {
setLoading(document.getElementById('browse-notes'), 'cards');
ensureAllNotes(function(ok) {
if (ok === false) {
setError(document.getElementById('browse-notes'), 'Could not load notes', new Error('failed'));
return;
}
renderBrowse();
});
}
function renderBrowse() {
var filter = document.getElementById('browse-filter').value.toLowerCase();
var typeFilter = document.getElementById('browse-type-filter').value;
var sort = document.getElementById('browse-sort').value;
var filtered = allNotes.filter(function(n) {
var title = (n.title || n.Title || '').toLowerCase();
var path = (n.path || n.Path || '').toLowerCase();
var type = n.content_type || n.ContentType || 'note';
if (filter && title.indexOf(filter) === -1 && path.indexOf(filter) === -1) return false;
if (typeFilter && type !== typeFilter) return false;
return true;
});
filtered.sort(function(a, b) {
switch (sort) {
case 'modified': return (b.modified || b.Modified || 0) - (a.modified || a.Modified || 0);
case 'modified-asc': return (a.modified || a.Modified || 0) - (b.modified || b.Modified || 0);
case 'title': return (a.title || a.Title || '').localeCompare(b.title || b.Title || '');
case 'title-desc': return (b.title || b.Title || '').localeCompare(a.title || a.Title || '');
default: return 0;
}
});
document.getElementById('browse-count').textContent = filtered.length + ' of ' + allNotes.length + ' notes';
var browseEl = document.getElementById('browse-notes');
if (allNotes.length === 0) {
setEmpty(browseEl, 'Your vault has no indexed notes yet.', 'Run same reindex to index markdown notes, or run same seed list to install starter vaults.');
return;
}
if (filtered.length === 0) {
setEmpty(browseEl, 'No matching notes');
} else {
browseEl.textContent = '';
filtered.forEach(function(n) { browseEl.appendChild(buildNoteCard(n, false)); });
}
}
document.getElementById('browse-filter').addEventListener('input', renderBrowse);
document.getElementById('browse-type-filter').addEventListener('change', renderBrowse);
document.getElementById('browse-sort').addEventListener('change', renderBrowse);
// Ensure allNotes is loaded, then call callback
function ensureAllNotes(cb) {
if (allNotes.length > 0) { cb(); return; }
api('/api/notes').then(function(data) {
allNotes = data || [];
cb(true);
}).catch(function() {
cb(false);
});
}
// Vault composition breakdown
var typeColors = {
note: '#6b7280', decision: '#3b82f6', handoff: '#22c55e',
entity: '#a855f7', hub: '#f97316', research: '#f59e0b'
};
function renderComposition() {
var counts = {};
allNotes.forEach(function(n) {
var t = n.content_type || n.ContentType || 'note';
counts[t] = (counts[t] || 0) + 1;
});
var types = Object.keys(counts).sort(function(a, b) { return counts[b] - counts[a]; });
if (types.length === 0) return;
var total = allNotes.length;
var section = document.getElementById('composition-section');
var list = document.getElementById('composition-list');
section.style.display = 'block';
list.textContent = '';
types.forEach(function(t) {
var pct = Math.round((counts[t] / total) * 100);
var color = typeColors[t] || '#6b7280';
var row = el('div', {className: 'composition-row', onclick: function() {
window.location.hash = '#browse';
setTimeout(function() {
var sel = document.getElementById('browse-type-filter');
if (sel) { sel.value = t; renderBrowse(); }
}, 50);
}}, [
el('span', {className: 'composition-label'}, [buildTypeBadge(t)]),
el('div', {className: 'composition-bar'}, [
el('div', {className: 'composition-bar-fill', style: 'width:' + pct + '%;background:' + color})
]),
el('span', {className: 'composition-count', textContent: counts[t].toString()})
]);
list.appendChild(row);
});
}
// Decisions page
function loadDecisions() {
setLoading(document.getElementById('decisions-notes'), 'cards');
ensureAllNotes(function(ok) {
if (ok === false) {
setError(document.getElementById('decisions-notes'), 'Could not load decisions', new Error('failed'));
return;
}
var decisions = allNotes.filter(function(n) {
return (n.content_type || n.ContentType || 'note') === 'decision';
}).sort(function(a, b) {
return (b.modified || b.Modified || 0) - (a.modified || a.Modified || 0);
});
var notesEl = document.getElementById('decisions-notes');
document.getElementById('decisions-count').textContent = decisions.length + ' decision(s)';
if (decisions.length === 0) {
setEmpty(notesEl, 'No decisions recorded yet', 'Decisions are captured automatically via hooks, or manually: same save-decision "title"');
} else {
notesEl.textContent = '';
decisions.forEach(function(n) { notesEl.appendChild(buildNoteCard(n, false)); });
}
});
}
// Handoffs page
function loadHandoffs() {
setLoading(document.getElementById('handoffs-notes'), 'cards');
ensureAllNotes(function(ok) {
if (ok === false) {
setError(document.getElementById('handoffs-notes'), 'Could not load handoffs', new Error('failed'));
return;
}
var handoffs = allNotes.filter(function(n) {
return (n.content_type || n.ContentType || 'note') === 'handoff';
}).sort(function(a, b) {
return (b.modified || b.Modified || 0) - (a.modified || a.Modified || 0);
});
var notesEl = document.getElementById('handoffs-notes');
document.getElementById('handoffs-count').textContent = handoffs.length + ' session handoff(s)';
if (handoffs.length === 0) {
setEmpty(notesEl, 'No session handoffs yet', 'Handoffs capture what was done and what\'s pending: same create-handoff "summary"');
} else {
notesEl.textContent = '';
handoffs.forEach(function(n) { notesEl.appendChild(buildNoteCard(n, false)); });
}
});
}
// Note viewer
function loadNote(path) {
var viewer = document.getElementById('note-viewer');
setLoading(viewer);
// Hide related while loading
var relatedSection = document.getElementById('related-section');
relatedSection.style.display = 'none';
api('/api/notes/' + encodeURIComponent(path)).then(function(data) {
var tags = parseTags(data.tags);
var type = data.content_type || 'note';
var agent = data.agent || '';
viewer.textContent = '';
viewer.appendChild(el('div', {className: 'note-viewer-title', textContent: data.title}));
viewer.appendChild(el('div', {className: 'note-viewer-path', textContent: data.path}));
var metaChildren = [buildTypeBadge(type)];
if (agent) metaChildren.push(el('span', {className: 'tag agent-badge', textContent: '@' + agent}));
if (data.domain) metaChildren.push(el('span', {className: 'tag', textContent: data.domain}));
if (data.workstream) metaChildren.push(el('span', {className: 'tag', textContent: data.workstream}));
tags.forEach(function(t) { metaChildren.push(el('span', {className: 'tag', textContent: t})); });
if (data.modified) metaChildren.push(el('span', {className: 'tag', textContent: formatDate(data.modified)}));
viewer.appendChild(el('div', {className: 'note-viewer-meta'}, metaChildren));
// CLI actions for this note
viewer.appendChild(el('div', {className: 'cli-actions'}, [
buildCliBtn('same related "' + data.path + '"', 'same related "' + data.path + '"'),
buildCliBtn('same pin "' + data.path + '"', 'same pin "' + data.path + '"'),
buildCliBtn('same search "' + (data.title || '') + '"', 'same search "' + (data.title || '') + '"')
]));
// Note content uses markdown rendering on escaped text
var contentDiv = document.createElement('div');
contentDiv.className = 'note-viewer-content';
contentDiv.innerHTML = renderMarkdown(data.text);
viewer.appendChild(contentDiv);
// Load related notes
loadRelated(data.path);
}).catch(function(err) {
setError(viewer, 'Note not available', err);
});
}
// Related notes for note viewer
function loadRelated(path) {
var section = document.getElementById('related-section');
var list = document.getElementById('related-notes');
api('/api/related/' + encodeURIComponent(path)).then(function(data) {
if (data && data.length > 0) {
section.style.display = 'block';
list.textContent = '';
data.forEach(function(r) { list.appendChild(buildNoteCard(r, true)); });
} else {
section.style.display = 'none';
}
}).catch(function() {
section.style.display = 'none';
});
}
// Init
loadDashboard();
loadBrowse();
handleHash();
// Keyboard shortcuts
document.addEventListener('keydown', function(e) {
var isInput = e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA';
if (!isInput && (e.key === '/' || e.key === 's')) {
e.preventDefault();
navigate('search');
return;
}
if (!isInput && window.location.hash.slice(1).indexOf('search') === 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
moveSearchSelection(1);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
moveSearchSelection(-1);
return;
}
if (e.key === 'Enter') {
e.preventDefault();
openActiveSearchCard();
return;
}
if (e.key === 'Escape') {
var input = document.getElementById('search-input');
if (input) {
input.value = '';
doSearch('');
input.focus();
}
return;
}
}
if (isInput) return;
});
})();
</script>
</body>
</html>