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(//gi, '') - .replace(//gi, '') - .replace(//gi, '') - .replace(/\son\w+="[^"]*"/gi, ''); -} - -function highlightTextNodes(rootEl, query) { - if (!query) return; - const q = query.toLowerCase(); - const walker = document.createTreeWalker(rootEl, NodeFilter.SHOW_TEXT, null); - const nodes = []; - while (walker.nextNode()) nodes.push(walker.currentNode); - for (const node of nodes) { - const text = node.nodeValue; - if (!text) continue; - const lower = text.toLowerCase(); - if (!lower.includes(q)) continue; - const frag = document.createDocumentFragment(); - let last = 0, i = lower.indexOf(q); - while (i !== -1) { - if (i > last) frag.appendChild(document.createTextNode(text.slice(last, i))); - const mark = document.createElement('mark'); - mark.textContent = text.slice(i, i + q.length); - frag.appendChild(mark); - last = i + q.length; - i = lower.indexOf(q, last); - } - if (last < text.length) frag.appendChild(document.createTextNode(text.slice(last))); - node.parentNode.replaceChild(frag, node); - } -} - -function renderMarkdown(text, opts = {}) { - if (text == null) return ''; - const html = sanitizeMarkdown(marked.parse(text)); - const cls = opts.variant === 'msg' ? 'markdown-msg' - : opts.variant === 'compact' ? 'markdown-compact' - : 'markdown-body'; - const container = document.createElement('div'); - container.className = cls; - container.innerHTML = html; - if (opts.query) highlightTextNodes(container, opts.query.trim()); - return container.outerHTML; -} - -// --- DOM helpers --- - -const $ = sel => document.querySelector(sel); - -function ensureVisible(el, wrapSel) { - const wrap = $(wrapSel); - if (!wrap || !el) return; - const elRect = el.getBoundingClientRect(); - const wrapRect = wrap.getBoundingClientRect(); - if (elRect.top < wrapRect.top + 30) wrap.scrollTop -= (wrapRect.top + 30 - elRect.top); - else if (elRect.bottom > wrapRect.bottom - 10) wrap.scrollTop += (elRect.bottom - wrapRect.bottom + 10); -} - -// --- Data filtering (mirrors render.js) --- - -function dominantRowStatus(m) { - if (m.health === 'broken') return 'broken'; - if (m.health === 'partial') return 'partial'; - if (m.archived) return 'archived'; - return null; -} - -function statusGlyphHTML(status) { - if (!status) return ''; - const glyphs = { - broken: ``, - partial: ``, - archived: `` - }; - return `${glyphs[status] || ''}`; -} - -function formatProjectLabel(slug) { - if (!slug) return '(no project)'; - const session = state.sessions.find(s => s.project === slug && s.project_path); - if (session?.project_path) { - const parts = session.project_path.split('/'); - return parts.slice(-2).join('/'); - } - return slug.replace(/^-/, ''); -} - -export function visibleMemories() { - const q = state.query.trim().toLowerCase(); - return state.memories - .filter(m => { - if (state.view === 'archived') return m.archived; - return !m.archived; - }) - .filter(m => state.projectFilter === 'all' || m.project === state.projectFilter) - .filter(m => !q || (m.path || '').toLowerCase().includes(q) || (m.summary || '').toLowerCase().includes(q)) - .sort((a, b) => state.sortDesc ? b.ts - a.ts : a.ts - b.ts); -} - -// --- Memory list --- - -export function renderMemoryList() { - const items = visibleMemories(); - const list = $('#list'); - if (!list) return; - if (!items.length) { - list.innerHTML = `
No memories${state.view === 'archived' ? ' archived' : ''} here.${state.query ? 'Try a different search term.' : 'Press / to search.'}
`; - return; - } - list.innerHTML = items.map(m => renderMemoryRow(m)).join(''); - if (state.cursorId) { - const cursorEl = list.querySelector(`.row[data-id="${state.cursorId}"]`); - if (cursorEl) ensureVisible(cursorEl, '#list-wrap'); - } -} - -function renderMemoryRow(m) { - const isCursor = state.cursorId === m.id; - const isSelected = state.selection.has(m.id); - const q = state.query.trim(); - const showProjectPrefix = state.projectFilter === 'all'; - const status = dominantRowStatus(m); - const actionLabel = m.archived - ? `` - : ``; - return ` -
- -
-
- ${statusGlyphHTML(status)} - ${showProjectPrefix ? `${escapeHTML(formatProjectLabel(m.project))}/` : ''} - ${highlightPlain(m.path || '', q)} -
-
${highlightPlain(m.summary || '', q)}
-
-
-
${fmtListTime(m.ts)}
-
${actionLabel}
-
-
- `; -} - -// --- Memory detail --- - -export async function renderMemoryDetail() { - const m = state.memories.find(x => x.id === state.detailId); - if (!m) return; - const detail = $('#detail'); - if (!detail) return; - - // Load markdown on demand - if (m.markdown === null && m.path) { - m.markdown = await loadMemoryMarkdown(m.path); - } - - const provenanceHTML = ` -
- ${m.session_id ? `` : ''} - ${fmtRelative(m.ts)} -
- `; - - let markdownHTML; - if (m.markdown == null) { - markdownHTML = `
File not found or empty.
`; - } else if (state.showSource) { - markdownHTML = `
${escapeHTML(m.markdown)}
`; - } else { - markdownHTML = renderMarkdown(m.markdown, { variant: 'body' }); - } - - detail.innerHTML = ` -
-
- ${FOLDER_SVG} - ${escapeHTML(formatProjectLabel(m.project))} - ${m.archived ? 'archived' : ''} -
-
${escapeHTML(m.path)}
-
${escapeHTML(m.summary)}
- ${provenanceHTML} -
-
-
- Body - -
- ${markdownHTML} -
-
- - -
- `; - - // Wire event listeners via registry (avoids circular imports) - $('#detail-back')?.addEventListener('click', () => { - if (registry.exitDetail) registry.exitDetail(); - }); - $('#detail-archive')?.addEventListener('click', () => { - if (m.archived) { if (registry.restore) registry.restore([m.id]); } - else { if (registry.archive) registry.archive([m.id]); } - }); - detail.querySelectorAll('[data-action]').forEach(el => { - el.addEventListener('click', e => { - e.stopPropagation(); - if (el.dataset.action === 'toggle-source') { - state.showSource = !state.showSource; - renderMemoryDetail(); - } else if (el.dataset.action === 'open-session' && el.dataset.session) { - if (registry.navigateToSession) registry.navigateToSession(el.dataset.session, null); - } - }); - }); -} diff --git a/app/src/renderer/js/registry.js b/app/src/renderer/js/registry.js deleted file mode 100644 index 5fb5f75..0000000 --- a/app/src/renderer/js/registry.js +++ /dev/null @@ -1,3 +0,0 @@ -// Shared registry to break circular dependencies between modules. -const registry = {}; -export default registry; diff --git a/app/src/renderer/js/render.js b/app/src/renderer/js/render.js deleted file mode 100644 index 00c409e..0000000 --- a/app/src/renderer/js/render.js +++ /dev/null @@ -1,246 +0,0 @@ -// Rendering coordinator -- thin orchestration layer. -// Delegates to extracted modules; keeps only cross-module functions locally. - -import { state } from './state.js'; -import { archiveMemory, restoreMemory } from './data.js'; -import registry from './registry.js'; - -// --- Module imports --- -import { escapeHTML, $ } from './utils.js'; -import { renderSidebar, renderBreadcrumb, updateWindowTitle } from './sidebar.js'; -import { visibleMemories as _visibleMemories, renderMemoryList, renderMemoryDetail } from './memory-list.js'; -import { renderSessionList, renderSessionDetail } from './session-list.js'; -import { renderUsage } from './usage.js'; - -// --- Data filtering (coordinator owns the cross-module view) --- - -export function visibleMemories() { return _visibleMemories(); } - -export function visibleSessions() { - const q = state.query.trim().toLowerCase(); - return state.sessions - .filter(s => state.projectFilter === 'all' || s.project === state.projectFilter) - .map(s => { - if (!q) return { ...s, messageHit: null }; - const topMatch = (s.title || '').toLowerCase().includes(q) || - (s.project || '').toLowerCase().includes(q) || - (s.git_branch || '').toLowerCase().includes(q); - if (topMatch) return { ...s, messageHit: null }; - return null; - }) - .filter(Boolean) - .sort((a, b) => { - const ta = new Date(a.started_at || 0).getTime(); - const tb = new Date(b.started_at || 0).getTime(); - return state.sortDesc ? tb - ta : ta - tb; - }); -} - -// --- Cursor / selection --- - -export function flatList() { return visibleMemories(); } - -export function cursorIndex() { - const flat = flatList(); - if (!state.cursorId) return -1; - return flat.findIndex(m => m.id === state.cursorId); -} - -export function moveCursor(delta, extendSelection = false) { - const flat = flatList(); - if (!flat.length) return; - let idx = cursorIndex(); - if (idx === -1) idx = 0; - else idx = Math.max(0, Math.min(flat.length - 1, idx + delta)); - const newId = flat[idx].id; - if (extendSelection) { state.selection.add(state.cursorId); state.selection.add(newId); } - state.cursorId = newId; - renderMemoryList(); renderStatus(); -} - -export function setCursor(id, opts = {}) { - state.cursorId = id; - if (!opts.keepSelection) state.selection.clear(); - renderMemoryList(); renderStatus(); -} - -// --- Mutations --- - -export function archive(ids) { if (!ids.length) return; doMutation(ids, true); } -export function restore(ids) { if (!ids.length) return; doMutation(ids, false); } - -async function doMutation(ids, toArchived) { - for (const id of ids) { - if (toArchived) await archiveMemory(id); - else await restoreMemory(id); - } - state.selection = new Set(); - if (state.mode === 'detail' && state.route === 'memory' && ids.includes(state.detailId)) exitDetail(); - const flat = flatList(); - if (state.cursorId && !flat.find(m => m.id === state.cursorId)) state.cursorId = flat[0]?.id ?? null; - state.lastArchiveSnapshot = ids; - state.undoExpires = Date.now() + 5000; - clearInterval(state.undoTimer); - state.undoTimer = setInterval(() => { - if (Date.now() >= state.undoExpires) { state.lastArchiveSnapshot = null; clearInterval(state.undoTimer); } - renderStatus(); - }, 500); - renderAll(); -} - -export async function doUndo() { - if (!state.lastArchiveSnapshot) return; - for (const id of state.lastArchiveSnapshot) { - const m = state.memories.find(x => x.id === id); - if (m) { - if (m.archived) await restoreMemory(id); - else await archiveMemory(id); - } - } - state.lastArchiveSnapshot = null; - clearInterval(state.undoTimer); - renderAll(); -} - -// --- Navigation --- - -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 = ''; - const searchEl = $('#search'); - if (searchEl) searchEl.value = ''; - switchView(); renderAll(); -} - -export function navigateToSubagent(agentId, description) { - state.subagentId = agentId; - state.subagentDescription = description || agentId; - switchView(); renderAll(); -} - -export function enterDetail(id) { state.detailId = id; state.mode = 'detail'; state.showSource = false; switchView(); renderAll(); } - -export function exitDetail() { - if (state.subagentId) { - state.subagentId = null; - state.subagentDescription = null; - switchView(); renderAll(); - return; - } - state.mode = 'list'; state.detailId = null; state.pendingFocusUuid = null; switchView(); renderAll(); -} - -export function setRoute(route) { - state.route = route; state.mode = 'list'; state.detailId = null; - state.cursorId = null; state.selection.clear(); - state.query = ''; const s = $('#search'); if (s) s.value = ''; - switchView(); renderAll(); - if (route === 'memory') { const flat = visibleMemories(); if (flat.length) state.cursorId = flat[0].id; } -} - -export function setView(v) { - state.route = 'memory'; state.view = v; state.mode = 'list'; state.detailId = null; - state.cursorId = null; state.selection.clear(); state.projectFilter = 'all'; - switchView(); renderAll(); - const flat = visibleMemories(); - if (flat.length) state.cursorId = flat[0].id; - renderMemoryList(); -} - -export function setProject(p) { - state.projectFilter = p; state.cursorId = null; state.selection.clear(); - state.mode = 'list'; state.detailId = null; - switchView(); renderAll(); - if (state.route === 'memory') { const flat = visibleMemories(); if (flat.length) state.cursorId = flat[0].id; } -} - -export function toggleSort() { - state.sortDesc = !state.sortDesc; - const btn = $('#sort-toggle'); - if (btn) { btn.classList.toggle('desc', state.sortDesc); btn.classList.toggle('asc', !state.sortDesc); } - const lbl = $('#sort-label'); - if (lbl) lbl.textContent = state.sortDesc ? 'newest' : 'oldest'; - if (state.route === 'sessions') renderSessionList(); - else renderMemoryList(); -} - -export function switchView() { - const showList = state.mode === 'list'; - const showSessions = state.route === 'sessions'; - const showUsage = state.route === 'usage'; - const inSessionDetail = !showList && showSessions; - const inSubagent = inSessionDetail && !!state.subagentId; - const el = (id, show) => { const e = $(id); if (e) e.style.display = show ? '' : 'none'; }; - el('#list-wrap', showList && !showSessions && !showUsage); - el('#detail-wrap', !showList && !showSessions && !showUsage); - el('#session-list-wrap', showList && showSessions); - el('#session-detail-wrap', inSessionDetail && !inSubagent); - el('#subagent-detail-wrap', inSubagent); - el('#usage-wrap', showUsage); - el('#search-wrap', showList && !showUsage); - el('#sort-toggle', showList && !showUsage); - el('#search-msgs-toggle', showList && showSessions); -} - -// --- Status bar --- - -export function renderStatus() { - const left = $('#status-left'); - const right = $('#status-right'); - if (!left || !right) return; - - if (state.route === 'sessions' && state.mode === 'list') { - right.innerHTML = ` open/ search`; - } else if (state.route === 'sessions' && state.mode === 'detail') { - right.innerHTML = `Esc back`; - } else if (state.mode === 'detail') { - right.innerHTML = `Esc backD archive`; - } else { - right.innerHTML = `↑↓ nav openD archive/ search`; - } - - if (state.lastArchiveSnapshot && state.undoExpires > Date.now()) { - const ids = state.lastArchiveSnapshot; - const secs = Math.ceil((state.undoExpires - Date.now()) / 1000); - const target = ids.length === 1 ? (state.memories.find(x => x.id === ids[0])?.path || '').split('/').pop() : `${ids.length} memories`; - left.innerHTML = `Action pending ${escapeHTML(target)}${secs}s`; - $('#undo-btn')?.addEventListener('click', doUndo); - return; - } - left.textContent = ''; -} - -// --- Master render --- - -export function renderAll() { - renderSidebar(); - renderBreadcrumb(); - switchView(); - if (state.route === 'usage') { - renderUsage(); - } else if (state.route === 'sessions') { - if (state.mode === 'list') renderSessionList(); - else renderSessionDetail(); - } else { - if (state.mode === 'list') renderMemoryList(); - else renderMemoryDetail(); - } - renderStatus(); - updateWindowTitle(); -} - -// --- Registry (break circular deps for child modules) --- - -registry.navigateToSession = navigateToSession; -registry.navigateToSubagent = navigateToSubagent; -registry.exitDetail = exitDetail; -registry.archive = archive; -registry.restore = restore; - -// --- Re-exports for app.js and keys.js --- - -export { renderMemoryList, renderSessionList, escapeHTML }; diff --git a/app/src/renderer/js/session-list.js b/app/src/renderer/js/session-list.js deleted file mode 100644 index 07acf75..0000000 --- a/app/src/renderer/js/session-list.js +++ /dev/null @@ -1,530 +0,0 @@ -// Session list and detail rendering module. -// Extracted from render.js -- all session/subagent DOM generation. - -import { state } from './state.js'; -import { loadSessionDetail, loadSubagentDetail, isTextTruncated, loadFullText } from './data.js'; -import { escapeHTML, highlightPlain, fmtListTime, fmtRelative, fmtClockTime, renderMarkdown, formatProjectLabel, $ } from './utils.js'; -import { FOLDER_SVG } from './state.js'; -import registry from './registry.js'; - -// --- Session list --- - -export function renderSessionList() { - const items = visibleSessions(); - const list = $('#session-list'); - if (!list) return; - if (!items.length) { - list.innerHTML = `
No sessions here.${state.query ? 'Try a different search term.' : 'Press / to search.'}
`; - return; - } - list.innerHTML = items.map(s => renderSessionRow(s)).join(''); -} - -function renderSessionRow(s) { - const q = state.query.trim(); - const showProjectPrefix = state.projectFilter === 'all'; - const startedTs = new Date(s.started_at || 0).getTime(); - return ` -
-
-
${highlightPlain(s.title || '(untitled)', q)}
-
- ${showProjectPrefix ? `${escapeHTML(formatProjectLabel(s.project))}` : ''} - ${s.message_count || 0} msg -
-
-
${fmtListTime(startedTs)}
-
- `; -} - -// --- Session detail --- - -export async function renderSessionDetail() { - // If viewing a subagent, render that instead - if (state.subagentId) { - return renderSubagentDetail(); - } - - const s = state.sessions.find(x => x.id === state.detailId); - if (!s) return; - const detail = $('#session-detail'); - if (!detail) return; - const wrap = $('#session-detail-wrap'); - - // If DOM was already built for this session, skip rebuild - if (detail.dataset.renderedSession === state.detailId) { - return; - } - - // Load messages on demand - if (!s.messages || s.messages.length === 0) { - const loaded = await loadSessionDetail(s.id); - if (loaded) Object.assign(s, loaded); - } - - const startedTs = new Date(s.started_at || 0).getTime(); - const headerHTML = ` -
-
- ${FOLDER_SVG} - ${escapeHTML(formatProjectLabel(s.project))} - · - ${escapeHTML(s.project_path || '')} -
-
${escapeHTML(s.title || '(untitled)')}
-
- ${fmtRelative(startedTs)} - - ${s.message_count || 0} messages - ${s.git_branch ? `${escapeHTML(s.git_branch)}` : ''} -
-
- `; - - const messagesHTML = (s.messages || []).map((msg, idx) => renderMessage(msg, idx)).join(''); - detail.innerHTML = `
${headerHTML}
${messagesHTML}
`; - detail.dataset.renderedSession = state.detailId; - - // Progress bar: track scroll position relative to messages - const progressFill = detail.querySelector('#session-progress-fill'); - if (wrap && progressFill) { - const updateProgress = () => { - const msgs = detail.querySelectorAll('.msg, .wf-card'); - if (!msgs.length) return; - const wrapTop = wrap.getBoundingClientRect().top; - let topMsgIdx = 0; - for (let i = 0; i < msgs.length; i++) { - if (msgs[i].getBoundingClientRect().top <= wrapTop + 50) topMsgIdx = i; - else break; - } - const pct = msgs.length <= 1 ? 100 : Math.round((topMsgIdx / (msgs.length - 1)) * 100); - progressFill.style.width = pct + '%'; - - // Show/hide back-to-top button - const topBtn = detail.querySelector('#back-to-top'); - if (topBtn) topBtn.classList.toggle('show', wrap.scrollTop > 300); - }; - wrap.addEventListener('scroll', updateProgress); - updateProgress(); - } - - // Back to top button - const topBtn = document.createElement('button'); - topBtn.id = 'back-to-top'; - topBtn.className = 'back-to-top'; - topBtn.innerHTML = ``; - topBtn.addEventListener('click', () => { if (wrap) wrap.scrollTo({ top: 0, behavior: 'smooth' }); }); - detail.appendChild(topBtn); - - // Wire up tool call toggles - detail.querySelectorAll('.toolcall-toggle').forEach(btn => { - btn.addEventListener('click', e => { e.stopPropagation(); btn.closest('.msg-tool').classList.toggle('open'); }); - }); - detail.querySelectorAll('.summary-toggle').forEach(btn => { - btn.addEventListener('click', e => { e.stopPropagation(); btn.closest('.msg-summary').classList.toggle('open'); }); - }); - detail.querySelectorAll('.thinking-toggle').forEach(btn => { - btn.addEventListener('click', e => { e.stopPropagation(); btn.closest('.msg-thinking').classList.toggle('open'); }); - }); - detail.querySelectorAll('.meta-toggle').forEach(btn => { - btn.addEventListener('click', e => { e.stopPropagation(); btn.closest('.msg-meta-collapsed').classList.toggle('open'); }); - }); - detail.querySelectorAll('.truncated-btn').forEach(btn => { - btn.addEventListener('click', async e => { - e.stopPropagation(); - const uuid = btn.dataset.uuid; - btn.textContent = 'Loading…'; - const fullText = await loadFullText(uuid); - if (fullText) { - const msgEl = btn.closest('.msg'); - const bodyEl = msgEl.querySelector('.markdown-msg') || msgEl.querySelector('.markdown-compact'); - const variant = bodyEl?.classList.contains('markdown-compact') ? 'compact' : 'msg'; - const rendered = renderMarkdown(fullText, { variant, query: state.query }); - if (bodyEl) bodyEl.outerHTML = rendered; - btn.remove(); - } else { - btn.textContent = 'Failed to load full text'; - } - }); - }); - - // Subagent navigation - detail.querySelectorAll('[data-action="open-subagent"]').forEach(btn => { - btn.addEventListener('click', e => { - e.stopPropagation(); - registry.navigateToSubagent(btn.dataset.agentId, btn.dataset.agentDesc); - }); - }); - - // Focus on pending message - if (state.pendingFocusUuid) { - const targetUuid = state.pendingFocusUuid; - requestAnimationFrame(() => { - const target = detail.querySelector(`.msg[data-uuid="${targetUuid}"]`); - if (target) { - target.scrollIntoView({ behavior: 'smooth', block: 'start' }); - requestAnimationFrame(() => { - target.classList.add('is-focused'); - setTimeout(() => target.classList.remove('is-focused'), 1200); - }); - } - }); - state.pendingFocusUuid = null; - } -} - -async function renderSubagentDetail() { - const detail = $('#subagent-detail'); - if (!detail) return; - const wrap = $('#subagent-detail-wrap'); - if (wrap) wrap.scrollTop = 0; - - const messages = await loadSubagentDetail(state.subagentId); - - const headerHTML = ` -
-
- SUBAGENT -
-
${escapeHTML(state.subagentDescription || state.subagentId)}
-
- ${messages.length} messages -
-
- `; - - const messagesHTML = messages.map((msg, idx) => { - return renderMessage(msg, idx, { isSubagent: true }); - }).join(''); - - detail.innerHTML = `
${headerHTML}
${messagesHTML}
`; - - // Wire up toggles - detail.querySelectorAll('.toolcall-toggle').forEach(btn => { - btn.addEventListener('click', e => { e.stopPropagation(); btn.closest('.msg-tool').classList.toggle('open'); }); - }); - detail.querySelectorAll('.summary-toggle').forEach(btn => { - btn.addEventListener('click', e => { e.stopPropagation(); btn.closest('.msg-summary').classList.toggle('open'); }); - }); - detail.querySelectorAll('.thinking-toggle').forEach(btn => { - btn.addEventListener('click', e => { e.stopPropagation(); btn.closest('.msg-thinking').classList.toggle('open'); }); - }); - detail.querySelectorAll('.meta-toggle').forEach(btn => { - btn.addEventListener('click', e => { e.stopPropagation(); btn.closest('.msg-meta-collapsed').classList.toggle('open'); }); - }); - detail.querySelectorAll('.truncated-btn').forEach(btn => { - btn.addEventListener('click', async e => { - e.stopPropagation(); - const uuid = btn.dataset.uuid; - btn.textContent = 'Loading…'; - const fullText = await loadFullText(uuid); - if (fullText) { - const msgEl = btn.closest('.msg'); - const bodyEl = msgEl.querySelector('.markdown-msg') || msgEl.querySelector('.markdown-compact'); - const variant = bodyEl?.classList.contains('markdown-compact') ? 'compact' : 'msg'; - const rendered = renderMarkdown(fullText, { variant, query: state.query }); - if (bodyEl) bodyEl.outerHTML = rendered; - btn.remove(); - } else { - btn.textContent = 'Failed to load full text'; - } - }); - }); - // Nested subagent navigation - detail.querySelectorAll('[data-action="open-subagent"]').forEach(btn => { - btn.addEventListener('click', e => { - e.stopPropagation(); - registry.navigateToSubagent(btn.dataset.agentId, btn.dataset.agentDesc); - }); - }); - - // Progress bar - const progressFill = detail.querySelector('#session-progress-fill'); - if (wrap && progressFill) { - const updateProgress = () => { - const msgs = detail.querySelectorAll('.msg'); - if (!msgs.length) return; - const wrapTop = wrap.getBoundingClientRect().top; - let topMsgIdx = 0; - for (let i = 0; i < msgs.length; i++) { - if (msgs[i].getBoundingClientRect().top <= wrapTop + 50) topMsgIdx = i; - else break; - } - const pct = msgs.length <= 1 ? 100 : Math.round((topMsgIdx / (msgs.length - 1)) * 100); - progressFill.style.width = pct + '%'; - }; - wrap.addEventListener('scroll', updateProgress); - updateProgress(); - } -} - -function renderMessage(msg, idx, opts = {}) { - const isUser = msg.type === 'user'; - const isThinking = msg.content_type === 'thinking'; - const isMeta = msg.is_meta === 1; - const tools = (msg.tool_calls || []).map(renderToolCall).join(''); - - // In subagent context, all user text messages are prompts (from main agent or human) - let roleLabel = isUser ? 'You' : 'Assistant'; - if (opts.isSubagent && isUser) { - roleLabel = 'Prompt'; - } - - // Meta messages: collapsed by default, shown as a small system indicator - if (isMeta) { - const preview = (msg.text || '').replace(/<[^>]+>/g, '').slice(0, 80); - const truncated = isTextTruncated(msg.text); - return ` -
-
- -
- ${renderMarkdown(msg.text, { variant: 'compact', query: state.query })} - ${truncated ? `` : ''} -
-
-
- `; - } - - // Workflow as standalone card (not inside assistant bubble) - const workflowCall = (msg.tool_calls || []).find(tc => tc.name === 'Workflow' && tc.workflow); - if (workflowCall && !isUser) { - const wf = workflowCall.workflow; - const wfName = wf.workflow_name || 'Workflow'; - const agents = wf.agents || []; - - const phases = {}; - for (const a of agents) { - const phase = a.phase || 'Other'; - if (!phases[phase]) phases[phase] = []; - phases[phase].push(a); - } - - const phasesHTML = Object.entries(phases).map(([phase, agentList]) => ` -
-
${escapeHTML(phase)}
- ${agentList.map(a => ` - - `).join('')} -
- `).join(''); - - // Render other tool calls (non-workflow) if any - const otherTools = (msg.tool_calls || []).filter(tc => tc !== workflowCall).map(renderToolCall).join(''); - - return ` -
-
- - ${escapeHTML(wfName)} - ${agents.length} agents - ${wf.status ? `${escapeHTML(wf.status)}` : ''} -
-
${phasesHTML}
-
- ${otherTools ? `
${otherTools}
` : ''} - `; - } - - // Standalone thinking message (no following assistant to attach to) - if (isThinking) { - return ` -
-
- -
${renderMarkdown(msg.text, { variant: 'msg', query: state.query })}
-
-
- `; - } - - // Thinking block attached to this message (merged from preceding thinking messages) - let thinkingHTML = ''; - if (msg._thinking) { - thinkingHTML = ` -
- -
${renderMarkdown(msg._thinking, { variant: 'msg', query: state.query })}
-
- `; - } - - const truncated = isTextTruncated(msg.text); - let textHTML = msg.text ? renderMarkdown(msg.text, { variant: 'msg', query: state.query }) : (tools ? '' : '
(no text content)
'); - if (truncated) { - textHTML += ``; - } - - let summaryHTML = ''; - if (msg.summary) { - summaryHTML = ` -
- -
${renderMarkdown(msg.summary.content, { variant: 'compact' })}
-
- `; - } - - return ` -
-
- ${roleLabel} - ${msg.timestamp ? fmtClockTime(msg.timestamp) : ''} -
- ${thinkingHTML} - ${textHTML} - ${tools ? `
${tools}
` : ''} - ${summaryHTML} -
- `; -} - -function renderToolCall(tc) { - const isError = tc.result && tc.result.is_error; - - // Special rendering for Agent/Task tool calls (subagents) - if (tc.name === 'Agent' || tc.name === 'Task') { - let parsed = {}; - try { parsed = JSON.parse(tc.input_json || '{}'); } catch {} - const agentType = parsed.subagent_type || parsed.agentType || 'Agent'; - const description = parsed.description || parsed.prompt?.slice(0, 80) || ''; - const resultContent = tc.result?.content || ''; - const subagentId = tc.subagent?.agent_id || null; - - return ` -
- ` : ''} - -
- ${parsed.prompt ? `
Prompt
${escapeHTML(parsed.prompt.slice(0, 500))}${parsed.prompt.length > 500 ? '…' : ''}
` : ''} - ${resultContent ? `
Result
${renderMarkdown(resultContent, { variant: 'compact' })}
` : ''} -
-
- `; - } - - // Special rendering for Workflow tool calls - if (tc.name === 'Workflow') { - let parsed = {}; - try { parsed = JSON.parse(tc.input_json || '{}'); } catch {} - const wf = tc.workflow; - const wfName = wf?.workflow_name || parsed.name || 'Workflow'; - const wfStatus = wf?.status || ''; - const agents = wf?.agents || []; - - // Group agents by phase - const phases = {}; - for (const a of agents) { - const phase = a.phase || 'Other'; - if (!phases[phase]) phases[phase] = []; - phases[phase].push(a); - } - - const phasesHTML = Object.entries(phases).map(([phase, agentList]) => ` -
-
${escapeHTML(phase)}
-
- ${agentList.map(a => ` - - `).join('')} -
-
- `).join(''); - - const agentListHTML = agents.length ? ` -
Agents · ${agents.length}
-
${phasesHTML}
- ` : ''; - - return ` -
- -
- ${agentListHTML} -
-
- `; - } - - let argPreview = ''; - try { - const j = JSON.parse(tc.input_json || '{}'); - if (j.file_path) argPreview = j.file_path; - else if (j.command) argPreview = j.command; - else if (j.path) argPreview = j.path; - else if (j.description) argPreview = j.description; - else argPreview = JSON.stringify(j).slice(0, 100); - } catch { argPreview = (tc.input_json || '').slice(0, 100); } - - return ` -
- -
-
Input
-
${escapeHTML(tc.input_json || '')}
- ${tc.result ? `
${isError ? 'Error' : 'Output'}
${escapeHTML(tc.result.content || '(empty)')}
` : ''} -
-
- `; -} - -// --- Private helper: visibleSessions (same logic as render.js) --- - -function visibleSessions() { - const q = state.query.trim().toLowerCase(); - return state.sessions - .filter(s => state.projectFilter === 'all' || s.project === state.projectFilter) - .map(s => { - if (!q) return { ...s, messageHit: null }; - const topMatch = (s.title || '').toLowerCase().includes(q) || - (s.project || '').toLowerCase().includes(q) || - (s.git_branch || '').toLowerCase().includes(q); - if (topMatch) return { ...s, messageHit: null }; - return null; - }) - .filter(Boolean) - .sort((a, b) => { - const ta = new Date(a.started_at || 0).getTime(); - const tb = new Date(b.started_at || 0).getTime(); - return state.sortDesc ? tb - ta : ta - tb; - }); -} diff --git a/app/src/renderer/js/sidebar.js b/app/src/renderer/js/sidebar.js deleted file mode 100644 index 13aca10..0000000 --- a/app/src/renderer/js/sidebar.js +++ /dev/null @@ -1,137 +0,0 @@ -// Sidebar, breadcrumb, and window title rendering. -// Extracted from render.js for modularity. - -import { state, FOLDER_SVG } from './state.js'; -import { $, $$, escapeHTML, formatProjectLabel } from './utils.js'; - -// --- Data helpers (sidebar-local) --- - -function projectCountsForCurrentRoute() { - const counts = {}; - if (state.route === 'sessions') { - for (const s of state.sessions) if (s.project) counts[s.project] = (counts[s.project] || 0) + 1; - } else { - for (const m of state.memories) { - const matches = state.view === 'archived' ? m.archived : !m.archived; - if (!matches) continue; - if (m.project) counts[m.project] = (counts[m.project] || 0) + 1; - } - } - return counts; -} - -// --- Sidebar --- - -export function renderSidebar() { - const activeCount = state.memories.filter(m => !m.archived).length; - const archivedCount = state.memories.filter(m => m.archived).length; - const el = id => $(id); - if (el('#count-sessions')) el('#count-sessions').textContent = state.sessions.length; - if (el('#count-memory-total')) el('#count-memory-total').textContent = activeCount + archivedCount; - if (el('#count-active')) el('#count-active').textContent = activeCount; - if (el('#count-archived')) el('#count-archived').textContent = archivedCount; - - $$('.sidebar-item').forEach(item => { - let isActive = false; - if (item.dataset.route === 'sessions' && state.route === 'sessions' && state.projectFilter === 'all') isActive = true; - else if (item.dataset.route === 'usage' && state.route === 'usage') isActive = true; - else if (item.dataset.route === 'memory' && item.dataset.view === state.view && state.projectFilter === 'all') isActive = true; - else if (item.dataset.project && item.dataset.project === state.projectFilter) isActive = true; - if (item.dataset.route === 'memory' && !item.classList.contains('sub')) isActive = false; - item.classList.toggle('active', isActive); - }); - - const counts = projectCountsForCurrentRoute(); - let projects = [...new Set( - (state.route === 'sessions' ? state.sessions : state.memories) - .filter(item => { - if (state.route === 'sessions') return true; - return state.view === 'archived' ? item.archived : !item.archived; - }) - .map(item => item.project) - .filter(Boolean) - )]; - if (state.projectSearch) { - const q = state.projectSearch.toLowerCase(); - projects = projects.filter(p => p.toLowerCase().includes(q)); - } - projects.sort((a, b) => formatProjectLabel(a).localeCompare(formatProjectLabel(b))); - const projectsEl = $('#sidebar-projects'); - if (projectsEl) { - projectsEl.innerHTML = projects.map(p => ` - - `).join('') || `
No projects
`; - } -} - -// --- Breadcrumb --- - -export function renderBreadcrumb() { - const bc = $('#breadcrumb'); - if (!bc) return; - if (state.route === 'sessions') { - if (state.mode === 'detail') { - const s = state.sessions.find(x => x.id === state.detailId); - if (!s) return; - if (state.subagentId) { - bc.innerHTML = `//${escapeHTML((state.subagentDescription || '').slice(0, 40))}`; - } else { - bc.innerHTML = `/${escapeHTML(s.title || s.id)}`; - } - } else { - let html = ``; - if (state.projectFilter !== 'all') html += `/${escapeHTML(formatProjectLabel(state.projectFilter))}`; - bc.innerHTML = html; - } - } else if (state.route === 'usage') { - bc.innerHTML = `Usage`; - } else { - if (state.mode === 'detail') { - const m = state.memories.find(x => x.id === state.detailId); - if (!m) return; - bc.innerHTML = `/${escapeHTML(m.path.split('/').pop())}`; - } else { - let html = ``; - if (state.projectFilter !== 'all') html += `/${escapeHTML(formatProjectLabel(state.projectFilter))}`; - bc.innerHTML = html; - } - } -} - -// --- Window title --- - -export function updateWindowTitle() { - const appName = 'Obelisk'; - let scopeText = ''; - if (state.route === 'usage') { - scopeText = 'Usage'; - } else if (state.route === 'sessions') { - if (state.mode === 'detail') { - const s = state.sessions.find(x => x.id === state.detailId); - scopeText = s ? `Sessions · ${s.title}` : 'Sessions'; - } else { - const proj = state.projectFilter !== 'all' ? ` · ${formatProjectLabel(state.projectFilter)}` : ''; - scopeText = `Sessions${proj}`; - } - } else { - if (state.mode === 'detail') { - const m = state.memories.find(x => x.id === state.detailId); - scopeText = m ? `Memory · ${m.path.split('/').pop()}` : 'Memory'; - } else { - const viewLabel = state.view === 'archived' ? 'Archived' : 'Active'; - const proj = state.projectFilter !== 'all' ? ` · ${formatProjectLabel(state.projectFilter)}` : ''; - scopeText = `Memory · ${viewLabel}${proj}`; - } - } - const titleEl = $('#titlebar-text'); - if (titleEl) { - const truncated = scopeText.length > 50 ? scopeText.slice(0, 50) + '…' : scopeText; - titleEl.innerHTML = `${appName}${escapeHTML(truncated)}`; - titleEl.title = `${appName} — ${scopeText}`; - } - document.title = `${appName} — ${scopeText}`; -} diff --git a/app/src/renderer/js/state.js b/app/src/renderer/js/state.js deleted file mode 100644 index 5588778..0000000 --- a/app/src/renderer/js/state.js +++ /dev/null @@ -1,34 +0,0 @@ -// App state -- single source of truth for the renderer process. -// Loaded collections (memories, sessions, projects) start empty and are -// populated from the DB at boot. - -export const state = { - memories: [], - sessions: [], - projects: [], - route: 'memory', - view: 'active', // 'active' | 'archived' - mode: 'list', // 'list' | 'detail' - detailId: null, - subagentId: null, - subagentDescription: null, - pendingFocusUuid: null, - query: '', - projectFilter: 'all', - projectSearch: '', - sortDesc: true, - includeMessageBodies: false, - cursorId: null, - selection: new Set(), - showSource: false, - lastArchiveSnapshot: null, - undoTimer: null, - undoExpires: 0 -}; - -// Platform detection -export const IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform); - -// SVG icon constants -export const FOLDER_SVG = ``; -export const FILE_SVG = ``; diff --git a/app/src/renderer/js/usage.js b/app/src/renderer/js/usage.js deleted file mode 100644 index 96e9627..0000000 --- a/app/src/renderer/js/usage.js +++ /dev/null @@ -1,573 +0,0 @@ -// Usage rendering module -- heatmap, weekly chart, cumulative chart. -// Extracted from render.js with identical logic. - -import { state } from './state.js'; -import { escapeHTML, fmtDuration, fmtTokens, fmtTooltipDate, positionTooltip, formatProjectLabel, $ } from './utils.js'; -import registry from './registry.js'; - -function navigateToSession(sessionId, focusUuid) { - if (registry.navigateToSession) { - registry.navigateToSession(sessionId, focusUuid); - } -} - -export async function renderUsage() { - const usage = $('#usage'); - if (!usage) return; - - const data = await window.obelisk.getUsageStats(); - const { daily, totalTokens, peakDay, longestTurn } = data; - - // Build heatmap: 52 weeks x 7 days grid - const today = new Date(); - const dayMs = 86400000; - // Start from the first Sunday on or after 364 days ago (full weeks only) - let startDate = new Date(today.getTime() - 364 * dayMs); - startDate.setHours(0, 0, 0, 0); - const daysUntilSunday = (7 - startDate.getDay()) % 7; - startDate = new Date(startDate.getTime() + daysUntilSunday * dayMs); - - const dailyMap = {}; - for (const d of daily) dailyMap[d.day] = d.tokens; - - const values = daily.map(d => d.tokens).filter(Boolean); - const maxTokens = Math.max(...values, 1); - - // Generate cells (startDate is always a Sunday now) - const cells = []; - for (let i = 0; i < 371; i++) { - const date = new Date(startDate.getTime() + i * dayMs); - if (date > today) break; - const key = date.toISOString().slice(0, 10); - const tokens = dailyMap[key] || 0; - const level = tokens === 0 ? 0 : Math.min(4, Math.ceil((tokens / maxTokens) * 4)); - const col = Math.floor(i / 7); - const row = i % 7; - cells.push({ key, tokens, level, col, row, date }); - } - - const maxCol = cells.length ? cells[cells.length - 1].col : 0; - const cellSize = 11; - const cellGap = 2; - const step = cellSize + cellGap; - const gridWidth = (maxCol + 1) * step + 20; // extra padding for last month label - const gridHeight = 7 * step; - - const cellsHTML = cells.map(c => { - const x = c.col * step; - const y = c.row * step; - return ``; - }).join(''); - - // Month labels - const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; - const monthLabels = []; - let lastMonth = -1; - for (const c of cells) { - const m = c.date.getMonth(); - if (m !== lastMonth && c.row === 0) { - monthLabels.push({ col: c.col, label: months[m] }); - lastMonth = m; - } - } - const monthLabelsHTML = monthLabels.map(m => - `${m.label}` - ).join(''); - - // Streak calculation — check gaps between consecutive active days - let longestStreak = 0; - let streak = 0; - const sortedDays = [...daily].filter(d => d.tokens > 0).sort((a, b) => a.day.localeCompare(b.day)); - for (let i = 0; i < sortedDays.length; i++) { - if (i === 0) { - streak = 1; - } else { - const prev = new Date(sortedDays[i - 1].day).getTime(); - const curr = new Date(sortedDays[i].day).getTime(); - if (curr - prev === dayMs) { - streak++; - } else { - streak = 1; - } - } - if (streak > longestStreak) longestStreak = streak; - } - // Current streak: find the most recent active day, then count consecutive days backwards - let currentStreak = 0; - let startedCounting = false; - for (let i = 0; i <= 365; i++) { - const d = new Date(today.getTime() - i * dayMs).toISOString().slice(0, 10); - if (dailyMap[d] && dailyMap[d] > 0) { - startedCounting = true; - currentStreak++; - } else if (startedCounting) { - break; - } - } - - usage.innerHTML = ` -
- Token activity -
- - - -
-
- -
-
- ${fmtTokens(totalTokens)} - Lifetime tokens -
-
- ${peakDay ? fmtTokens(peakDay.tokens) : '—'} - Peak tokens -
-
- ${longestTurn ? fmtDuration(longestTurn.turn_duration_ms) : '—'} - Longest task -
-
- ${currentStreak}d - Current streak -
-
- ${longestStreak}d - Longest streak -
-
- -
- - ${cellsHTML} - ${monthLabelsHTML} - -
- Less - - More -
-
- -
- `; - - // Heatmap tooltip - const heatmapTooltip = document.createElement('div'); - heatmapTooltip.className = 'chart-tooltip'; - usage.appendChild(heatmapTooltip); - usage.querySelectorAll('.heatmap-cell[data-label]').forEach(cell => { - cell.addEventListener('mouseenter', () => { - heatmapTooltip.textContent = cell.dataset.label; - heatmapTooltip.classList.add('show'); - }); - cell.addEventListener('mousemove', e => { - positionTooltip(heatmapTooltip, e.clientX, e.clientY); - }); - cell.addEventListener('mouseleave', () => heatmapTooltip.classList.remove('show')); - cell.addEventListener('click', () => { - usage.querySelectorAll('.heatmap-cell.selected').forEach(c => c.classList.remove('selected')); - cell.classList.add('selected'); - const date = cell.dataset.label.match(/on (.+)$/)?.[1] || ''; - const dateKey = cell.getAttribute('data-label').split(' tokens')[0]; // not ideal - // Extract ISO date from cells array by matching position - const allCells = [...usage.querySelectorAll('.heatmap-cell[data-label]')]; - const idx = allCells.indexOf(cell); - if (idx >= 0 && idx < cells.length) { - showDaySessions(cells[idx].key); - } - }); - }); - - async function showDaySessions(dateKey) { - const panel = usage.querySelector('#day-sessions'); - if (!panel) return; - const dayStart = dateKey + 'T00:00:00'; - const dayEnd = dateKey + 'T23:59:59'; - - // Find sessions active on this day - const daySessions = state.sessions.filter(s => { - if (!s.started_at) return false; - const end = s.ended_at || s.started_at; - return s.started_at <= dayEnd && end >= dayStart; - }); - - // Classify each session - const classified = daySessions.map(s => { - const isNew = s.started_at.slice(0, 10) === dateKey; - let kind = 'continued'; // default: session spans this day - if (isNew) { - // Check if this project had any session before this one - const hasEarlierSession = state.sessions.some( - other => other.project === s.project && other.id !== s.id && other.started_at < s.started_at - ); - kind = hasEarlierSession ? 'new-session' : 'new-workspace'; - } - return { ...s, kind }; - }); - - if (!classified.length) { - panel.innerHTML = `
${fmtTooltipDate(dateKey)} — no sessions
`; - return; - } - - // Group by kind for visual hierarchy - const newWorkspaces = classified.filter(s => s.kind === 'new-workspace'); - const newSessions = classified.filter(s => s.kind === 'new-session'); - const continued = classified.filter(s => s.kind === 'continued'); - - let html = `
${fmtTooltipDate(dateKey)}
`; - - if (newWorkspaces.length) { - html += ` -
-
- - Created ${newWorkspaces.length} new workspace${newWorkspaces.length > 1 ? 's' : ''} -
-
- ${newWorkspaces.map(s => ` - - `).join('')} -
-
- `; - } - - if (newSessions.length) { - // Group new sessions by project - const byProject = {}; - for (const s of newSessions) { - const p = s.project || '(none)'; - if (!byProject[p]) byProject[p] = []; - byProject[p].push(s); - } - html += ` -
-
- + - Started ${newSessions.length} session${newSessions.length > 1 ? 's' : ''} in ${Object.keys(byProject).length} project${Object.keys(byProject).length > 1 ? 's' : ''} -
-
- ${newSessions.map(s => ` - - `).join('')} -
-
- `; - } - - if (continued.length) { - html += ` -
-
- - Continued ${continued.length} session${continued.length > 1 ? 's' : ''} -
-
- ${continued.map(s => ` - - `).join('')} -
-
- `; - } - - html += `
`; - panel.innerHTML = html; - panel.querySelectorAll('.activity-item').forEach(row => { - row.addEventListener('click', () => navigateToSession(row.dataset.sessionId)); - }); - } - - // Tab switching - usage.querySelectorAll('.usage-tab').forEach(tab => { - tab.addEventListener('click', () => { - usage.querySelectorAll('.usage-tab').forEach(t => t.classList.remove('active')); - tab.classList.add('active'); - const view = tab.dataset.view; - const heatmap = usage.querySelector('.heatmap-container'); - const chart = usage.querySelector('#usage-chart'); - if (view === 'daily') { - heatmap.style.display = ''; chart.style.display = 'none'; - } else { - heatmap.style.display = 'none'; chart.style.display = ''; - if (view === 'weekly') renderWeeklyChart(chart, daily); - else renderCumulativeChart(chart, daily); - } - }); - }); - - // Default: show current month's activity with "show more" for previous months - let loadedMonths = 0; - showNextMonth(); - - function showNextMonth() { - const panel = usage.querySelector('#day-sessions'); - if (!panel) return; - const targetDate = new Date(today.getFullYear(), today.getMonth() - loadedMonths, 1); - const year = targetDate.getFullYear(); - const month = targetDate.getMonth(); - loadedMonths++; - - const monthHTML = buildMonthHTML(year, month); - - // Remove existing "show more" button - const existing = panel.querySelector('.show-more-btn'); - if (existing) existing.remove(); - - panel.insertAdjacentHTML('beforeend', monthHTML); - - // Add "show more" button - const btn = document.createElement('button'); - btn.className = 'show-more-btn'; - btn.textContent = 'Show more activity'; - btn.addEventListener('click', () => showNextMonth()); - panel.appendChild(btn); - - // Wire up session links - panel.querySelectorAll('.activity-item:not([data-wired])').forEach(row => { - row.setAttribute('data-wired', '1'); - row.addEventListener('click', () => navigateToSession(row.dataset.sessionId)); - }); - } - - function buildMonthHTML(year, month) { - const monthStart = `${year}-${String(month + 1).padStart(2, '0')}-01`; - const nextMonth = month === 11 ? `${year + 1}-01-01` : `${year}-${String(month + 2).padStart(2, '0')}-01`; - const monthNames = ['January','February','March','April','May','June','July','August','September','October','November','December']; - - const monthSessions = state.sessions.filter(s => { - if (!s.started_at) return false; - const end = s.ended_at || s.started_at; - return s.started_at < nextMonth && end >= monthStart; - }); - - const classified = monthSessions.map(s => { - const startedInMonth = s.started_at >= monthStart && s.started_at < nextMonth; - let kind = 'continued'; - if (startedInMonth) { - const hasEarlierSession = state.sessions.some( - other => other.project === s.project && other.id !== s.id && other.started_at < s.started_at - ); - kind = hasEarlierSession ? 'new-session' : 'new-workspace'; - } - return { ...s, kind }; - }); - - const newWorkspaces = classified.filter(s => s.kind === 'new-workspace'); - const newSessions = classified.filter(s => s.kind === 'new-session'); - const continued = classified.filter(s => s.kind === 'continued'); - - const headerText = `${monthNames[month]} ${year}`; - let html = `
${headerText}
`; - - if (newWorkspaces.length) { - html += ` -
-
- - Created ${newWorkspaces.length} new workspace${newWorkspaces.length > 1 ? 's' : ''} -
-
- ${newWorkspaces.map(s => ` - - `).join('')} -
-
- `; - } - - if (newSessions.length) { - const byProject = {}; - for (const s of newSessions) { const p = s.project || '(none)'; if (!byProject[p]) byProject[p] = []; byProject[p].push(s); } - html += ` -
-
- + - Started ${newSessions.length} session${newSessions.length > 1 ? 's' : ''} in ${Object.keys(byProject).length} project${Object.keys(byProject).length > 1 ? 's' : ''} -
-
- ${newSessions.map(s => ` - - `).join('')} -
-
- `; - } - - if (continued.length) { - html += ` -
-
- - Continued ${continued.length} session${continued.length > 1 ? 's' : ''} -
-
- ${continued.map(s => ` - - `).join('')} -
-
- `; - } - - if (!classified.length) html += `
No sessions this month.
`; - html += `
`; - return html; - } -} - -export function renderWeeklyChart(container, daily) { - // Build 52 weekly buckets aligned to the same time range as the heatmap - const today = new Date(); - const dayMs = 86400000; - let startDate = new Date(today.getTime() - 364 * dayMs); - startDate.setHours(0, 0, 0, 0); - const daysUntilSunday = (7 - startDate.getDay()) % 7; - startDate = new Date(startDate.getTime() + daysUntilSunday * dayMs); - - const dailyMap = {}; - for (const d of daily) dailyMap[d.day] = d.tokens; - - // Aggregate into weeks - const weeks = []; - for (let w = 0; w < 53; w++) { - const weekStart = new Date(startDate.getTime() + w * 7 * dayMs); - if (weekStart > today) break; - let tokens = 0; - for (let d = 0; d < 7; d++) { - const date = new Date(weekStart.getTime() + d * dayMs); - if (date > today) break; - const key = date.toISOString().slice(0, 10); - tokens += dailyMap[key] || 0; - } - weeks.push({ weekStart, tokens }); - } - - if (!weeks.length) { container.innerHTML = '
No data
'; return; } - - const maxVal = Math.max(...weeks.map(w => w.tokens), 1); - const barWidth = 10; - const barGap = 3; - const chartHeight = 120; - const chartWidth = weeks.length * (barWidth + barGap); - const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; - - // Month labels - const labels = []; - let lastMonth = -1; - for (let i = 0; i < weeks.length; i++) { - const m = weeks[i].weekStart.getMonth(); - if (m !== lastMonth) { labels.push({ i, label: months[m] }); lastMonth = m; } - } - - const barsHTML = weeks.map((w, i) => { - const h = maxVal > 0 ? (w.tokens / maxVal) * chartHeight : 0; - const x = i * (barWidth + barGap); - return ``; - }).join(''); - - const labelsHTML = labels.map(l => { - const x = l.i * (barWidth + barGap); - return `${l.label}`; - }).join(''); - - container.innerHTML = ` -
- - ${barsHTML} - ${labelsHTML} - - `; - - // Tooltip on hover - const tooltip = container.querySelector('#chart-tooltip'); - container.querySelectorAll('.bar-fill').forEach(bar => { - bar.addEventListener('mouseenter', e => { - tooltip.textContent = bar.dataset.label; - tooltip.classList.add('show'); - }); - bar.addEventListener('mousemove', e => { - positionTooltip(tooltip, e.clientX, e.clientY); - }); - bar.addEventListener('mouseleave', () => tooltip.classList.remove('show')); - }); -} - -export function renderCumulativeChart(container, daily) { - const sorted = [...daily].sort((a, b) => a.day.localeCompare(b.day)); - if (!sorted.length) { container.innerHTML = '
No data
'; return; } - - let cumulative = 0; - const points = sorted.map(d => { cumulative += d.tokens; return { day: d.day, total: cumulative }; }); - const maxVal = points[points.length - 1].total; - - const chartWidth = 700; - const chartHeight = 140; - const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; - - // Scale x by index, y by value - const xScale = (i) => (i / (points.length - 1)) * chartWidth; - const yScale = (v) => chartHeight - (v / maxVal) * chartHeight; - - // Build path - const pathParts = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${xScale(i).toFixed(1)},${yScale(p.total).toFixed(1)}`); - const linePath = pathParts.join(' '); - const areaPath = linePath + ` L${chartWidth},${chartHeight} L0,${chartHeight} Z`; - - // Month labels - const labels = []; - let lastMonth = -1; - for (let i = 0; i < points.length; i++) { - const m = new Date(points[i].day).getMonth(); - if (m !== lastMonth) { labels.push({ x: xScale(i), label: months[m] }); lastMonth = m; } - } - const labelsHTML = labels.map(l => `${l.label}`).join(''); - - // Invisible hover dots for tooltip - const dotsHTML = points.map((p, i) => { - return ``; - }).join(''); - - container.innerHTML = ` -
- - - - ${dotsHTML} - ${labelsHTML} - - `; - - const tooltip = container.querySelector('#chart-tooltip-cum'); - container.querySelectorAll('.cumulative-dot').forEach(dot => { - dot.addEventListener('mouseenter', e => { - tooltip.textContent = dot.dataset.label; - tooltip.classList.add('show'); - }); - dot.addEventListener('mousemove', e => { - positionTooltip(tooltip, e.clientX, e.clientY); - }); - dot.addEventListener('mouseleave', () => tooltip.classList.remove('show')); - }); -} diff --git a/app/src/renderer/js/utils.js b/app/src/renderer/js/utils.js deleted file mode 100644 index 570819c..0000000 --- a/app/src/renderer/js/utils.js +++ /dev/null @@ -1,194 +0,0 @@ -// Utility functions extracted from render.js -// Pure helpers with no side-effects on global state (except formatProjectLabel which reads state). - -import { state } from './state.js'; - -// --- Time / formatting --- - -export function pad2(n) { return String(n).padStart(2, '0'); } - -export function isSameDay(a, b) { - return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); -} - -export 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}`; -} - -export 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'; -} - -export function fmtClockTime(iso) { - const d = new Date(iso); - return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`; -} - -export function fmtSize(bytes) { - if (!bytes) return '-'; - if (bytes < 1024) return bytes + 'B'; - if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'K'; - return (bytes / 1024 / 1024).toFixed(1) + 'M'; -} - -// --- HTML / Markdown --- - -export function escapeHTML(s) { return String(s || '').replace(/&/g, '&').replace(//g, '>'); } - -export 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}`); -} - -export function sanitizeMarkdown(html) { - return html - .replace(//gi, '') - .replace(//gi, '') - .replace(//gi, '') - .replace(/\son\w+="[^"]*"/gi, ''); -} - -export function highlightTextNodes(rootEl, query) { - if (!query) return; - const q = query.toLowerCase(); - const walker = document.createTreeWalker(rootEl, NodeFilter.SHOW_TEXT, null); - const nodes = []; - while (walker.nextNode()) nodes.push(walker.currentNode); - for (const node of nodes) { - const text = node.nodeValue; - if (!text) continue; - const lower = text.toLowerCase(); - if (!lower.includes(q)) continue; - const frag = document.createDocumentFragment(); - let last = 0, i = lower.indexOf(q); - while (i !== -1) { - if (i > last) frag.appendChild(document.createTextNode(text.slice(last, i))); - const mark = document.createElement('mark'); - mark.textContent = text.slice(i, i + q.length); - frag.appendChild(mark); - last = i + q.length; - i = lower.indexOf(q, last); - } - if (last < text.length) frag.appendChild(document.createTextNode(text.slice(last))); - node.parentNode.replaceChild(frag, node); - } -} - -export function renderMarkdown(text, opts = {}) { - if (text == null) return ''; - const html = sanitizeMarkdown(marked.parse(text)); - const cls = opts.variant === 'msg' ? 'markdown-msg' - : opts.variant === 'compact' ? 'markdown-compact' - : 'markdown-body'; - const container = document.createElement('div'); - container.className = cls; - container.innerHTML = html; - if (opts.query) highlightTextNodes(container, opts.query.trim()); - return container.outerHTML; -} - -// --- Duration / tokens / tooltip --- - -export function fmtDuration(ms) { - if (!ms) return '—'; - const s = Math.floor(ms / 1000); - const d = Math.floor(s / 86400); - const h = Math.floor((s % 86400) / 3600); - const m = Math.floor((s % 3600) / 60); - const sec = s % 60; - const parts = []; - if (d) parts.push(`${d}d`); - if (h) parts.push(`${h}h`); - if (m) parts.push(`${m}m`); - if (sec || !parts.length) parts.push(`${sec}s`); - return parts.join(' '); -} - -export function fmtTokens(n) { - if (!n) return '0'; - if (n >= 1_000_000_000) return (n / 1_000_000_000).toFixed(2) + 'B'; - if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'; - if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K'; - return String(n); -} - -export function fmtTooltipDate(isoDay) { - const d = new Date(isoDay + 'T00:00:00'); - const months = ['January','February','March','April','May','June','July','August','September','October','November','December']; - const day = d.getDate(); - const suffix = day === 1 || day === 21 || day === 31 ? 'st' : day === 2 || day === 22 ? 'nd' : day === 3 || day === 23 ? 'rd' : 'th'; - const thisYear = new Date().getFullYear(); - if (d.getFullYear() === thisYear) return `${months[d.getMonth()]} ${day}${suffix}`; - return `${months[d.getMonth()]} ${day}${suffix}, ${d.getFullYear()}`; -} - -export function positionTooltip(el, x, y) { - const pad = 12; - const rect = el.getBoundingClientRect(); - let left = x + pad; - if (left + rect.width > window.innerWidth - pad) left = x - rect.width - pad; - el.style.left = left + 'px'; - el.style.top = (y - 28) + 'px'; -} - -// --- DOM helpers --- - -export const $ = sel => document.querySelector(sel); -export const $$ = sel => Array.from(document.querySelectorAll(sel)); - -export function ensureVisible(el, wrapSel) { - const wrap = $(wrapSel); - if (!wrap || !el) return; - const elRect = el.getBoundingClientRect(); - const wrapRect = wrap.getBoundingClientRect(); - if (elRect.top < wrapRect.top + 30) wrap.scrollTop -= (wrapRect.top + 30 - elRect.top); - else if (elRect.bottom > wrapRect.bottom - 10) wrap.scrollTop += (elRect.bottom - wrapRect.bottom + 10); -} - -// --- Project label --- - -export function formatProjectLabel(slug) { - if (!slug) return '(no project)'; - // Use project_path if available from sessions, otherwise show slug as-is - const session = state.sessions.find(s => s.project === slug && s.project_path); - if (session?.project_path) { - const parts = session.project_path.split('/'); - return parts.slice(-2).join('/'); - } - return slug.replace(/^-/, ''); -} - -// --- Row status --- - -export function dominantRowStatus(m) { - if (m.health === 'broken') return 'broken'; - if (m.health === 'partial') return 'partial'; - if (m.archived) return 'archived'; - return null; -} - -export function statusGlyphHTML(status) { - if (!status) return ''; - const glyphs = { - broken: ``, - partial: ``, - archived: `` - }; - return `${glyphs[status] || ''}`; -} diff --git a/app/src/renderer/src/App.vue b/app/src/renderer/src/App.vue index d889807..a6c2d53 100644 --- a/app/src/renderer/src/App.vue +++ b/app/src/renderer/src/App.vue @@ -1,13 +1,13 @@ - - - - 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 @@ - - - - - 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 @@ - - 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 @@