feat(app): add Electron desktop UI and evolve memory/retrieval layer

Introduce an Electron app with session browser, memory list, and usage
  views (vanilla JS + Vue scaffolding). On the data layer: add content_type
  and is_meta to messages for transcript control-plane filtering, introduce
  FTS5-backed memory recall with safe tokenization, support memory archival
  via forget() through the renamed --attune runtime, and expose anchors on
  memory records.
This commit is contained in:
tommy0103
2026-06-12 22:20:29 +08:00
parent b52f57b538
commit b524339d85
48 changed files with 16538 additions and 97 deletions
+235
View File
@@ -0,0 +1,235 @@
// Entry point -- wires data, rendering, and event handlers together.
// No exports; this is the bootstrap module.
import { loadInitialData } from './data.js';
import { state, IS_MAC } from './state.js';
import {
renderAll,
renderMemoryList,
renderSessionList,
setRoute,
setView,
setProject,
enterDetail,
archive,
restore,
setCursor,
navigateToSession,
switchView
} from './render.js';
import { initKeyboard } from './keys.js';
// -- Helpers ----------------------------------------------------------------
let searchDebounceTimer = null;
function debounce(fn, ms) {
return (...args) => {
clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(() => fn(...args), ms);
};
}
// -- Boot -------------------------------------------------------------------
document.addEventListener('DOMContentLoaded', async () => {
await loadInitialData();
initKeyboard();
// -- Sidebar navigation (route switching + project filter) ----------------
const sidebar = document.querySelector('.sidebar');
if (sidebar) {
sidebar.addEventListener('click', e => {
const item = e.target.closest('.sidebar-item');
if (!item) return;
const route = item.dataset.route;
const project = item.dataset.project;
const view = item.dataset.view;
if (route) {
setRoute(route);
} else if (view) {
setView(view);
} else if (project !== undefined) {
setProject(project);
}
});
}
// -- Sidebar search (filter projects list) --------------------------------
const sidebarSearch = document.querySelector('.sidebar-search input');
if (sidebarSearch) {
sidebarSearch.addEventListener('input', e => {
state.projectSearch = e.target.value;
renderAll();
});
}
// -- Breadcrumb click (navigate back to list) -----------------------------
const breadcrumb = document.querySelector('.breadcrumb');
if (breadcrumb) {
breadcrumb.addEventListener('click', e => {
const crumb = e.target.closest('[data-action]');
if (!crumb) return;
const action = crumb.dataset.action;
if (action === 'goto-sessions') { state.projectFilter = 'all'; setRoute('sessions'); }
else if (action === 'goto-memory') { state.projectFilter = 'all'; setView('active'); }
else if (action === 'goto-session-detail') { state.subagentId = null; state.subagentDescription = null; switchView(); renderAll(); }
});
}
// -- Toolbar search with debounce -----------------------------------------
const searchInput = document.getElementById('search');
if (searchInput) {
const handleSearch = debounce(value => {
state.query = value;
if (state.route === 'sessions') renderSessionList();
else renderMemoryList();
}, 200);
searchInput.addEventListener('input', e => {
handleSearch(e.target.value);
});
}
// -- Sort toggle -----------------------------------------------------------
const sortToggle = document.querySelector('.sort-group');
if (sortToggle) {
sortToggle.addEventListener('click', () => {
state.sortDesc = !state.sortDesc;
sortToggle.classList.toggle('desc', state.sortDesc);
sortToggle.classList.toggle('asc', !state.sortDesc);
if (state.route === 'sessions') renderSessionList();
else renderMemoryList();
});
}
// -- Search messages toggle ------------------------------------------------
const searchMsgsToggle = document.querySelector('.filter-toggle');
if (searchMsgsToggle) {
searchMsgsToggle.addEventListener('click', () => {
state.includeMessageBodies = !state.includeMessageBodies;
searchMsgsToggle.classList.toggle('active', state.includeMessageBodies);
if (state.query) {
if (state.route === 'sessions') renderSessionList();
else renderMemoryList();
}
});
}
// -- #list click (memory rows: selection, actions, navigation) ------------
const list = document.getElementById('list');
if (list) {
list.addEventListener('click', e => {
// Action buttons (archive/restore)
const action = e.target.closest('.row-action');
if (action) {
e.stopPropagation();
const row = action.closest('.row');
const id = row?.dataset.id;
if (!id) return;
if (action.classList.contains('restore')) {
restore([id]);
} else {
archive([id]);
}
return;
}
// Checkbox toggling
const checkbox = e.target.closest('.row-checkbox');
if (checkbox) {
e.stopPropagation();
const row = checkbox.closest('.row');
const id = row?.dataset.id;
if (!id) return;
if (e.shiftKey && state.cursorId) {
// Range select between cursor and clicked
const rows = Array.from(list.querySelectorAll('.row'));
const ids = rows.map(r => r.dataset.id);
const fromIdx = ids.indexOf(state.cursorId);
const toIdx = ids.indexOf(id);
const [lo, hi] = fromIdx < toIdx ? [fromIdx, toIdx] : [toIdx, fromIdx];
for (let i = lo; i <= hi; i++) {
state.selection.add(ids[i]);
}
} else if (e.metaKey || e.ctrlKey) {
// Toggle single
if (state.selection.has(id)) state.selection.delete(id);
else state.selection.add(id);
} else {
// Simple toggle
if (state.selection.has(id)) state.selection.delete(id);
else state.selection.add(id);
}
setCursor(id);
renderMemoryList();
return;
}
// Row click (navigate cursor / open detail)
const row = e.target.closest('.row');
if (!row) return;
const id = row.dataset.id;
if (!id) return;
if (e.shiftKey && state.cursorId) {
// Shift-click: range select
const rows = Array.from(list.querySelectorAll('.row'));
const ids = rows.map(r => r.dataset.id);
const fromIdx = ids.indexOf(state.cursorId);
const toIdx = ids.indexOf(id);
const [lo, hi] = fromIdx < toIdx ? [fromIdx, toIdx] : [toIdx, fromIdx];
for (let i = lo; i <= hi; i++) {
state.selection.add(ids[i]);
}
renderMemoryList();
} else if ((IS_MAC ? e.metaKey : e.ctrlKey)) {
// Cmd/Ctrl-click: toggle selection
if (state.selection.has(id)) state.selection.delete(id);
else state.selection.add(id);
setCursor(id);
renderMemoryList();
} else {
// Plain click: move cursor
setCursor(id);
renderMemoryList();
}
});
// -- #list dblclick (open detail) -----------------------------------------
list.addEventListener('dblclick', e => {
const row = e.target.closest('.row');
if (!row) return;
const id = row.dataset.id;
if (id) enterDetail(id);
});
}
// -- #session-list click (session rows) ------------------------------------
const sessionList = document.getElementById('session-list');
if (sessionList) {
sessionList.addEventListener('click', e => {
const srow = e.target.closest('.srow');
if (!srow) return;
const id = srow.dataset.sessionId;
if (id) navigateToSession(id);
});
}
// -- Start on memory view -------------------------------------------------
setRoute('memory');
renderAll();
});