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:
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user