From b524339d85b888f0014c89346c18f40704e91d1c Mon Sep 17 00:00:00 2001 From: tommy0103 Date: Fri, 12 Jun 2026 22:20:29 +0800 Subject: [PATCH 1/7] feat(app): add Electron desktop UI and evolve memory/retrieval layer Introduce an Electron app with session browser, memory list, and usage views (vanilla JS + Vue scaffolding). On the data layer: add content_type and is_meta to messages for transcript control-plane filtering, introduce FTS5-backed memory recall with safe tokenization, support memory archival via forget() through the renamed --attune runtime, and expose anchors on memory records. --- .gitignore | 1 + README.md | 15 +- SKILL.md | 85 +- app/main.js | 269 + app/package-lock.json | 6858 +++++++++++++++++++++ app/package.json | 41 + app/preload.js | 22 + app/renderer/index.html | 13 + app/renderer/js/app.js | 235 + app/renderer/js/data.js | 380 ++ app/renderer/js/keys.js | 129 + app/renderer/js/memory-list.js | 269 + app/renderer/js/registry.js | 3 + app/renderer/js/render.js | 246 + app/renderer/js/session-list.js | 530 ++ app/renderer/js/sidebar.js | 137 + app/renderer/js/state.js | 34 + app/renderer/js/usage.js | 573 ++ app/renderer/js/utils.js | 194 + app/renderer/src/App.vue | 440 ++ app/renderer/src/components/Sidebar.vue | 308 + app/renderer/src/components/Toolbar.vue | 379 ++ app/renderer/src/data.js | 376 ++ app/renderer/src/main.js | 25 + app/renderer/src/router.js | 65 + app/renderer/src/store.js | 128 + app/renderer/src/utils.js | 163 + app/renderer/src/views/MemoryDetail.vue | 8 + app/renderer/src/views/MemoryList.vue | 736 +++ app/renderer/src/views/SessionDetail.vue | 474 ++ app/renderer/src/views/SessionList.vue | 188 + app/renderer/src/views/SubagentDetail.vue | 8 + app/renderer/src/views/Usage.vue | 770 +++ app/renderer/styles/base.css | 107 + app/renderer/styles/detail.css | 875 +++ app/renderer/styles/list.css | 157 + app/renderer/styles/sidebar.css | 74 + app/renderer/styles/statusbar.css | 29 + app/renderer/styles/toolbar.css | 81 + app/renderer/vite.config.js | 11 + panel.html | 737 +++ references/query-patterns.md | 64 +- references/retrieval-semantics.md | 38 +- references/schema.md | 157 +- scripts/db.mjs | 54 +- scripts/indexer.mjs | 13 +- scripts/query.mjs | 124 +- scripts/runtime.mjs | 12 +- 48 files changed, 16538 insertions(+), 97 deletions(-) create mode 100644 app/main.js create mode 100644 app/package-lock.json create mode 100644 app/package.json create mode 100644 app/preload.js create mode 100644 app/renderer/index.html create mode 100644 app/renderer/js/app.js create mode 100644 app/renderer/js/data.js create mode 100644 app/renderer/js/keys.js create mode 100644 app/renderer/js/memory-list.js create mode 100644 app/renderer/js/registry.js create mode 100644 app/renderer/js/render.js create mode 100644 app/renderer/js/session-list.js create mode 100644 app/renderer/js/sidebar.js create mode 100644 app/renderer/js/state.js create mode 100644 app/renderer/js/usage.js create mode 100644 app/renderer/js/utils.js create mode 100644 app/renderer/src/App.vue create mode 100644 app/renderer/src/components/Sidebar.vue create mode 100644 app/renderer/src/components/Toolbar.vue create mode 100644 app/renderer/src/data.js create mode 100644 app/renderer/src/main.js create mode 100644 app/renderer/src/router.js create mode 100644 app/renderer/src/store.js create mode 100644 app/renderer/src/utils.js create mode 100644 app/renderer/src/views/MemoryDetail.vue create mode 100644 app/renderer/src/views/MemoryList.vue create mode 100644 app/renderer/src/views/SessionDetail.vue create mode 100644 app/renderer/src/views/SessionList.vue create mode 100644 app/renderer/src/views/SubagentDetail.vue create mode 100644 app/renderer/src/views/Usage.vue create mode 100644 app/renderer/styles/base.css create mode 100644 app/renderer/styles/detail.css create mode 100644 app/renderer/styles/list.css create mode 100644 app/renderer/styles/sidebar.css create mode 100644 app/renderer/styles/statusbar.css create mode 100644 app/renderer/styles/toolbar.css create mode 100644 app/renderer/vite.config.js create mode 100644 panel.html diff --git a/.gitignore b/.gitignore index 338e374..4499ae8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ plans/ .skillopt-backups tests/ +node_modules/ diff --git a/README.md b/README.md index 837ee47..750e912 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,14 @@ Reads the JSON result, answers you in natural language When a retrieval produces a memory worth keeping, the agent proposes a markdown memory file. After user approval, it registers that file with the narrow -`runtime.mjs --remember + + +
+ + + diff --git a/app/renderer/js/app.js b/app/renderer/js/app.js new file mode 100644 index 0000000..8c41bfd --- /dev/null +++ b/app/renderer/js/app.js @@ -0,0 +1,235 @@ +// Entry point -- wires data, rendering, and event handlers together. +// No exports; this is the bootstrap module. + +import { loadInitialData } from './data.js'; +import { state, IS_MAC } from './state.js'; +import { + renderAll, + renderMemoryList, + renderSessionList, + setRoute, + setView, + setProject, + enterDetail, + archive, + restore, + setCursor, + navigateToSession, + switchView +} from './render.js'; +import { initKeyboard } from './keys.js'; + +// -- Helpers ---------------------------------------------------------------- + +let searchDebounceTimer = null; + +function debounce(fn, ms) { + return (...args) => { + clearTimeout(searchDebounceTimer); + searchDebounceTimer = setTimeout(() => fn(...args), ms); + }; +} + +// -- Boot ------------------------------------------------------------------- + +document.addEventListener('DOMContentLoaded', async () => { + await loadInitialData(); + initKeyboard(); + + // -- Sidebar navigation (route switching + project filter) ---------------- + + const sidebar = document.querySelector('.sidebar'); + if (sidebar) { + sidebar.addEventListener('click', e => { + const item = e.target.closest('.sidebar-item'); + if (!item) return; + + const route = item.dataset.route; + const project = item.dataset.project; + const view = item.dataset.view; + + if (route) { + setRoute(route); + } else if (view) { + setView(view); + } else if (project !== undefined) { + setProject(project); + } + }); + } + + // -- Sidebar search (filter projects list) -------------------------------- + + const sidebarSearch = document.querySelector('.sidebar-search input'); + if (sidebarSearch) { + sidebarSearch.addEventListener('input', e => { + state.projectSearch = e.target.value; + renderAll(); + }); + } + + // -- Breadcrumb click (navigate back to list) ----------------------------- + + const breadcrumb = document.querySelector('.breadcrumb'); + if (breadcrumb) { + breadcrumb.addEventListener('click', e => { + const crumb = e.target.closest('[data-action]'); + if (!crumb) return; + const action = crumb.dataset.action; + if (action === 'goto-sessions') { state.projectFilter = 'all'; setRoute('sessions'); } + else if (action === 'goto-memory') { state.projectFilter = 'all'; setView('active'); } + else if (action === 'goto-session-detail') { state.subagentId = null; state.subagentDescription = null; switchView(); renderAll(); } + }); + } + + // -- Toolbar search with debounce ----------------------------------------- + + const searchInput = document.getElementById('search'); + if (searchInput) { + const handleSearch = debounce(value => { + state.query = value; + if (state.route === 'sessions') renderSessionList(); + else renderMemoryList(); + }, 200); + + searchInput.addEventListener('input', e => { + handleSearch(e.target.value); + }); + } + + // -- Sort toggle ----------------------------------------------------------- + + const sortToggle = document.querySelector('.sort-group'); + if (sortToggle) { + sortToggle.addEventListener('click', () => { + state.sortDesc = !state.sortDesc; + sortToggle.classList.toggle('desc', state.sortDesc); + sortToggle.classList.toggle('asc', !state.sortDesc); + if (state.route === 'sessions') renderSessionList(); + else renderMemoryList(); + }); + } + + // -- Search messages toggle ------------------------------------------------ + + const searchMsgsToggle = document.querySelector('.filter-toggle'); + if (searchMsgsToggle) { + searchMsgsToggle.addEventListener('click', () => { + state.includeMessageBodies = !state.includeMessageBodies; + searchMsgsToggle.classList.toggle('active', state.includeMessageBodies); + if (state.query) { + if (state.route === 'sessions') renderSessionList(); + else renderMemoryList(); + } + }); + } + + // -- #list click (memory rows: selection, actions, navigation) ------------ + + const list = document.getElementById('list'); + if (list) { + list.addEventListener('click', e => { + // Action buttons (archive/restore) + const action = e.target.closest('.row-action'); + if (action) { + e.stopPropagation(); + const row = action.closest('.row'); + const id = row?.dataset.id; + if (!id) return; + if (action.classList.contains('restore')) { + restore([id]); + } else { + archive([id]); + } + return; + } + + // Checkbox toggling + const checkbox = e.target.closest('.row-checkbox'); + if (checkbox) { + e.stopPropagation(); + const row = checkbox.closest('.row'); + const id = row?.dataset.id; + if (!id) return; + + if (e.shiftKey && state.cursorId) { + // Range select between cursor and clicked + const rows = Array.from(list.querySelectorAll('.row')); + const ids = rows.map(r => r.dataset.id); + const fromIdx = ids.indexOf(state.cursorId); + const toIdx = ids.indexOf(id); + const [lo, hi] = fromIdx < toIdx ? [fromIdx, toIdx] : [toIdx, fromIdx]; + for (let i = lo; i <= hi; i++) { + state.selection.add(ids[i]); + } + } else if (e.metaKey || e.ctrlKey) { + // Toggle single + if (state.selection.has(id)) state.selection.delete(id); + else state.selection.add(id); + } else { + // Simple toggle + if (state.selection.has(id)) state.selection.delete(id); + else state.selection.add(id); + } + setCursor(id); + renderMemoryList(); + return; + } + + // Row click (navigate cursor / open detail) + const row = e.target.closest('.row'); + if (!row) return; + const id = row.dataset.id; + if (!id) return; + + if (e.shiftKey && state.cursorId) { + // Shift-click: range select + const rows = Array.from(list.querySelectorAll('.row')); + const ids = rows.map(r => r.dataset.id); + const fromIdx = ids.indexOf(state.cursorId); + const toIdx = ids.indexOf(id); + const [lo, hi] = fromIdx < toIdx ? [fromIdx, toIdx] : [toIdx, fromIdx]; + for (let i = lo; i <= hi; i++) { + state.selection.add(ids[i]); + } + renderMemoryList(); + } else if ((IS_MAC ? e.metaKey : e.ctrlKey)) { + // Cmd/Ctrl-click: toggle selection + if (state.selection.has(id)) state.selection.delete(id); + else state.selection.add(id); + setCursor(id); + renderMemoryList(); + } else { + // Plain click: move cursor + setCursor(id); + renderMemoryList(); + } + }); + + // -- #list dblclick (open detail) ----------------------------------------- + + list.addEventListener('dblclick', e => { + const row = e.target.closest('.row'); + if (!row) return; + const id = row.dataset.id; + if (id) enterDetail(id); + }); + } + + // -- #session-list click (session rows) ------------------------------------ + + const sessionList = document.getElementById('session-list'); + if (sessionList) { + sessionList.addEventListener('click', e => { + const srow = e.target.closest('.srow'); + if (!srow) return; + const id = srow.dataset.sessionId; + if (id) navigateToSession(id); + }); + } + + // -- Start on memory view ------------------------------------------------- + + setRoute('memory'); + renderAll(); +}); diff --git a/app/renderer/js/data.js b/app/renderer/js/data.js new file mode 100644 index 0000000..81d605d --- /dev/null +++ b/app/renderer/js/data.js @@ -0,0 +1,380 @@ +// 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/renderer/js/keys.js b/app/renderer/js/keys.js new file mode 100644 index 0000000..6b18101 --- /dev/null +++ b/app/renderer/js/keys.js @@ -0,0 +1,129 @@ +// 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/renderer/js/memory-list.js b/app/renderer/js/memory-list.js new file mode 100644 index 0000000..eddbf00 --- /dev/null +++ b/app/renderer/js/memory-list.js @@ -0,0 +1,269 @@ +// 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/renderer/js/registry.js b/app/renderer/js/registry.js new file mode 100644 index 0000000..5fb5f75 --- /dev/null +++ b/app/renderer/js/registry.js @@ -0,0 +1,3 @@ +// Shared registry to break circular dependencies between modules. +const registry = {}; +export default registry; diff --git a/app/renderer/js/render.js b/app/renderer/js/render.js new file mode 100644 index 0000000..00c409e --- /dev/null +++ b/app/renderer/js/render.js @@ -0,0 +1,246 @@ +// 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/renderer/js/session-list.js b/app/renderer/js/session-list.js new file mode 100644 index 0000000..07acf75 --- /dev/null +++ b/app/renderer/js/session-list.js @@ -0,0 +1,530 @@ +// 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/renderer/js/sidebar.js b/app/renderer/js/sidebar.js new file mode 100644 index 0000000..13aca10 --- /dev/null +++ b/app/renderer/js/sidebar.js @@ -0,0 +1,137 @@ +// 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/renderer/js/state.js b/app/renderer/js/state.js new file mode 100644 index 0000000..5588778 --- /dev/null +++ b/app/renderer/js/state.js @@ -0,0 +1,34 @@ +// 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/renderer/js/usage.js b/app/renderer/js/usage.js new file mode 100644 index 0000000..96e9627 --- /dev/null +++ b/app/renderer/js/usage.js @@ -0,0 +1,573 @@ +// 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/renderer/js/utils.js b/app/renderer/js/utils.js new file mode 100644 index 0000000..570819c --- /dev/null +++ b/app/renderer/js/utils.js @@ -0,0 +1,194 @@ +// 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/renderer/src/App.vue b/app/renderer/src/App.vue new file mode 100644 index 0000000..9ffe3a9 --- /dev/null +++ b/app/renderer/src/App.vue @@ -0,0 +1,440 @@ + + + + + + + diff --git a/app/renderer/src/components/Sidebar.vue b/app/renderer/src/components/Sidebar.vue new file mode 100644 index 0000000..51bcdde --- /dev/null +++ b/app/renderer/src/components/Sidebar.vue @@ -0,0 +1,308 @@ + + + + + diff --git a/app/renderer/src/components/Toolbar.vue b/app/renderer/src/components/Toolbar.vue new file mode 100644 index 0000000..bb483c4 --- /dev/null +++ b/app/renderer/src/components/Toolbar.vue @@ -0,0 +1,379 @@ + + + + + diff --git a/app/renderer/src/data.js b/app/renderer/src/data.js new file mode 100644 index 0000000..2745128 --- /dev/null +++ b/app/renderer/src/data.js @@ -0,0 +1,376 @@ +// Data loading layer -- bridges Electron IPC (window.obelisk.*) to reactive store. +// All DB access goes through this module. + +import { markRaw } from 'vue'; +import { state, clearUndo } from './store.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 + 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; + 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; + } + + // 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 { + 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/renderer/src/main.js b/app/renderer/src/main.js new file mode 100644 index 0000000..1fee82f --- /dev/null +++ b/app/renderer/src/main.js @@ -0,0 +1,25 @@ +// Vue 3 application entry point for Obelisk. + +import { createApp } from 'vue'; +import App from './App.vue'; +import router from './router.js'; +import { loadInitialData } from './data.js'; + +// Import all original CSS globally +import '../styles/base.css'; +import '../styles/sidebar.css'; +import '../styles/toolbar.css'; +import '../styles/list.css'; +import '../styles/detail.css'; +import '../styles/statusbar.css'; + +const app = createApp(App); + +app.use(router); + +// Load data before the first render completes +router.isReady().then(() => { + loadInitialData(); +}); + +app.mount('#app'); diff --git a/app/renderer/src/router.js b/app/renderer/src/router.js new file mode 100644 index 0000000..c8fe9cf --- /dev/null +++ b/app/renderer/src/router.js @@ -0,0 +1,65 @@ +// Vue Router configuration for Obelisk. +// Routes map to the main content views; sidebar navigation drives route changes. + +import { createRouter, createWebHashHistory } from 'vue-router'; + +// Lazy-loaded view components (will be created as Vue SFCs later) +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 Usage = () => import('./views/Usage.vue'); + +const routes = [ + { + path: '/sessions', + name: 'SessionList', + component: SessionList + }, + { + path: '/sessions/:id', + name: 'SessionDetail', + component: SessionDetail, + props: true, + meta: { keepAlive: true } + }, + { + path: '/sessions/:id/agent/:agentId', + name: 'SubagentDetail', + component: SubagentDetail, + props: true + }, + { + path: '/memory', + name: 'MemoryList', + component: MemoryList + }, + { + path: '/memory/:id', + name: 'MemoryDetail', + component: MemoryDetail, + props: true + }, + { + path: '/usage', + name: 'Usage', + component: Usage + }, + { + path: '/', + redirect: '/memory' + }, + { + // Catch-all redirect + path: '/:pathMatch(.*)*', + redirect: '/memory' + } +]; + +const router = createRouter({ + history: createWebHashHistory(), + routes +}); + +export default router; diff --git a/app/renderer/src/store.js b/app/renderer/src/store.js new file mode 100644 index 0000000..d828cb7 --- /dev/null +++ b/app/renderer/src/store.js @@ -0,0 +1,128 @@ +// Reactive store -- Vue 3 reactive() replaces the plain object from state.js. +// All state fields are ported; action functions mutate the reactive state. + +import { reactive, markRaw } from 'vue'; + +export const state = reactive({ + memories: [], + sessions: [], + projects: [], + stats: {}, + 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: markRaw(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 = ``; + +// --- Action functions --- + +export function setRoute(route) { + state.route = route; + state.mode = 'list'; + state.detailId = null; + state.cursorId = null; + state.selection = markRaw(new Set()); + 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()); + state.projectFilter = 'all'; +} + +export function setProject(p) { + state.projectFilter = p; + state.cursorId = null; + state.selection = markRaw(new Set()); + state.mode = 'list'; + state.detailId = null; +} + +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; +} + +export function setProjectSearch(q) { + state.projectSearch = 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/renderer/src/utils.js b/app/renderer/src/utils.js new file mode 100644 index 0000000..b0d2139 --- /dev/null +++ b/app/renderer/src/utils.js @@ -0,0 +1,163 @@ +// Utility functions ported from the vanilla JS utils.js. +// Pure helpers with no side-effects on global state (except formatProjectLabel which reads store). + +import { state } from './store.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 ''; + // marked is loaded globally via CDN in index.html + const html = sanitizeMarkdown(window.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'; +} + +// --- Project label --- + +export 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(/^-/, ''); +} diff --git a/app/renderer/src/views/MemoryDetail.vue b/app/renderer/src/views/MemoryDetail.vue new file mode 100644 index 0000000..5887206 --- /dev/null +++ b/app/renderer/src/views/MemoryDetail.vue @@ -0,0 +1,8 @@ + + + diff --git a/app/renderer/src/views/MemoryList.vue b/app/renderer/src/views/MemoryList.vue new file mode 100644 index 0000000..07462ff --- /dev/null +++ b/app/renderer/src/views/MemoryList.vue @@ -0,0 +1,736 @@ + + + + + diff --git a/app/renderer/src/views/SessionDetail.vue b/app/renderer/src/views/SessionDetail.vue new file mode 100644 index 0000000..4ce20ba --- /dev/null +++ b/app/renderer/src/views/SessionDetail.vue @@ -0,0 +1,474 @@ + + + + + diff --git a/app/renderer/src/views/SessionList.vue b/app/renderer/src/views/SessionList.vue new file mode 100644 index 0000000..5049109 --- /dev/null +++ b/app/renderer/src/views/SessionList.vue @@ -0,0 +1,188 @@ + + + + + diff --git a/app/renderer/src/views/SubagentDetail.vue b/app/renderer/src/views/SubagentDetail.vue new file mode 100644 index 0000000..04f3281 --- /dev/null +++ b/app/renderer/src/views/SubagentDetail.vue @@ -0,0 +1,8 @@ + + + diff --git a/app/renderer/src/views/Usage.vue b/app/renderer/src/views/Usage.vue new file mode 100644 index 0000000..c953c09 --- /dev/null +++ b/app/renderer/src/views/Usage.vue @@ -0,0 +1,770 @@ + + + + + diff --git a/app/renderer/styles/base.css b/app/renderer/styles/base.css new file mode 100644 index 0000000..5217f48 --- /dev/null +++ b/app/renderer/styles/base.css @@ -0,0 +1,107 @@ +:root { + --bg: #0a0b14; + --bg-2: #11131f; + --surface: rgba(255,255,255,0.03); + --surface-strong: rgba(255,255,255,0.06); + --surface-hi: rgba(255,255,255,0.09); + --fg: rgba(255,255,255,0.92); + --fg-2: rgba(255,255,255,0.72); + --muted: rgba(255,255,255,0.48); + --muted-2: rgba(255,255,255,0.28); + --edge-hi: rgba(255,255,255,0.08); + --edge-lo: rgba(0,0,0,0.35); + --hairline: rgba(255,255,255,0.05); + --hairline-strong: rgba(255,255,255,0.08); + --accent: #a78bfa; + --accent-2: #c4b5fd; + --accent-glow: rgba(167,139,250,0.35); + --accent-soft: rgba(167,139,250,0.12); + --danger: #f87171; + --danger-soft: rgba(248,113,113,0.12); + --warn: #fbbf24; + --warn-soft: rgba(251,191,36,0.14); + --workflow: #f59e0b; + --workflow-soft: rgba(245,158,11,0.12); + --workflow-strong: rgba(245,158,11,0.28); + --user-bubble: rgba(167,139,250,0.08); + --user-bubble-border: rgba(167,139,250,0.18); + --asst-bubble: rgba(255,255,255,0.025); + --asst-bubble-border: rgba(255,255,255,0.06); + --font-sans: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', system-ui, sans-serif; + --font-mono: ui-monospace, 'JetBrains Mono', 'IBM Plex Mono', 'SF Mono', Menlo, monospace; + --text-xs: 11px; + --text-sm: 12px; + --text-base: 13px; + --text-md: 14px; + --row-h: 88px; + --row-h-session: 64px; + --row-h-compact: 28px; + --col-sidebar: 220px; +} +* { box-sizing: border-box; margin: 0; padding: 0; } +html, body { height: 100%; overflow: hidden; } +body { + color: var(--fg); + font: var(--text-base)/1.4 var(--font-sans); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + background-color: var(--bg); + background-image: + radial-gradient(80% 60% at 100% 0%, rgba(236, 72, 153, 0.14), transparent 55%), + radial-gradient(60% 50% at 50% 40%, rgba(167, 139, 250, 0.12), transparent 60%), + radial-gradient(70% 60% at 0% 100%, rgba(99, 102, 241, 0.16), transparent 60%), + linear-gradient(to bottom, var(--bg) 0%, var(--bg-2) 100%); +} +body::before { + content: ''; + position: fixed; inset: 0; + pointer-events: none; z-index: 1; + opacity: 0.3; + background-image: url("data:image/svg+xml;utf8,"); + mix-blend-mode: overlay; +} +button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; padding: 0; } +button:disabled { cursor: not-allowed; } +input { font: inherit; color: inherit; } +::selection { background: var(--accent-soft); color: var(--fg); } +::-webkit-scrollbar { width: 8px; height: 8px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 4px; border: 2px solid transparent; background-clip: padding-box; } +::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.16); background-clip: padding-box; border: 2px solid transparent; } + +.titlebar { + height: 32px; width: 100%; + -webkit-app-region: drag; + background: rgba(0,0,0,0.15); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-bottom: 1px solid var(--hairline); + flex-shrink: 0; z-index: 100; + display: flex; align-items: center; justify-content: center; + padding: 0 16px 0 78px; +} +.titlebar-text { + font-size: var(--text-sm); + color: var(--muted); + font-weight: 500; + letter-spacing: -0.005em; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + max-width: 100%; user-select: none; pointer-events: none; + display: inline-block; +} +.titlebar-text .app-name { color: var(--fg-2); font-weight: 600; } +.titlebar-text .sep { margin: 0 6px; color: var(--muted-2); } +.titlebar-text .scope { color: var(--muted); } +.titlebar-text .scope-leaf { color: var(--fg-2); font-family: var(--font-mono); font-size: 11.5px; } + +button, input, .row, .sidebar-item, .toolbar-btn, +.row-action, .row-checkbox, .crumb, .provenance-link, +.banner-action, .source-toggle, .anchor-link, +.session-link, .msg-tool, .toolcall-toggle, +.agent-indicator, .agent-row, .filter-toggle, +.summary-toggle { + -webkit-app-region: no-drag; +} + +.app { position: relative; z-index: 2; height: 100vh; display: flex; flex-direction: column; } +.columns { flex: 1; display: grid; grid-template-columns: var(--col-sidebar) 1fr; min-height: 0; } diff --git a/app/renderer/styles/detail.css b/app/renderer/styles/detail.css new file mode 100644 index 0000000..324d7ec --- /dev/null +++ b/app/renderer/styles/detail.css @@ -0,0 +1,875 @@ +.detail { max-width: 720px; margin: 0 auto; padding: 32px 32px 60px; } +.detail-wide { max-width: 860px; margin: 0 auto; padding: 28px 32px 60px; } + +/* Usage page */ +.usage-wrap { flex: 1; overflow-y: auto; min-height: 0; } +.usage-header { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 24px; +} +.usage-title { font-size: 16px; font-weight: 600; color: var(--fg); letter-spacing: -0.01em; } +.usage-subtitle { font-size: 13px; color: var(--muted); } + +.usage-view-tabs { display: flex; gap: 0; } +.usage-tab { + padding: 5px 12px; font-size: 12px; font-family: var(--font-mono); + color: var(--muted); background: transparent; + border: 1px solid var(--hairline); cursor: pointer; + transition: all 0.1s; +} +.usage-tab:first-child { border-radius: 4px 0 0 4px; } +.usage-tab:last-child { border-radius: 0 4px 4px 0; } +.usage-tab:not(:first-child) { border-left: 0; } +.usage-tab:hover { color: var(--fg-2); background: var(--surface-strong); } +.usage-tab.active { color: var(--fg); background: var(--accent-soft); border-color: var(--accent-soft); } + +.usage-stats { + display: flex; gap: 0; margin-bottom: 32px; + border-radius: 8px; + background: var(--surface); border: 1px solid var(--hairline); + overflow: hidden; +} +.usage-stat { + flex: 1; display: flex; flex-direction: column; align-items: center; + gap: 4px; padding: 16px 12px; + border-right: 1px solid var(--hairline); +} +.usage-stat:last-child { border-right: 0; } +.usage-stat-value { font-size: 18px; font-weight: 600; color: var(--fg); font-variant-numeric: tabular-nums; } +.usage-stat-label { font-size: 10.5px; color: var(--muted); font-family: var(--font-mono); text-align: center; } + +.heatmap-container { margin-top: 8px; } +.heatmap { display: block; width: 100%; height: auto; } +.heatmap-cell { transition: opacity 0.08s; } +.heatmap-cell.level-0 { fill: var(--surface-strong); } +.heatmap-cell.level-1 { fill: rgba(99, 102, 241, 0.3); } +.heatmap-cell.level-2 { fill: rgba(99, 102, 241, 0.5); } +.heatmap-cell.level-3 { fill: rgba(139, 92, 246, 0.7); } +.heatmap-cell.level-4 { fill: rgba(168, 85, 247, 0.9); } +.heatmap-cell:hover { opacity: 0.7; } +.heatmap-cell.selected { stroke: var(--fg); stroke-width: 1.5; } +.heatmap-month { font-size: 10px; fill: var(--muted-2); font-family: var(--font-mono); } + +/* Day sessions panel (below heatmap on click) */ +.day-sessions { margin-top: 24px; } +.day-sessions-header { + font-size: 14px; font-weight: 600; color: var(--fg); + margin-top: 28px; margin-bottom: 16px; padding-bottom: 10px; + border-bottom: 1px solid var(--hairline); +} +.day-sessions-header:first-child { margin-top: 0; } + +.day-activity-timeline { + display: flex; flex-direction: column; gap: 20px; + padding-left: 16px; border-left: 2px solid var(--hairline); +} + +.activity-group { position: relative; } +.activity-group-header { + display: flex; align-items: center; gap: 10px; + margin-bottom: 8px; font-size: 14px; color: var(--fg); + font-weight: 500; +} +.activity-icon { + width: 24px; height: 24px; border-radius: 50%; + display: inline-flex; align-items: center; justify-content: center; + font-size: 12px; flex-shrink: 0; + margin-left: -28px; + border: 2px solid var(--bg); +} +.activity-icon.workspace { background: rgba(245,158,11,0.2); color: #f59e0b; } +.activity-icon.new { background: var(--accent-soft); color: var(--accent-2); } +.activity-icon.continued { background: var(--surface-strong); color: var(--muted); } + +.activity-group-title { font-size: 13px; } +.activity-group.continued .activity-group-title { color: var(--muted); } + +.activity-group-items { display: flex; flex-direction: column; gap: 3px; padding-left: 6px; } +.activity-item { + display: flex; align-items: center; justify-content: space-between; + padding: 8px 12px; border-radius: 5px; + background: transparent; border: 0; + cursor: pointer; transition: background 0.08s; + text-align: left; width: 100%; + font: inherit; color: inherit; +} +.activity-item:hover { background: var(--surface-strong); } +.activity-item-name { font-size: 13px; color: var(--accent-2); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.activity-item-name .activity-item-project { color: var(--muted); font-weight: 400; margin-right: 4px; } +.activity-item:hover .activity-item-name { text-decoration: underline; text-underline-offset: 2px; } +.activity-item-meta { font-size: 11px; color: var(--muted); font-family: var(--font-mono); flex-shrink: 0; } + +.activity-group.continued .activity-item-name { color: var(--fg-2); } + +.show-more-btn { + display: block; width: 100%; margin-top: 20px; + padding: 8px; border-radius: 4px; + background: var(--accent-soft); border: 1px solid rgba(167,139,250,0.2); + color: var(--accent-2); font-size: 12px; font-family: var(--font-mono); + cursor: pointer; transition: all 0.1s; text-align: center; +} +.show-more-btn:hover { background: rgba(167,139,250,0.2); border-color: var(--accent); } + +.heatmap-legend { + display: flex; align-items: center; gap: 6px; + margin-top: 12px; justify-content: flex-end; +} +.heatmap-legend-label { font-size: 10px; color: var(--muted-2); font-family: var(--font-mono); } + +/* Chart container (weekly / cumulative) */ +.chart-container { margin-top: 8px; overflow-x: auto; } +.chart-container svg { display: block; width: 100%; max-height: 160px; } + +.bar-fill { fill: var(--accent); opacity: 0.8; transition: opacity 0.08s; } +.bar-fill:hover { opacity: 1; } + +.cumulative-area { fill: rgba(99, 102, 241, 0.12); } +.cumulative-line { fill: none; stroke: var(--accent); stroke-width: 1.5; } +.cumulative-dot { fill: var(--accent); opacity: 0; transition: opacity 0.08s; } +.cumulative-dot:hover { opacity: 1; } + +/* Chart tooltip */ +.chart-tooltip { + position: fixed; z-index: 200; + padding: 5px 10px; border-radius: 4px; + background: rgba(30, 35, 50, 0.95); + border: 1px solid var(--hairline-strong); + color: var(--fg-2); + font-family: var(--font-mono); font-size: 11px; + pointer-events: none; opacity: 0; + white-space: nowrap; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} +.chart-tooltip.show { opacity: 1; } + +/* Session progress bar */ +.session-progress { + position: sticky; top: 0; z-index: 10; + height: 2px; background: var(--hairline); + margin: 0 -32px 0; + flex-shrink: 0; +} +.session-progress-fill { + height: 100%; background: var(--accent); + box-shadow: 0 0 6px var(--accent-glow); + transition: width 0.15s ease-out; + width: 0%; +} + +.detail-banner { + display: flex; align-items: flex-start; gap: 10px; + padding: 10px 12px; border-radius: 6px; margin-bottom: 18px; + font-size: var(--text-base); line-height: 1.5; +} +.detail-banner.broken { background: var(--danger-soft); border: 1px solid rgba(248,113,113,0.25); color: var(--fg); } +.detail-banner.partial { background: var(--warn-soft); border: 1px solid rgba(251,191,36,0.25); color: var(--fg); } +.detail-banner-icon { width: 16px; height: 16px; flex-shrink: 0; margin-top: 1px; } +.detail-banner.broken .detail-banner-icon { color: var(--danger); } +.detail-banner.partial .detail-banner-icon { color: var(--warn); } +.detail-banner-body { flex: 1; min-width: 0; } +.detail-banner-body strong { font-weight: 600; } +.detail-banner-body ul { margin-top: 4px; padding-left: 16px; color: var(--fg-2); font-size: var(--text-sm); } +.detail-banner-body li { list-style: disc; margin: 2px 0; } +.detail-banner-actions { display: flex; gap: 6px; margin-top: 8px; } +.banner-action { + height: 24px; padding: 0 10px; border-radius: 4px; + border: 1px solid var(--hairline-strong); + background: var(--surface); + color: var(--fg-2); font-size: var(--text-sm); + transition: all 0.1s; +} +.banner-action:hover { background: var(--surface-strong); color: var(--fg); } +.banner-action.danger { color: var(--danger); border-color: rgba(248,113,113,0.3); } +.banner-action.danger:hover { background: var(--danger-soft); } + +.detail-header { margin-bottom: 24px; } +.detail-eyebrow { + display: flex; align-items: center; gap: 6px; + font-size: 11px; color: var(--muted); + margin-bottom: 14px; flex-wrap: wrap; +} +.detail-eyebrow .project-icon { width: 13px; height: 13px; color: var(--muted); } +.detail-eyebrow .project-name { color: var(--fg-2); font-weight: 500; } +.detail-eyebrow .project-path { font-family: var(--font-mono); color: var(--muted); } +.detail-eyebrow .sep { color: var(--muted-2); margin: 0 2px; } +.detail-eyebrow .archived-tag { + color: var(--accent-2); + display: inline-flex; align-items: center; gap: 5px; + margin-left: auto; +} +.detail-eyebrow .archived-tag::before { + content: ''; width: 6px; height: 6px; border-radius: 50%; + background: var(--accent); box-shadow: 0 0 6px var(--accent-glow); +} +.detail-path { + font-family: var(--font-mono); font-size: 17px; font-weight: 500; + color: var(--fg); line-height: 1.5; + word-break: break-all; margin-bottom: 16px; +} +.detail-summary { font-size: var(--text-md); color: var(--fg-2); line-height: 1.6; margin-bottom: 16px; } +.detail-meta { + display: flex; align-items: center; gap: 8px; flex-wrap: wrap; + font-family: var(--font-mono); font-size: var(--text-sm); + color: var(--muted); font-variant-numeric: tabular-nums; + padding-bottom: 16px; border-bottom: 1px solid var(--hairline); +} +.detail-meta .dot { width: 2px; height: 2px; background: var(--muted-2); border-radius: 50%; flex-shrink: 0; } + +.session-link { + color: var(--accent-2); border: 0; background: transparent; + padding: 2px 5px; margin: -2px 0; border-radius: 3px; + font: inherit; cursor: pointer; transition: all 0.1s; + text-decoration: underline; text-decoration-color: var(--accent-soft); + text-underline-offset: 3px; + display: inline-flex; align-items: center; gap: 5px; +} +.session-link:hover { background: var(--accent-soft); color: var(--accent-2); text-decoration-color: var(--accent-2); } +.session-link svg { width: 11px; height: 11px; } + +.markdown-section { margin: 28px 0 8px; } +.markdown-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; } +.markdown-toolbar-label { + font-size: 10.5px; color: var(--muted); + font-weight: 500; letter-spacing: 0.04em; flex: 1; +} +.source-toggle { + height: 22px; padding: 0 8px; border-radius: 4px; + border: 1px solid var(--hairline-strong); background: var(--surface); + color: var(--muted); font-size: var(--text-sm); + transition: all 0.1s; +} +.source-toggle:hover { background: var(--surface-strong); color: var(--fg-2); } +.source-toggle.active { background: var(--accent-soft); color: var(--accent-2); border-color: var(--accent-soft); } + +.markdown-body { font-size: var(--text-md); line-height: 1.65; color: var(--fg); word-wrap: break-word; } +.markdown-body h1, .markdown-body h2, .markdown-body h3 { + font-weight: 600; letter-spacing: -0.01em; + margin: 1.5em 0 0.5em; line-height: 1.3; +} +.markdown-body h1:first-child, .markdown-body h2:first-child, .markdown-body h3:first-child { margin-top: 0; } +.markdown-body h1 { font-size: 20px; } +.markdown-body h2 { font-size: 17px; } +.markdown-body h3 { font-size: 15px; } +.markdown-body p { margin: 0.6em 0; } +.markdown-body ul, .markdown-body ol { margin: 0.6em 0; padding-left: 24px; } +.markdown-body li { margin: 0.2em 0; } +.markdown-body code { + font-family: var(--font-mono); font-size: 12.5px; + background: rgba(255,255,255,0.06); padding: 1px 5px; + border-radius: 3px; color: var(--accent-2); +} +.markdown-body pre { + background: rgba(0,0,0,0.4); + border: 1px solid var(--hairline); + border-radius: 6px; padding: 12px 14px; + overflow-x: auto; margin: 0.8em 0; +} +.markdown-body pre code { background: transparent; padding: 0; color: var(--fg-2); font-size: 12px; line-height: 1.55; } +.markdown-body blockquote { + margin: 0.8em 0; padding: 0 0 0 14px; + border-left: 2px solid var(--accent-soft); + color: var(--fg-2); font-style: italic; +} +.markdown-body a { color: var(--accent-2); text-decoration: underline; text-underline-offset: 2px; } +.markdown-body hr { border: 0; border-top: 1px solid var(--hairline); margin: 1.5em 0; } +.markdown-body table { border-collapse: collapse; margin: 0.8em 0; font-size: 12.5px; } +.markdown-body th, .markdown-body td { border: 1px solid var(--hairline); padding: 6px 10px; text-align: left; } +.markdown-body th { background: rgba(255,255,255,0.04); font-weight: 600; } +.markdown-source { + background: rgba(0,0,0,0.3); border: 1px solid var(--hairline); + border-radius: 6px; padding: 14px 16px; + font-family: var(--font-mono); font-size: 12px; line-height: 1.55; + color: var(--fg-2); white-space: pre-wrap; word-wrap: break-word; +} + +.markdown-compact { font-size: var(--text-sm); line-height: 1.55; color: var(--fg-2); word-wrap: break-word; } +.markdown-compact h1, .markdown-compact h2, .markdown-compact h3 { + font-weight: 600; letter-spacing: -0.01em; + margin: 1em 0 0.4em; line-height: 1.3; color: var(--fg); +} +.markdown-compact h1:first-child, .markdown-compact h2:first-child, .markdown-compact h3:first-child { margin-top: 0; } +.markdown-compact h1 { font-size: var(--text-md); } +.markdown-compact h2 { font-size: var(--text-base); } +.markdown-compact h3 { font-size: var(--text-sm); } +.markdown-compact p { margin: 0.5em 0; } +.markdown-compact p:first-child { margin-top: 0; } +.markdown-compact p:last-child { margin-bottom: 0; } +.markdown-compact ul, .markdown-compact ol { margin: 0.5em 0; padding-left: 20px; } +.markdown-compact li { margin: 0.15em 0; } +.markdown-compact code { + font-family: var(--font-mono); font-size: 11.5px; + background: rgba(255,255,255,0.06); padding: 1px 4px; + border-radius: 3px; color: var(--accent-2); +} +.markdown-compact pre { + background: rgba(0,0,0,0.4); border: 1px solid var(--hairline); + border-radius: 4px; padding: 8px 10px; + overflow-x: auto; margin: 0.6em 0; +} +.markdown-compact pre code { background: transparent; padding: 0; color: var(--fg-2); font-size: 11px; line-height: 1.5; } +.markdown-compact blockquote { + margin: 0.6em 0; padding: 0 0 0 12px; + border-left: 2px solid var(--accent-soft); + color: var(--muted); font-style: italic; +} +.markdown-compact strong { color: var(--fg); font-weight: 600; } +.markdown-compact a { color: var(--accent-2); text-decoration: underline; text-underline-offset: 2px; } +.markdown-compact table { border-collapse: collapse; margin: 0.6em 0; font-size: 11.5px; } +.markdown-compact th, .markdown-compact td { border: 1px solid var(--hairline); padding: 4px 8px; text-align: left; } +.markdown-compact th { background: rgba(255,255,255,0.04); font-weight: 600; } +.markdown-compact mark { background: var(--accent-soft); color: var(--accent-2); padding: 0 2px; border-radius: 2px; } + +.markdown-msg { font-size: var(--text-base); line-height: 1.6; color: var(--fg); word-wrap: break-word; } +.markdown-msg h1, .markdown-msg h2, .markdown-msg h3 { + font-weight: 600; letter-spacing: -0.01em; + margin: 1em 0 0.4em; line-height: 1.3; +} +.markdown-msg h1:first-child, .markdown-msg h2:first-child, .markdown-msg h3:first-child { margin-top: 0; } +.markdown-msg h1 { font-size: 16px; } +.markdown-msg h2 { font-size: 15px; } +.markdown-msg h3 { font-size: var(--text-md); } +.markdown-msg p { margin: 0.5em 0; } +.markdown-msg p:first-child { margin-top: 0; } +.markdown-msg p:last-child { margin-bottom: 0; } +.markdown-msg ul, .markdown-msg ol { margin: 0.5em 0; padding-left: 22px; } +.markdown-msg li { margin: 0.18em 0; } +.markdown-msg code { + font-family: var(--font-mono); font-size: 12px; + background: rgba(255,255,255,0.06); padding: 1px 5px; + border-radius: 3px; color: var(--accent-2); +} +.markdown-msg pre { + background: rgba(0,0,0,0.4); border: 1px solid var(--hairline); + border-radius: 5px; padding: 10px 12px; + overflow-x: auto; margin: 0.7em 0; +} +.markdown-msg pre code { background: transparent; padding: 0; color: var(--fg-2); font-size: 11.5px; line-height: 1.55; } +.markdown-msg blockquote { + margin: 0.6em 0; padding: 0 0 0 12px; + border-left: 2px solid var(--accent-soft); + color: var(--fg-2); font-style: italic; +} +.markdown-msg strong { font-weight: 600; } +.markdown-msg a { color: var(--accent-2); text-decoration: underline; text-underline-offset: 2px; } +.markdown-msg table { border-collapse: collapse; margin: 0.6em 0; font-size: 12px; } +.markdown-msg th, .markdown-msg td { border: 1px solid var(--hairline); padding: 5px 9px; text-align: left; } +.markdown-msg th { background: rgba(255,255,255,0.04); font-weight: 600; } +.markdown-msg mark { background: var(--accent-soft); color: var(--accent-2); padding: 0 2px; border-radius: 2px; } + +.detail-section-divider { + display: flex; align-items: center; gap: 10px; + margin: 32px 0 14px; color: var(--muted); + font-size: 10.5px; font-weight: 500; letter-spacing: 0.04em; +} +.detail-section-divider::after { content: ''; flex: 1; height: 1px; background: var(--hairline); } +.detail-section-divider .count { font-family: var(--font-mono); color: var(--fg-2); } +.anchor-list { display: flex; flex-direction: column; gap: 4px; } +.anchor-link { + display: flex; align-items: center; gap: 8px; + padding: 6px 10px; border-radius: 4px; + color: var(--fg-2); font-family: var(--font-mono); font-size: 12px; + transition: all 0.1s; cursor: pointer; + text-align: left; border: 0; background: transparent; width: 100%; +} +.anchor-link:hover { background: var(--surface-strong); color: var(--fg); } +.anchor-link .anchor-icon { width: 12px; height: 12px; color: var(--muted); flex-shrink: 0; } +.anchor-link:hover .anchor-icon { color: var(--accent-2); } +.anchor-link .anchor-path { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.anchor-link .anchor-line { color: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; } +.anchor-link:disabled { + color: var(--muted-2); + text-decoration: line-through; text-decoration-color: var(--danger); text-decoration-thickness: 1px; + cursor: not-allowed; +} +.anchor-link:disabled .anchor-icon { color: var(--danger); } +.anchor-link:disabled:hover { background: transparent; color: var(--muted-2); } + +.detail-actions { margin-top: 32px; display: flex; justify-content: flex-end; gap: 8px; } +.detail-actions .btn { + height: 30px; padding: 0 14px; border-radius: 6px; + font-size: var(--text-base); font-weight: 500; + transition: all 0.1s; + display: inline-flex; align-items: center; gap: 8px; + border: 1px solid var(--hairline-strong); + color: var(--fg-2); background: var(--surface); +} +.detail-actions .btn:hover { background: var(--surface-strong); color: var(--fg); } +.detail-actions .btn.danger { color: var(--danger); } +.detail-actions .btn.danger:hover { background: var(--danger-soft); border-color: rgba(248,113,113,0.3); } +.detail-actions .btn.primary { color: var(--accent-2); } +.detail-actions .btn.primary:hover { background: var(--accent-soft); border-color: var(--accent-soft); color: var(--fg); } +.detail-actions .btn .kbd { + font-family: var(--font-mono); font-size: 10px; color: var(--muted-2); + padding: 0 4px; border: 1px solid var(--hairline); border-radius: 3px; + line-height: 1.4; +} + +.session-header { + margin-bottom: 28px; padding-bottom: 20px; + border-bottom: 1px solid var(--hairline); +} +.session-eyebrow { + display: flex; align-items: center; gap: 6px; + font-size: 11px; color: var(--muted); + margin-bottom: 12px; flex-wrap: wrap; +} +.session-eyebrow .project-icon { width: 13px; height: 13px; } +.session-eyebrow .project-name { color: var(--fg-2); font-weight: 500; } +.session-eyebrow .project-path { font-family: var(--font-mono); color: var(--muted); } +.session-eyebrow .sep { color: var(--muted-2); margin: 0 2px; } +.session-title { + font-size: 22px; font-weight: 600; color: var(--fg); + line-height: 1.3; margin-bottom: 14px; letter-spacing: -0.01em; +} +.session-meta-inline { + display: flex; align-items: center; gap: 10px; + font-family: var(--font-mono); font-size: var(--text-sm); + color: var(--muted); font-variant-numeric: tabular-nums; + flex-wrap: wrap; +} +.session-meta-inline .dot { width: 2px; height: 2px; background: var(--muted-2); border-radius: 50%; flex-shrink: 0; } + +.timeline { display: flex; flex-direction: column; gap: 14px; } +.msg { + border-radius: 8px; padding: 12px 14px; + border: 1px solid; position: relative; + transition: border-color 0.6s ease-out, box-shadow 0.6s ease-out; +} +.msg.user { background: var(--user-bubble); border-color: var(--user-bubble-border); } +.msg.assistant { background: var(--asst-bubble); border-color: var(--asst-bubble-border); } +.msg.is-focused { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent), 0 0 22px var(--accent-glow); +} +.msg-head { + display: flex; align-items: center; gap: 8px; + font-size: 11px; color: var(--muted); + margin-bottom: 8px; font-family: var(--font-mono); +} +.msg-head .role { + font-weight: 600; color: var(--fg-2); + text-transform: uppercase; font-size: 10px; letter-spacing: 0.06em; +} +.msg.user .msg-head .role { color: var(--accent-2); } +.msg-head .when { margin-left: auto; color: var(--muted); font-variant-numeric: tabular-nums; } +.msg-text { + font-size: var(--text-base); line-height: 1.55; color: var(--fg); + white-space: pre-wrap; word-wrap: break-word; +} +.msg-text.empty-text { color: var(--muted-2); font-style: italic; } +.msg-text mark { background: var(--accent-soft); color: var(--accent-2); padding: 0 2px; border-radius: 2px; } + +.msg-summary { + margin-top: 12px; + border: 1px solid var(--hairline); + border-left: 3px solid var(--accent-soft); + border-radius: 5px; + background: rgba(167,139,250,0.04); + overflow: hidden; +} +.summary-toggle { + display: flex; align-items: center; gap: 8px; + width: 100%; padding: 7px 12px; + cursor: pointer; transition: background 0.08s; + text-align: left; border: 0; background: transparent; + color: inherit; font: inherit; +} +.summary-toggle:hover { background: rgba(167,139,250,0.06); } +.summary-toggle .chevron { + width: 8px; height: 8px; color: var(--muted); + transition: transform 0.15s; flex-shrink: 0; +} +.msg-summary.open .summary-toggle .chevron { transform: rotate(90deg); color: var(--accent-2); } +.summary-toggle .label { + font-family: var(--font-mono); font-size: 10.5px; + color: var(--accent-2); font-weight: 600; + text-transform: uppercase; letter-spacing: 0.05em; +} +.summary-toggle .source { font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); margin-left: 4px; } +.summary-body { + display: none; padding: 8px 14px 12px; + border-top: 1px solid var(--hairline); +} +.msg-summary.open .summary-body { display: block; } + +.msg-tools { margin-top: 10px; display: flex; flex-direction: column; gap: 5px; } +.msg-tool { + border: 1px solid var(--hairline); border-radius: 5px; + background: rgba(0,0,0,0.2); + overflow: hidden; transition: border-color 0.1s; +} +.msg-tool.is-error { border-color: rgba(248,113,113,0.3); background: var(--danger-soft); } +.toolcall-toggle { + display: flex; align-items: center; gap: 8px; + width: 100%; padding: 6px 10px; + cursor: pointer; transition: background 0.08s; + text-align: left; border: 0; background: transparent; + color: inherit; font: inherit; +} +.toolcall-toggle:hover { background: rgba(255,255,255,0.03); } +.toolcall-toggle .chevron { + width: 8px; height: 8px; color: var(--muted); + transition: transform 0.15s; flex-shrink: 0; +} +.msg-tool.open .toolcall-toggle .chevron { transform: rotate(90deg); color: var(--accent-2); } +.toolcall-toggle .tool-name { + font-family: var(--font-mono); font-size: 11px; + color: var(--accent-2); font-weight: 600; flex-shrink: 0; +} +.msg-tool.is-error .toolcall-toggle .tool-name { color: var(--danger); } +.toolcall-toggle .tool-arg { + font-family: var(--font-mono); font-size: 11px; color: var(--fg-2); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + flex: 1; min-width: 0; +} +.toolcall-toggle .tool-error { + font-size: 10px; color: var(--danger); + padding: 1px 6px; background: rgba(248,113,113,0.18); border-radius: 3px; + flex-shrink: 0; text-transform: uppercase; + letter-spacing: 0.04em; font-weight: 500; +} +.toolcall-body { + display: none; border-top: 1px solid var(--hairline); + background: rgba(0,0,0,0.35); + padding: 10px 12px; max-height: 300px; overflow: auto; +} +.msg-tool.open .toolcall-body { display: block; } +.toolcall-body .tc-section { + font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); + letter-spacing: 0.04em; text-transform: uppercase; + margin: 4px 0 4px; font-weight: 500; +} +.toolcall-body .tc-section:first-child { margin-top: 0; } +.toolcall-body pre { + font-family: var(--font-mono); font-size: 11.5px; line-height: 1.5; + color: var(--fg-2); white-space: pre-wrap; word-wrap: break-word; + margin-bottom: 8px; +} + +.tc-subagent { + margin-top: 8px; padding: 8px 10px; + border: 1px solid var(--workflow-soft); + border-left: 3px solid var(--workflow); + border-radius: 4px; background: var(--workflow-soft); +} +.tc-subagent-head { + display: flex; align-items: center; gap: 8px; + font-family: var(--font-mono); font-size: 10.5px; + color: var(--workflow); margin-bottom: 6px; +} +.tc-subagent-head svg { width: 11px; height: 11px; flex-shrink: 0; } +.tc-subagent-head .label { + font-weight: 600; text-transform: uppercase; + font-size: 9.5px; letter-spacing: 0.06em; +} +.tc-subagent-head .type { + color: var(--fg-2); font-weight: 500; + text-transform: none; letter-spacing: 0; +} + +.agent-indicator { + margin-top: 10px; display: inline-flex; align-items: center; gap: 6px; + padding: 5px 10px; border-radius: 5px; + border: 1px solid var(--workflow-soft); + background: var(--workflow-soft); + font-family: var(--font-mono); font-size: 11px; + color: var(--workflow); + cursor: pointer; transition: all 0.1s; text-align: left; +} +.agent-indicator:hover { background: var(--workflow-strong); border-color: var(--workflow); } +.agent-indicator svg { width: 11px; height: 11px; flex-shrink: 0; } +.agent-indicator .chevron { transition: transform 0.15s; } +.agent-indicator.open .chevron { transform: rotate(90deg); } +.agent-indicator .label { color: var(--fg-2); } +.agent-indicator .meta { color: var(--muted); margin-left: 4px; } + +.workflow-card { + margin-top: 8px; + border: 1px solid var(--workflow-soft); + border-left: 3px solid var(--workflow); + border-radius: 6px; + background: rgba(0,0,0,0.18); + overflow: hidden; display: none; +} +.workflow-card.show { display: block; } +.workflow-card-head { + padding: 10px 12px; + border-bottom: 1px solid var(--hairline); + display: flex; align-items: center; gap: 10px; flex-wrap: wrap; + font-family: var(--font-mono); font-size: 11px; +} +.workflow-card-head .title { + color: var(--fg); font-weight: 600; + font-size: var(--text-sm); font-family: var(--font-sans); + letter-spacing: -0.005em; +} +.workflow-card-head .status { + padding: 1px 6px; border-radius: 3px; + font-size: 10px; text-transform: uppercase; + letter-spacing: 0.04em; font-weight: 500; +} +.workflow-card-head .status.completed { background: rgba(74,222,128,0.14); color: #4ade80; } +.workflow-card-head .status.failed { background: var(--danger-soft); color: var(--danger); } +.workflow-card-head .meta { margin-left: auto; color: var(--muted); } +.agent-list { display: flex; flex-direction: column; } +.agent-row { + display: grid; grid-template-columns: 80px 1fr auto; gap: 10px; + align-items: center; padding: 10px 12px; + border-bottom: 1px solid var(--hairline); + transition: background 0.08s; cursor: pointer; + text-align: left; + border-left: 0; border-right: 0; border-top: 0; + background: transparent; color: inherit; font: inherit; width: 100%; +} +.agent-row:last-child { border-bottom: 0; } +.agent-row:hover { background: rgba(255,255,255,0.03); } +.agent-row .phase { + font-family: var(--font-mono); font-size: 10px; + color: var(--workflow); + text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; + padding: 2px 6px; background: var(--workflow-soft); + border-radius: 3px; text-align: center; +} +.agent-row .agent-body { min-width: 0; display: flex; flex-direction: column; gap: 2px; } +.agent-row .agent-label { + font-size: var(--text-sm); color: var(--fg); + font-weight: 500; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.agent-row .agent-meta { + display: flex; align-items: center; gap: 8px; + font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); +} +.agent-row .agent-state { + padding: 1px 5px; border-radius: 3px; + font-size: 9.5px; text-transform: uppercase; + letter-spacing: 0.04em; font-weight: 500; +} +.agent-row .agent-state.done { background: rgba(74,222,128,0.14); color: #4ade80; } +.agent-row .agent-state.error { background: var(--danger-soft); color: var(--danger); } +.agent-row .chevron-r { + width: 8px; height: 8px; color: var(--muted-2); + transition: transform 0.15s; flex-shrink: 0; +} +.agent-row.open .chevron-r { transform: rotate(90deg); color: var(--workflow); } +.agent-detail { + display: none; padding: 10px 14px 14px 102px; + border-bottom: 1px solid var(--hairline); + background: rgba(0,0,0,0.25); +} +.agent-detail.show { display: block; } + +/* Muted messages (all except first) - no opacity change */ + +/* Thinking messages - collapsed by default */ +.msg-thinking { + border: 1px solid var(--hairline); + border-radius: 5px; + background: rgba(0,0,0,0.15); + overflow: hidden; +} +.thinking-toggle { + display: flex; align-items: center; gap: 8px; + width: 100%; padding: 7px 10px; + cursor: pointer; transition: background 0.08s; + text-align: left; border: 0; background: transparent; + color: inherit; font: inherit; +} +.thinking-toggle:hover { background: rgba(255,255,255,0.03); } +.thinking-toggle .chevron { + width: 8px; height: 8px; color: var(--muted); + transition: transform 0.15s; flex-shrink: 0; +} +.msg-thinking.open .thinking-toggle .chevron { transform: rotate(90deg); color: var(--accent-2); } +.thinking-toggle .thinking-label { + font-family: var(--font-mono); font-size: 10.5px; + color: var(--muted); font-weight: 600; + text-transform: uppercase; letter-spacing: 0.05em; + flex-shrink: 0; +} +.thinking-toggle .thinking-preview { + font-size: 11px; color: var(--muted-2); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + flex: 1; min-width: 0; +} +.thinking-body { + display: none; padding: 8px 14px 12px; + border-top: 1px solid var(--hairline); +} +.msg-thinking.open .thinking-body { display: block; } + +/* Truncated message button */ +.truncated-btn { + display: block; width: 100%; + margin-top: 8px; padding: 6px 12px; + font-family: var(--font-mono); font-size: 11px; + color: var(--accent-2); background: var(--accent-soft); + border: 1px solid rgba(167,139,250,0.2); + border-radius: 4px; cursor: pointer; + text-align: center; transition: all 0.1s; +} +.truncated-btn:hover { background: rgba(167,139,250,0.2); border-color: var(--accent); } + +/* Meta (system) messages - collapsed by default */ +.msg.meta { + border-color: var(--hairline); + background: transparent; + padding: 4px 10px; +} +.msg-meta-collapsed { + border: 1px solid var(--hairline); + border-radius: 4px; + background: rgba(0,0,0,0.1); + overflow: hidden; +} +.meta-toggle { + display: flex; align-items: center; gap: 8px; + width: 100%; padding: 5px 10px; + cursor: pointer; transition: background 0.08s; + text-align: left; border: 0; background: transparent; + color: inherit; font: inherit; +} +.meta-toggle:hover { background: rgba(255,255,255,0.03); } +.meta-toggle .chevron { + width: 8px; height: 8px; color: var(--muted-2); + transition: transform 0.15s; flex-shrink: 0; +} +.msg-meta-collapsed.open .meta-toggle .chevron { transform: rotate(90deg); color: var(--muted); } +.meta-toggle .meta-label { + font-family: var(--font-mono); font-size: 10px; + color: var(--muted-2); font-weight: 600; + text-transform: uppercase; letter-spacing: 0.05em; + flex-shrink: 0; +} +.meta-toggle .meta-preview { + font-size: 11px; color: var(--muted-2); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + flex: 1; min-width: 0; +} +.meta-body { + display: none; padding: 6px 12px 10px; + border-top: 1px solid var(--hairline); +} +.msg-meta-collapsed.open .meta-body { display: block; } + +/* Agent tool call special styling */ +.msg-tool.agent-call { border-color: var(--workflow-soft); border-left: 2px solid var(--workflow); } +.msg-tool.agent-call .tool-name { color: var(--workflow); } +.agent-prompt { + font-size: 12px; color: var(--fg-2); line-height: 1.5; + padding: 8px 10px; margin-bottom: 8px; + background: rgba(0,0,0,0.2); border-radius: 4px; + white-space: pre-wrap; word-wrap: break-word; +} +.agent-result { max-height: 400px; overflow-y: auto; } +.agent-nav-btn { + margin-left: auto; padding: 2px 8px; + font-family: var(--font-mono); font-size: 10.5px; + color: var(--workflow); background: var(--workflow-soft); + border: 1px solid rgba(245,158,11,0.25); border-radius: 3px; + cursor: pointer; transition: all 0.1s; + white-space: nowrap; +} +.agent-nav-btn:hover { background: rgba(245,158,11,0.25); border-color: var(--workflow); } + +/* Workflow agent list inside tool call */ +.workflow-agent-list { display: flex; flex-direction: column; gap: 12px; } +.workflow-phase-group {} +.workflow-phase-header { + font-family: var(--font-mono); font-size: 11px; + color: var(--muted); text-transform: uppercase; + letter-spacing: 0.04em; font-weight: 500; + padding: 4px 0 4px; margin-bottom: 2px; + border-bottom: 1px solid var(--hairline); +} +.workflow-phase-agents { display: flex; flex-direction: column; gap: 1px; } +.workflow-agent-row { + display: flex; align-items: center; gap: 10px; + padding: 6px 10px; border-radius: 4px; + background: transparent; border: 0; + cursor: pointer; transition: background 0.08s; + text-align: left; width: 100%; + font: inherit; color: inherit; +} +.workflow-agent-row:hover { background: rgba(255,255,255,0.04); } +.workflow-agent-label { font-size: 12px; color: var(--fg-2); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.workflow-agent-row:hover .workflow-agent-label { color: var(--accent-2); text-decoration: underline; text-underline-offset: 2px; } +.workflow-agent-state { font-family: var(--font-mono); font-size: 10px; padding: 1px 5px; border-radius: 3px; flex-shrink: 0; } +.workflow-agent-state.done { background: rgba(74,222,128,0.14); color: #4ade80; } +.workflow-agent-state.error { background: var(--danger-soft); color: var(--danger); } +.workflow-status { font-family: var(--font-mono); font-size: 10px; padding: 1px 6px; border-radius: 3px; margin-left: auto; } +.workflow-status.completed { background: rgba(74,222,128,0.14); color: #4ade80; } +.workflow-status.failed { background: var(--danger-soft); color: var(--danger); } + +/* Standalone workflow card (outside assistant bubble) */ +.wf-card { + border: 1px solid var(--workflow-soft); + border-left: 3px solid var(--workflow); + border-radius: 8px; + background: rgba(245, 158, 11, 0.04); + padding: 0; overflow: hidden; +} +.wf-card-header { + display: flex; align-items: center; gap: 10px; + padding: 12px 16px; + border-bottom: 1px solid var(--workflow-soft); + background: rgba(245, 158, 11, 0.06); +} +.wf-card-icon { font-size: 14px; } +.wf-card-name { font-size: 14px; font-weight: 600; color: var(--fg); font-family: var(--font-mono); } +.wf-card-count { font-size: 11px; color: var(--muted); font-family: var(--font-mono); } +.wf-card-status { font-family: var(--font-mono); font-size: 10px; padding: 2px 8px; border-radius: 3px; margin-left: auto; text-transform: uppercase; letter-spacing: 0.04em; font-weight: 500; } +.wf-card-status.completed { background: rgba(74,222,128,0.14); color: #4ade80; } +.wf-card-status.failed { background: var(--danger-soft); color: var(--danger); } +.wf-card-body { padding: 12px 16px; } +.wf-card-phase { margin-bottom: 12px; } +.wf-card-phase:last-child { margin-bottom: 0; } +.wf-card-phase-title { + font-family: var(--font-mono); font-size: 11px; + color: var(--workflow); text-transform: uppercase; + letter-spacing: 0.05em; font-weight: 600; + margin-bottom: 4px; padding-bottom: 4px; + border-bottom: 1px solid var(--hairline); +} +.wf-card-agent { + display: flex; align-items: center; gap: 10px; + padding: 7px 10px; border-radius: 4px; + background: transparent; border: 0; + cursor: pointer; transition: background 0.08s; + text-align: left; width: 100%; + font: inherit; color: inherit; +} +.wf-card-agent:hover { background: rgba(245, 158, 11, 0.08); } +.wf-card-agent-label { font-size: 13px; color: var(--fg-2); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.wf-card-agent:hover .wf-card-agent-label { color: var(--accent-2); text-decoration: underline; text-underline-offset: 2px; } +.wf-card-agent-state { font-family: var(--font-mono); font-size: 10px; padding: 2px 6px; border-radius: 3px; flex-shrink: 0; } +.wf-card-agent-state.error { background: var(--danger-soft); color: var(--danger); } +.wf-card-agent-arrow { color: var(--muted-2); font-size: 12px; flex-shrink: 0; transition: color 0.08s, transform 0.08s; } +.wf-card-agent:hover .wf-card-agent-arrow { color: var(--accent-2); transform: translateX(2px); } + +/* Back to parent session link */ +.back-to-bar { + display: inline-flex; align-items: center; gap: 6px; + padding: 4px 0; margin-bottom: 12px; + font-size: 12px; font-family: var(--font-mono); + color: var(--accent-2); background: none; + border: none; cursor: pointer; + transition: color 0.1s; +} +.back-to-bar:hover { color: var(--fg); text-decoration: underline; text-underline-offset: 2px; } + +/* Back to top floating button */ +.back-to-top { + position: sticky; bottom: 20px; float: right; + margin-right: 8px; margin-top: -40px; + width: 32px; height: 32px; border-radius: 50%; + background: var(--surface-strong); border: 1px solid var(--hairline-strong); + color: var(--muted); cursor: pointer; + display: grid; place-items: center; + opacity: 0; transition: opacity 0.15s, background 0.1s; + pointer-events: none; z-index: 10; +} +.back-to-top.show { opacity: 1; pointer-events: auto; } +.back-to-top:hover { background: var(--accent-soft); color: var(--accent-2); border-color: var(--accent-soft); } +.back-to-top svg { width: 14px; height: 14px; } diff --git a/app/renderer/styles/list.css b/app/renderer/styles/list.css new file mode 100644 index 0000000..732016c --- /dev/null +++ b/app/renderer/styles/list.css @@ -0,0 +1,157 @@ + .list-wrap, .detail-wrap, .session-list-wrap, .session-detail-wrap { + flex: 1; overflow-y: auto; min-height: 0; + } + + .row { + display: grid; grid-template-columns: 22px 1fr auto; + align-items: start; column-gap: 12px; + padding: 14px 16px 14px 14px; + min-height: var(--row-h); + cursor: pointer; user-select: none; + border-bottom: 1px solid var(--hairline); + transition: background 0.06s; position: relative; + } + .row:last-child { border-bottom: 0; } + .row:hover { background: rgba(255,255,255,0.025); } + .row.cursor { background: var(--surface); } + .row.cursor::before { + content: ''; position: absolute; left: 0; top: 0; bottom: 0; + width: 2px; background: var(--muted-2); + } + .row.selected { background: var(--accent-soft); } + .row.selected::before { + content: ''; position: absolute; left: 0; top: 0; bottom: 0; + width: 2px; background: var(--accent); + box-shadow: 0 0 12px var(--accent-glow); + } + .row.cursor.selected { background: rgba(167,139,250,0.16); } + .row-checkbox { + width: 18px; height: 18px; margin-top: 1px; + border-radius: 4px; border: 1.5px solid var(--muted-2); + background: transparent; cursor: pointer; + display: grid; place-items: center; + opacity: 0; transition: all 0.1s; + justify-self: center; + } + .row:hover .row-checkbox, + .row.selected .row-checkbox, + .row.cursor .row-checkbox { opacity: 1; } + .row-checkbox:hover { border-color: var(--accent); } + .row-checkbox.checked { + background: var(--accent); border-color: var(--accent); + box-shadow: 0 0 8px var(--accent-glow); + } + .row-checkbox svg { width: 10px; height: 10px; color: #0a0b14; opacity: 0; } + .row-checkbox.checked svg { opacity: 1; } + .row-body { min-width: 0; display: flex; flex-direction: column; gap: 6px; } + .row-path { + font-family: var(--font-mono); font-size: var(--text-md); + font-weight: 500; color: var(--fg); line-height: 1.4; + display: flex; align-items: center; gap: 6px; min-width: 0; + } + .row-status { + display: inline-flex; align-items: center; justify-content: center; + width: 14px; height: 14px; flex-shrink: 0; + } + .row-status svg { width: 100%; height: 100%; } + .row-status.broken { color: var(--danger); } + .row-status.partial { color: var(--warn); } + .row-status.archived { color: var(--muted-2); } + .row-path .project-prefix { color: var(--muted); font-weight: 400; flex-shrink: 0; } + .row-path .project-prefix-sep { color: var(--muted-2); margin: 0 2px; flex-shrink: 0; } + .row-path .path-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } + .row-path mark, .row-summary mark, .srow-title mark, .srow-snippet mark { + background: var(--accent-soft); color: var(--accent-2); + padding: 0 2px; border-radius: 2px; + } + .row-summary { + font-size: var(--text-base); color: var(--fg-2); + line-height: 1.5; + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; + overflow: hidden; word-break: break-word; + } + .row-right { + display: flex; flex-direction: column; align-items: flex-end; + gap: 10px; flex-shrink: 0; padding-top: 1px; + } + .row-meta { + font-family: var(--font-mono); font-size: 10.5px; + color: var(--muted); letter-spacing: 0.02em; + font-variant-numeric: tabular-nums; white-space: nowrap; + display: flex; gap: 8px; + } + .row:hover .row-meta { color: var(--muted-2); } + .row-actions { display: flex; gap: 4px; opacity: 0; transition: opacity 0.1s; } + .row:hover .row-actions, .row.cursor .row-actions { opacity: 1; } + .row-action { + height: 24px; padding: 0 8px; border-radius: 4px; + color: var(--muted); font-size: var(--text-sm); + display: inline-flex; align-items: center; gap: 5px; + transition: all 0.1s; border: 1px solid transparent; + } + .row-action:hover { background: var(--surface-hi); color: var(--fg); border-color: var(--hairline-strong); } + .row-action.danger:hover { background: var(--danger-soft); color: var(--danger); border-color: rgba(248,113,113,0.3); } + .row-action.restore { color: var(--accent-2); } + .row-action.restore:hover { background: var(--accent-soft); color: var(--fg); border-color: var(--accent-soft); } + .row-action .kbd { + font-family: var(--font-mono); font-size: 9.5px; color: var(--muted-2); + padding: 0 3px; border: 1px solid var(--hairline); border-radius: 2px; line-height: 1.4; + } + .row-action:hover .kbd { color: var(--fg-2); border-color: var(--hairline-strong); } + .row.archived .row-path, .row.archived .row-summary { color: var(--muted); } + .row.archived .row-path .project-prefix { color: var(--muted-2); } + + .srow { + display: grid; grid-template-columns: 1fr auto; + align-items: start; column-gap: 12px; + padding: 12px 16px; + min-height: var(--row-h-session); + cursor: pointer; user-select: none; + border-bottom: 1px solid var(--hairline); + transition: background 0.06s; position: relative; + } + .srow:hover { background: rgba(255,255,255,0.025); } + .srow.cursor { background: var(--surface); } + .srow.cursor::before { + content: ''; position: absolute; left: 0; top: 0; bottom: 0; + width: 2px; background: var(--muted-2); + } + .srow-body { min-width: 0; display: flex; flex-direction: column; gap: 4px; } + .srow-title { + font-size: var(--text-md); font-weight: 500; color: var(--fg); + line-height: 1.35; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + .srow-meta { + font-family: var(--font-mono); font-size: 11px; + color: var(--muted); + display: flex; gap: 8px; align-items: center; flex-wrap: wrap; + } + .srow-meta .project-tag { color: var(--fg-2); font-weight: 500; } + .srow-meta .dot { width: 2px; height: 2px; background: var(--muted-2); border-radius: 50%; flex-shrink: 0; } + .srow-snippet { + margin-top: 4px; + font-size: var(--text-sm); color: var(--fg-2); line-height: 1.4; + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; + overflow: hidden; + padding-left: 14px; border-left: 2px solid var(--accent-soft); + } + .srow-snippet .snippet-label { + font-family: var(--font-mono); font-size: 9.5px; + color: var(--accent-2); letter-spacing: 0.04em; + text-transform: uppercase; margin-right: 6px; + } + .srow-right { + font-family: var(--font-mono); font-size: 11px; + color: var(--fg-2); text-align: right; + font-variant-numeric: tabular-nums; + flex-shrink: 0; padding-top: 2px; white-space: nowrap; + } + + .empty { + flex: 1; display: flex; align-items: center; justify-content: center; + color: var(--muted-2); font-size: var(--text-sm); + padding: 60px 20px; text-align: center; + flex-direction: column; gap: 8px; + } + .empty .hint { font-size: 11px; color: var(--muted-2); } diff --git a/app/renderer/styles/sidebar.css b/app/renderer/styles/sidebar.css new file mode 100644 index 0000000..bfca2e7 --- /dev/null +++ b/app/renderer/styles/sidebar.css @@ -0,0 +1,74 @@ +.sidebar { + border-right: 1px solid var(--hairline-strong); + background: rgba(0,0,0,0.2); + display: flex; flex-direction: column; + min-height: 0; +} +.sidebar-brand { + display: flex; align-items: center; gap: 8px; + padding: 0 14px; height: 36px; + border-bottom: 1px solid var(--hairline); + flex-shrink: 0; +} +.sidebar-brand svg { width: 18px; height: 18px; filter: drop-shadow(0 0 8px rgba(167,139,250,0.4)); } +.sidebar-brand .name { font-size: var(--text-base); font-weight: 600; color: var(--fg-2); } +.sidebar-section { padding: 8px 6px; flex-shrink: 0; } +.sidebar-section.projects { flex: 1; min-height: 0; display: flex; flex-direction: column; padding-bottom: 0; } +.sidebar-section + .sidebar-section { border-top: 1px solid var(--hairline); } +.sidebar-section-title { + padding: 4px 10px 6px; + font-size: 10.5px; color: var(--muted); + font-weight: 500; letter-spacing: 0.04em; + display: flex; justify-content: space-between; + flex-shrink: 0; +} +.sidebar-search { position: relative; padding: 0 6px 6px; flex-shrink: 0; } +.sidebar-search input { + width: 100%; height: 24px; + padding: 0 8px 0 24px; + border: 1px solid var(--hairline); border-radius: 4px; + background: var(--surface); + font-size: var(--text-sm); color: var(--fg); + transition: all 0.1s; +} +.sidebar-search input::placeholder { color: var(--muted-2); } +.sidebar-search input:focus { outline: 0; border-color: var(--accent); background: var(--surface-strong); } +.sidebar-search-icon { + position: absolute; left: 14px; top: 50%; transform: translateY(-50%); + width: 11px; height: 11px; color: var(--muted); pointer-events: none; +} +.sidebar-list { overflow-y: auto; padding: 2px 0 8px; flex: 1; min-height: 0; } +.sidebar-item { + display: flex; align-items: center; gap: 8px; + padding: 0 10px; height: var(--row-h-compact); + border-radius: 5px; + color: var(--fg-2); font-size: var(--text-base); + cursor: pointer; user-select: none; + transition: background 0.08s; position: relative; + width: 100%; text-align: left; +} +.sidebar-item:hover { background: var(--surface-strong); color: var(--fg); } +.sidebar-item.active { background: var(--accent-soft); color: var(--fg); } +.sidebar-item.active::before { + content: ''; position: absolute; left: -6px; top: 4px; bottom: 4px; + width: 2px; background: var(--accent); border-radius: 1px; + box-shadow: 0 0 8px var(--accent-glow); +} +.sidebar-item .icon { width: 14px; height: 14px; color: var(--muted); flex-shrink: 0; transition: all 0.08s; } +.sidebar-item.active .icon { color: var(--accent-2); filter: drop-shadow(0 0 4px var(--accent-glow)); } +.sidebar-item.warning .icon { color: var(--danger); } +.sidebar-item .label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.sidebar-item .badge { + font-family: var(--font-mono); font-size: 10.5px; + color: var(--muted); font-variant-numeric: tabular-nums; + line-height: 1; min-width: 22px; text-align: right; + flex-shrink: 0; padding: 2px 0; +} +.sidebar-item.active .badge { color: var(--fg-2); } +.sidebar-item.warning .badge { + color: var(--danger); background: var(--danger-soft); + padding: 2px 6px; border-radius: 8px; + margin-right: -6px; min-width: 22px; +} +.sidebar-item.sub { padding-left: 30px; height: 26px; font-size: var(--text-sm); } +.sidebar-item.sub .icon { width: 12px; height: 12px; } diff --git a/app/renderer/styles/statusbar.css b/app/renderer/styles/statusbar.css new file mode 100644 index 0000000..373ea9b --- /dev/null +++ b/app/renderer/styles/statusbar.css @@ -0,0 +1,29 @@ +.statusbar { + height: 24px; flex-shrink: 0; + border-top: 1px solid var(--hairline-strong); + background: rgba(0,0,0,0.3); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + display: flex; align-items: center; gap: 12px; + padding: 0 12px; + font-size: 10.5px; color: var(--muted); + font-family: var(--font-mono); +} +.statusbar .status-left { display: flex; gap: 8px; flex: 1; } +.statusbar .status-right { display: flex; gap: 8px; } +.statusbar .kbd-hint { display: inline-flex; align-items: center; gap: 4px; transition: opacity 0.15s; } +.statusbar .kbd-hint.secondary { opacity: 0; } +.statusbar:hover .kbd-hint.secondary { opacity: 1; } +.statusbar .kbd { + color: var(--fg-2); padding: 0 4px; + border: 1px solid var(--hairline-strong); border-radius: 3px; line-height: 1.4; +} +.status-pending { color: var(--accent-2); display: flex; align-items: center; gap: 8px; } +.status-pending strong { color: var(--fg); font-weight: 500; } +.status-pending .undo-btn { + color: var(--accent-2); padding: 0 6px; + border: 1px solid var(--accent-soft); border-radius: 3px; + transition: all 0.1s; +} +.status-pending .undo-btn:hover { background: var(--accent-soft); color: var(--fg); } +.status-pending .timer { color: var(--muted); font-variant-numeric: tabular-nums; } diff --git a/app/renderer/styles/toolbar.css b/app/renderer/styles/toolbar.css new file mode 100644 index 0000000..6ea9f1d --- /dev/null +++ b/app/renderer/styles/toolbar.css @@ -0,0 +1,81 @@ + .main { display: flex; flex-direction: column; min-width: 0; min-height: 0; } + .toolbar { + height: 44px; flex-shrink: 0; + display: flex; align-items: center; gap: 10px; + padding: 0 14px; + border-bottom: 1px solid var(--hairline-strong); + background: rgba(0,0,0,0.15); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + } + .breadcrumb { display: flex; align-items: center; gap: 6px; min-width: 0; } + .crumb { + font-size: var(--text-md); color: var(--muted); + padding: 4px 6px; border-radius: 4px; + cursor: pointer; transition: all 0.1s; + display: inline-flex; align-items: center; gap: 6px; + line-height: 1; border: 0; background: transparent; + white-space: nowrap; + } + .crumb:hover { background: var(--surface-strong); color: var(--fg-2); } + .crumb.terminal { color: var(--fg); font-weight: 600; cursor: default; } + .crumb.terminal:hover { background: transparent; } + .crumb svg { width: 13px; height: 13px; color: var(--muted); } + .crumb.filename { + font-family: var(--font-mono); font-weight: 500; color: var(--fg); + overflow: hidden; text-overflow: ellipsis; min-width: 0; + } + .crumb-sep { color: var(--muted-2); font-size: var(--text-md); user-select: none; } + .toolbar-spacer { flex: 1; } + .toolbar-search { width: 220px; position: relative; } + .toolbar-search input { + width: 100%; height: 26px; + padding: 0 30px 0 26px; + border: 1px solid var(--hairline); border-radius: 5px; + background: var(--surface); + font-size: var(--text-base); color: var(--fg); + transition: all 0.12s; + } + .toolbar-search input::placeholder { color: var(--muted-2); } + .toolbar-search input:focus { + outline: 0; border-color: var(--accent); + background: var(--surface-strong); + box-shadow: 0 0 0 2px var(--accent-soft); + } + .toolbar-search-icon { + position: absolute; left: 8px; top: 50%; transform: translateY(-50%); + width: 12px; height: 12px; color: var(--muted); pointer-events: none; + } + .toolbar-search-kbd { + position: absolute; right: 6px; top: 50%; transform: translateY(-50%); + font-family: var(--font-mono); font-size: 10px; color: var(--muted-2); + padding: 1px 5px; + border: 1px solid var(--hairline); border-radius: 3px; + pointer-events: none; line-height: 1.2; + } + .toolbar-search input:focus ~ .toolbar-search-kbd, + .toolbar-search input:not(:placeholder-shown) ~ .toolbar-search-kbd { opacity: 0; } + .filter-toggle { + height: 26px; width: 26px; border-radius: 5px; + color: var(--muted); display: inline-grid; place-items: center; + transition: all 0.1s; border: 1px solid transparent; + } + .filter-toggle:hover { color: var(--fg-2); background: var(--surface-strong); } + .filter-toggle.active { color: var(--accent-2); background: var(--accent-soft); border-color: var(--accent-soft); } + .filter-toggle svg { width: 13px; height: 13px; } + + .sort-group { + display: inline-flex; align-items: center; gap: 2px; + height: 26px; padding: 0 4px 0 8px; + border-radius: 5px; cursor: pointer; + color: var(--muted); font-size: var(--text-sm); + transition: background 0.1s, color 0.1s; + } + .sort-group:hover { background: var(--surface-strong); color: var(--fg-2); } + .sort-group .label { font-family: var(--font-mono); letter-spacing: 0.02em; } + .sort-group svg { width: 13px; height: 13px; } + .sort-group .arrow-up, .sort-group .arrow-down { transition: opacity 0.12s; } + .sort-group.desc .arrow-up { opacity: 0.25; } + .sort-group.desc .arrow-down { opacity: 1; } + .sort-group.asc .arrow-up { opacity: 1; } + .sort-group.asc .arrow-down { opacity: 0.25; } diff --git a/app/renderer/vite.config.js b/app/renderer/vite.config.js new file mode 100644 index 0000000..62c7a0a --- /dev/null +++ b/app/renderer/vite.config.js @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + base: './', + build: { + outDir: '../dist-renderer', + emptyOutDir: true, + }, +}) diff --git a/panel.html b/panel.html new file mode 100644 index 0000000..4eb02b9 --- /dev/null +++ b/panel.html @@ -0,0 +1,737 @@ + + + + + + +Obelisk — Memory + + + +
+
+
+
+

Memory

+
+
Let Claude Code search its own memory.
+
+
0active
+
0archived
+
0projects
+
+
+ +
+
Active 0
+
Archived 0
+
+ +
+
+ +
+ + Undo +
+ + + + diff --git a/references/query-patterns.md b/references/query-patterns.md index 3a25cd4..a4b462b 100644 --- a/references/query-patterns.md +++ b/references/query-patterns.md @@ -1,7 +1,7 @@ # Obelisk Query Patterns -These are copyable CodeAct patterns for `runtime.mjs --query` scripts, plus one -`--remember` registration pattern. They are not new APIs. Adapt them to the +These are copyable CodeAct patterns for `runtime.mjs --query` scripts plus +`--attune` memory mutation patterns. They are not new APIs. Adapt them to the user's scope and return compact evidence. Read this before the first query for broad synthesis, progress summaries, @@ -43,14 +43,17 @@ return { memories: map.current_project.memories.map(m => ({ id: m.id, path: m.path, + anchors: m.anchors, summary: m.summary?.slice(0, 240), })), }, prior_memories: memories({ ...scoped, query: topic, limit: 5 }).map(m => ({ id: m.id, path: m.path, + anchors: m.anchors, session_id: m.session_id, created_at: m.created_at, + rank: m.rank, summary: m.summary?.slice(0, 260), })), session_evidence: search(topic.replace(/[-_]/g, ' '), { ...scoped, limit: 8 }) @@ -88,6 +91,7 @@ return { memories: map.current_project.memories.map(m => ({ id: m.id, path: m.path, + anchors: m.anchors, summary: m.summary?.slice(0, 240), })), }, @@ -138,11 +142,13 @@ const prior_memories = memories({ }).map(m => ({ id: m.id, path: m.path, + anchors: m.anchors, session_id: m.session_id, message_start: m.message_start, message_end: m.message_end, created_at: m.created_at, summary: m.summary?.slice(0, 260), + rank: m.rank, })); const session_evidence = search(ftsTopic, { project, limit: 8 }) @@ -167,14 +173,14 @@ return { }; ``` -## Register Approved Memory +## Attune Approved Memory Use this only after the user approves writing memory and the markdown file already exists. `remember()` validates the file and stores a normalized absolute path, so keep the script small and return the registered record. -Run this script with `runtime.mjs --remember + + + @@ -333,4 +384,65 @@ function obeliskStyle(session) { font-family: var(--font-mono); color: var(--fg-2); background: rgba(0,0,0,0.3); padding: 1px 6px; border-radius: 3px; } + +/* Noise fold */ +.fold-banner { + display: flex; align-items: center; gap: 12px; + padding: 10px 22px; + background: rgba(255,255,255,0.015); + border-top: 1px solid var(--hairline); + border-bottom: 1px solid var(--hairline); + font-size: 12.5px; color: var(--muted); + cursor: pointer; transition: all 0.1s; +} +.fold-banner:hover { background: rgba(255,255,255,0.03); color: var(--fg-2); } +.fold-banner.expanded { color: var(--fg-3); background: rgba(255,255,255,0.02); } +.fold-banner .chev { + width: 10px; height: 10px; color: var(--muted-2); + transition: transform 0.15s; flex-shrink: 0; +} +.fold-banner.expanded .chev { transform: rotate(90deg); color: var(--accent-2); } +.fold-banner .body { flex: 1; } +.fold-banner .body strong { + color: var(--fg-2); font-weight: 500; + font-variant-numeric: tabular-nums; + font-family: var(--font-mono); font-size: 11.5px; +} +.fold-banner .reveal-link { + font-size: 11.5px; color: var(--accent-2); + text-decoration: none; border-bottom: 1px solid rgba(167,139,250,0.4); + padding-bottom: 1px; transition: all 0.12s; flex-shrink: 0; +} +.fold-banner:hover .reveal-link { color: var(--accent); border-bottom-color: var(--accent); } + +.noise-group { + border-bottom: 1px solid var(--hairline-strong); + background: rgba(0,0,0,0.15); +} +.noise-group-head { + padding: 6px 22px; + font-family: var(--font-mono); font-size: 10px; color: var(--muted-2); + letter-spacing: 0.06em; text-transform: uppercase; + background: rgba(0,0,0,0.1); border-bottom: 1px solid var(--hairline); +} +.srow.noise { padding: 8px 22px 8px 18px; } +.srow.noise .srow-title { + color: var(--muted); font-style: italic; + font-size: 13px; font-weight: 400; +} +.srow.noise .srow-meta { color: var(--muted-2); } + +.noise-fold-bottom { + padding: 8px 22px; background: rgba(0,0,0,0.2); + font-family: var(--font-mono); font-size: 11px; color: var(--muted); + cursor: pointer; transition: all 0.1s; + display: flex; align-items: center; gap: 8px; + border-top: 1px solid var(--hairline); + border: none; width: 100%; text-align: left; +} +.noise-fold-bottom:hover { background: rgba(0,0,0,0.3); color: var(--fg-2); } +.noise-fold-bottom .chev { + width: 9px; height: 9px; color: var(--muted-2); + transform: rotate(-90deg); +} diff --git a/app/renderer/src/views/Settings.vue b/app/renderer/src/views/Settings.vue index 3664c60..0bf9376 100644 --- a/app/renderer/src/views/Settings.vue +++ b/app/renderer/src/views/Settings.vue @@ -1,17 +1,13 @@