Complete Vue renderer migration

This commit is contained in:
tommy0103
2026-07-12 02:26:01 +08:00
parent dbf3ea4b51
commit 644942202e
24 changed files with 311 additions and 3740 deletions
-235
View File
@@ -1,235 +0,0 @@
// Entry point -- wires data, rendering, and event handlers together.
// No exports; this is the bootstrap module.
import { loadInitialData } from './data.js';
import { state, IS_MAC } from './state.js';
import {
renderAll,
renderMemoryList,
renderSessionList,
setRoute,
setView,
setProject,
enterDetail,
archive,
restore,
setCursor,
navigateToSession,
switchView
} from './render.js';
import { initKeyboard } from './keys.js';
// -- Helpers ----------------------------------------------------------------
let searchDebounceTimer = null;
function debounce(fn, ms) {
return (...args) => {
clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(() => fn(...args), ms);
};
}
// -- Boot -------------------------------------------------------------------
document.addEventListener('DOMContentLoaded', async () => {
await loadInitialData();
initKeyboard();
// -- Sidebar navigation (route switching + project filter) ----------------
const sidebar = document.querySelector('.sidebar');
if (sidebar) {
sidebar.addEventListener('click', e => {
const item = e.target.closest('.sidebar-item');
if (!item) return;
const route = item.dataset.route;
const project = item.dataset.project;
const view = item.dataset.view;
if (route) {
setRoute(route);
} else if (view) {
setView(view);
} else if (project !== undefined) {
setProject(project);
}
});
}
// -- Sidebar search (filter projects list) --------------------------------
const sidebarSearch = document.querySelector('.sidebar-search input');
if (sidebarSearch) {
sidebarSearch.addEventListener('input', e => {
state.projectSearch = e.target.value;
renderAll();
});
}
// -- Breadcrumb click (navigate back to list) -----------------------------
const breadcrumb = document.querySelector('.breadcrumb');
if (breadcrumb) {
breadcrumb.addEventListener('click', e => {
const crumb = e.target.closest('[data-action]');
if (!crumb) return;
const action = crumb.dataset.action;
if (action === 'goto-sessions') { state.projectFilter = 'all'; setRoute('sessions'); }
else if (action === 'goto-memory') { state.projectFilter = 'all'; setView('active'); }
else if (action === 'goto-session-detail') { state.subagentId = null; state.subagentDescription = null; switchView(); renderAll(); }
});
}
// -- Toolbar search with debounce -----------------------------------------
const searchInput = document.getElementById('search');
if (searchInput) {
const handleSearch = debounce(value => {
state.query = value;
if (state.route === 'sessions') renderSessionList();
else renderMemoryList();
}, 200);
searchInput.addEventListener('input', e => {
handleSearch(e.target.value);
});
}
// -- Sort toggle -----------------------------------------------------------
const sortToggle = document.querySelector('.sort-group');
if (sortToggle) {
sortToggle.addEventListener('click', () => {
state.sortDesc = !state.sortDesc;
sortToggle.classList.toggle('desc', state.sortDesc);
sortToggle.classList.toggle('asc', !state.sortDesc);
if (state.route === 'sessions') renderSessionList();
else renderMemoryList();
});
}
// -- Search messages toggle ------------------------------------------------
const searchMsgsToggle = document.querySelector('.filter-toggle');
if (searchMsgsToggle) {
searchMsgsToggle.addEventListener('click', () => {
state.includeMessageBodies = !state.includeMessageBodies;
searchMsgsToggle.classList.toggle('active', state.includeMessageBodies);
if (state.query) {
if (state.route === 'sessions') renderSessionList();
else renderMemoryList();
}
});
}
// -- #list click (memory rows: selection, actions, navigation) ------------
const list = document.getElementById('list');
if (list) {
list.addEventListener('click', e => {
// Action buttons (archive/restore)
const action = e.target.closest('.row-action');
if (action) {
e.stopPropagation();
const row = action.closest('.row');
const id = row?.dataset.id;
if (!id) return;
if (action.classList.contains('restore')) {
restore([id]);
} else {
archive([id]);
}
return;
}
// Checkbox toggling
const checkbox = e.target.closest('.row-checkbox');
if (checkbox) {
e.stopPropagation();
const row = checkbox.closest('.row');
const id = row?.dataset.id;
if (!id) return;
if (e.shiftKey && state.cursorId) {
// Range select between cursor and clicked
const rows = Array.from(list.querySelectorAll('.row'));
const ids = rows.map(r => r.dataset.id);
const fromIdx = ids.indexOf(state.cursorId);
const toIdx = ids.indexOf(id);
const [lo, hi] = fromIdx < toIdx ? [fromIdx, toIdx] : [toIdx, fromIdx];
for (let i = lo; i <= hi; i++) {
state.selection.add(ids[i]);
}
} else if (e.metaKey || e.ctrlKey) {
// Toggle single
if (state.selection.has(id)) state.selection.delete(id);
else state.selection.add(id);
} else {
// Simple toggle
if (state.selection.has(id)) state.selection.delete(id);
else state.selection.add(id);
}
setCursor(id);
renderMemoryList();
return;
}
// Row click (navigate cursor / open detail)
const row = e.target.closest('.row');
if (!row) return;
const id = row.dataset.id;
if (!id) return;
if (e.shiftKey && state.cursorId) {
// Shift-click: range select
const rows = Array.from(list.querySelectorAll('.row'));
const ids = rows.map(r => r.dataset.id);
const fromIdx = ids.indexOf(state.cursorId);
const toIdx = ids.indexOf(id);
const [lo, hi] = fromIdx < toIdx ? [fromIdx, toIdx] : [toIdx, fromIdx];
for (let i = lo; i <= hi; i++) {
state.selection.add(ids[i]);
}
renderMemoryList();
} else if ((IS_MAC ? e.metaKey : e.ctrlKey)) {
// Cmd/Ctrl-click: toggle selection
if (state.selection.has(id)) state.selection.delete(id);
else state.selection.add(id);
setCursor(id);
renderMemoryList();
} else {
// Plain click: move cursor
setCursor(id);
renderMemoryList();
}
});
// -- #list dblclick (open detail) -----------------------------------------
list.addEventListener('dblclick', e => {
const row = e.target.closest('.row');
if (!row) return;
const id = row.dataset.id;
if (id) enterDetail(id);
});
}
// -- #session-list click (session rows) ------------------------------------
const sessionList = document.getElementById('session-list');
if (sessionList) {
sessionList.addEventListener('click', e => {
const srow = e.target.closest('.srow');
if (!srow) return;
const id = srow.dataset.sessionId;
if (id) navigateToSession(id);
});
}
// -- Start on memory view -------------------------------------------------
setRoute('memory');
renderAll();
});
-380
View File
@@ -1,380 +0,0 @@
// Data loading layer -- bridges Electron IPC (window.obelisk.*) to app state.
// All DB access goes through this module.
import { state } from './state.js';
/**
* Load initial data from the DB and populate state.memories, state.sessions,
* and state.projects.
*/
export async function loadInitialData() {
const [rawMemories, rawSessions, stats, projects] = await Promise.all([
window.obelisk.getMemories(),
window.obelisk.getSessions(),
window.obelisk.getStats(),
window.obelisk.getProjects()
]);
// Transform memories: DB records -> render-layer shape
state.memories = (rawMemories || []).map(m => ({
...m,
ts: m.created_at ? new Date(m.created_at).getTime() : 0,
archived: !!m.deleted_at,
archivedAt: m.deleted_at ? new Date(m.deleted_at).getTime() : null,
health: 'ok',
anchors: [],
markdown: null // loaded on demand via loadMemoryMarkdown
}));
// Sessions: keep DB shape, add empty messages array for on-demand loading
state.sessions = (rawSessions || []).map(s => ({
...s,
messages: []
}));
state.projects = projects || [];
state.stats = stats || {};
}
/**
* Load full detail for a session: messages with inline tool_calls (each with
* result), summaries, subagents, and workflow data.
*
* Returns the assembled session object (also updates state.sessions entry).
*/
export async function loadSessionDetail(sessionId) {
const [messages, toolCalls, toolResults, subagents, workflows, summaries] =
await Promise.all([
window.obelisk.getSessionMessages(sessionId),
window.obelisk.getSessionToolCalls(sessionId),
window.obelisk.getSessionToolResults(sessionId),
window.obelisk.getSessionSubagents(sessionId),
window.obelisk.getSessionWorkflows(sessionId),
window.obelisk.getSessionSummaries(sessionId)
]);
// Index tool results by tool_use_id for fast lookup
const resultsByCallId = {};
for (const r of (toolResults || [])) {
resultsByCallId[r.tool_use_id] = r;
}
// Index subagents by parent_tool_use_id
const subagentsByCallId = {};
for (const sa of (subagents || [])) {
if (sa.parent_tool_use_id) {
subagentsByCallId[sa.parent_tool_use_id] = sa;
}
}
// Group tool_calls by message_uuid, attaching result and subagent inline
const callsByMessageUuid = {};
for (const tc of (toolCalls || [])) {
const call = {
id: tc.id,
name: tc.name,
input_json: tc.input_json,
result: resultsByCallId[tc.id] || null
};
// Attach subagent data if present
const sa = subagentsByCallId[tc.id];
if (sa) {
call.subagent = {
agent_id: sa.agent_id,
agent_type: sa.agent_type,
description: sa.description
};
}
const msgUuid = tc.message_uuid;
if (!callsByMessageUuid[msgUuid]) callsByMessageUuid[msgUuid] = [];
callsByMessageUuid[msgUuid].push(call);
}
// Attach workflow data to Workflow tool calls
for (const wf of (workflows || [])) {
for (const calls of Object.values(callsByMessageUuid)) {
for (const call of calls) {
if (call.name === 'Workflow' && !call.workflow) {
const resultText = call.result?.content || '';
if (resultText.includes(wf.run_id) || resultText.includes(wf.workflow_name || '___none___')) {
call.workflow = {
run_id: wf.run_id,
workflow_name: wf.workflow_name,
status: wf.status,
duration_ms: wf.duration_ms,
total_tokens: wf.total_tokens,
agent_count: wf.agent_count,
agents: (wf.agents || []).map(a => ({
agent_id: a.agent_id,
phase: a.phase,
label: a.label,
state: a.state,
tokens: a.tokens,
duration_ms: a.duration_ms,
}))
};
}
}
}
}
}
// Index summaries by session (summaries don't have per-message IDs in our schema)
const sessionSummaries = (summaries || []).map(s => ({
source: s.source,
content: s.content,
timestamp: s.timestamp
}));
// Assemble messages with tool_calls inline
const rawAssembled = (messages || []).map(msg => {
const assembled = {
uuid: msg.uuid,
type: msg.type || msg.role,
timestamp: msg.timestamp,
text: msg.text,
content_type: msg.content_type || null,
is_meta: msg.is_meta || 0
};
const calls = callsByMessageUuid[msg.uuid];
if (calls && calls.length > 0) {
assembled.tool_calls = calls;
}
return assembled;
});
// Merge adjacent assistant messages:
// - tool_result user messages are skipped (results shown inside tool_call panels)
// - consecutive tool_use messages (separated by tool_results) merge into one
// - thinking messages merge into the next non-thinking assistant message
const assembledMessages = [];
for (let i = 0; i < rawAssembled.length; i++) {
const msg = rawAssembled[i];
// Skip tool_result user messages
if (msg.content_type === 'tool_result') continue;
// For thinking messages, collect consecutive thinking blocks and attach to the next assistant
if (msg.type === 'assistant' && msg.content_type === 'thinking') {
const thinkingParts = [msg.text || ''];
let j = i + 1;
// Absorb consecutive thinking messages
while (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type === 'thinking') {
thinkingParts.push(rawAssembled[j].text || '');
j++;
}
// Find the next non-thinking assistant message to attach to
if (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type !== 'thinking') {
// Will be picked up by the next iteration; store thinking on it
rawAssembled[j]._thinking = thinkingParts.join('\n\n');
i = j - 1;
continue;
}
// No following assistant message — render as standalone collapsed thinking
assembledMessages.push({ ...msg, text: thinkingParts.join('\n\n'), content_type: 'thinking' });
i = j - 1;
continue;
}
// For tool_use assistant messages, absorb subsequent tool_use (skipping tool_results)
if (msg.type === 'assistant' && msg.content_type === 'tool_use') {
const merged = { ...msg, tool_calls: [...(msg.tool_calls || [])] };
if (msg._thinking) merged._thinking = msg._thinking;
let j = i + 1;
while (j < rawAssembled.length) {
const next = rawAssembled[j];
if (next.content_type === 'tool_result') { j++; continue; }
if (next.type === 'assistant' && next.content_type === 'tool_use') {
if (next.tool_calls) merged.tool_calls.push(...next.tool_calls);
if (next.text && !merged.text) merged.text = next.text;
j++;
continue;
}
break;
}
assembledMessages.push(merged);
i = j - 1;
} else {
// text or other assistant/user messages
const out = { ...msg };
if (msg._thinking) out._thinking = msg._thinking;
assembledMessages.push(out);
}
}
// Attach workflow data if present
const workflow = (workflows && workflows.length > 0) ? workflows[0] : null;
// Build assembled session object
const session = state.sessions.find(s => s.id === sessionId);
const assembled = {
...(session || {}),
id: sessionId,
messages: assembledMessages
};
if (workflow) {
assembled.workflow = workflow;
}
// Update in-place in state.sessions
const idx = state.sessions.findIndex(s => s.id === sessionId);
if (idx !== -1) {
state.sessions[idx] = assembled;
}
return assembled;
}
/**
* Load full detail for a subagent conversation.
* Returns assembled messages with tool_calls inline.
*/
export async function loadSubagentDetail(agentId) {
const [messages, toolCalls, toolResults] = await Promise.all([
window.obelisk.getSubagentMessages(agentId),
window.obelisk.getSubagentToolCalls(agentId),
window.obelisk.getSubagentToolResults(agentId),
]);
const resultsByCallId = {};
for (const r of (toolResults || [])) {
resultsByCallId[r.tool_use_id] = r;
}
const callsByMessageUuid = {};
for (const tc of (toolCalls || [])) {
const call = {
id: tc.id,
name: tc.name,
input_json: tc.input_json,
result: resultsByCallId[tc.id] || null
};
const msgUuid = tc.message_uuid;
if (!callsByMessageUuid[msgUuid]) callsByMessageUuid[msgUuid] = [];
callsByMessageUuid[msgUuid].push(call);
}
const rawAssembled = (messages || []).map(msg => {
const assembled = {
uuid: msg.uuid,
type: msg.type || msg.role,
timestamp: msg.timestamp,
text: msg.text,
content_type: msg.content_type || null,
is_meta: msg.is_meta || 0
};
const calls = callsByMessageUuid[msg.uuid];
if (calls && calls.length > 0) assembled.tool_calls = calls;
return assembled;
});
// Same merging logic as session detail
const assembledMessages = [];
for (let i = 0; i < rawAssembled.length; i++) {
const msg = rawAssembled[i];
if (msg.content_type === 'tool_result') continue;
if (msg.type === 'assistant' && msg.content_type === 'thinking') {
const thinkingParts = [msg.text || ''];
let j = i + 1;
while (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type === 'thinking') {
thinkingParts.push(rawAssembled[j].text || '');
j++;
}
if (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type !== 'thinking') {
rawAssembled[j]._thinking = thinkingParts.join('\n\n');
i = j - 1;
continue;
}
assembledMessages.push({ ...msg, text: thinkingParts.join('\n\n'), content_type: 'thinking' });
i = j - 1;
continue;
}
if (msg.type === 'assistant' && msg.content_type === 'tool_use') {
const merged = { ...msg, tool_calls: [...(msg.tool_calls || [])] };
if (msg._thinking) merged._thinking = msg._thinking;
let j = i + 1;
while (j < rawAssembled.length) {
const next = rawAssembled[j];
if (next.content_type === 'tool_result') { j++; continue; }
if (next.type === 'assistant' && next.content_type === 'tool_use') {
if (next.tool_calls) merged.tool_calls.push(...next.tool_calls);
if (next.text && !merged.text) merged.text = next.text;
j++;
continue;
}
break;
}
assembledMessages.push(merged);
i = j - 1;
} else {
const out = { ...msg };
if (msg._thinking) out._thinking = msg._thinking;
assembledMessages.push(out);
}
}
return assembledMessages;
}
const TEXT_LIMIT = 10000;
/**
* Check if a message text was truncated during indexing.
*/
export function isTextTruncated(text) {
return text && text.length >= TEXT_LIMIT;
}
/**
* Fetch the full untruncated text for a message from its source JSONL.
* Returns the full text string or null.
*/
export async function loadFullText(uuid) {
try {
return await window.obelisk.getMessageFullText(uuid);
} catch {
return null;
}
}
/**
* Load the markdown content of a memory file.
* Returns the content string or null on failure.
*/
export async function loadMemoryMarkdown(memoryPath) {
try {
const content = await window.obelisk.readMemoryFile(memoryPath);
return content || null;
} catch {
return null;
}
}
/**
* Archive a memory by id. Updates state after successful IPC call.
*/
export async function archiveMemory(id) {
await window.obelisk.archiveMemory(id);
const mem = state.memories.find(m => m.id === id);
if (mem) {
mem.archived = true;
mem.archivedAt = Date.now();
}
}
/**
* Restore an archived memory by id. Updates state after successful IPC call.
*/
export async function restoreMemory(id) {
await window.obelisk.restoreMemory(id);
const mem = state.memories.find(m => m.id === id);
if (mem) {
mem.archived = false;
mem.archivedAt = null;
}
}
-129
View File
@@ -1,129 +0,0 @@
// Keyboard shortcut handling -- ported from the HTML mock.
// Registers a single document-level keydown listener that dispatches
// all navigation, mutation, and route-switching shortcuts.
import { state, IS_MAC } from './state.js';
import {
renderAll,
renderMemoryList,
renderSessionList,
renderStatus,
enterDetail,
exitDetail,
setRoute,
setView,
toggleSort,
moveCursor,
archive,
restore,
doUndo,
navigateToSession
} from './render.js';
export function initKeyboard() {
document.addEventListener('keydown', e => {
const inInput =
document.activeElement.tagName === 'INPUT' ||
document.activeElement.tagName === 'TEXTAREA';
const mod = IS_MAC ? e.metaKey : e.ctrlKey;
// -- Route switching: Cmd+1/2/3/4 --
if (mod && e.key === '1') { e.preventDefault(); setRoute('sessions'); return; }
if (mod && e.key === '2') { e.preventDefault(); setView('active'); return; }
if (mod && e.key === '3') { e.preventDefault(); setView('archived'); return; }
if (mod && e.key === '4') { e.preventDefault(); setView('broken'); return; }
// -- Undo: Cmd+Z --
if (mod && e.key.toLowerCase() === 'z' && !e.shiftKey) {
if (state.lastArchiveSnapshot) { e.preventDefault(); doUndo(); }
return;
}
// -- When inside an input, only Escape is handled (to blur) --
if (inInput) {
if (e.key === 'Escape') e.target.blur();
return;
}
// -- Search focus: / --
if (e.key === '/') {
e.preventDefault();
const searchInput = document.getElementById('search');
if (searchInput) { searchInput.focus(); searchInput.select(); }
return;
}
// -- Detail mode shortcuts --
if (state.mode === 'detail') {
if (e.key === 'Escape') { e.preventDefault(); exitDetail(); return; }
if (state.route === 'memory' && (e.key === 'd' || e.key === 'D')) {
e.preventDefault();
const m = state.memories.find(x => x.id === state.detailId);
if (m && m.archived) restore([m.id]);
else if (m) archive([m.id]);
return;
}
return;
}
// -- List mode shortcuts --
// Navigation: j/k/arrows
if (e.key === 'j' || e.key === 'ArrowDown') {
e.preventDefault();
if (state.route === 'memory') moveCursor(1, e.shiftKey);
return;
}
if (e.key === 'k' || e.key === 'ArrowUp') {
e.preventDefault();
if (state.route === 'memory') moveCursor(-1, e.shiftKey);
return;
}
// Open detail: Enter
if (e.key === 'Enter') {
e.preventDefault();
if (state.route === 'memory' && state.cursorId) enterDetail(state.cursorId);
return;
}
// Archive / Restore: d/D
if (state.route === 'memory' && (e.key === 'd' || e.key === 'D')) {
e.preventDefault();
const ids = state.selection.size > 0
? Array.from(state.selection)
: state.cursorId ? [state.cursorId] : [];
if (!ids.length) return;
const m = state.memories.find(x => x.id === ids[0]);
if (state.view === 'archived' || (m && m.archived)) restore(ids);
else archive(ids);
return;
}
// Undo: u
if (e.key === 'u') {
e.preventDefault();
if (state.lastArchiveSnapshot) doUndo();
return;
}
// Sort toggle: s
if (e.key === 's') { e.preventDefault(); toggleSort(); return; }
// Escape: clear selection or search
if (e.key === 'Escape') {
if (state.selection.size) {
state.selection.clear();
renderMemoryList();
renderStatus();
} else if (state.query) {
state.query = '';
const searchInput = document.getElementById('search');
if (searchInput) searchInput.value = '';
if (state.route === 'sessions') renderSessionList();
else renderMemoryList();
}
return;
}
});
}
-269
View File
@@ -1,269 +0,0 @@
// Memory list and detail rendering, extracted from render.js.
import { state, FOLDER_SVG } from './state.js';
import { loadMemoryMarkdown, isTextTruncated, loadFullText } from './data.js';
import registry from './registry.js';
// --- Utilities (local copies to avoid importing render.js) ---
function escapeHTML(s) { return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
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 => `<mark>${m}</mark>`);
}
function sanitizeMarkdown(html) {
return html
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<iframe[\s\S]*?<\/iframe>/gi, '')
.replace(/<style[\s\S]*?<\/style>/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: `<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M7 1.5l5.5 9.5h-11z M7 5v3M7 9.2v.6"/></svg>`,
partial: `<svg viewBox="0 0 14 14" fill="currentColor"><circle cx="7" cy="7" r="3.5"/></svg>`,
archived: `<svg viewBox="0 0 14 14" fill="currentColor"><circle cx="7" cy="7" r="2.5"/></svg>`
};
return `<span class="row-status ${status}" title="${status}">${glyphs[status] || ''}</span>`;
}
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 = `<div class="empty">No memories${state.view === 'archived' ? ' archived' : ''} here.<span class="hint">${state.query ? 'Try a different search term.' : 'Press / to search.'}</span></div>`;
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
? `<button class="row-action restore" data-action="restore">Restore<span class="kbd">D</span></button>`
: `<button class="row-action danger" data-action="archive">Archive<span class="kbd">D</span></button>`;
return `
<div class="row ${isCursor ? 'cursor' : ''} ${isSelected ? 'selected' : ''} ${m.archived ? 'archived' : ''}" data-id="${m.id}">
<button class="row-checkbox ${isSelected ? 'checked' : ''}" data-action="check" aria-label="Select">
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M2.5 6.5l2.5 2.5 4.5-5"/></svg>
</button>
<div class="row-body">
<div class="row-path">
${statusGlyphHTML(status)}
${showProjectPrefix ? `<span class="project-prefix">${escapeHTML(formatProjectLabel(m.project))}</span><span class="project-prefix-sep">/</span>` : ''}
<span class="path-text">${highlightPlain(m.path || '', q)}</span>
</div>
<div class="row-summary">${highlightPlain(m.summary || '', q)}</div>
</div>
<div class="row-right">
<div class="row-meta"><span>${fmtListTime(m.ts)}</span></div>
<div class="row-actions">${actionLabel}</div>
</div>
</div>
`;
}
// --- 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 = `
<div class="detail-meta">
${m.session_id ? `<button class="session-link" data-action="open-session" data-session="${m.session_id}">
${FOLDER_SVG}<span>Source session</span>
</button><span class="dot"></span>` : ''}
<span>${fmtRelative(m.ts)}</span>
</div>
`;
let markdownHTML;
if (m.markdown == null) {
markdownHTML = `<div style="color:var(--muted-2);font-style:italic;padding:20px;text-align:center;border:1px dashed var(--hairline);border-radius:6px;">File not found or empty.</div>`;
} else if (state.showSource) {
markdownHTML = `<pre class="markdown-source">${escapeHTML(m.markdown)}</pre>`;
} else {
markdownHTML = renderMarkdown(m.markdown, { variant: 'body' });
}
detail.innerHTML = `
<div class="detail-header">
<div class="detail-eyebrow">
<span class="project-icon">${FOLDER_SVG}</span>
<span class="project-name">${escapeHTML(formatProjectLabel(m.project))}</span>
${m.archived ? '<span class="archived-tag">archived</span>' : ''}
</div>
<div class="detail-path">${escapeHTML(m.path)}</div>
<div class="detail-summary">${escapeHTML(m.summary)}</div>
${provenanceHTML}
</div>
<div class="markdown-section">
<div class="markdown-toolbar">
<span class="markdown-toolbar-label">Body</span>
<button class="source-toggle ${state.showSource ? 'active' : ''}" data-action="toggle-source" ${m.markdown == null ? 'disabled' : ''}>
${state.showSource ? 'Show rendered' : 'Show source'}
</button>
</div>
${markdownHTML}
</div>
<div class="detail-actions">
<button class="btn" id="detail-back">Back<span class="kbd">Esc</span></button>
<button class="btn ${m.archived ? 'primary' : 'danger'}" id="detail-archive">
${m.archived ? 'Restore' : 'Archive'}<span class="kbd">D</span>
</button>
</div>
`;
// 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);
}
});
});
}
-3
View File
@@ -1,3 +0,0 @@
// Shared registry to break circular dependencies between modules.
const registry = {};
export default registry;
-246
View File
@@ -1,246 +0,0 @@
// Rendering coordinator -- thin orchestration layer.
// Delegates to extracted modules; keeps only cross-module functions locally.
import { state } from './state.js';
import { archiveMemory, restoreMemory } from './data.js';
import registry from './registry.js';
// --- Module imports ---
import { escapeHTML, $ } from './utils.js';
import { renderSidebar, renderBreadcrumb, updateWindowTitle } from './sidebar.js';
import { visibleMemories as _visibleMemories, renderMemoryList, renderMemoryDetail } from './memory-list.js';
import { renderSessionList, renderSessionDetail } from './session-list.js';
import { renderUsage } from './usage.js';
// --- Data filtering (coordinator owns the cross-module view) ---
export function visibleMemories() { return _visibleMemories(); }
export function visibleSessions() {
const q = state.query.trim().toLowerCase();
return state.sessions
.filter(s => state.projectFilter === 'all' || s.project === state.projectFilter)
.map(s => {
if (!q) return { ...s, messageHit: null };
const topMatch = (s.title || '').toLowerCase().includes(q) ||
(s.project || '').toLowerCase().includes(q) ||
(s.git_branch || '').toLowerCase().includes(q);
if (topMatch) return { ...s, messageHit: null };
return null;
})
.filter(Boolean)
.sort((a, b) => {
const ta = new Date(a.started_at || 0).getTime();
const tb = new Date(b.started_at || 0).getTime();
return state.sortDesc ? tb - ta : ta - tb;
});
}
// --- Cursor / selection ---
export function flatList() { return visibleMemories(); }
export function cursorIndex() {
const flat = flatList();
if (!state.cursorId) return -1;
return flat.findIndex(m => m.id === state.cursorId);
}
export function moveCursor(delta, extendSelection = false) {
const flat = flatList();
if (!flat.length) return;
let idx = cursorIndex();
if (idx === -1) idx = 0;
else idx = Math.max(0, Math.min(flat.length - 1, idx + delta));
const newId = flat[idx].id;
if (extendSelection) { state.selection.add(state.cursorId); state.selection.add(newId); }
state.cursorId = newId;
renderMemoryList(); renderStatus();
}
export function setCursor(id, opts = {}) {
state.cursorId = id;
if (!opts.keepSelection) state.selection.clear();
renderMemoryList(); renderStatus();
}
// --- Mutations ---
export function archive(ids) { if (!ids.length) return; doMutation(ids, true); }
export function restore(ids) { if (!ids.length) return; doMutation(ids, false); }
async function doMutation(ids, toArchived) {
for (const id of ids) {
if (toArchived) await archiveMemory(id);
else await restoreMemory(id);
}
state.selection = new Set();
if (state.mode === 'detail' && state.route === 'memory' && ids.includes(state.detailId)) exitDetail();
const flat = flatList();
if (state.cursorId && !flat.find(m => m.id === state.cursorId)) state.cursorId = flat[0]?.id ?? null;
state.lastArchiveSnapshot = ids;
state.undoExpires = Date.now() + 5000;
clearInterval(state.undoTimer);
state.undoTimer = setInterval(() => {
if (Date.now() >= state.undoExpires) { state.lastArchiveSnapshot = null; clearInterval(state.undoTimer); }
renderStatus();
}, 500);
renderAll();
}
export async function doUndo() {
if (!state.lastArchiveSnapshot) return;
for (const id of state.lastArchiveSnapshot) {
const m = state.memories.find(x => x.id === id);
if (m) {
if (m.archived) await restoreMemory(id);
else await archiveMemory(id);
}
}
state.lastArchiveSnapshot = null;
clearInterval(state.undoTimer);
renderAll();
}
// --- Navigation ---
export function navigateToSession(sessionId, focusUuid) {
state.route = 'sessions'; state.mode = 'detail';
state.detailId = sessionId;
state.subagentId = null;
state.subagentDescription = null;
state.pendingFocusUuid = focusUuid || null;
state.query = '';
const searchEl = $('#search');
if (searchEl) searchEl.value = '';
switchView(); renderAll();
}
export function navigateToSubagent(agentId, description) {
state.subagentId = agentId;
state.subagentDescription = description || agentId;
switchView(); renderAll();
}
export function enterDetail(id) { state.detailId = id; state.mode = 'detail'; state.showSource = false; switchView(); renderAll(); }
export function exitDetail() {
if (state.subagentId) {
state.subagentId = null;
state.subagentDescription = null;
switchView(); renderAll();
return;
}
state.mode = 'list'; state.detailId = null; state.pendingFocusUuid = null; switchView(); renderAll();
}
export function setRoute(route) {
state.route = route; state.mode = 'list'; state.detailId = null;
state.cursorId = null; state.selection.clear();
state.query = ''; const s = $('#search'); if (s) s.value = '';
switchView(); renderAll();
if (route === 'memory') { const flat = visibleMemories(); if (flat.length) state.cursorId = flat[0].id; }
}
export function setView(v) {
state.route = 'memory'; state.view = v; state.mode = 'list'; state.detailId = null;
state.cursorId = null; state.selection.clear(); state.projectFilter = 'all';
switchView(); renderAll();
const flat = visibleMemories();
if (flat.length) state.cursorId = flat[0].id;
renderMemoryList();
}
export function setProject(p) {
state.projectFilter = p; state.cursorId = null; state.selection.clear();
state.mode = 'list'; state.detailId = null;
switchView(); renderAll();
if (state.route === 'memory') { const flat = visibleMemories(); if (flat.length) state.cursorId = flat[0].id; }
}
export function toggleSort() {
state.sortDesc = !state.sortDesc;
const btn = $('#sort-toggle');
if (btn) { btn.classList.toggle('desc', state.sortDesc); btn.classList.toggle('asc', !state.sortDesc); }
const lbl = $('#sort-label');
if (lbl) lbl.textContent = state.sortDesc ? 'newest' : 'oldest';
if (state.route === 'sessions') renderSessionList();
else renderMemoryList();
}
export function switchView() {
const showList = state.mode === 'list';
const showSessions = state.route === 'sessions';
const showUsage = state.route === 'usage';
const inSessionDetail = !showList && showSessions;
const inSubagent = inSessionDetail && !!state.subagentId;
const el = (id, show) => { const e = $(id); if (e) e.style.display = show ? '' : 'none'; };
el('#list-wrap', showList && !showSessions && !showUsage);
el('#detail-wrap', !showList && !showSessions && !showUsage);
el('#session-list-wrap', showList && showSessions);
el('#session-detail-wrap', inSessionDetail && !inSubagent);
el('#subagent-detail-wrap', inSubagent);
el('#usage-wrap', showUsage);
el('#search-wrap', showList && !showUsage);
el('#sort-toggle', showList && !showUsage);
el('#search-msgs-toggle', showList && showSessions);
}
// --- Status bar ---
export function renderStatus() {
const left = $('#status-left');
const right = $('#status-right');
if (!left || !right) return;
if (state.route === 'sessions' && state.mode === 'list') {
right.innerHTML = `<span class="kbd-hint"><span class="kbd">⏎</span> open</span><span class="kbd-hint secondary"><span class="kbd">/</span> search</span>`;
} else if (state.route === 'sessions' && state.mode === 'detail') {
right.innerHTML = `<span class="kbd-hint"><span class="kbd">Esc</span> back</span>`;
} else if (state.mode === 'detail') {
right.innerHTML = `<span class="kbd-hint"><span class="kbd">Esc</span> back</span><span class="kbd-hint"><span class="kbd">D</span> archive</span>`;
} else {
right.innerHTML = `<span class="kbd-hint"><span class="kbd">↑↓</span> nav</span><span class="kbd-hint"><span class="kbd">⏎</span> open</span><span class="kbd-hint secondary"><span class="kbd">D</span> archive</span><span class="kbd-hint secondary"><span class="kbd">/</span> search</span>`;
}
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 = `<span class="status-pending">Action pending <strong>${escapeHTML(target)}</strong><button class="undo-btn" id="undo-btn">Undo</button><span class="timer">${secs}s</span></span>`;
$('#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 };
-530
View File
@@ -1,530 +0,0 @@
// Session list and detail rendering module.
// Extracted from render.js -- all session/subagent DOM generation.
import { state } from './state.js';
import { loadSessionDetail, loadSubagentDetail, isTextTruncated, loadFullText } from './data.js';
import { escapeHTML, highlightPlain, fmtListTime, fmtRelative, fmtClockTime, renderMarkdown, formatProjectLabel, $ } from './utils.js';
import { FOLDER_SVG } from './state.js';
import registry from './registry.js';
// --- Session list ---
export function renderSessionList() {
const items = visibleSessions();
const list = $('#session-list');
if (!list) return;
if (!items.length) {
list.innerHTML = `<div class="empty">No sessions here.<span class="hint">${state.query ? 'Try a different search term.' : 'Press / to search.'}</span></div>`;
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 `
<div class="srow ${state.cursorId === s.id ? 'cursor' : ''}" data-session-id="${s.id}">
<div class="srow-body">
<div class="srow-title">${highlightPlain(s.title || '(untitled)', q)}</div>
<div class="srow-meta">
${showProjectPrefix ? `<span class="project-tag">${escapeHTML(formatProjectLabel(s.project))}</span><span class="dot"></span>` : ''}
<span>${s.message_count || 0} msg</span>
</div>
</div>
<div class="srow-right">${fmtListTime(startedTs)}</div>
</div>
`;
}
// --- 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 = `
<div class="session-header">
<div class="session-eyebrow">
<span class="project-icon">${FOLDER_SVG}</span>
<span class="project-name">${escapeHTML(formatProjectLabel(s.project))}</span>
<span class="sep">·</span>
<span class="project-path">${escapeHTML(s.project_path || '')}</span>
</div>
<div class="session-title">${escapeHTML(s.title || '(untitled)')}</div>
<div class="session-meta-inline">
<span>${fmtRelative(startedTs)}</span>
<span class="dot"></span>
<span>${s.message_count || 0} messages</span>
${s.git_branch ? `<span class="dot"></span><span>${escapeHTML(s.git_branch)}</span>` : ''}
</div>
</div>
`;
const messagesHTML = (s.messages || []).map((msg, idx) => renderMessage(msg, idx)).join('');
detail.innerHTML = `<div class="session-progress"><div class="session-progress-fill" id="session-progress-fill"></div></div>${headerHTML}<div class="timeline">${messagesHTML}</div>`;
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 = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 12V4M4 7l4-4 4 4"/></svg>`;
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 = `
<div class="session-header">
<div class="session-eyebrow">
<span class="meta-label" style="font-size:11px;">SUBAGENT</span>
</div>
<div class="session-title">${escapeHTML(state.subagentDescription || state.subagentId)}</div>
<div class="session-meta-inline">
<span>${messages.length} messages</span>
</div>
</div>
`;
const messagesHTML = messages.map((msg, idx) => {
return renderMessage(msg, idx, { isSubagent: true });
}).join('');
detail.innerHTML = `<div class="session-progress"><div class="session-progress-fill" id="session-progress-fill"></div></div>${headerHTML}<div class="timeline">${messagesHTML}</div>`;
// 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 `
<div class="msg meta" data-uuid="${msg.uuid}">
<div class="msg-meta-collapsed">
<button class="meta-toggle">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span class="meta-label">System</span>
<span class="meta-preview">${escapeHTML(preview)}</span>
</button>
<div class="meta-body">
${renderMarkdown(msg.text, { variant: 'compact', query: state.query })}
${truncated ? `<button class="truncated-btn" data-action="load-full" data-uuid="${msg.uuid}">Message truncated — click to load full text</button>` : ''}
</div>
</div>
</div>
`;
}
// 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]) => `
<div class="wf-card-phase">
<div class="wf-card-phase-title">${escapeHTML(phase)}</div>
${agentList.map(a => `
<button class="wf-card-agent" data-action="open-subagent" data-agent-id="${a.agent_id}" data-agent-desc="${escapeHTML(a.label || '')}">
<span class="wf-card-agent-label">${escapeHTML(a.label || a.agent_id)}</span>
${a.state === 'error' ? `<span class="wf-card-agent-state error">error</span>` : ''}
<span class="wf-card-agent-arrow">→</span>
</button>
`).join('')}
</div>
`).join('');
// Render other tool calls (non-workflow) if any
const otherTools = (msg.tool_calls || []).filter(tc => tc !== workflowCall).map(renderToolCall).join('');
return `
<div class="wf-card" data-uuid="${msg.uuid}">
<div class="wf-card-header">
<span class="wf-card-icon">⚙</span>
<span class="wf-card-name">${escapeHTML(wfName)}</span>
<span class="wf-card-count">${agents.length} agents</span>
${wf.status ? `<span class="wf-card-status ${wf.status}">${escapeHTML(wf.status)}</span>` : ''}
</div>
<div class="wf-card-body">${phasesHTML}</div>
</div>
${otherTools ? `<div class="msg assistant" data-uuid="${msg.uuid}-tools"><div class="msg-tools">${otherTools}</div></div>` : ''}
`;
}
// Standalone thinking message (no following assistant to attach to)
if (isThinking) {
return `
<div class="msg assistant" data-uuid="${msg.uuid}">
<div class="msg-thinking">
<button class="thinking-toggle">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span class="thinking-label">Thinking</span>
</button>
<div class="thinking-body">${renderMarkdown(msg.text, { variant: 'msg', query: state.query })}</div>
</div>
</div>
`;
}
// Thinking block attached to this message (merged from preceding thinking messages)
let thinkingHTML = '';
if (msg._thinking) {
thinkingHTML = `
<div class="msg-thinking">
<button class="thinking-toggle">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span class="thinking-label">Thinking</span>
</button>
<div class="thinking-body">${renderMarkdown(msg._thinking, { variant: 'msg', query: state.query })}</div>
</div>
`;
}
const truncated = isTextTruncated(msg.text);
let textHTML = msg.text ? renderMarkdown(msg.text, { variant: 'msg', query: state.query }) : (tools ? '' : '<div class="msg-text empty-text">(no text content)</div>');
if (truncated) {
textHTML += `<button class="truncated-btn" data-action="load-full" data-uuid="${msg.uuid}">Message truncated — click to load full text</button>`;
}
let summaryHTML = '';
if (msg.summary) {
summaryHTML = `
<div class="msg-summary">
<button class="summary-toggle">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span class="label">Session summary</span>
<span class="source">${escapeHTML(msg.summary.source || '')}</span>
</button>
<div class="summary-body">${renderMarkdown(msg.summary.content, { variant: 'compact' })}</div>
</div>
`;
}
return `
<div class="msg ${isUser ? 'user' : 'assistant'}" data-uuid="${msg.uuid}">
<div class="msg-head">
<span class="role">${roleLabel}</span>
<span class="when">${msg.timestamp ? fmtClockTime(msg.timestamp) : ''}</span>
</div>
${thinkingHTML}
${textHTML}
${tools ? `<div class="msg-tools">${tools}</div>` : ''}
${summaryHTML}
</div>
`;
}
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 `
<div class="msg-tool agent-call">
<button class="toolcall-toggle">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span class="tool-name">${escapeHTML(agentType)}</span>
<span class="tool-arg">${escapeHTML(description)}</span>
${isError ? '<span class="tool-error">error</span>' : ''}
${subagentId ? `<button class="agent-nav-btn" data-action="open-subagent" data-agent-id="${subagentId}" data-agent-desc="${escapeHTML(description)}">View conversation →</button>` : ''}
</button>
<div class="toolcall-body">
${parsed.prompt ? `<div class="tc-section">Prompt</div><div class="agent-prompt">${escapeHTML(parsed.prompt.slice(0, 500))}${parsed.prompt.length > 500 ? '…' : ''}</div>` : ''}
${resultContent ? `<div class="tc-section">Result</div><div class="agent-result">${renderMarkdown(resultContent, { variant: 'compact' })}</div>` : ''}
</div>
</div>
`;
}
// 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]) => `
<div class="workflow-phase-group">
<div class="workflow-phase-header">${escapeHTML(phase)}</div>
<div class="workflow-phase-agents">
${agentList.map(a => `
<button class="workflow-agent-row" data-action="open-subagent" data-agent-id="${a.agent_id}" data-agent-desc="${escapeHTML(a.label || '')}">
<span class="workflow-agent-label">${escapeHTML(a.label || a.agent_id)}</span>
<span class="workflow-agent-state ${a.state || ''}">${escapeHTML(a.state || '')}</span>
</button>
`).join('')}
</div>
</div>
`).join('');
const agentListHTML = agents.length ? `
<div class="tc-section">Agents · ${agents.length}</div>
<div class="workflow-agent-list">${phasesHTML}</div>
` : '';
return `
<div class="msg-tool agent-call">
<button class="toolcall-toggle">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span class="tool-name">Workflow</span>
<span class="tool-arg">${escapeHTML(wfName)}</span>
${wfStatus ? `<span class="workflow-status ${wfStatus}">${escapeHTML(wfStatus)}</span>` : ''}
${isError ? '<span class="tool-error">error</span>' : ''}
</button>
<div class="toolcall-body">
${agentListHTML}
</div>
</div>
`;
}
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 `
<div class="msg-tool ${isError ? 'is-error' : ''}">
<button class="toolcall-toggle">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span class="tool-name">${escapeHTML(tc.name)}</span>
<span class="tool-arg">${escapeHTML(argPreview)}</span>
${isError ? '<span class="tool-error">error</span>' : ''}
</button>
<div class="toolcall-body">
<div class="tc-section">Input</div>
<pre>${escapeHTML(tc.input_json || '')}</pre>
${tc.result ? `<div class="tc-section">${isError ? 'Error' : 'Output'}</div><pre>${escapeHTML(tc.result.content || '(empty)')}</pre>` : ''}
</div>
</div>
`;
}
// --- 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;
});
}
-137
View File
@@ -1,137 +0,0 @@
// Sidebar, breadcrumb, and window title rendering.
// Extracted from render.js for modularity.
import { state, FOLDER_SVG } from './state.js';
import { $, $$, escapeHTML, formatProjectLabel } from './utils.js';
// --- Data helpers (sidebar-local) ---
function projectCountsForCurrentRoute() {
const counts = {};
if (state.route === 'sessions') {
for (const s of state.sessions) if (s.project) counts[s.project] = (counts[s.project] || 0) + 1;
} else {
for (const m of state.memories) {
const matches = state.view === 'archived' ? m.archived : !m.archived;
if (!matches) continue;
if (m.project) counts[m.project] = (counts[m.project] || 0) + 1;
}
}
return counts;
}
// --- Sidebar ---
export function renderSidebar() {
const activeCount = state.memories.filter(m => !m.archived).length;
const archivedCount = state.memories.filter(m => m.archived).length;
const el = id => $(id);
if (el('#count-sessions')) el('#count-sessions').textContent = state.sessions.length;
if (el('#count-memory-total')) el('#count-memory-total').textContent = activeCount + archivedCount;
if (el('#count-active')) el('#count-active').textContent = activeCount;
if (el('#count-archived')) el('#count-archived').textContent = archivedCount;
$$('.sidebar-item').forEach(item => {
let isActive = false;
if (item.dataset.route === 'sessions' && state.route === 'sessions' && state.projectFilter === 'all') isActive = true;
else if (item.dataset.route === 'usage' && state.route === 'usage') isActive = true;
else if (item.dataset.route === 'memory' && item.dataset.view === state.view && state.projectFilter === 'all') isActive = true;
else if (item.dataset.project && item.dataset.project === state.projectFilter) isActive = true;
if (item.dataset.route === 'memory' && !item.classList.contains('sub')) isActive = false;
item.classList.toggle('active', isActive);
});
const counts = projectCountsForCurrentRoute();
let projects = [...new Set(
(state.route === 'sessions' ? state.sessions : state.memories)
.filter(item => {
if (state.route === 'sessions') return true;
return state.view === 'archived' ? item.archived : !item.archived;
})
.map(item => item.project)
.filter(Boolean)
)];
if (state.projectSearch) {
const q = state.projectSearch.toLowerCase();
projects = projects.filter(p => p.toLowerCase().includes(q));
}
projects.sort((a, b) => formatProjectLabel(a).localeCompare(formatProjectLabel(b)));
const projectsEl = $('#sidebar-projects');
if (projectsEl) {
projectsEl.innerHTML = projects.map(p => `
<button class="sidebar-item ${state.projectFilter === p ? 'active' : ''}" data-project="${p}">
<span class="icon">${FOLDER_SVG}</span>
<span class="label">${escapeHTML(formatProjectLabel(p))}</span>
<span class="badge">${counts[p] || 0}</span>
</button>
`).join('') || `<div style="padding:8px 10px;font-size:11px;color:var(--muted-2);">No projects</div>`;
}
}
// --- 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 = `<button class="crumb" data-action="goto-sessions">Sessions</button><span class="crumb-sep">/</span><button class="crumb" data-action="goto-session-detail">${escapeHTML((s.title || s.id).slice(0, 30))}</button><span class="crumb-sep">/</span><span class="crumb terminal">${escapeHTML((state.subagentDescription || '').slice(0, 40))}</span>`;
} else {
bc.innerHTML = `<button class="crumb" data-action="goto-sessions">Sessions</button><span class="crumb-sep">/</span><span class="crumb terminal">${escapeHTML(s.title || s.id)}</span>`;
}
} else {
let html = `<button class="crumb ${state.projectFilter === 'all' ? 'terminal' : ''}" data-action="goto-sessions">Sessions</button>`;
if (state.projectFilter !== 'all') html += `<span class="crumb-sep">/</span><span class="crumb terminal">${escapeHTML(formatProjectLabel(state.projectFilter))}</span>`;
bc.innerHTML = html;
}
} else if (state.route === 'usage') {
bc.innerHTML = `<span class="crumb terminal">Usage</span>`;
} else {
if (state.mode === 'detail') {
const m = state.memories.find(x => x.id === state.detailId);
if (!m) return;
bc.innerHTML = `<button class="crumb" data-action="goto-memory">Memory</button><span class="crumb-sep">/</span><span class="crumb terminal filename">${escapeHTML(m.path.split('/').pop())}</span>`;
} else {
let html = `<button class="crumb ${state.projectFilter === 'all' ? 'terminal' : ''}" data-action="goto-memory">Memory</button>`;
if (state.projectFilter !== 'all') html += `<span class="crumb-sep">/</span><span class="crumb terminal">${escapeHTML(formatProjectLabel(state.projectFilter))}</span>`;
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 = `<span class="app-name">${appName}</span><span class="sep">—</span><span class="scope">${escapeHTML(truncated)}</span>`;
titleEl.title = `${appName}${scopeText}`;
}
document.title = `${appName}${scopeText}`;
}
-34
View File
@@ -1,34 +0,0 @@
// App state -- single source of truth for the renderer process.
// Loaded collections (memories, sessions, projects) start empty and are
// populated from the DB at boot.
export const state = {
memories: [],
sessions: [],
projects: [],
route: 'memory',
view: 'active', // 'active' | 'archived'
mode: 'list', // 'list' | 'detail'
detailId: null,
subagentId: null,
subagentDescription: null,
pendingFocusUuid: null,
query: '',
projectFilter: 'all',
projectSearch: '',
sortDesc: true,
includeMessageBodies: false,
cursorId: null,
selection: new Set(),
showSource: false,
lastArchiveSnapshot: null,
undoTimer: null,
undoExpires: 0
};
// Platform detection
export const IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform);
// SVG icon constants
export const FOLDER_SVG = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"><path d="M2.5 4h4l1.5 1.5h5.5v7a1 1 0 0 1-1 1h-10a1 1 0 0 1-1-1v-8z"/></svg>`;
export const FILE_SVG = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" stroke-linecap="round"><path d="M3.5 2h6l3 3v9a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M9.5 2v3h3"/></svg>`;
-573
View File
@@ -1,573 +0,0 @@
// Usage rendering module -- heatmap, weekly chart, cumulative chart.
// Extracted from render.js with identical logic.
import { state } from './state.js';
import { escapeHTML, fmtDuration, fmtTokens, fmtTooltipDate, positionTooltip, formatProjectLabel, $ } from './utils.js';
import registry from './registry.js';
function navigateToSession(sessionId, focusUuid) {
if (registry.navigateToSession) {
registry.navigateToSession(sessionId, focusUuid);
}
}
export async function renderUsage() {
const usage = $('#usage');
if (!usage) return;
const data = await window.obelisk.getUsageStats();
const { daily, totalTokens, peakDay, longestTurn } = data;
// Build heatmap: 52 weeks x 7 days grid
const today = new Date();
const dayMs = 86400000;
// Start from the first Sunday on or after 364 days ago (full weeks only)
let startDate = new Date(today.getTime() - 364 * dayMs);
startDate.setHours(0, 0, 0, 0);
const daysUntilSunday = (7 - startDate.getDay()) % 7;
startDate = new Date(startDate.getTime() + daysUntilSunday * dayMs);
const dailyMap = {};
for (const d of daily) dailyMap[d.day] = d.tokens;
const values = daily.map(d => d.tokens).filter(Boolean);
const maxTokens = Math.max(...values, 1);
// Generate cells (startDate is always a Sunday now)
const cells = [];
for (let i = 0; i < 371; i++) {
const date = new Date(startDate.getTime() + i * dayMs);
if (date > today) break;
const key = date.toISOString().slice(0, 10);
const tokens = dailyMap[key] || 0;
const level = tokens === 0 ? 0 : Math.min(4, Math.ceil((tokens / maxTokens) * 4));
const col = Math.floor(i / 7);
const row = i % 7;
cells.push({ key, tokens, level, col, row, date });
}
const maxCol = cells.length ? cells[cells.length - 1].col : 0;
const cellSize = 11;
const cellGap = 2;
const step = cellSize + cellGap;
const gridWidth = (maxCol + 1) * step + 20; // extra padding for last month label
const gridHeight = 7 * step;
const cellsHTML = cells.map(c => {
const x = c.col * step;
const y = c.row * step;
return `<rect x="${x}" y="${y}" width="${cellSize}" height="${cellSize}" rx="2" class="heatmap-cell level-${c.level}" data-label="${fmtTokens(c.tokens)} tokens on ${fmtTooltipDate(c.key)}"></rect>`;
}).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 =>
`<text x="${m.col * step}" y="${gridHeight + 14}" class="heatmap-month">${m.label}</text>`
).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 = `
<div class="usage-header">
<span class="usage-title">Token activity</span>
<div class="usage-view-tabs">
<button class="usage-tab active" data-view="daily">Daily</button>
<button class="usage-tab" data-view="weekly">Weekly</button>
<button class="usage-tab" data-view="cumulative">Cumulative</button>
</div>
</div>
<div class="usage-stats">
<div class="usage-stat">
<span class="usage-stat-value">${fmtTokens(totalTokens)}</span>
<span class="usage-stat-label">Lifetime tokens</span>
</div>
<div class="usage-stat">
<span class="usage-stat-value">${peakDay ? fmtTokens(peakDay.tokens) : '—'}</span>
<span class="usage-stat-label">Peak tokens</span>
</div>
<div class="usage-stat">
<span class="usage-stat-value">${longestTurn ? fmtDuration(longestTurn.turn_duration_ms) : '—'}</span>
<span class="usage-stat-label">Longest task</span>
</div>
<div class="usage-stat">
<span class="usage-stat-value">${currentStreak}d</span>
<span class="usage-stat-label">Current streak</span>
</div>
<div class="usage-stat">
<span class="usage-stat-value">${longestStreak}d</span>
<span class="usage-stat-label">Longest streak</span>
</div>
</div>
<div class="heatmap-container">
<svg class="heatmap" width="${gridWidth}" height="${gridHeight + 20}" viewBox="0 0 ${gridWidth} ${gridHeight + 20}">
${cellsHTML}
${monthLabelsHTML}
</svg>
<div class="heatmap-legend">
<span class="heatmap-legend-label">Less</span>
<svg width="70" height="11"><rect x="0" width="11" height="11" rx="2" class="heatmap-cell level-0"/><rect x="14" width="11" height="11" rx="2" class="heatmap-cell level-1"/><rect x="28" width="11" height="11" rx="2" class="heatmap-cell level-2"/><rect x="42" width="11" height="11" rx="2" class="heatmap-cell level-3"/><rect x="56" width="11" height="11" rx="2" class="heatmap-cell level-4"/></svg>
<span class="heatmap-legend-label">More</span>
</div>
</div>
<div class="chart-container" id="usage-chart" style="display:none;"></div>
<div class="day-sessions" id="day-sessions"></div>
`;
// 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 = `<div class="day-sessions-header">${fmtTooltipDate(dateKey)} — no sessions</div>`;
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 = `<div class="day-sessions-header">${fmtTooltipDate(dateKey)}</div><div class="day-activity-timeline">`;
if (newWorkspaces.length) {
html += `
<div class="activity-group">
<div class="activity-group-header">
<span class="activity-icon workspace">★</span>
<span class="activity-group-title">Created ${newWorkspaces.length} new workspace${newWorkspaces.length > 1 ? 's' : ''}</span>
</div>
<div class="activity-group-items">
${newWorkspaces.map(s => `
<button class="activity-item" data-session-id="${s.id}">
<span class="activity-item-name"><span class="activity-item-project">${escapeHTML(formatProjectLabel(s.project))}</span> ${escapeHTML(s.title || '(untitled)')}</span>
<span class="activity-item-meta">${s.message_count || 0} msg</span>
</button>
`).join('')}
</div>
</div>
`;
}
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 += `
<div class="activity-group">
<div class="activity-group-header">
<span class="activity-icon new">+</span>
<span class="activity-group-title">Started ${newSessions.length} session${newSessions.length > 1 ? 's' : ''} in ${Object.keys(byProject).length} project${Object.keys(byProject).length > 1 ? 's' : ''}</span>
</div>
<div class="activity-group-items">
${newSessions.map(s => `
<button class="activity-item" data-session-id="${s.id}">
<span class="activity-item-name">${escapeHTML(s.title || '(untitled)')}</span>
<span class="activity-item-meta">${escapeHTML(formatProjectLabel(s.project))} · ${s.message_count || 0} msg</span>
</button>
`).join('')}
</div>
</div>
`;
}
if (continued.length) {
html += `
<div class="activity-group continued">
<div class="activity-group-header">
<span class="activity-icon continued">↳</span>
<span class="activity-group-title">Continued ${continued.length} session${continued.length > 1 ? 's' : ''}</span>
</div>
<div class="activity-group-items">
${continued.map(s => `
<button class="activity-item" data-session-id="${s.id}">
<span class="activity-item-name">${escapeHTML(s.title || '(untitled)')}</span>
<span class="activity-item-meta">${escapeHTML(formatProjectLabel(s.project))} · ${s.message_count || 0} msg</span>
</button>
`).join('')}
</div>
</div>
`;
}
html += `</div>`;
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 = `<div class="day-sessions-header">${headerText}</div><div class="day-activity-timeline">`;
if (newWorkspaces.length) {
html += `
<div class="activity-group">
<div class="activity-group-header">
<span class="activity-icon workspace">★</span>
<span class="activity-group-title">Created ${newWorkspaces.length} new workspace${newWorkspaces.length > 1 ? 's' : ''}</span>
</div>
<div class="activity-group-items">
${newWorkspaces.map(s => `
<button class="activity-item" data-session-id="${s.id}">
<span class="activity-item-name"><span class="activity-item-project">${escapeHTML(formatProjectLabel(s.project))}</span> ${escapeHTML(s.title || '(untitled)')}</span>
<span class="activity-item-meta">${s.message_count || 0} msg</span>
</button>
`).join('')}
</div>
</div>
`;
}
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 += `
<div class="activity-group">
<div class="activity-group-header">
<span class="activity-icon new">+</span>
<span class="activity-group-title">Started ${newSessions.length} session${newSessions.length > 1 ? 's' : ''} in ${Object.keys(byProject).length} project${Object.keys(byProject).length > 1 ? 's' : ''}</span>
</div>
<div class="activity-group-items">
${newSessions.map(s => `
<button class="activity-item" data-session-id="${s.id}">
<span class="activity-item-name">${escapeHTML(s.title || '(untitled)')}</span>
<span class="activity-item-meta">${escapeHTML(formatProjectLabel(s.project))} · ${s.message_count || 0} msg</span>
</button>
`).join('')}
</div>
</div>
`;
}
if (continued.length) {
html += `
<div class="activity-group continued">
<div class="activity-group-header">
<span class="activity-icon continued">↳</span>
<span class="activity-group-title">Continued ${continued.length} session${continued.length > 1 ? 's' : ''}</span>
</div>
<div class="activity-group-items">
${continued.map(s => `
<button class="activity-item" data-session-id="${s.id}">
<span class="activity-item-name">${escapeHTML(s.title || '(untitled)')}</span>
<span class="activity-item-meta">${escapeHTML(formatProjectLabel(s.project))} · ${s.message_count || 0} msg</span>
</button>
`).join('')}
</div>
</div>
`;
}
if (!classified.length) html += `<div style="color:var(--muted);font-size:13px;padding:8px 0;">No sessions this month.</div>`;
html += `</div>`;
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 = '<div class="empty">No data</div>'; 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 `<rect x="${x}" y="${chartHeight - h}" width="${barWidth}" height="${Math.max(h, 0.5)}" rx="2" class="bar-fill" data-label="Week of ${w.weekStart.toISOString().slice(0, 10)}: ${fmtTokens(w.tokens)}"></rect>`;
}).join('');
const labelsHTML = labels.map(l => {
const x = l.i * (barWidth + barGap);
return `<text x="${x}" y="${chartHeight + 16}" class="heatmap-month">${l.label}</text>`;
}).join('');
container.innerHTML = `
<div class="chart-tooltip" id="chart-tooltip"></div>
<svg class="weekly-chart" viewBox="0 0 ${chartWidth + 20} ${chartHeight + 24}" preserveAspectRatio="xMidYMid meet">
${barsHTML}
${labelsHTML}
</svg>
`;
// 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 = '<div class="empty">No data</div>'; 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 => `<text x="${l.x}" y="${chartHeight + 16}" class="heatmap-month">${l.label}</text>`).join('');
// Invisible hover dots for tooltip
const dotsHTML = points.map((p, i) => {
return `<circle cx="${xScale(i).toFixed(1)}" cy="${yScale(p.total).toFixed(1)}" r="6" class="cumulative-dot" data-label="${p.day}: ${fmtTokens(p.total)} total"/>`;
}).join('');
container.innerHTML = `
<div class="chart-tooltip" id="chart-tooltip-cum"></div>
<svg viewBox="0 0 ${chartWidth} ${chartHeight + 24}" preserveAspectRatio="xMidYMid meet" class="cumulative-chart">
<path d="${areaPath}" class="cumulative-area"/>
<path d="${linePath}" class="cumulative-line"/>
${dotsHTML}
${labelsHTML}
</svg>
`;
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'));
});
}
-194
View File
@@ -1,194 +0,0 @@
// Utility functions extracted from render.js
// Pure helpers with no side-effects on global state (except formatProjectLabel which reads state).
import { state } from './state.js';
// --- Time / formatting ---
export function pad2(n) { return String(n).padStart(2, '0'); }
export function isSameDay(a, b) {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
}
export function fmtListTime(ts) {
const d = new Date(ts);
const now = new Date();
const hhmm = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
if (isSameDay(d, now)) return hhmm;
const mmdd = `${pad2(d.getMonth() + 1)}/${pad2(d.getDate())}`;
if (d.getFullYear() === now.getFullYear()) return `${mmdd} ${hhmm}`;
return `${d.getFullYear()}/${mmdd} ${hhmm}`;
}
export function fmtRelative(ts) {
const diff = Date.now() - ts;
const min = 60000, hr = 3600000, day = 86400000;
if (diff < 0) return 'in the future';
if (diff < min) return 'just now';
if (diff < hr) return Math.floor(diff / min) + 'm ago';
if (diff < day) return Math.floor(diff / hr) + 'h ago';
if (diff < day * 30) return Math.floor(diff / day) + 'd ago';
if (diff < day * 365) return Math.floor(diff / (day * 30)) + 'mo ago';
return Math.floor(diff / (day * 365)) + 'y ago';
}
export function fmtClockTime(iso) {
const d = new Date(iso);
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
}
export function fmtSize(bytes) {
if (!bytes) return '-';
if (bytes < 1024) return bytes + 'B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'K';
return (bytes / 1024 / 1024).toFixed(1) + 'M';
}
// --- HTML / Markdown ---
export function escapeHTML(s) { return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
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 => `<mark>${m}</mark>`);
}
export function sanitizeMarkdown(html) {
return html
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<iframe[\s\S]*?<\/iframe>/gi, '')
.replace(/<style[\s\S]*?<\/style>/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: `<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M7 1.5l5.5 9.5h-11z M7 5v3M7 9.2v.6"/></svg>`,
partial: `<svg viewBox="0 0 14 14" fill="currentColor"><circle cx="7" cy="7" r="3.5"/></svg>`,
archived: `<svg viewBox="0 0 14 14" fill="currentColor"><circle cx="7" cy="7" r="2.5"/></svg>`
};
return `<span class="row-status ${status}" title="${status}">${glyphs[status] || ''}</span>`;
}
+43 -7
View File
@@ -1,13 +1,13 @@
<script setup>
import { computed, watch, ref, provide } from 'vue';
import { computed, watch, ref, provide, onMounted, onUnmounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import {
state,
IS_MAC,
FOLDER_SVG,
setRoute,
resetListState,
setView,
setProject,
clearSelection,
setQuery,
setProjectSearch,
toggleSort,
@@ -15,9 +15,11 @@ import {
} from './store.js';
import { formatProjectLabel } from './utils.js';
import { buildSidebarProjects } from './sidebar-projects.mjs';
import { resolveGlobalShortcut } from './keyboard-shortcuts.mjs';
const router = useRouter();
const route = useRoute();
let searchTimer = null;
// --- Sidebar data ---
@@ -108,7 +110,8 @@ watch(() => windowTitle.value.scopeText, (scopeText) => {
// --- Navigation helpers ---
function handleSidebarRoute(routeName) {
setRoute(routeName);
clearTimeout(searchTimer);
resetListState();
if (routeName === 'sessions') {
router.push('/sessions');
} else if (routeName === 'activity') {
@@ -141,7 +144,7 @@ function handleProjectSearch(e) {
// --- Search ---
let searchTimer = null;
const searchInputRef = ref(null);
function handleSearch(e) {
const value = e.target.value;
clearTimeout(searchTimer);
@@ -158,6 +161,38 @@ function handleToggleSearchMsgs() {
toggleIncludeMessageBodies();
}
function handleGlobalKeydown(event) {
const tagName = event.target?.tagName;
const command = resolveGlobalShortcut(event, {
isTextInput: tagName === 'INPUT' || tagName === 'TEXTAREA' || event.target?.isContentEditable,
isListRoute: showToolbar.value,
hasSelection: state.selection.size > 0,
hasQuery: Boolean(state.query),
});
if (!command) return;
event.preventDefault();
if (command === 'open-sessions') handleSidebarRoute('sessions');
else if (command === 'open-active-memories') handleSidebarView('active');
else if (command === 'open-archived-memories') handleSidebarView('archived');
else if (command === 'focus-search') {
searchInputRef.value?.focus();
searchInputRef.value?.select();
} else if (command === 'blur-input') event.target?.blur?.();
else if (command === 'toggle-sort') handleToggleSort();
else if (command === 'clear-selection') clearSelection();
else if (command === 'clear-query') {
clearTimeout(searchTimer);
setQuery('');
}
}
onMounted(() => window.addEventListener('keydown', handleGlobalKeydown));
onUnmounted(() => {
window.removeEventListener('keydown', handleGlobalKeydown);
clearTimeout(searchTimer);
});
// --- Keep-alive includes ---
const keepAliveIncludes = ['SessionDetail'];
@@ -417,14 +452,14 @@ provide('recapGenerateOpen', recapGenerateOpen);
<template v-if="showToolbar">
<template v-if="state.projectFilter !== 'all'">
<button class="crumb" @click="handleClearProject">
{{ state.route === 'sessions' ? 'Sessions' : 'Memory' }}
{{ currentRouteType === 'sessions' ? 'Sessions' : 'Memory' }}
</button>
<span class="crumb-sep">/</span>
<span class="crumb terminal">{{ formatProjectLabel(state.projectFilter) }}</span>
</template>
<template v-else>
<span class="crumb terminal">
{{ state.route === 'sessions' ? 'Sessions' : state.route === 'memory' ? 'Memory' : 'Activity' }}
{{ currentRouteType === 'sessions' ? 'Sessions' : 'Memory' }}
</span>
</template>
</template>
@@ -516,6 +551,7 @@ provide('recapGenerateOpen', recapGenerateOpen);
<path d="M11 11l3 3" stroke-linecap="round"/>
</svg>
<input
ref="searchInputRef"
id="search"
type="text"
placeholder="Search…"
-308
View File
@@ -1,308 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import {
state,
FOLDER_SVG,
setRoute,
setView,
setProject,
setProjectSearch
} from '../store.js';
import { formatProjectLabel } from '../utils.js';
const router = useRouter();
const route = useRoute();
// --- Counts ---
const sessionCount = computed(() => state.sessions.length);
const activeCount = computed(() => state.memories.filter(m => !m.archived).length);
const archivedCount = computed(() => state.memories.filter(m => m.archived).length);
const totalMemoryCount = computed(() => state.memories.length);
// --- Projects list ---
const sidebarProjects = computed(() => {
const items = state.route === 'sessions' ? state.sessions : state.memories;
const filtered = items.filter(item => {
if (state.route === 'sessions') return true;
return state.view === 'archived' ? item.archived : !item.archived;
});
let projects = [...new Set(filtered.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)));
// Count per project
const counts = {};
for (const item of filtered) {
if (item.project) counts[item.project] = (counts[item.project] || 0) + 1;
}
return projects.map(p => ({
slug: p,
label: formatProjectLabel(p),
count: counts[p] || 0
}));
});
// --- Active state helpers ---
function isSessionsActive() {
return state.route === 'sessions' && state.projectFilter === 'all';
}
function isMemoryViewActive(view) {
return state.route === 'memory' && state.view === view && state.projectFilter === 'all';
}
function isActivityActive() {
return state.route === 'activity';
}
function isProjectActive(slug) {
return state.projectFilter === slug;
}
// --- Navigation handlers ---
function handleSidebarRoute(routeName) {
setRoute(routeName);
if (routeName === 'sessions') {
router.push('/sessions');
} else if (routeName === 'activity') {
router.push('/activity');
} else {
router.push('/memory');
}
}
function handleSidebarView(view) {
setView(view);
router.push('/memory');
}
function handleSidebarProject(slug) {
setProject(slug);
if (state.route === 'sessions') router.push('/sessions');
else router.push('/memory');
}
function handleProjectSearch(e) {
setProjectSearch(e.target.value);
}
</script>
<template>
<aside class="sidebar">
<div class="sidebar-brand">
<svg viewBox="0 0 20 20" fill="none">
<circle cx="10" cy="10" r="8" stroke="currentColor" stroke-width="1.2" opacity="0.6"/>
<path d="M10 4 L10 16" stroke="url(#obelisk-grad)" stroke-width="2.5" stroke-linecap="round"/>
<defs>
<linearGradient id="obelisk-grad" x1="10" y1="4" x2="10" y2="16" gradientUnits="userSpaceOnUse">
<stop stop-color="#a78bfa"/>
<stop offset="1" stop-color="#6366f1"/>
</linearGradient>
</defs>
</svg>
<span class="name">Obelisk</span>
</div>
<!-- Library section -->
<div class="sidebar-section">
<div class="sidebar-section-title"><span>Library</span></div>
<button
class="sidebar-item"
:class="{ active: isSessionsActive() }"
@click="handleSidebarRoute('sessions')"
>
<span class="icon">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
<rect x="2" y="3" width="12" height="10" rx="1.5"/>
<path d="M5 1v4M11 1v4"/>
</svg>
</span>
<span class="label">Sessions</span>
<span class="badge">{{ sessionCount }}</span>
</button>
<!-- Memory parent (non-clickable label) -->
<div class="sidebar-section-title" style="padding-top: 8px;">
<span>Memory</span>
<span class="badge">{{ totalMemoryCount }}</span>
</div>
<button
class="sidebar-item sub"
:class="{ active: isMemoryViewActive('active') }"
@click="handleSidebarView('active')"
>
<span class="icon">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
<circle cx="8" cy="8" r="5.5"/>
<path d="M8 5v3l2 1.5"/>
</svg>
</span>
<span class="label">Active</span>
<span class="badge">{{ activeCount }}</span>
</button>
<button
class="sidebar-item sub"
:class="{ active: isMemoryViewActive('archived') }"
@click="handleSidebarView('archived')"
>
<span class="icon">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
<path d="M2.5 5h11v7.5a1.5 1.5 0 0 1-1.5 1.5H4a1.5 1.5 0 0 1-1.5-1.5V5z"/>
<path d="M1.5 3.5h13v2h-13z"/>
<path d="M6 8h4"/>
</svg>
</span>
<span class="label">Archived</span>
<span class="badge">{{ archivedCount }}</span>
</button>
</div>
<!-- Stats section -->
<div class="sidebar-section">
<div class="sidebar-section-title"><span>Stats</span></div>
<button
class="sidebar-item"
:class="{ active: isActivityActive() }"
@click="handleSidebarRoute('usage')"
>
<span class="icon">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
<path d="M2 13h12M4 9v4M7 6v7M10 8v5M13 4v9"/>
</svg>
</span>
<span class="label">Activity</span>
</button>
</div>
<!-- Projects section -->
<div class="sidebar-section projects">
<div class="sidebar-section-title">
<span>Projects</span>
</div>
<div class="sidebar-search">
<svg class="sidebar-search-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="7" cy="7" r="4.5"/>
<path d="M10.5 10.5L14 14"/>
</svg>
<input
type="text"
placeholder="Filter..."
:value="state.projectSearch"
@input="handleProjectSearch"
/>
</div>
<div class="sidebar-list">
<button
v-for="p in sidebarProjects"
:key="p.slug"
class="sidebar-item"
:class="{ active: isProjectActive(p.slug) }"
@click="handleSidebarProject(p.slug)"
>
<span class="icon" v-html="FOLDER_SVG"></span>
<span class="label">{{ p.label }}</span>
<span class="badge">{{ p.count }}</span>
</button>
<div v-if="!sidebarProjects.length" class="sidebar-empty">
No projects
</div>
</div>
</div>
</aside>
</template>
<style scoped>
.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;
border: none; background: none;
}
.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; }
.sidebar-empty {
padding: 8px 10px;
font-size: 11px;
color: var(--muted-2);
}
</style>
-381
View File
@@ -1,381 +0,0 @@
<script setup>
import { computed, ref, onMounted, onUnmounted } from 'vue';
import { useRoute } from 'vue-router';
import { state, toggleSort, setQuery, toggleIncludeMessageBodies } from '../store.js';
import { formatProjectLabel } from '../utils.js';
// --- Route info ---
const route = useRoute();
const isListView = computed(() => {
return route.name === 'SessionList' || route.name === 'MemoryList';
});
const showSearchMsgsToggle = computed(() => {
return route.name === 'SessionList';
});
// --- Breadcrumb computation ---
const breadcrumbs = computed(() => {
const name = route.name;
const crumbs = [];
if (name === 'SessionList') {
crumbs.push({ label: 'Sessions', terminal: true });
if (state.projectFilter !== 'all') {
crumbs.push({ label: formatProjectLabel(state.projectFilter), terminal: true });
}
} else if (name === 'SessionDetail') {
crumbs.push({ label: 'Sessions', to: '/sessions' });
const s = state.sessions.find(x => x.id === route.params.id);
crumbs.push({ label: s?.title || route.params.id, terminal: true });
} else if (name === 'SubagentDetail') {
crumbs.push({ label: 'Sessions', to: '/sessions' });
const s = state.sessions.find(x => x.id === route.params.id);
crumbs.push({ label: (s?.title || '').slice(0, 30) || route.params.id, to: `/sessions/${route.params.id}` });
crumbs.push({ label: route.params.agentId, terminal: true });
} else if (name === 'MemoryList') {
crumbs.push({ label: 'Memory', terminal: true });
if (state.projectFilter !== 'all') {
crumbs.push({ label: formatProjectLabel(state.projectFilter), terminal: true });
}
} else if (name === 'MemoryDetail') {
crumbs.push({ label: 'Memory', to: '/memory' });
const m = state.memories.find(x => x.id === route.params.id);
const filename = (m?.path || '').split('/').pop();
crumbs.push({ label: filename, terminal: true, filename: true });
} else if (name === 'Activity') {
crumbs.push({ label: 'Activity', terminal: true });
} else if (name === 'Recap') {
crumbs.push({ label: 'Recap', terminal: true });
}
return crumbs;
});
// --- Search ---
const searchInput = ref(null);
let searchTimer = null;
function handleSearch(e) {
const value = e.target.value;
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
setQuery(value);
}, 200);
}
// --- Keyboard shortcut: / to focus search ---
function handleKeydown(e) {
if (e.key === '/' && !e.ctrlKey && !e.metaKey && !e.altKey) {
const tag = document.activeElement?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
e.preventDefault();
searchInput.value?.focus();
}
}
onMounted(() => {
document.addEventListener('keydown', handleKeydown);
});
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown);
clearTimeout(searchTimer);
});
// --- Sort ---
function handleToggleSort() {
toggleSort();
}
function handleToggleSearchMsgs() {
toggleIncludeMessageBodies();
}
</script>
<template>
<div class="toolbar">
<div class="breadcrumb">
<template v-for="(crumb, i) in breadcrumbs" :key="i">
<span v-if="i > 0" class="crumb-sep">/</span>
<router-link
v-if="crumb.to"
class="crumb"
:to="crumb.to"
>
{{ crumb.label }}
</router-link>
<span
v-else
class="crumb terminal"
:class="{ filename: crumb.filename }"
>
{{ crumb.label }}
</span>
</template>
</div>
<div class="toolbar-spacer"></div>
<!-- Search + sort controls (list views only) -->
<template v-if="isListView">
<div class="toolbar-search">
<svg class="toolbar-search-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6">
<circle cx="6.5" cy="6.5" r="4"/>
<path d="M10 10l3.5 3.5"/>
</svg>
<input
ref="searchInput"
type="text"
placeholder="Search..."
@input="handleSearch"
/>
<span class="toolbar-search-kbd">/</span>
</div>
<button
v-if="showSearchMsgsToggle"
class="filter-toggle"
:class="{ active: state.includeMessageBodies }"
@click="handleToggleSearchMsgs"
title="Include message bodies in search"
>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
<rect x="2" y="3" width="12" height="10" rx="1.5"/>
<path d="M2 5.5l6 3.5 6-3.5"/>
</svg>
</button>
<button
class="sort-group"
:class="{ desc: state.sortDesc, asc: !state.sortDesc }"
@click="handleToggleSort"
>
<span class="label">{{ state.sortDesc ? 'newest' : 'oldest' }}</span>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
<path class="arrow-up" d="M8 3v5M5.5 5.5L8 3l2.5 2.5"/>
<path class="arrow-down" d="M8 8v5M5.5 10.5L8 13l2.5-2.5"/>
</svg>
</button>
</template>
</div>
</template>
<style scoped>
.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;
text-decoration: none;
}
.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;
background: transparent;
cursor: pointer;
}
.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;
border: none;
background: transparent;
}
.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;
}
</style>
+1 -1
View File
@@ -2,7 +2,7 @@
// All DB access goes through this module.
import { markRaw } from 'vue';
import { state, clearUndo } from './store.js';
import { state } from './store.js';
/**
* Load initial data from the DB and populate state.memories, state.sessions,
@@ -0,0 +1,62 @@
export function normalizeShortcutKey(event) {
return event.key.length === 1 ? event.key.toLowerCase() : event.key;
}
export function resolveGlobalShortcut(event, context) {
if (event.defaultPrevented) return null;
const key = normalizeShortcutKey(event);
const modifier = event.metaKey || event.ctrlKey;
if (modifier) {
if (key === '1') return 'open-sessions';
if (key === '2') return 'open-active-memories';
if (key === '3') return 'open-archived-memories';
return null;
}
if (event.altKey) return null;
if (context.isTextInput) {
return key === 'Escape' ? 'blur-input' : null;
}
if (key === '/' && context.isListRoute) return 'focus-search';
if (key === 's' && context.isListRoute) return 'toggle-sort';
if (key === 'Escape') {
if (context.hasSelection) return 'clear-selection';
if (context.hasQuery) return 'clear-query';
}
return null;
}
export function resolveMemoryShortcut(event, context) {
if (event.defaultPrevented || context.isTextInput) return null;
const key = normalizeShortcutKey(event);
const modifier = event.metaKey || event.ctrlKey;
if (modifier) {
if (key === 'z' && !event.shiftKey && context.hasUndo) return { type: 'undo' };
return null;
}
if (event.altKey) return null;
if (context.showDetail) {
if (key === 'Escape') return { type: 'close-detail' };
if (key === 'd') return { type: 'mutate-detail' };
return null;
}
if (key === 'j' || key === 'ArrowDown') {
return { type: 'move-cursor', direction: 1, extend: Boolean(event.shiftKey) };
}
if (key === 'k' || key === 'ArrowUp') {
return { type: 'move-cursor', direction: -1, extend: Boolean(event.shiftKey) };
}
if (key === 'Enter' && context.hasCursor) return { type: 'open-detail' };
if (key === 'x' && context.hasCursor) return { type: 'toggle-selection' };
if (key === 'd') return { type: 'mutate-selection' };
if (key === 'u' && context.hasUndo) return { type: 'undo' };
return null;
}
+1 -1
View File
@@ -6,7 +6,7 @@ import router from './router.js';
import { loadInitialData } from './data.js';
import { noteSessionUpdated, sessionLiveState } from './session-live.mjs';
// Import all original CSS globally
// Import shared renderer CSS globally
import '../styles/base.css';
import '../styles/sidebar.css';
import '../styles/toolbar.css';
+1 -2
View File
@@ -8,7 +8,6 @@ const SessionList = () => import('./views/SessionList.vue');
const SessionDetail = () => import('./views/SessionDetail.vue');
const SubagentDetail = () => import('./views/SubagentDetail.vue');
const MemoryList = () => import('./views/MemoryList.vue');
const MemoryDetail = () => import('./views/MemoryDetail.vue');
const Activity = () => import('./views/Activity.vue');
const Recap = () => import('./views/RecapList.vue');
const RecapDetail = () => import('./views/RecapDetail.vue');
@@ -42,7 +41,7 @@ const routes = [
{
path: '/memory/:id',
name: 'MemoryDetail',
component: MemoryDetail,
component: MemoryList,
props: true
},
{
+14 -74
View File
@@ -1,5 +1,5 @@
// Reactive store -- Vue 3 reactive() replaces the plain object from state.js.
// All state fields are ported; action functions mutate the reactive state.
// Shared renderer state. Navigation state belongs to Vue Router; this store
// holds only data and cross-view UI preferences.
import { reactive, markRaw } from 'vue';
@@ -8,12 +8,7 @@ export const state = reactive({
sessions: [],
projects: [],
stats: {},
route: 'memory',
view: 'active', // 'active' | 'archived'
mode: 'list', // 'list' | 'detail'
detailId: null,
subagentId: null,
subagentDescription: null,
pendingFocusUuid: null,
query: '',
projectFilter: 'all',
@@ -23,92 +18,45 @@ export const state = reactive({
includeMessageBodies: false,
cursorId: null,
selection: markRaw(new Set()),
showSource: false,
lastArchiveSnapshot: null,
undoTimer: null,
undoExpires: 0,
loaded: false
});
// Platform detection
export const IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform);
// SVG icon constants
export const FOLDER_SVG = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"><path d="M2.5 4h4l1.5 1.5h5.5v7a1 1 0 0 1-1 1h-10a1 1 0 0 1-1-1v-8z"/></svg>`;
export const FILE_SVG = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" stroke-linecap="round"><path d="M3.5 2h6l3 3v9a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M9.5 2v3h3"/></svg>`;
// --- Action functions ---
export function setRoute(route) {
state.route = route;
state.mode = 'list';
state.detailId = null;
export function setSelection(ids) {
state.selection = markRaw(new Set(ids));
}
export function clearSelection() {
setSelection([]);
}
export function resetListState() {
state.cursorId = null;
state.selection = markRaw(new Set());
clearSelection();
state.query = '';
}
export function setView(v) {
state.route = 'memory';
state.view = v;
state.mode = 'list';
state.detailId = null;
state.cursorId = null;
state.selection = markRaw(new Set());
clearSelection();
state.projectFilter = 'all';
}
export function setProject(p) {
state.projectFilter = p;
state.cursorId = null;
state.selection = markRaw(new Set());
state.mode = 'list';
state.detailId = null;
clearSelection();
}
export function toggleSort() {
state.sortDesc = !state.sortDesc;
}
export function enterDetail(id) {
state.detailId = id;
state.mode = 'detail';
state.showSource = false;
}
export function exitDetail() {
if (state.subagentId) {
state.subagentId = null;
state.subagentDescription = null;
return;
}
state.mode = 'list';
state.detailId = null;
state.pendingFocusUuid = null;
}
export function navigateToSession(sessionId, focusUuid) {
state.route = 'sessions';
state.mode = 'detail';
state.detailId = sessionId;
state.subagentId = null;
state.subagentDescription = null;
state.pendingFocusUuid = focusUuid || null;
state.query = '';
}
export function navigateToSubagent(agentId, description) {
state.subagentId = agentId;
state.subagentDescription = description || agentId;
}
export function setCursor(id, opts = {}) {
state.cursorId = id;
if (!opts.keepSelection) {
state.selection = markRaw(new Set());
}
}
export function setQuery(q) {
state.query = q;
}
@@ -120,11 +68,3 @@ export function setProjectSearch(q) {
export function toggleIncludeMessageBodies() {
state.includeMessageBodies = !state.includeMessageBodies;
}
export function clearUndo() {
state.lastArchiveSnapshot = null;
if (state.undoTimer) {
clearInterval(state.undoTimer);
state.undoTimer = null;
}
}
+1 -2
View File
@@ -1,7 +1,7 @@
<script setup>
import { ref, reactive, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { state, navigateToSession } from '../store.js';
import { state } from '../store.js';
import { fmtTokens, fmtDuration, fmtTooltipDate, positionTooltip, escapeHTML, formatProjectLabel } from '../utils.js';
defineOptions({ name: 'Activity' });
@@ -322,7 +322,6 @@ function updateTooltipPos(event) {
}
function goToSession(sessionId) {
navigateToSession(sessionId);
router.push({ name: 'SessionDetail', params: { id: sessionId } });
}
-116
View File
@@ -1,116 +0,0 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import { state, FOLDER_SVG } from '../store.js';
import { loadMemoryMarkdown, archiveMemory, restoreMemory, isTextTruncated } from '../data.js';
import { escapeHTML, fmtRelative, renderMarkdown, formatProjectLabel } from '../utils.js';
defineOptions({ name: 'MemoryDetail' });
const props = defineProps({ id: String });
const router = useRouter();
const memory = computed(() => state.memories.find(m => m.id === props.id));
const markdown = ref(null);
const showSource = ref(false);
const loading = ref(false);
onMounted(async () => { await loadContent(); });
watch(() => props.id, async () => { markdown.value = null; showSource.value = false; await loadContent(); });
async function loadContent() {
const m = memory.value;
if (!m) return;
if (m.markdown != null) { markdown.value = m.markdown; return; }
if (m.path) {
loading.value = true;
const content = await loadMemoryMarkdown(m.path);
m.markdown = content;
markdown.value = content;
loading.value = false;
}
}
async function handleArchive() {
const m = memory.value;
if (!m) return;
if (m.archived) await restoreMemory(m.id);
else await archiveMemory(m.id);
router.push('/memory');
}
function goToSession() {
const m = memory.value;
if (m?.session_id) router.push(`/sessions/${m.session_id}`);
}
</script>
<template>
<div class="detail" v-if="memory">
<div class="detail-header">
<div class="detail-eyebrow">
<span class="project-icon" v-html="FOLDER_SVG"></span>
<span class="project-name">{{ formatProjectLabel(memory.project) }}</span>
<span v-if="memory.archived" class="archived-tag">archived</span>
</div>
<div class="detail-path">{{ memory.path }}</div>
<div class="detail-summary">{{ memory.summary }}</div>
<div class="detail-meta">
<button v-if="memory.session_id" class="session-link" @click="goToSession">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" style="width:11px;height:11px;">
<path d="M3 4h10v8a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4z"/>
<path d="M5.5 7h5M5.5 9.5h3" stroke-linecap="round"/>
</svg>
<span>Source session</span>
</button>
<span class="dot" v-if="memory.session_id"></span>
<span>created {{ fmtRelative(memory.ts) }}</span>
<template v-if="memory.message_start">
<span class="dot"></span>
<span style="font-family:var(--font-mono);font-size:11px;">{{ memory.message_start.slice(0, 8) }} {{ (memory.message_end || '').slice(0, 8) }}</span>
</template>
</div>
</div>
<div class="markdown-section">
<div class="markdown-toolbar">
<span class="markdown-toolbar-label">Body</span>
<button
class="source-toggle"
:class="{ active: showSource }"
:disabled="markdown == null"
@click="showSource = !showSource"
>{{ showSource ? 'Show rendered' : 'Show source' }}</button>
</div>
<div v-if="loading" style="color:var(--muted);padding:20px;text-align:center;">Loading</div>
<div v-else-if="markdown == null" style="color:var(--muted-2);font-style:italic;padding:20px;text-align:center;border:1px dashed var(--hairline);border-radius:6px;">File not found or empty.</div>
<pre v-else-if="showSource" class="markdown-source">{{ markdown }}</pre>
<div v-else class="markdown-msg" v-html="renderMarkdown(markdown, { variant: 'body' })"></div>
</div>
<div v-if="memory.anchors && memory.anchors.length" class="detail-section-divider" id="anchors-section">
<span>Anchors</span><span class="count">{{ memory.anchors.length }}</span>
</div>
<div v-if="memory.anchors && memory.anchors.length" class="anchor-list">
<button
v-for="a in memory.anchors"
:key="a.path + ':' + a.line"
class="anchor-link"
:disabled="a.exists === false"
:title="a.exists === false ? 'File no longer exists' : 'Open in editor'"
>
<span class="anchor-icon">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" stroke-linecap="round"><path d="M3.5 2h6l3 3v9a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M9.5 2v3h3"/></svg>
</span>
<span class="anchor-path">{{ a.path }}</span>
<span class="anchor-line" v-if="a.line">:{{ a.line }}</span>
</button>
</div>
<div class="detail-actions">
<button class="btn" @click="router.push('/memory')">Back</button>
<button class="btn" :class="memory.archived ? 'primary' : 'danger'" @click="handleArchive">
{{ memory.archived ? 'Restore' : 'Archive' }}
</button>
</div>
</div>
</template>
+110 -89
View File
@@ -1,11 +1,13 @@
<script setup>
import { computed, ref, nextTick, onMounted, onUnmounted } from 'vue';
import { computed, ref, nextTick, onMounted, onUnmounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import { state, FOLDER_SVG, clearUndo } from '../store.js';
import { state, FOLDER_SVG, setSelection, clearSelection } from '../store.js';
import { highlightPlain, escapeHTML, formatProjectLabel, fmtListTime, fmtRelative, renderMarkdown } from '../utils.js';
import { loadMemoryMarkdown, archiveMemory, restoreMemory } from '../data.js';
import { resolveMemoryShortcut } from '../keyboard-shortcuts.mjs';
defineOptions({ name: 'MemoryList' });
const props = defineProps({ id: String });
const router = useRouter();
const listWrapRef = ref(null);
@@ -29,12 +31,12 @@ const showProjectPrefix = computed(() => state.projectFilter === 'all');
// --- Detail state ---
const detailMemory = ref(null);
const detailMemory = computed(() => props.id ? state.memories.find(memory => memory.id === props.id) : null);
const detailMarkdown = ref(null);
const showSource = ref(false);
const loadingMarkdown = ref(false);
const showDetail = computed(() => detailMemory.value !== null);
const showDetail = computed(() => Boolean(props.id));
// --- Row helpers ---
@@ -100,18 +102,31 @@ function projectLabel(m) {
// --- Selection ---
function toggleSelection(id) {
function toggleSelection(id, { range = false } = {}) {
const s = new Set(state.selection);
if (s.has(id)) s.delete(id);
else s.add(id);
state.selection = s;
if (range && state.cursorId) {
const ids = visibleMemories.value.map(memory => memory.id);
const from = ids.indexOf(state.cursorId);
const to = ids.indexOf(id);
if (from !== -1 && to !== -1) {
const [start, end] = from < to ? [from, to] : [to, from];
for (let index = start; index <= end; index++) s.add(ids[index]);
}
} else if (s.has(id)) {
s.delete(id);
} else {
s.add(id);
}
state.cursorId = id;
setSelection(s);
}
// --- Cursor navigation ---
function moveCursor(direction) {
function moveCursor(direction, extendSelection = false) {
const items = visibleMemories.value;
if (!items.length) return;
const previousId = state.cursorId;
const curIdx = items.findIndex(m => m.id === state.cursorId);
let next;
if (curIdx === -1) {
@@ -121,7 +136,11 @@ function moveCursor(direction) {
if (next < 0) next = 0;
if (next >= items.length) next = items.length - 1;
}
state.cursorId = items[next].id;
const nextId = items[next].id;
if (extendSelection && previousId) {
setSelection([...state.selection, previousId, nextId]);
}
state.cursorId = nextId;
nextTick(() => ensureVisible());
}
@@ -140,23 +159,29 @@ function ensureVisible() {
// --- Open detail ---
async function openDetail(m) {
detailMemory.value = m;
let detailLoadVersion = 0;
async function loadDetail(memory) {
const version = ++detailLoadVersion;
showSource.value = false;
loadingMarkdown.value = true;
detailMarkdown.value = null;
loadingMarkdown.value = Boolean(memory);
if (m.markdown === null && m.path) {
m.markdown = await loadMemoryMarkdown(m.path);
if (memory?.markdown == null && memory.path) {
memory.markdown = await loadMemoryMarkdown(memory.path);
}
detailMarkdown.value = m.markdown;
if (version !== detailLoadVersion) return;
detailMarkdown.value = memory?.markdown ?? null;
loadingMarkdown.value = false;
}
watch(detailMemory, loadDetail, { immediate: true });
function openDetail(m) {
router.push({ name: 'MemoryDetail', params: { id: m.id } });
}
function closeDetail() {
detailMemory.value = null;
detailMarkdown.value = null;
showSource.value = false;
router.push({ name: 'MemoryList' });
}
function toggleSourceView() {
@@ -165,7 +190,11 @@ function toggleSourceView() {
// --- Row click ---
function onRowClick(m) {
function onRowClick(m, event) {
if (event.shiftKey || event.metaKey || event.ctrlKey) {
toggleSelection(m.id, { range: event.shiftKey });
return;
}
state.cursorId = m.id;
openDetail(m);
}
@@ -175,16 +204,17 @@ function onRowClick(m) {
const undoSnapshot = ref(null);
let undoTimer = null;
async function doArchive(ids) {
async function mutateMemories(ids, action) {
const targets = ids || (state.cursorId ? [state.cursorId] : []);
if (!targets.length) return;
undoSnapshot.value = { action: 'archive', ids: [...targets] };
undoSnapshot.value = { action, ids: [...targets] };
undoCountdown.value = 5;
for (const id of targets) {
await archiveMemory(id);
if (action === 'archive') await archiveMemory(id);
else await restoreMemory(id);
}
clearSelection();
startUndoTimer();
// Move cursor if needed
if (targets.includes(state.cursorId)) {
const items = visibleMemories.value;
if (items.length) state.cursorId = items[0].id;
@@ -195,24 +225,8 @@ async function doArchive(ids) {
}
}
async function doRestore(ids) {
const targets = ids || (state.cursorId ? [state.cursorId] : []);
if (!targets.length) return;
undoSnapshot.value = { action: 'restore', ids: [...targets] };
undoCountdown.value = 5;
for (const id of targets) {
await restoreMemory(id);
}
startUndoTimer();
if (targets.includes(state.cursorId)) {
const items = visibleMemories.value;
if (items.length) state.cursorId = items[0].id;
else state.cursorId = null;
}
if (showDetail.value && targets.includes(detailMemory.value?.id)) {
closeDetail();
}
}
const doArchive = (ids) => mutateMemories(ids, 'archive');
const doRestore = (ids) => mutateMemories(ids, 'restore');
async function undoAction() {
if (!undoSnapshot.value) return;
@@ -261,51 +275,28 @@ const renderedMarkdown = computed(() => {
// --- Keyboard handler ---
function onKeydown(e) {
// Do not handle if user is typing in an input
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
const tagName = e.target?.tagName;
const command = resolveMemoryShortcut(e, {
isTextInput: tagName === 'INPUT' || tagName === 'TEXTAREA' || e.target?.isContentEditable,
showDetail: showDetail.value,
hasUndo: Boolean(undoSnapshot.value),
hasCursor: Boolean(state.cursorId),
});
if (!command) return;
if (showDetail.value) {
if (e.key === 'Escape') { e.preventDefault(); closeDetail(); return; }
if (e.key === 'd' || e.key === 'D') { e.preventDefault(); detailArchiveRestore(); return; }
return;
}
switch (e.key) {
case 'j':
e.preventDefault();
moveCursor(1);
break;
case 'k':
e.preventDefault();
moveCursor(-1);
break;
case 'Enter':
e.preventDefault();
if (state.cursorId) {
const m = visibleMemories.value.find(x => x.id === state.cursorId);
if (m) openDetail(m);
}
break;
case 'x':
e.preventDefault();
if (state.cursorId) toggleSelection(state.cursorId);
break;
case 'd':
case 'D':
e.preventDefault();
if (state.view === 'archived') {
doRestore(state.selection.size ? [...state.selection] : (state.cursorId ? [state.cursorId] : []));
} else {
doArchive(state.selection.size ? [...state.selection] : (state.cursorId ? [state.cursorId] : []));
}
break;
case 'z':
if ((e.metaKey || e.ctrlKey) && undoSnapshot.value) {
e.preventDefault();
undoAction();
}
break;
}
e.preventDefault();
if (command.type === 'move-cursor') moveCursor(command.direction, command.extend);
else if (command.type === 'open-detail') {
const memory = visibleMemories.value.find(item => item.id === state.cursorId);
if (memory) openDetail(memory);
} else if (command.type === 'toggle-selection') toggleSelection(state.cursorId);
else if (command.type === 'mutate-selection') {
const targets = state.selection.size ? [...state.selection] : (state.cursorId ? [state.cursorId] : []);
if (state.view === 'archived') doRestore(targets);
else doArchive(targets);
} else if (command.type === 'undo') undoAction();
else if (command.type === 'close-detail') closeDetail();
else if (command.type === 'mutate-detail') detailArchiveRestore();
}
onMounted(() => {
@@ -321,7 +312,7 @@ onUnmounted(() => {
<template>
<!-- Detail panel overlay -->
<div v-if="showDetail" class="detail-wrap">
<div class="detail">
<div v-if="detailMemory" class="detail">
<div class="detail-header">
<div class="detail-eyebrow">
<span class="project-icon" v-html="FOLDER_SVG"></span>
@@ -344,6 +335,12 @@ onUnmounted(() => {
</button>
<span v-if="detailMemory.session_id" class="dot"></span>
<span>{{ fmtRelative(detailMemory.ts) }}</span>
<template v-if="detailMemory.message_start">
<span class="dot"></span>
<span class="message-range">
{{ detailMemory.message_start.slice(0, 8) }} {{ (detailMemory.message_end || '').slice(0, 8) }}
</span>
</template>
</div>
</div>
@@ -368,6 +365,28 @@ onUnmounted(() => {
<div v-else class="markdown-body" v-html="renderedMarkdown"></div>
</div>
<div v-if="detailMemory.anchors?.length" class="detail-section-divider" id="anchors-section">
<span>Anchors</span><span class="count">{{ detailMemory.anchors.length }}</span>
</div>
<div v-if="detailMemory.anchors?.length" class="anchor-list">
<button
v-for="anchor in detailMemory.anchors"
:key="`${anchor.path}:${anchor.line}`"
class="anchor-link"
:disabled="anchor.exists === false"
:title="anchor.exists === false ? 'File no longer exists' : 'Open in editor'"
>
<span class="anchor-icon">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" stroke-linecap="round">
<path d="M3.5 2h6l3 3v9a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/>
<path d="M9.5 2v3h3"/>
</svg>
</span>
<span class="anchor-path">{{ anchor.path }}</span>
<span v-if="anchor.line" class="anchor-line">:{{ anchor.line }}</span>
</button>
</div>
<div class="detail-actions">
<button class="btn" @click="closeDetail">
Back<span class="kbd">Esc</span>
@@ -381,6 +400,7 @@ onUnmounted(() => {
</button>
</div>
</div>
<div v-else class="empty">{{ state.loaded ? 'Memory not found.' : 'Loading...' }}</div>
</div>
<!-- List panel -->
@@ -401,13 +421,13 @@ onUnmounted(() => {
archived: m.archived
}"
:data-id="m.id"
@click="onRowClick(m)"
@click="onRowClick(m, $event)"
>
<button
class="row-checkbox"
:class="{ checked: state.selection.has(m.id) }"
aria-label="Select"
@click.stop="toggleSelection(m.id)"
@click.stop="toggleSelection(m.id, { range: $event.shiftKey })"
>
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round">
<path d="M2.5 6.5l2.5 2.5 4.5-5"/>
@@ -687,6 +707,7 @@ onUnmounted(() => {
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; }
.message-range { font-size: 11px; }
.session-link {
color: var(--accent-2); border: 0; background: transparent;
padding: 2px 5px; margin: -2px 0; border-radius: 3px;
-29
View File
@@ -1,29 +0,0 @@
.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; }