diff --git a/app/src/renderer/js/app.js b/app/src/renderer/js/app.js deleted file mode 100644 index 8c41bfd..0000000 --- a/app/src/renderer/js/app.js +++ /dev/null @@ -1,235 +0,0 @@ -// 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(); -}); diff --git a/app/src/renderer/js/data.js b/app/src/renderer/js/data.js deleted file mode 100644 index 81d605d..0000000 --- a/app/src/renderer/js/data.js +++ /dev/null @@ -1,380 +0,0 @@ -// Data loading layer -- bridges Electron IPC (window.obelisk.*) to app state. -// All DB access goes through this module. - -import { state } from './state.js'; - -/** - * Load initial data from the DB and populate state.memories, state.sessions, - * and state.projects. - */ -export async function loadInitialData() { - const [rawMemories, rawSessions, stats, projects] = await Promise.all([ - window.obelisk.getMemories(), - window.obelisk.getSessions(), - window.obelisk.getStats(), - window.obelisk.getProjects() - ]); - - // Transform memories: DB records -> render-layer shape - state.memories = (rawMemories || []).map(m => ({ - ...m, - ts: m.created_at ? new Date(m.created_at).getTime() : 0, - archived: !!m.deleted_at, - archivedAt: m.deleted_at ? new Date(m.deleted_at).getTime() : null, - health: 'ok', - anchors: [], - markdown: null // loaded on demand via loadMemoryMarkdown - })); - - // Sessions: keep DB shape, add empty messages array for on-demand loading - state.sessions = (rawSessions || []).map(s => ({ - ...s, - messages: [] - })); - - state.projects = projects || []; - state.stats = stats || {}; -} - -/** - * Load full detail for a session: messages with inline tool_calls (each with - * result), summaries, subagents, and workflow data. - * - * Returns the assembled session object (also updates state.sessions entry). - */ -export async function loadSessionDetail(sessionId) { - const [messages, toolCalls, toolResults, subagents, workflows, summaries] = - await Promise.all([ - window.obelisk.getSessionMessages(sessionId), - window.obelisk.getSessionToolCalls(sessionId), - window.obelisk.getSessionToolResults(sessionId), - window.obelisk.getSessionSubagents(sessionId), - window.obelisk.getSessionWorkflows(sessionId), - window.obelisk.getSessionSummaries(sessionId) - ]); - - // Index tool results by tool_use_id for fast lookup - const resultsByCallId = {}; - for (const r of (toolResults || [])) { - resultsByCallId[r.tool_use_id] = r; - } - - // Index subagents by parent_tool_use_id - const subagentsByCallId = {}; - for (const sa of (subagents || [])) { - if (sa.parent_tool_use_id) { - subagentsByCallId[sa.parent_tool_use_id] = sa; - } - } - - // Group tool_calls by message_uuid, attaching result and subagent inline - const callsByMessageUuid = {}; - for (const tc of (toolCalls || [])) { - const call = { - id: tc.id, - name: tc.name, - input_json: tc.input_json, - result: resultsByCallId[tc.id] || null - }; - - // Attach subagent data if present - const sa = subagentsByCallId[tc.id]; - if (sa) { - call.subagent = { - agent_id: sa.agent_id, - agent_type: sa.agent_type, - description: sa.description - }; - } - - const msgUuid = tc.message_uuid; - if (!callsByMessageUuid[msgUuid]) callsByMessageUuid[msgUuid] = []; - callsByMessageUuid[msgUuid].push(call); - } - - // Attach workflow data to Workflow tool calls - for (const wf of (workflows || [])) { - for (const calls of Object.values(callsByMessageUuid)) { - for (const call of calls) { - if (call.name === 'Workflow' && !call.workflow) { - const resultText = call.result?.content || ''; - if (resultText.includes(wf.run_id) || resultText.includes(wf.workflow_name || '___none___')) { - call.workflow = { - run_id: wf.run_id, - workflow_name: wf.workflow_name, - status: wf.status, - duration_ms: wf.duration_ms, - total_tokens: wf.total_tokens, - agent_count: wf.agent_count, - agents: (wf.agents || []).map(a => ({ - agent_id: a.agent_id, - phase: a.phase, - label: a.label, - state: a.state, - tokens: a.tokens, - duration_ms: a.duration_ms, - })) - }; - } - } - } - } - } - - // Index summaries by session (summaries don't have per-message IDs in our schema) - const sessionSummaries = (summaries || []).map(s => ({ - source: s.source, - content: s.content, - timestamp: s.timestamp - })); - - // Assemble messages with tool_calls inline - const rawAssembled = (messages || []).map(msg => { - const assembled = { - uuid: msg.uuid, - type: msg.type || msg.role, - timestamp: msg.timestamp, - text: msg.text, - content_type: msg.content_type || null, - is_meta: msg.is_meta || 0 - }; - - const calls = callsByMessageUuid[msg.uuid]; - if (calls && calls.length > 0) { - assembled.tool_calls = calls; - } - - return assembled; - }); - - // Merge adjacent assistant messages: - // - tool_result user messages are skipped (results shown inside tool_call panels) - // - consecutive tool_use messages (separated by tool_results) merge into one - // - thinking messages merge into the next non-thinking assistant message - const assembledMessages = []; - for (let i = 0; i < rawAssembled.length; i++) { - const msg = rawAssembled[i]; - - // Skip tool_result user messages - if (msg.content_type === 'tool_result') continue; - - // For thinking messages, collect consecutive thinking blocks and attach to the next assistant - if (msg.type === 'assistant' && msg.content_type === 'thinking') { - const thinkingParts = [msg.text || '']; - let j = i + 1; - // Absorb consecutive thinking messages - while (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type === 'thinking') { - thinkingParts.push(rawAssembled[j].text || ''); - j++; - } - // Find the next non-thinking assistant message to attach to - if (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type !== 'thinking') { - // Will be picked up by the next iteration; store thinking on it - rawAssembled[j]._thinking = thinkingParts.join('\n\n'); - i = j - 1; - continue; - } - // No following assistant message — render as standalone collapsed thinking - assembledMessages.push({ ...msg, text: thinkingParts.join('\n\n'), content_type: 'thinking' }); - i = j - 1; - continue; - } - - // For tool_use assistant messages, absorb subsequent tool_use (skipping tool_results) - if (msg.type === 'assistant' && msg.content_type === 'tool_use') { - const merged = { ...msg, tool_calls: [...(msg.tool_calls || [])] }; - if (msg._thinking) merged._thinking = msg._thinking; - let j = i + 1; - while (j < rawAssembled.length) { - const next = rawAssembled[j]; - if (next.content_type === 'tool_result') { j++; continue; } - if (next.type === 'assistant' && next.content_type === 'tool_use') { - if (next.tool_calls) merged.tool_calls.push(...next.tool_calls); - if (next.text && !merged.text) merged.text = next.text; - j++; - continue; - } - break; - } - assembledMessages.push(merged); - i = j - 1; - } else { - // text or other assistant/user messages - const out = { ...msg }; - if (msg._thinking) out._thinking = msg._thinking; - assembledMessages.push(out); - } - } - - // Attach workflow data if present - const workflow = (workflows && workflows.length > 0) ? workflows[0] : null; - - // Build assembled session object - const session = state.sessions.find(s => s.id === sessionId); - const assembled = { - ...(session || {}), - id: sessionId, - messages: assembledMessages - }; - - if (workflow) { - assembled.workflow = workflow; - } - - // Update in-place in state.sessions - const idx = state.sessions.findIndex(s => s.id === sessionId); - if (idx !== -1) { - state.sessions[idx] = assembled; - } - - return assembled; -} - -/** - * Load full detail for a subagent conversation. - * Returns assembled messages with tool_calls inline. - */ -export async function loadSubagentDetail(agentId) { - const [messages, toolCalls, toolResults] = await Promise.all([ - window.obelisk.getSubagentMessages(agentId), - window.obelisk.getSubagentToolCalls(agentId), - window.obelisk.getSubagentToolResults(agentId), - ]); - - const resultsByCallId = {}; - for (const r of (toolResults || [])) { - resultsByCallId[r.tool_use_id] = r; - } - - const callsByMessageUuid = {}; - for (const tc of (toolCalls || [])) { - const call = { - id: tc.id, - name: tc.name, - input_json: tc.input_json, - result: resultsByCallId[tc.id] || null - }; - const msgUuid = tc.message_uuid; - if (!callsByMessageUuid[msgUuid]) callsByMessageUuid[msgUuid] = []; - callsByMessageUuid[msgUuid].push(call); - } - - const rawAssembled = (messages || []).map(msg => { - const assembled = { - uuid: msg.uuid, - type: msg.type || msg.role, - timestamp: msg.timestamp, - text: msg.text, - content_type: msg.content_type || null, - is_meta: msg.is_meta || 0 - }; - const calls = callsByMessageUuid[msg.uuid]; - if (calls && calls.length > 0) assembled.tool_calls = calls; - return assembled; - }); - - // Same merging logic as session detail - const assembledMessages = []; - for (let i = 0; i < rawAssembled.length; i++) { - const msg = rawAssembled[i]; - if (msg.content_type === 'tool_result') continue; - if (msg.type === 'assistant' && msg.content_type === 'thinking') { - const thinkingParts = [msg.text || '']; - let j = i + 1; - while (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type === 'thinking') { - thinkingParts.push(rawAssembled[j].text || ''); - j++; - } - if (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type !== 'thinking') { - rawAssembled[j]._thinking = thinkingParts.join('\n\n'); - i = j - 1; - continue; - } - assembledMessages.push({ ...msg, text: thinkingParts.join('\n\n'), content_type: 'thinking' }); - i = j - 1; - continue; - } - if (msg.type === 'assistant' && msg.content_type === 'tool_use') { - const merged = { ...msg, tool_calls: [...(msg.tool_calls || [])] }; - if (msg._thinking) merged._thinking = msg._thinking; - let j = i + 1; - while (j < rawAssembled.length) { - const next = rawAssembled[j]; - if (next.content_type === 'tool_result') { j++; continue; } - if (next.type === 'assistant' && next.content_type === 'tool_use') { - if (next.tool_calls) merged.tool_calls.push(...next.tool_calls); - if (next.text && !merged.text) merged.text = next.text; - j++; - continue; - } - break; - } - assembledMessages.push(merged); - i = j - 1; - } else { - const out = { ...msg }; - if (msg._thinking) out._thinking = msg._thinking; - assembledMessages.push(out); - } - } - - return assembledMessages; -} - -const TEXT_LIMIT = 10000; - -/** - * Check if a message text was truncated during indexing. - */ -export function isTextTruncated(text) { - return text && text.length >= TEXT_LIMIT; -} - -/** - * Fetch the full untruncated text for a message from its source JSONL. - * Returns the full text string or null. - */ -export async function loadFullText(uuid) { - try { - return await window.obelisk.getMessageFullText(uuid); - } catch { - return null; - } -} - -/** - * Load the markdown content of a memory file. - * Returns the content string or null on failure. - */ -export async function loadMemoryMarkdown(memoryPath) { - try { - const content = await window.obelisk.readMemoryFile(memoryPath); - return content || null; - } catch { - return null; - } -} - -/** - * Archive a memory by id. Updates state after successful IPC call. - */ -export async function archiveMemory(id) { - await window.obelisk.archiveMemory(id); - const mem = state.memories.find(m => m.id === id); - if (mem) { - mem.archived = true; - mem.archivedAt = Date.now(); - } -} - -/** - * Restore an archived memory by id. Updates state after successful IPC call. - */ -export async function restoreMemory(id) { - await window.obelisk.restoreMemory(id); - const mem = state.memories.find(m => m.id === id); - if (mem) { - mem.archived = false; - mem.archivedAt = null; - } -} diff --git a/app/src/renderer/js/keys.js b/app/src/renderer/js/keys.js deleted file mode 100644 index 6b18101..0000000 --- a/app/src/renderer/js/keys.js +++ /dev/null @@ -1,129 +0,0 @@ -// Keyboard shortcut handling -- ported from the HTML mock. -// Registers a single document-level keydown listener that dispatches -// all navigation, mutation, and route-switching shortcuts. - -import { state, IS_MAC } from './state.js'; -import { - renderAll, - renderMemoryList, - renderSessionList, - renderStatus, - enterDetail, - exitDetail, - setRoute, - setView, - toggleSort, - moveCursor, - archive, - restore, - doUndo, - navigateToSession -} from './render.js'; - -export function initKeyboard() { - document.addEventListener('keydown', e => { - const inInput = - document.activeElement.tagName === 'INPUT' || - document.activeElement.tagName === 'TEXTAREA'; - const mod = IS_MAC ? e.metaKey : e.ctrlKey; - - // -- Route switching: Cmd+1/2/3/4 -- - if (mod && e.key === '1') { e.preventDefault(); setRoute('sessions'); return; } - if (mod && e.key === '2') { e.preventDefault(); setView('active'); return; } - if (mod && e.key === '3') { e.preventDefault(); setView('archived'); return; } - if (mod && e.key === '4') { e.preventDefault(); setView('broken'); return; } - - // -- Undo: Cmd+Z -- - if (mod && e.key.toLowerCase() === 'z' && !e.shiftKey) { - if (state.lastArchiveSnapshot) { e.preventDefault(); doUndo(); } - return; - } - - // -- When inside an input, only Escape is handled (to blur) -- - if (inInput) { - if (e.key === 'Escape') e.target.blur(); - return; - } - - // -- Search focus: / -- - if (e.key === '/') { - e.preventDefault(); - const searchInput = document.getElementById('search'); - if (searchInput) { searchInput.focus(); searchInput.select(); } - return; - } - - // -- Detail mode shortcuts -- - if (state.mode === 'detail') { - if (e.key === 'Escape') { e.preventDefault(); exitDetail(); return; } - if (state.route === 'memory' && (e.key === 'd' || e.key === 'D')) { - e.preventDefault(); - const m = state.memories.find(x => x.id === state.detailId); - if (m && m.archived) restore([m.id]); - else if (m) archive([m.id]); - return; - } - return; - } - - // -- List mode shortcuts -- - - // Navigation: j/k/arrows - if (e.key === 'j' || e.key === 'ArrowDown') { - e.preventDefault(); - if (state.route === 'memory') moveCursor(1, e.shiftKey); - return; - } - if (e.key === 'k' || e.key === 'ArrowUp') { - e.preventDefault(); - if (state.route === 'memory') moveCursor(-1, e.shiftKey); - return; - } - - // Open detail: Enter - if (e.key === 'Enter') { - e.preventDefault(); - if (state.route === 'memory' && state.cursorId) enterDetail(state.cursorId); - return; - } - - // Archive / Restore: d/D - if (state.route === 'memory' && (e.key === 'd' || e.key === 'D')) { - e.preventDefault(); - const ids = state.selection.size > 0 - ? Array.from(state.selection) - : state.cursorId ? [state.cursorId] : []; - if (!ids.length) return; - const m = state.memories.find(x => x.id === ids[0]); - if (state.view === 'archived' || (m && m.archived)) restore(ids); - else archive(ids); - return; - } - - // Undo: u - if (e.key === 'u') { - e.preventDefault(); - if (state.lastArchiveSnapshot) doUndo(); - return; - } - - // Sort toggle: s - if (e.key === 's') { e.preventDefault(); toggleSort(); return; } - - // Escape: clear selection or search - if (e.key === 'Escape') { - if (state.selection.size) { - state.selection.clear(); - renderMemoryList(); - renderStatus(); - } else if (state.query) { - state.query = ''; - const searchInput = document.getElementById('search'); - if (searchInput) searchInput.value = ''; - if (state.route === 'sessions') renderSessionList(); - else renderMemoryList(); - } - return; - } - }); -} diff --git a/app/src/renderer/js/memory-list.js b/app/src/renderer/js/memory-list.js deleted file mode 100644 index eddbf00..0000000 --- a/app/src/renderer/js/memory-list.js +++ /dev/null @@ -1,269 +0,0 @@ -// Memory list and detail rendering, extracted from render.js. - -import { state, FOLDER_SVG } from './state.js'; -import { loadMemoryMarkdown, isTextTruncated, loadFullText } from './data.js'; -import registry from './registry.js'; - -// --- Utilities (local copies to avoid importing render.js) --- - -function escapeHTML(s) { return String(s || '').replace(/&/g, '&').replace(//g, '>'); } - -function pad2(n) { return String(n).padStart(2, '0'); } -function isSameDay(a, b) { - return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); -} -function fmtListTime(ts) { - const d = new Date(ts); - const now = new Date(); - const hhmm = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`; - if (isSameDay(d, now)) return hhmm; - const mmdd = `${pad2(d.getMonth() + 1)}/${pad2(d.getDate())}`; - if (d.getFullYear() === now.getFullYear()) return `${mmdd} ${hhmm}`; - return `${d.getFullYear()}/${mmdd} ${hhmm}`; -} -function fmtRelative(ts) { - const diff = Date.now() - ts; - const min = 60000, hr = 3600000, day = 86400000; - if (diff < 0) return 'in the future'; - if (diff < min) return 'just now'; - if (diff < hr) return Math.floor(diff / min) + 'm ago'; - if (diff < day) return Math.floor(diff / hr) + 'h ago'; - if (diff < day * 30) return Math.floor(diff / day) + 'd ago'; - if (diff < day * 365) return Math.floor(diff / (day * 30)) + 'mo ago'; - return Math.floor(diff / (day * 365)) + 'y ago'; -} - -function highlightPlain(text, query) { - if (!query) return escapeHTML(text); - const safe = escapeHTML(text); - const q = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - return safe.replace(new RegExp(q, 'gi'), m => `${m}`); -} - -function sanitizeMarkdown(html) { - return html - .replace(/ - - - - - - diff --git a/app/src/renderer/src/components/Toolbar.vue b/app/src/renderer/src/components/Toolbar.vue deleted file mode 100644 index c6f227f..0000000 --- a/app/src/renderer/src/components/Toolbar.vue +++ /dev/null @@ -1,381 +0,0 @@ - - - - - - - / - - {{ crumb.label }} - - - {{ crumb.label }} - - - - - - - - - - - - - - - / - - - - - - - - - - - {{ state.sortDesc ? 'newest' : 'oldest' }} - - - - - - - - - - diff --git a/app/src/renderer/src/data.js b/app/src/renderer/src/data.js index af5ba5e..6837df8 100644 --- a/app/src/renderer/src/data.js +++ b/app/src/renderer/src/data.js @@ -2,7 +2,7 @@ // All DB access goes through this module. import { markRaw } from 'vue'; -import { state, clearUndo } from './store.js'; +import { state } from './store.js'; /** * Load initial data from the DB and populate state.memories, state.sessions, diff --git a/app/src/renderer/src/keyboard-shortcuts.mjs b/app/src/renderer/src/keyboard-shortcuts.mjs new file mode 100644 index 0000000..7ffea25 --- /dev/null +++ b/app/src/renderer/src/keyboard-shortcuts.mjs @@ -0,0 +1,62 @@ +export function normalizeShortcutKey(event) { + return event.key.length === 1 ? event.key.toLowerCase() : event.key; +} + +export function resolveGlobalShortcut(event, context) { + if (event.defaultPrevented) return null; + + const key = normalizeShortcutKey(event); + const modifier = event.metaKey || event.ctrlKey; + if (modifier) { + if (key === '1') return 'open-sessions'; + if (key === '2') return 'open-active-memories'; + if (key === '3') return 'open-archived-memories'; + return null; + } + if (event.altKey) return null; + + if (context.isTextInput) { + return key === 'Escape' ? 'blur-input' : null; + } + + if (key === '/' && context.isListRoute) return 'focus-search'; + if (key === 's' && context.isListRoute) return 'toggle-sort'; + + if (key === 'Escape') { + if (context.hasSelection) return 'clear-selection'; + if (context.hasQuery) return 'clear-query'; + } + + return null; +} + +export function resolveMemoryShortcut(event, context) { + if (event.defaultPrevented || context.isTextInput) return null; + + const key = normalizeShortcutKey(event); + const modifier = event.metaKey || event.ctrlKey; + if (modifier) { + if (key === 'z' && !event.shiftKey && context.hasUndo) return { type: 'undo' }; + return null; + } + if (event.altKey) return null; + + if (context.showDetail) { + if (key === 'Escape') return { type: 'close-detail' }; + if (key === 'd') return { type: 'mutate-detail' }; + return null; + } + + if (key === 'j' || key === 'ArrowDown') { + return { type: 'move-cursor', direction: 1, extend: Boolean(event.shiftKey) }; + } + if (key === 'k' || key === 'ArrowUp') { + return { type: 'move-cursor', direction: -1, extend: Boolean(event.shiftKey) }; + } + if (key === 'Enter' && context.hasCursor) return { type: 'open-detail' }; + if (key === 'x' && context.hasCursor) return { type: 'toggle-selection' }; + if (key === 'd') return { type: 'mutate-selection' }; + if (key === 'u' && context.hasUndo) return { type: 'undo' }; + + return null; +} diff --git a/app/src/renderer/src/main.js b/app/src/renderer/src/main.js index fdb2a79..9051684 100644 --- a/app/src/renderer/src/main.js +++ b/app/src/renderer/src/main.js @@ -6,7 +6,7 @@ import router from './router.js'; import { loadInitialData } from './data.js'; import { noteSessionUpdated, sessionLiveState } from './session-live.mjs'; -// Import all original CSS globally +// Import shared renderer CSS globally import '../styles/base.css'; import '../styles/sidebar.css'; import '../styles/toolbar.css'; diff --git a/app/src/renderer/src/router.js b/app/src/renderer/src/router.js index c2ccba9..6455d76 100644 --- a/app/src/renderer/src/router.js +++ b/app/src/renderer/src/router.js @@ -8,7 +8,6 @@ const SessionList = () => import('./views/SessionList.vue'); const SessionDetail = () => import('./views/SessionDetail.vue'); const SubagentDetail = () => import('./views/SubagentDetail.vue'); const MemoryList = () => import('./views/MemoryList.vue'); -const MemoryDetail = () => import('./views/MemoryDetail.vue'); const Activity = () => import('./views/Activity.vue'); const Recap = () => import('./views/RecapList.vue'); const RecapDetail = () => import('./views/RecapDetail.vue'); @@ -42,7 +41,7 @@ const routes = [ { path: '/memory/:id', name: 'MemoryDetail', - component: MemoryDetail, + component: MemoryList, props: true }, { diff --git a/app/src/renderer/src/store.js b/app/src/renderer/src/store.js index 48a1a9f..3b0070c 100644 --- a/app/src/renderer/src/store.js +++ b/app/src/renderer/src/store.js @@ -1,5 +1,5 @@ -// Reactive store -- Vue 3 reactive() replaces the plain object from state.js. -// All state fields are ported; action functions mutate the reactive state. +// Shared renderer state. Navigation state belongs to Vue Router; this store +// holds only data and cross-view UI preferences. import { reactive, markRaw } from 'vue'; @@ -8,12 +8,7 @@ export const state = reactive({ sessions: [], projects: [], stats: {}, - route: 'memory', view: 'active', // 'active' | 'archived' - mode: 'list', // 'list' | 'detail' - detailId: null, - subagentId: null, - subagentDescription: null, pendingFocusUuid: null, query: '', projectFilter: 'all', @@ -23,92 +18,45 @@ export const state = reactive({ includeMessageBodies: false, cursorId: null, selection: markRaw(new Set()), - showSource: false, - lastArchiveSnapshot: null, - undoTimer: null, - undoExpires: 0, loaded: false }); -// Platform detection -export const IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform); - // SVG icon constants export const FOLDER_SVG = ``; -export const FILE_SVG = ``; // --- Action functions --- -export function setRoute(route) { - state.route = route; - state.mode = 'list'; - state.detailId = null; +export function setSelection(ids) { + state.selection = markRaw(new Set(ids)); +} + +export function clearSelection() { + setSelection([]); +} + +export function resetListState() { state.cursorId = null; - state.selection = markRaw(new Set()); + clearSelection(); state.query = ''; } export function setView(v) { - state.route = 'memory'; state.view = v; - state.mode = 'list'; - state.detailId = null; state.cursorId = null; - state.selection = markRaw(new Set()); + clearSelection(); state.projectFilter = 'all'; } export function setProject(p) { state.projectFilter = p; state.cursorId = null; - state.selection = markRaw(new Set()); - state.mode = 'list'; - state.detailId = null; + clearSelection(); } export function toggleSort() { state.sortDesc = !state.sortDesc; } -export function enterDetail(id) { - state.detailId = id; - state.mode = 'detail'; - state.showSource = false; -} - -export function exitDetail() { - if (state.subagentId) { - state.subagentId = null; - state.subagentDescription = null; - return; - } - state.mode = 'list'; - state.detailId = null; - state.pendingFocusUuid = null; -} - -export function navigateToSession(sessionId, focusUuid) { - state.route = 'sessions'; - state.mode = 'detail'; - state.detailId = sessionId; - state.subagentId = null; - state.subagentDescription = null; - state.pendingFocusUuid = focusUuid || null; - state.query = ''; -} - -export function navigateToSubagent(agentId, description) { - state.subagentId = agentId; - state.subagentDescription = description || agentId; -} - -export function setCursor(id, opts = {}) { - state.cursorId = id; - if (!opts.keepSelection) { - state.selection = markRaw(new Set()); - } -} - export function setQuery(q) { state.query = q; } @@ -120,11 +68,3 @@ export function setProjectSearch(q) { export function toggleIncludeMessageBodies() { state.includeMessageBodies = !state.includeMessageBodies; } - -export function clearUndo() { - state.lastArchiveSnapshot = null; - if (state.undoTimer) { - clearInterval(state.undoTimer); - state.undoTimer = null; - } -} diff --git a/app/src/renderer/src/views/Activity.vue b/app/src/renderer/src/views/Activity.vue index 088703d..9c01085 100644 --- a/app/src/renderer/src/views/Activity.vue +++ b/app/src/renderer/src/views/Activity.vue @@ -1,7 +1,7 @@ - - - - - - - {{ formatProjectLabel(memory.project) }} - archived - - {{ memory.path }} - {{ memory.summary }} - - - - - - - Source session - - - created {{ fmtRelative(memory.ts) }} - - - {{ memory.message_start.slice(0, 8) }}…→ {{ (memory.message_end || '').slice(0, 8) }}… - - - - - - - Body - {{ showSource ? 'Show rendered' : 'Show source' }} - - Loading… - File not found or empty. - {{ markdown }} - - - - - Anchors{{ memory.anchors.length }} - - - - - - - {{ a.path }} - :{{ a.line }} - - - - - Back - - {{ memory.archived ? 'Restore' : 'Archive' }} - - - - diff --git a/app/src/renderer/src/views/MemoryList.vue b/app/src/renderer/src/views/MemoryList.vue index 0ea0ef9..45ebb2f 100644 --- a/app/src/renderer/src/views/MemoryList.vue +++ b/app/src/renderer/src/views/MemoryList.vue @@ -1,11 +1,13 @@
{{ markdown }}