feat(app): add Electron desktop UI and evolve memory/retrieval layer
Introduce an Electron app with session browser, memory list, and usage views (vanilla JS + Vue scaffolding). On the data layer: add content_type and is_meta to messages for transcript control-plane filtering, introduce FTS5-backed memory recall with safe tokenization, support memory archival via forget() through the renamed --attune runtime, and expose anchors on memory records.
This commit is contained in:
+269
@@ -0,0 +1,269 @@
|
||||
const { app, BrowserWindow, ipcMain } = require('electron');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const fs = require('fs');
|
||||
const Database = require('better-sqlite3');
|
||||
|
||||
const DB_PATH = path.join(os.homedir(), '.claude', 'obelisk.sqlite');
|
||||
|
||||
let db;
|
||||
|
||||
function openDb() {
|
||||
if (!fs.existsSync(DB_PATH)) return null;
|
||||
db = new Database(DB_PATH, { readonly: false });
|
||||
db.pragma('journal_mode = WAL');
|
||||
return db;
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const win = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
minWidth: 800,
|
||||
minHeight: 500,
|
||||
titleBarStyle: 'hiddenInset',
|
||||
trafficLightPosition: { x: 14, y: 10 },
|
||||
backgroundColor: '#0a0b14',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
|
||||
const isDev = process.argv.includes('--dev');
|
||||
if (isDev) {
|
||||
win.loadURL('http://localhost:5173');
|
||||
win.webContents.openDevTools();
|
||||
} else {
|
||||
win.loadFile(path.join(__dirname, 'dist-renderer', 'index.html'));
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
openDb();
|
||||
createWindow();
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (db) db.close();
|
||||
if (process.platform !== 'darwin') app.quit();
|
||||
});
|
||||
|
||||
// --- IPC Handlers ---
|
||||
|
||||
ipcMain.handle('db:getSessions', (_, opts = {}) => {
|
||||
if (!db) return [];
|
||||
const { project, limit = 200 } = opts;
|
||||
let sql = `SELECT id, title, project, project_path, started_at, ended_at, git_branch, version, message_count, jsonl_path FROM sessions`;
|
||||
const params = [];
|
||||
if (project) { sql += ` WHERE project LIKE ?`; params.push(project); }
|
||||
sql += ` ORDER BY COALESCE(ended_at, started_at) DESC LIMIT ?`;
|
||||
params.push(limit);
|
||||
return db.prepare(sql).all(...params);
|
||||
});
|
||||
|
||||
ipcMain.handle('db:getSessionMessages', (_, sessionId) => {
|
||||
if (!db) return [];
|
||||
return db.prepare(`
|
||||
SELECT m.uuid, m.session_id, m.type, m.parent_uuid, m.timestamp, m.role, m.text, m.model,
|
||||
m.is_sidechain, m.agent_id, m.input_tokens, m.output_tokens, m.cwd, m.skill, m.turn_duration_ms,
|
||||
m.content_type, m.is_meta
|
||||
FROM messages m WHERE m.session_id = ? AND m.agent_id IS NULL ORDER BY m.timestamp
|
||||
`).all(sessionId);
|
||||
});
|
||||
|
||||
ipcMain.handle('db:getSessionToolCalls', (_, sessionId) => {
|
||||
if (!db) return [];
|
||||
return db.prepare(`SELECT * FROM tool_calls WHERE session_id = ?`).all(sessionId);
|
||||
});
|
||||
|
||||
ipcMain.handle('db:getSessionToolResults', (_, sessionId) => {
|
||||
if (!db) return [];
|
||||
return db.prepare(`SELECT * FROM tool_results WHERE session_id = ?`).all(sessionId);
|
||||
});
|
||||
|
||||
ipcMain.handle('db:getSessionSubagents', (_, sessionId) => {
|
||||
if (!db) return [];
|
||||
return db.prepare(`SELECT * FROM subagents WHERE session_id = ?`).all(sessionId);
|
||||
});
|
||||
|
||||
ipcMain.handle('db:getSessionWorkflows', (_, sessionId) => {
|
||||
if (!db) return [];
|
||||
const workflows = db.prepare(`SELECT * FROM workflows WHERE session_id = ?`).all(sessionId);
|
||||
for (const wf of workflows) {
|
||||
wf.agents = db.prepare(`SELECT * FROM workflow_agents WHERE run_id = ?`).all(wf.run_id);
|
||||
}
|
||||
return workflows;
|
||||
});
|
||||
|
||||
ipcMain.handle('db:getSubagentMessages', (_, agentId) => {
|
||||
if (!db) return [];
|
||||
return db.prepare(`
|
||||
SELECT m.uuid, m.session_id, m.type, m.parent_uuid, m.timestamp, m.role, m.text, m.model,
|
||||
m.is_sidechain, m.agent_id, m.input_tokens, m.output_tokens, m.cwd, m.skill, m.turn_duration_ms,
|
||||
m.content_type, m.is_meta
|
||||
FROM messages m WHERE m.agent_id = ? ORDER BY m.timestamp
|
||||
`).all(agentId);
|
||||
});
|
||||
|
||||
ipcMain.handle('db:getSubagentToolCalls', (_, agentId) => {
|
||||
if (!db) return [];
|
||||
return db.prepare(`
|
||||
SELECT tc.* FROM tool_calls tc
|
||||
JOIN messages m ON m.uuid = tc.message_uuid
|
||||
WHERE m.agent_id = ?
|
||||
`).all(agentId);
|
||||
});
|
||||
|
||||
ipcMain.handle('db:getSubagentToolResults', (_, agentId) => {
|
||||
if (!db) return [];
|
||||
return db.prepare(`
|
||||
SELECT tr.* FROM tool_results tr
|
||||
JOIN messages m ON m.uuid = tr.message_uuid
|
||||
WHERE m.agent_id = ?
|
||||
`).all(agentId);
|
||||
});
|
||||
|
||||
ipcMain.handle('db:getSessionSummaries', (_, sessionId) => {
|
||||
if (!db) return [];
|
||||
return db.prepare(`SELECT * FROM summaries WHERE session_id = ?`).all(sessionId);
|
||||
});
|
||||
|
||||
ipcMain.handle('db:getMemories', () => {
|
||||
if (!db) return [];
|
||||
return db.prepare(`
|
||||
SELECT id, session_id, project, message_start, message_end, path, summary, created_at, deleted_at, deleted_reason
|
||||
FROM memories ORDER BY created_at DESC
|
||||
`).all();
|
||||
});
|
||||
|
||||
ipcMain.handle('db:getMessageFullText', (_, uuid) => {
|
||||
if (!db) return null;
|
||||
const msg = db.prepare('SELECT session_id, agent_id FROM messages WHERE uuid=?').get(uuid);
|
||||
if (!msg) return null;
|
||||
|
||||
// Resolve JSONL path
|
||||
let jsonlPath = null;
|
||||
if (msg.agent_id) {
|
||||
const wa = db.prepare('SELECT agent_id, run_id, session_id FROM workflow_agents WHERE agent_id=?').get(msg.agent_id);
|
||||
if (wa) {
|
||||
const ses = db.prepare('SELECT jsonl_path FROM sessions WHERE id=?').get(wa.session_id);
|
||||
if (ses) jsonlPath = path.join(path.dirname(ses.jsonl_path), wa.session_id, 'subagents', 'workflows', wa.run_id, wa.agent_id + '.jsonl');
|
||||
}
|
||||
if (!jsonlPath) {
|
||||
const sa = db.prepare('SELECT agent_id, session_id FROM subagents WHERE agent_id=?').get(msg.agent_id);
|
||||
if (sa) {
|
||||
const ses = db.prepare('SELECT jsonl_path FROM sessions WHERE id=?').get(sa.session_id);
|
||||
if (ses) jsonlPath = path.join(path.dirname(ses.jsonl_path), sa.session_id, 'subagents', sa.agent_id + '.jsonl');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!jsonlPath) {
|
||||
const ses = db.prepare('SELECT jsonl_path FROM sessions WHERE id=?').get(msg.session_id);
|
||||
if (ses) jsonlPath = ses.jsonl_path;
|
||||
}
|
||||
if (!jsonlPath || !fs.existsSync(jsonlPath)) return null;
|
||||
|
||||
// Scan JSONL for the message UUID and extract full text
|
||||
const readline = require('readline');
|
||||
const data = fs.readFileSync(jsonlPath, 'utf-8');
|
||||
const lines = data.split('\n');
|
||||
for (const line of lines) {
|
||||
if (!line.includes(uuid)) continue;
|
||||
try {
|
||||
const obj = JSON.parse(line);
|
||||
if (obj.uuid !== uuid) continue;
|
||||
const content = obj.message?.content;
|
||||
if (typeof content === 'string') return content;
|
||||
if (!Array.isArray(content)) return null;
|
||||
const parts = [];
|
||||
for (const b of content) {
|
||||
if (b.type === 'text' && b.text) parts.push(b.text);
|
||||
else if (b.type === 'thinking' && b.thinking) parts.push(b.thinking);
|
||||
}
|
||||
return parts.join('\n') || null;
|
||||
} catch { continue; }
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
ipcMain.handle('db:readMemoryFile', (_, filePath) => {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) return fs.readFileSync(filePath, 'utf-8');
|
||||
return null;
|
||||
} catch { return null; }
|
||||
});
|
||||
|
||||
ipcMain.handle('db:archiveMemory', (_, id, reason) => {
|
||||
if (!db) return false;
|
||||
db.prepare(`UPDATE memories SET deleted_at = ?, deleted_reason = ? WHERE id = ?`)
|
||||
.run(new Date().toISOString(), reason || 'Archived via panel', id);
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('db:restoreMemory', (_, id) => {
|
||||
if (!db) return false;
|
||||
db.prepare(`UPDATE memories SET deleted_at = NULL, deleted_reason = NULL WHERE id = ?`).run(id);
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('db:getProjects', () => {
|
||||
if (!db) return [];
|
||||
return db.prepare(`
|
||||
SELECT project, project_path, COUNT(*) as session_count,
|
||||
MAX(COALESCE(ended_at, started_at)) as last_active
|
||||
FROM sessions WHERE project IS NOT NULL
|
||||
GROUP BY project ORDER BY last_active DESC
|
||||
`).all();
|
||||
});
|
||||
|
||||
ipcMain.handle('db:getStats', () => {
|
||||
if (!db) return { sessions: 0, memories: 0, memoriesArchived: 0 };
|
||||
const sessions = db.prepare('SELECT COUNT(*) as c FROM sessions').get()?.c || 0;
|
||||
const memories = db.prepare('SELECT COUNT(*) as c FROM memories WHERE deleted_at IS NULL').get()?.c || 0;
|
||||
const memoriesArchived = db.prepare('SELECT COUNT(*) as c FROM memories WHERE deleted_at IS NOT NULL').get()?.c || 0;
|
||||
return { sessions, memories, memoriesArchived };
|
||||
});
|
||||
|
||||
ipcMain.handle('db:getUsageStats', () => {
|
||||
if (!db) return { daily: [], totalTokens: 0, peakDay: null, longestTurn: null };
|
||||
|
||||
const daily = db.prepare(`
|
||||
SELECT DATE(timestamp) as day,
|
||||
SUM(COALESCE(input_tokens,0) + COALESCE(output_tokens,0)) as tokens
|
||||
FROM messages
|
||||
WHERE timestamp IS NOT NULL AND (input_tokens IS NOT NULL OR output_tokens IS NOT NULL)
|
||||
GROUP BY DATE(timestamp)
|
||||
ORDER BY day
|
||||
`).all();
|
||||
|
||||
const totalTokens = db.prepare(`
|
||||
SELECT SUM(COALESCE(input_tokens,0) + COALESCE(output_tokens,0)) as total
|
||||
FROM messages
|
||||
`).get()?.total || 0;
|
||||
|
||||
const peakDay = db.prepare(`
|
||||
SELECT DATE(timestamp) as day,
|
||||
SUM(COALESCE(input_tokens,0) + COALESCE(output_tokens,0)) as tokens
|
||||
FROM messages
|
||||
WHERE timestamp IS NOT NULL AND (input_tokens IS NOT NULL OR output_tokens IS NOT NULL)
|
||||
GROUP BY DATE(timestamp)
|
||||
ORDER BY tokens DESC
|
||||
LIMIT 1
|
||||
`).get() || null;
|
||||
|
||||
const longestTurn = db.prepare(`
|
||||
SELECT turn_duration_ms, uuid, session_id, timestamp
|
||||
FROM messages
|
||||
WHERE turn_duration_ms IS NOT NULL
|
||||
ORDER BY turn_duration_ms DESC
|
||||
LIMIT 1
|
||||
`).get() || null;
|
||||
|
||||
return { daily, totalTokens, peakDay, longestTurn };
|
||||
});
|
||||
Generated
+6858
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "obelisk",
|
||||
"version": "0.1.0",
|
||||
"description": "Memory management for Obelisk — let Claude Code search its own memory",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"dev": "electron . --dev",
|
||||
"dev:renderer": "vite renderer",
|
||||
"build:renderer": "vite build renderer",
|
||||
"build": "electron-builder"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.obelisk.app",
|
||||
"productName": "Obelisk",
|
||||
"mac": {
|
||||
"target": "dmg",
|
||||
"category": "public.app-category.developer-tools"
|
||||
},
|
||||
"files": [
|
||||
"main.js",
|
||||
"preload.js",
|
||||
"dist-renderer/**/*",
|
||||
"node_modules/better-sqlite3/**/*"
|
||||
],
|
||||
"asarUnpack": [
|
||||
"node_modules/better-sqlite3/**/*"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"electron": "^33.0.0",
|
||||
"electron-builder": "^25.0.0",
|
||||
"vite": "^6.0.0",
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.3.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
contextBridge.exposeInMainWorld('obelisk', {
|
||||
getSessions: (opts) => ipcRenderer.invoke('db:getSessions', opts),
|
||||
getSessionMessages: (id) => ipcRenderer.invoke('db:getSessionMessages', id),
|
||||
getSessionToolCalls: (id) => ipcRenderer.invoke('db:getSessionToolCalls', id),
|
||||
getSessionToolResults: (id) => ipcRenderer.invoke('db:getSessionToolResults', id),
|
||||
getSessionSubagents: (id) => ipcRenderer.invoke('db:getSessionSubagents', id),
|
||||
getSessionWorkflows: (id) => ipcRenderer.invoke('db:getSessionWorkflows', id),
|
||||
getSubagentMessages: (agentId) => ipcRenderer.invoke('db:getSubagentMessages', agentId),
|
||||
getSubagentToolCalls: (agentId) => ipcRenderer.invoke('db:getSubagentToolCalls', agentId),
|
||||
getSubagentToolResults: (agentId) => ipcRenderer.invoke('db:getSubagentToolResults', agentId),
|
||||
getSessionSummaries: (id) => ipcRenderer.invoke('db:getSessionSummaries', id),
|
||||
getMessageFullText: (uuid) => ipcRenderer.invoke('db:getMessageFullText', uuid),
|
||||
getMemories: () => ipcRenderer.invoke('db:getMemories'),
|
||||
readMemoryFile: (path) => ipcRenderer.invoke('db:readMemoryFile', path),
|
||||
archiveMemory: (id, reason) => ipcRenderer.invoke('db:archiveMemory', id, reason),
|
||||
restoreMemory: (id) => ipcRenderer.invoke('db:restoreMemory', id),
|
||||
getProjects: () => ipcRenderer.invoke('db:getProjects'),
|
||||
getStats: () => ipcRenderer.invoke('db:getStats'),
|
||||
getUsageStats: () => ipcRenderer.invoke('db:getUsageStats'),
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Obelisk</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,235 @@
|
||||
// Entry point -- wires data, rendering, and event handlers together.
|
||||
// No exports; this is the bootstrap module.
|
||||
|
||||
import { loadInitialData } from './data.js';
|
||||
import { state, IS_MAC } from './state.js';
|
||||
import {
|
||||
renderAll,
|
||||
renderMemoryList,
|
||||
renderSessionList,
|
||||
setRoute,
|
||||
setView,
|
||||
setProject,
|
||||
enterDetail,
|
||||
archive,
|
||||
restore,
|
||||
setCursor,
|
||||
navigateToSession,
|
||||
switchView
|
||||
} from './render.js';
|
||||
import { initKeyboard } from './keys.js';
|
||||
|
||||
// -- Helpers ----------------------------------------------------------------
|
||||
|
||||
let searchDebounceTimer = null;
|
||||
|
||||
function debounce(fn, ms) {
|
||||
return (...args) => {
|
||||
clearTimeout(searchDebounceTimer);
|
||||
searchDebounceTimer = setTimeout(() => fn(...args), ms);
|
||||
};
|
||||
}
|
||||
|
||||
// -- Boot -------------------------------------------------------------------
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await loadInitialData();
|
||||
initKeyboard();
|
||||
|
||||
// -- Sidebar navigation (route switching + project filter) ----------------
|
||||
|
||||
const sidebar = document.querySelector('.sidebar');
|
||||
if (sidebar) {
|
||||
sidebar.addEventListener('click', e => {
|
||||
const item = e.target.closest('.sidebar-item');
|
||||
if (!item) return;
|
||||
|
||||
const route = item.dataset.route;
|
||||
const project = item.dataset.project;
|
||||
const view = item.dataset.view;
|
||||
|
||||
if (route) {
|
||||
setRoute(route);
|
||||
} else if (view) {
|
||||
setView(view);
|
||||
} else if (project !== undefined) {
|
||||
setProject(project);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -- Sidebar search (filter projects list) --------------------------------
|
||||
|
||||
const sidebarSearch = document.querySelector('.sidebar-search input');
|
||||
if (sidebarSearch) {
|
||||
sidebarSearch.addEventListener('input', e => {
|
||||
state.projectSearch = e.target.value;
|
||||
renderAll();
|
||||
});
|
||||
}
|
||||
|
||||
// -- Breadcrumb click (navigate back to list) -----------------------------
|
||||
|
||||
const breadcrumb = document.querySelector('.breadcrumb');
|
||||
if (breadcrumb) {
|
||||
breadcrumb.addEventListener('click', e => {
|
||||
const crumb = e.target.closest('[data-action]');
|
||||
if (!crumb) return;
|
||||
const action = crumb.dataset.action;
|
||||
if (action === 'goto-sessions') { state.projectFilter = 'all'; setRoute('sessions'); }
|
||||
else if (action === 'goto-memory') { state.projectFilter = 'all'; setView('active'); }
|
||||
else if (action === 'goto-session-detail') { state.subagentId = null; state.subagentDescription = null; switchView(); renderAll(); }
|
||||
});
|
||||
}
|
||||
|
||||
// -- Toolbar search with debounce -----------------------------------------
|
||||
|
||||
const searchInput = document.getElementById('search');
|
||||
if (searchInput) {
|
||||
const handleSearch = debounce(value => {
|
||||
state.query = value;
|
||||
if (state.route === 'sessions') renderSessionList();
|
||||
else renderMemoryList();
|
||||
}, 200);
|
||||
|
||||
searchInput.addEventListener('input', e => {
|
||||
handleSearch(e.target.value);
|
||||
});
|
||||
}
|
||||
|
||||
// -- Sort toggle -----------------------------------------------------------
|
||||
|
||||
const sortToggle = document.querySelector('.sort-group');
|
||||
if (sortToggle) {
|
||||
sortToggle.addEventListener('click', () => {
|
||||
state.sortDesc = !state.sortDesc;
|
||||
sortToggle.classList.toggle('desc', state.sortDesc);
|
||||
sortToggle.classList.toggle('asc', !state.sortDesc);
|
||||
if (state.route === 'sessions') renderSessionList();
|
||||
else renderMemoryList();
|
||||
});
|
||||
}
|
||||
|
||||
// -- Search messages toggle ------------------------------------------------
|
||||
|
||||
const searchMsgsToggle = document.querySelector('.filter-toggle');
|
||||
if (searchMsgsToggle) {
|
||||
searchMsgsToggle.addEventListener('click', () => {
|
||||
state.includeMessageBodies = !state.includeMessageBodies;
|
||||
searchMsgsToggle.classList.toggle('active', state.includeMessageBodies);
|
||||
if (state.query) {
|
||||
if (state.route === 'sessions') renderSessionList();
|
||||
else renderMemoryList();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -- #list click (memory rows: selection, actions, navigation) ------------
|
||||
|
||||
const list = document.getElementById('list');
|
||||
if (list) {
|
||||
list.addEventListener('click', e => {
|
||||
// Action buttons (archive/restore)
|
||||
const action = e.target.closest('.row-action');
|
||||
if (action) {
|
||||
e.stopPropagation();
|
||||
const row = action.closest('.row');
|
||||
const id = row?.dataset.id;
|
||||
if (!id) return;
|
||||
if (action.classList.contains('restore')) {
|
||||
restore([id]);
|
||||
} else {
|
||||
archive([id]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Checkbox toggling
|
||||
const checkbox = e.target.closest('.row-checkbox');
|
||||
if (checkbox) {
|
||||
e.stopPropagation();
|
||||
const row = checkbox.closest('.row');
|
||||
const id = row?.dataset.id;
|
||||
if (!id) return;
|
||||
|
||||
if (e.shiftKey && state.cursorId) {
|
||||
// Range select between cursor and clicked
|
||||
const rows = Array.from(list.querySelectorAll('.row'));
|
||||
const ids = rows.map(r => r.dataset.id);
|
||||
const fromIdx = ids.indexOf(state.cursorId);
|
||||
const toIdx = ids.indexOf(id);
|
||||
const [lo, hi] = fromIdx < toIdx ? [fromIdx, toIdx] : [toIdx, fromIdx];
|
||||
for (let i = lo; i <= hi; i++) {
|
||||
state.selection.add(ids[i]);
|
||||
}
|
||||
} else if (e.metaKey || e.ctrlKey) {
|
||||
// Toggle single
|
||||
if (state.selection.has(id)) state.selection.delete(id);
|
||||
else state.selection.add(id);
|
||||
} else {
|
||||
// Simple toggle
|
||||
if (state.selection.has(id)) state.selection.delete(id);
|
||||
else state.selection.add(id);
|
||||
}
|
||||
setCursor(id);
|
||||
renderMemoryList();
|
||||
return;
|
||||
}
|
||||
|
||||
// Row click (navigate cursor / open detail)
|
||||
const row = e.target.closest('.row');
|
||||
if (!row) return;
|
||||
const id = row.dataset.id;
|
||||
if (!id) return;
|
||||
|
||||
if (e.shiftKey && state.cursorId) {
|
||||
// Shift-click: range select
|
||||
const rows = Array.from(list.querySelectorAll('.row'));
|
||||
const ids = rows.map(r => r.dataset.id);
|
||||
const fromIdx = ids.indexOf(state.cursorId);
|
||||
const toIdx = ids.indexOf(id);
|
||||
const [lo, hi] = fromIdx < toIdx ? [fromIdx, toIdx] : [toIdx, fromIdx];
|
||||
for (let i = lo; i <= hi; i++) {
|
||||
state.selection.add(ids[i]);
|
||||
}
|
||||
renderMemoryList();
|
||||
} else if ((IS_MAC ? e.metaKey : e.ctrlKey)) {
|
||||
// Cmd/Ctrl-click: toggle selection
|
||||
if (state.selection.has(id)) state.selection.delete(id);
|
||||
else state.selection.add(id);
|
||||
setCursor(id);
|
||||
renderMemoryList();
|
||||
} else {
|
||||
// Plain click: move cursor
|
||||
setCursor(id);
|
||||
renderMemoryList();
|
||||
}
|
||||
});
|
||||
|
||||
// -- #list dblclick (open detail) -----------------------------------------
|
||||
|
||||
list.addEventListener('dblclick', e => {
|
||||
const row = e.target.closest('.row');
|
||||
if (!row) return;
|
||||
const id = row.dataset.id;
|
||||
if (id) enterDetail(id);
|
||||
});
|
||||
}
|
||||
|
||||
// -- #session-list click (session rows) ------------------------------------
|
||||
|
||||
const sessionList = document.getElementById('session-list');
|
||||
if (sessionList) {
|
||||
sessionList.addEventListener('click', e => {
|
||||
const srow = e.target.closest('.srow');
|
||||
if (!srow) return;
|
||||
const id = srow.dataset.sessionId;
|
||||
if (id) navigateToSession(id);
|
||||
});
|
||||
}
|
||||
|
||||
// -- Start on memory view -------------------------------------------------
|
||||
|
||||
setRoute('memory');
|
||||
renderAll();
|
||||
});
|
||||
@@ -0,0 +1,380 @@
|
||||
// Data loading layer -- bridges Electron IPC (window.obelisk.*) to app state.
|
||||
// All DB access goes through this module.
|
||||
|
||||
import { state } from './state.js';
|
||||
|
||||
/**
|
||||
* Load initial data from the DB and populate state.memories, state.sessions,
|
||||
* and state.projects.
|
||||
*/
|
||||
export async function loadInitialData() {
|
||||
const [rawMemories, rawSessions, stats, projects] = await Promise.all([
|
||||
window.obelisk.getMemories(),
|
||||
window.obelisk.getSessions(),
|
||||
window.obelisk.getStats(),
|
||||
window.obelisk.getProjects()
|
||||
]);
|
||||
|
||||
// Transform memories: DB records -> render-layer shape
|
||||
state.memories = (rawMemories || []).map(m => ({
|
||||
...m,
|
||||
ts: m.created_at ? new Date(m.created_at).getTime() : 0,
|
||||
archived: !!m.deleted_at,
|
||||
archivedAt: m.deleted_at ? new Date(m.deleted_at).getTime() : null,
|
||||
health: 'ok',
|
||||
anchors: [],
|
||||
markdown: null // loaded on demand via loadMemoryMarkdown
|
||||
}));
|
||||
|
||||
// Sessions: keep DB shape, add empty messages array for on-demand loading
|
||||
state.sessions = (rawSessions || []).map(s => ({
|
||||
...s,
|
||||
messages: []
|
||||
}));
|
||||
|
||||
state.projects = projects || [];
|
||||
state.stats = stats || {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load full detail for a session: messages with inline tool_calls (each with
|
||||
* result), summaries, subagents, and workflow data.
|
||||
*
|
||||
* Returns the assembled session object (also updates state.sessions entry).
|
||||
*/
|
||||
export async function loadSessionDetail(sessionId) {
|
||||
const [messages, toolCalls, toolResults, subagents, workflows, summaries] =
|
||||
await Promise.all([
|
||||
window.obelisk.getSessionMessages(sessionId),
|
||||
window.obelisk.getSessionToolCalls(sessionId),
|
||||
window.obelisk.getSessionToolResults(sessionId),
|
||||
window.obelisk.getSessionSubagents(sessionId),
|
||||
window.obelisk.getSessionWorkflows(sessionId),
|
||||
window.obelisk.getSessionSummaries(sessionId)
|
||||
]);
|
||||
|
||||
// Index tool results by tool_use_id for fast lookup
|
||||
const resultsByCallId = {};
|
||||
for (const r of (toolResults || [])) {
|
||||
resultsByCallId[r.tool_use_id] = r;
|
||||
}
|
||||
|
||||
// Index subagents by parent_tool_use_id
|
||||
const subagentsByCallId = {};
|
||||
for (const sa of (subagents || [])) {
|
||||
if (sa.parent_tool_use_id) {
|
||||
subagentsByCallId[sa.parent_tool_use_id] = sa;
|
||||
}
|
||||
}
|
||||
|
||||
// Group tool_calls by message_uuid, attaching result and subagent inline
|
||||
const callsByMessageUuid = {};
|
||||
for (const tc of (toolCalls || [])) {
|
||||
const call = {
|
||||
id: tc.id,
|
||||
name: tc.name,
|
||||
input_json: tc.input_json,
|
||||
result: resultsByCallId[tc.id] || null
|
||||
};
|
||||
|
||||
// Attach subagent data if present
|
||||
const sa = subagentsByCallId[tc.id];
|
||||
if (sa) {
|
||||
call.subagent = {
|
||||
agent_id: sa.agent_id,
|
||||
agent_type: sa.agent_type,
|
||||
description: sa.description
|
||||
};
|
||||
}
|
||||
|
||||
const msgUuid = tc.message_uuid;
|
||||
if (!callsByMessageUuid[msgUuid]) callsByMessageUuid[msgUuid] = [];
|
||||
callsByMessageUuid[msgUuid].push(call);
|
||||
}
|
||||
|
||||
// Attach workflow data to Workflow tool calls
|
||||
for (const wf of (workflows || [])) {
|
||||
for (const calls of Object.values(callsByMessageUuid)) {
|
||||
for (const call of calls) {
|
||||
if (call.name === 'Workflow' && !call.workflow) {
|
||||
const resultText = call.result?.content || '';
|
||||
if (resultText.includes(wf.run_id) || resultText.includes(wf.workflow_name || '___none___')) {
|
||||
call.workflow = {
|
||||
run_id: wf.run_id,
|
||||
workflow_name: wf.workflow_name,
|
||||
status: wf.status,
|
||||
duration_ms: wf.duration_ms,
|
||||
total_tokens: wf.total_tokens,
|
||||
agent_count: wf.agent_count,
|
||||
agents: (wf.agents || []).map(a => ({
|
||||
agent_id: a.agent_id,
|
||||
phase: a.phase,
|
||||
label: a.label,
|
||||
state: a.state,
|
||||
tokens: a.tokens,
|
||||
duration_ms: a.duration_ms,
|
||||
}))
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Index summaries by session (summaries don't have per-message IDs in our schema)
|
||||
const sessionSummaries = (summaries || []).map(s => ({
|
||||
source: s.source,
|
||||
content: s.content,
|
||||
timestamp: s.timestamp
|
||||
}));
|
||||
|
||||
// Assemble messages with tool_calls inline
|
||||
const rawAssembled = (messages || []).map(msg => {
|
||||
const assembled = {
|
||||
uuid: msg.uuid,
|
||||
type: msg.type || msg.role,
|
||||
timestamp: msg.timestamp,
|
||||
text: msg.text,
|
||||
content_type: msg.content_type || null,
|
||||
is_meta: msg.is_meta || 0
|
||||
};
|
||||
|
||||
const calls = callsByMessageUuid[msg.uuid];
|
||||
if (calls && calls.length > 0) {
|
||||
assembled.tool_calls = calls;
|
||||
}
|
||||
|
||||
return assembled;
|
||||
});
|
||||
|
||||
// Merge adjacent assistant messages:
|
||||
// - tool_result user messages are skipped (results shown inside tool_call panels)
|
||||
// - consecutive tool_use messages (separated by tool_results) merge into one
|
||||
// - thinking messages merge into the next non-thinking assistant message
|
||||
const assembledMessages = [];
|
||||
for (let i = 0; i < rawAssembled.length; i++) {
|
||||
const msg = rawAssembled[i];
|
||||
|
||||
// Skip tool_result user messages
|
||||
if (msg.content_type === 'tool_result') continue;
|
||||
|
||||
// For thinking messages, collect consecutive thinking blocks and attach to the next assistant
|
||||
if (msg.type === 'assistant' && msg.content_type === 'thinking') {
|
||||
const thinkingParts = [msg.text || ''];
|
||||
let j = i + 1;
|
||||
// Absorb consecutive thinking messages
|
||||
while (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type === 'thinking') {
|
||||
thinkingParts.push(rawAssembled[j].text || '');
|
||||
j++;
|
||||
}
|
||||
// Find the next non-thinking assistant message to attach to
|
||||
if (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type !== 'thinking') {
|
||||
// Will be picked up by the next iteration; store thinking on it
|
||||
rawAssembled[j]._thinking = thinkingParts.join('\n\n');
|
||||
i = j - 1;
|
||||
continue;
|
||||
}
|
||||
// No following assistant message — render as standalone collapsed thinking
|
||||
assembledMessages.push({ ...msg, text: thinkingParts.join('\n\n'), content_type: 'thinking' });
|
||||
i = j - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// For tool_use assistant messages, absorb subsequent tool_use (skipping tool_results)
|
||||
if (msg.type === 'assistant' && msg.content_type === 'tool_use') {
|
||||
const merged = { ...msg, tool_calls: [...(msg.tool_calls || [])] };
|
||||
if (msg._thinking) merged._thinking = msg._thinking;
|
||||
let j = i + 1;
|
||||
while (j < rawAssembled.length) {
|
||||
const next = rawAssembled[j];
|
||||
if (next.content_type === 'tool_result') { j++; continue; }
|
||||
if (next.type === 'assistant' && next.content_type === 'tool_use') {
|
||||
if (next.tool_calls) merged.tool_calls.push(...next.tool_calls);
|
||||
if (next.text && !merged.text) merged.text = next.text;
|
||||
j++;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
assembledMessages.push(merged);
|
||||
i = j - 1;
|
||||
} else {
|
||||
// text or other assistant/user messages
|
||||
const out = { ...msg };
|
||||
if (msg._thinking) out._thinking = msg._thinking;
|
||||
assembledMessages.push(out);
|
||||
}
|
||||
}
|
||||
|
||||
// Attach workflow data if present
|
||||
const workflow = (workflows && workflows.length > 0) ? workflows[0] : null;
|
||||
|
||||
// Build assembled session object
|
||||
const session = state.sessions.find(s => s.id === sessionId);
|
||||
const assembled = {
|
||||
...(session || {}),
|
||||
id: sessionId,
|
||||
messages: assembledMessages
|
||||
};
|
||||
|
||||
if (workflow) {
|
||||
assembled.workflow = workflow;
|
||||
}
|
||||
|
||||
// Update in-place in state.sessions
|
||||
const idx = state.sessions.findIndex(s => s.id === sessionId);
|
||||
if (idx !== -1) {
|
||||
state.sessions[idx] = assembled;
|
||||
}
|
||||
|
||||
return assembled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load full detail for a subagent conversation.
|
||||
* Returns assembled messages with tool_calls inline.
|
||||
*/
|
||||
export async function loadSubagentDetail(agentId) {
|
||||
const [messages, toolCalls, toolResults] = await Promise.all([
|
||||
window.obelisk.getSubagentMessages(agentId),
|
||||
window.obelisk.getSubagentToolCalls(agentId),
|
||||
window.obelisk.getSubagentToolResults(agentId),
|
||||
]);
|
||||
|
||||
const resultsByCallId = {};
|
||||
for (const r of (toolResults || [])) {
|
||||
resultsByCallId[r.tool_use_id] = r;
|
||||
}
|
||||
|
||||
const callsByMessageUuid = {};
|
||||
for (const tc of (toolCalls || [])) {
|
||||
const call = {
|
||||
id: tc.id,
|
||||
name: tc.name,
|
||||
input_json: tc.input_json,
|
||||
result: resultsByCallId[tc.id] || null
|
||||
};
|
||||
const msgUuid = tc.message_uuid;
|
||||
if (!callsByMessageUuid[msgUuid]) callsByMessageUuid[msgUuid] = [];
|
||||
callsByMessageUuid[msgUuid].push(call);
|
||||
}
|
||||
|
||||
const rawAssembled = (messages || []).map(msg => {
|
||||
const assembled = {
|
||||
uuid: msg.uuid,
|
||||
type: msg.type || msg.role,
|
||||
timestamp: msg.timestamp,
|
||||
text: msg.text,
|
||||
content_type: msg.content_type || null,
|
||||
is_meta: msg.is_meta || 0
|
||||
};
|
||||
const calls = callsByMessageUuid[msg.uuid];
|
||||
if (calls && calls.length > 0) assembled.tool_calls = calls;
|
||||
return assembled;
|
||||
});
|
||||
|
||||
// Same merging logic as session detail
|
||||
const assembledMessages = [];
|
||||
for (let i = 0; i < rawAssembled.length; i++) {
|
||||
const msg = rawAssembled[i];
|
||||
if (msg.content_type === 'tool_result') continue;
|
||||
if (msg.type === 'assistant' && msg.content_type === 'thinking') {
|
||||
const thinkingParts = [msg.text || ''];
|
||||
let j = i + 1;
|
||||
while (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type === 'thinking') {
|
||||
thinkingParts.push(rawAssembled[j].text || '');
|
||||
j++;
|
||||
}
|
||||
if (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type !== 'thinking') {
|
||||
rawAssembled[j]._thinking = thinkingParts.join('\n\n');
|
||||
i = j - 1;
|
||||
continue;
|
||||
}
|
||||
assembledMessages.push({ ...msg, text: thinkingParts.join('\n\n'), content_type: 'thinking' });
|
||||
i = j - 1;
|
||||
continue;
|
||||
}
|
||||
if (msg.type === 'assistant' && msg.content_type === 'tool_use') {
|
||||
const merged = { ...msg, tool_calls: [...(msg.tool_calls || [])] };
|
||||
if (msg._thinking) merged._thinking = msg._thinking;
|
||||
let j = i + 1;
|
||||
while (j < rawAssembled.length) {
|
||||
const next = rawAssembled[j];
|
||||
if (next.content_type === 'tool_result') { j++; continue; }
|
||||
if (next.type === 'assistant' && next.content_type === 'tool_use') {
|
||||
if (next.tool_calls) merged.tool_calls.push(...next.tool_calls);
|
||||
if (next.text && !merged.text) merged.text = next.text;
|
||||
j++;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
assembledMessages.push(merged);
|
||||
i = j - 1;
|
||||
} else {
|
||||
const out = { ...msg };
|
||||
if (msg._thinking) out._thinking = msg._thinking;
|
||||
assembledMessages.push(out);
|
||||
}
|
||||
}
|
||||
|
||||
return assembledMessages;
|
||||
}
|
||||
|
||||
const TEXT_LIMIT = 10000;
|
||||
|
||||
/**
|
||||
* Check if a message text was truncated during indexing.
|
||||
*/
|
||||
export function isTextTruncated(text) {
|
||||
return text && text.length >= TEXT_LIMIT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the full untruncated text for a message from its source JSONL.
|
||||
* Returns the full text string or null.
|
||||
*/
|
||||
export async function loadFullText(uuid) {
|
||||
try {
|
||||
return await window.obelisk.getMessageFullText(uuid);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the markdown content of a memory file.
|
||||
* Returns the content string or null on failure.
|
||||
*/
|
||||
export async function loadMemoryMarkdown(memoryPath) {
|
||||
try {
|
||||
const content = await window.obelisk.readMemoryFile(memoryPath);
|
||||
return content || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive a memory by id. Updates state after successful IPC call.
|
||||
*/
|
||||
export async function archiveMemory(id) {
|
||||
await window.obelisk.archiveMemory(id);
|
||||
const mem = state.memories.find(m => m.id === id);
|
||||
if (mem) {
|
||||
mem.archived = true;
|
||||
mem.archivedAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore an archived memory by id. Updates state after successful IPC call.
|
||||
*/
|
||||
export async function restoreMemory(id) {
|
||||
await window.obelisk.restoreMemory(id);
|
||||
const mem = state.memories.find(m => m.id === id);
|
||||
if (mem) {
|
||||
mem.archived = false;
|
||||
mem.archivedAt = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Keyboard shortcut handling -- ported from the HTML mock.
|
||||
// Registers a single document-level keydown listener that dispatches
|
||||
// all navigation, mutation, and route-switching shortcuts.
|
||||
|
||||
import { state, IS_MAC } from './state.js';
|
||||
import {
|
||||
renderAll,
|
||||
renderMemoryList,
|
||||
renderSessionList,
|
||||
renderStatus,
|
||||
enterDetail,
|
||||
exitDetail,
|
||||
setRoute,
|
||||
setView,
|
||||
toggleSort,
|
||||
moveCursor,
|
||||
archive,
|
||||
restore,
|
||||
doUndo,
|
||||
navigateToSession
|
||||
} from './render.js';
|
||||
|
||||
export function initKeyboard() {
|
||||
document.addEventListener('keydown', e => {
|
||||
const inInput =
|
||||
document.activeElement.tagName === 'INPUT' ||
|
||||
document.activeElement.tagName === 'TEXTAREA';
|
||||
const mod = IS_MAC ? e.metaKey : e.ctrlKey;
|
||||
|
||||
// -- Route switching: Cmd+1/2/3/4 --
|
||||
if (mod && e.key === '1') { e.preventDefault(); setRoute('sessions'); return; }
|
||||
if (mod && e.key === '2') { e.preventDefault(); setView('active'); return; }
|
||||
if (mod && e.key === '3') { e.preventDefault(); setView('archived'); return; }
|
||||
if (mod && e.key === '4') { e.preventDefault(); setView('broken'); return; }
|
||||
|
||||
// -- Undo: Cmd+Z --
|
||||
if (mod && e.key.toLowerCase() === 'z' && !e.shiftKey) {
|
||||
if (state.lastArchiveSnapshot) { e.preventDefault(); doUndo(); }
|
||||
return;
|
||||
}
|
||||
|
||||
// -- When inside an input, only Escape is handled (to blur) --
|
||||
if (inInput) {
|
||||
if (e.key === 'Escape') e.target.blur();
|
||||
return;
|
||||
}
|
||||
|
||||
// -- Search focus: / --
|
||||
if (e.key === '/') {
|
||||
e.preventDefault();
|
||||
const searchInput = document.getElementById('search');
|
||||
if (searchInput) { searchInput.focus(); searchInput.select(); }
|
||||
return;
|
||||
}
|
||||
|
||||
// -- Detail mode shortcuts --
|
||||
if (state.mode === 'detail') {
|
||||
if (e.key === 'Escape') { e.preventDefault(); exitDetail(); return; }
|
||||
if (state.route === 'memory' && (e.key === 'd' || e.key === 'D')) {
|
||||
e.preventDefault();
|
||||
const m = state.memories.find(x => x.id === state.detailId);
|
||||
if (m && m.archived) restore([m.id]);
|
||||
else if (m) archive([m.id]);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// -- List mode shortcuts --
|
||||
|
||||
// Navigation: j/k/arrows
|
||||
if (e.key === 'j' || e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (state.route === 'memory') moveCursor(1, e.shiftKey);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'k' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (state.route === 'memory') moveCursor(-1, e.shiftKey);
|
||||
return;
|
||||
}
|
||||
|
||||
// Open detail: Enter
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (state.route === 'memory' && state.cursorId) enterDetail(state.cursorId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Archive / Restore: d/D
|
||||
if (state.route === 'memory' && (e.key === 'd' || e.key === 'D')) {
|
||||
e.preventDefault();
|
||||
const ids = state.selection.size > 0
|
||||
? Array.from(state.selection)
|
||||
: state.cursorId ? [state.cursorId] : [];
|
||||
if (!ids.length) return;
|
||||
const m = state.memories.find(x => x.id === ids[0]);
|
||||
if (state.view === 'archived' || (m && m.archived)) restore(ids);
|
||||
else archive(ids);
|
||||
return;
|
||||
}
|
||||
|
||||
// Undo: u
|
||||
if (e.key === 'u') {
|
||||
e.preventDefault();
|
||||
if (state.lastArchiveSnapshot) doUndo();
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort toggle: s
|
||||
if (e.key === 's') { e.preventDefault(); toggleSort(); return; }
|
||||
|
||||
// Escape: clear selection or search
|
||||
if (e.key === 'Escape') {
|
||||
if (state.selection.size) {
|
||||
state.selection.clear();
|
||||
renderMemoryList();
|
||||
renderStatus();
|
||||
} else if (state.query) {
|
||||
state.query = '';
|
||||
const searchInput = document.getElementById('search');
|
||||
if (searchInput) searchInput.value = '';
|
||||
if (state.route === 'sessions') renderSessionList();
|
||||
else renderMemoryList();
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
// Memory list and detail rendering, extracted from render.js.
|
||||
|
||||
import { state, FOLDER_SVG } from './state.js';
|
||||
import { loadMemoryMarkdown, isTextTruncated, loadFullText } from './data.js';
|
||||
import registry from './registry.js';
|
||||
|
||||
// --- Utilities (local copies to avoid importing render.js) ---
|
||||
|
||||
function escapeHTML(s) { return String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||||
|
||||
function pad2(n) { return String(n).padStart(2, '0'); }
|
||||
function isSameDay(a, b) {
|
||||
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
||||
}
|
||||
function fmtListTime(ts) {
|
||||
const d = new Date(ts);
|
||||
const now = new Date();
|
||||
const hhmm = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||||
if (isSameDay(d, now)) return hhmm;
|
||||
const mmdd = `${pad2(d.getMonth() + 1)}/${pad2(d.getDate())}`;
|
||||
if (d.getFullYear() === now.getFullYear()) return `${mmdd} ${hhmm}`;
|
||||
return `${d.getFullYear()}/${mmdd} ${hhmm}`;
|
||||
}
|
||||
function fmtRelative(ts) {
|
||||
const diff = Date.now() - ts;
|
||||
const min = 60000, hr = 3600000, day = 86400000;
|
||||
if (diff < 0) return 'in the future';
|
||||
if (diff < min) return 'just now';
|
||||
if (diff < hr) return Math.floor(diff / min) + 'm ago';
|
||||
if (diff < day) return Math.floor(diff / hr) + 'h ago';
|
||||
if (diff < day * 30) return Math.floor(diff / day) + 'd ago';
|
||||
if (diff < day * 365) return Math.floor(diff / (day * 30)) + 'mo ago';
|
||||
return Math.floor(diff / (day * 365)) + 'y ago';
|
||||
}
|
||||
|
||||
function highlightPlain(text, query) {
|
||||
if (!query) return escapeHTML(text);
|
||||
const safe = escapeHTML(text);
|
||||
const q = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return safe.replace(new RegExp(q, 'gi'), m => `<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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Shared registry to break circular dependencies between modules.
|
||||
const registry = {};
|
||||
export default registry;
|
||||
@@ -0,0 +1,246 @@
|
||||
// Rendering coordinator -- thin orchestration layer.
|
||||
// Delegates to extracted modules; keeps only cross-module functions locally.
|
||||
|
||||
import { state } from './state.js';
|
||||
import { archiveMemory, restoreMemory } from './data.js';
|
||||
import registry from './registry.js';
|
||||
|
||||
// --- Module imports ---
|
||||
import { escapeHTML, $ } from './utils.js';
|
||||
import { renderSidebar, renderBreadcrumb, updateWindowTitle } from './sidebar.js';
|
||||
import { visibleMemories as _visibleMemories, renderMemoryList, renderMemoryDetail } from './memory-list.js';
|
||||
import { renderSessionList, renderSessionDetail } from './session-list.js';
|
||||
import { renderUsage } from './usage.js';
|
||||
|
||||
// --- Data filtering (coordinator owns the cross-module view) ---
|
||||
|
||||
export function visibleMemories() { return _visibleMemories(); }
|
||||
|
||||
export function visibleSessions() {
|
||||
const q = state.query.trim().toLowerCase();
|
||||
return state.sessions
|
||||
.filter(s => state.projectFilter === 'all' || s.project === state.projectFilter)
|
||||
.map(s => {
|
||||
if (!q) return { ...s, messageHit: null };
|
||||
const topMatch = (s.title || '').toLowerCase().includes(q) ||
|
||||
(s.project || '').toLowerCase().includes(q) ||
|
||||
(s.git_branch || '').toLowerCase().includes(q);
|
||||
if (topMatch) return { ...s, messageHit: null };
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => {
|
||||
const ta = new Date(a.started_at || 0).getTime();
|
||||
const tb = new Date(b.started_at || 0).getTime();
|
||||
return state.sortDesc ? tb - ta : ta - tb;
|
||||
});
|
||||
}
|
||||
|
||||
// --- Cursor / selection ---
|
||||
|
||||
export function flatList() { return visibleMemories(); }
|
||||
|
||||
export function cursorIndex() {
|
||||
const flat = flatList();
|
||||
if (!state.cursorId) return -1;
|
||||
return flat.findIndex(m => m.id === state.cursorId);
|
||||
}
|
||||
|
||||
export function moveCursor(delta, extendSelection = false) {
|
||||
const flat = flatList();
|
||||
if (!flat.length) return;
|
||||
let idx = cursorIndex();
|
||||
if (idx === -1) idx = 0;
|
||||
else idx = Math.max(0, Math.min(flat.length - 1, idx + delta));
|
||||
const newId = flat[idx].id;
|
||||
if (extendSelection) { state.selection.add(state.cursorId); state.selection.add(newId); }
|
||||
state.cursorId = newId;
|
||||
renderMemoryList(); renderStatus();
|
||||
}
|
||||
|
||||
export function setCursor(id, opts = {}) {
|
||||
state.cursorId = id;
|
||||
if (!opts.keepSelection) state.selection.clear();
|
||||
renderMemoryList(); renderStatus();
|
||||
}
|
||||
|
||||
// --- Mutations ---
|
||||
|
||||
export function archive(ids) { if (!ids.length) return; doMutation(ids, true); }
|
||||
export function restore(ids) { if (!ids.length) return; doMutation(ids, false); }
|
||||
|
||||
async function doMutation(ids, toArchived) {
|
||||
for (const id of ids) {
|
||||
if (toArchived) await archiveMemory(id);
|
||||
else await restoreMemory(id);
|
||||
}
|
||||
state.selection = new Set();
|
||||
if (state.mode === 'detail' && state.route === 'memory' && ids.includes(state.detailId)) exitDetail();
|
||||
const flat = flatList();
|
||||
if (state.cursorId && !flat.find(m => m.id === state.cursorId)) state.cursorId = flat[0]?.id ?? null;
|
||||
state.lastArchiveSnapshot = ids;
|
||||
state.undoExpires = Date.now() + 5000;
|
||||
clearInterval(state.undoTimer);
|
||||
state.undoTimer = setInterval(() => {
|
||||
if (Date.now() >= state.undoExpires) { state.lastArchiveSnapshot = null; clearInterval(state.undoTimer); }
|
||||
renderStatus();
|
||||
}, 500);
|
||||
renderAll();
|
||||
}
|
||||
|
||||
export async function doUndo() {
|
||||
if (!state.lastArchiveSnapshot) return;
|
||||
for (const id of state.lastArchiveSnapshot) {
|
||||
const m = state.memories.find(x => x.id === id);
|
||||
if (m) {
|
||||
if (m.archived) await restoreMemory(id);
|
||||
else await archiveMemory(id);
|
||||
}
|
||||
}
|
||||
state.lastArchiveSnapshot = null;
|
||||
clearInterval(state.undoTimer);
|
||||
renderAll();
|
||||
}
|
||||
|
||||
// --- Navigation ---
|
||||
|
||||
export function navigateToSession(sessionId, focusUuid) {
|
||||
state.route = 'sessions'; state.mode = 'detail';
|
||||
state.detailId = sessionId;
|
||||
state.subagentId = null;
|
||||
state.subagentDescription = null;
|
||||
state.pendingFocusUuid = focusUuid || null;
|
||||
state.query = '';
|
||||
const searchEl = $('#search');
|
||||
if (searchEl) searchEl.value = '';
|
||||
switchView(); renderAll();
|
||||
}
|
||||
|
||||
export function navigateToSubagent(agentId, description) {
|
||||
state.subagentId = agentId;
|
||||
state.subagentDescription = description || agentId;
|
||||
switchView(); renderAll();
|
||||
}
|
||||
|
||||
export function enterDetail(id) { state.detailId = id; state.mode = 'detail'; state.showSource = false; switchView(); renderAll(); }
|
||||
|
||||
export function exitDetail() {
|
||||
if (state.subagentId) {
|
||||
state.subagentId = null;
|
||||
state.subagentDescription = null;
|
||||
switchView(); renderAll();
|
||||
return;
|
||||
}
|
||||
state.mode = 'list'; state.detailId = null; state.pendingFocusUuid = null; switchView(); renderAll();
|
||||
}
|
||||
|
||||
export function setRoute(route) {
|
||||
state.route = route; state.mode = 'list'; state.detailId = null;
|
||||
state.cursorId = null; state.selection.clear();
|
||||
state.query = ''; const s = $('#search'); if (s) s.value = '';
|
||||
switchView(); renderAll();
|
||||
if (route === 'memory') { const flat = visibleMemories(); if (flat.length) state.cursorId = flat[0].id; }
|
||||
}
|
||||
|
||||
export function setView(v) {
|
||||
state.route = 'memory'; state.view = v; state.mode = 'list'; state.detailId = null;
|
||||
state.cursorId = null; state.selection.clear(); state.projectFilter = 'all';
|
||||
switchView(); renderAll();
|
||||
const flat = visibleMemories();
|
||||
if (flat.length) state.cursorId = flat[0].id;
|
||||
renderMemoryList();
|
||||
}
|
||||
|
||||
export function setProject(p) {
|
||||
state.projectFilter = p; state.cursorId = null; state.selection.clear();
|
||||
state.mode = 'list'; state.detailId = null;
|
||||
switchView(); renderAll();
|
||||
if (state.route === 'memory') { const flat = visibleMemories(); if (flat.length) state.cursorId = flat[0].id; }
|
||||
}
|
||||
|
||||
export function toggleSort() {
|
||||
state.sortDesc = !state.sortDesc;
|
||||
const btn = $('#sort-toggle');
|
||||
if (btn) { btn.classList.toggle('desc', state.sortDesc); btn.classList.toggle('asc', !state.sortDesc); }
|
||||
const lbl = $('#sort-label');
|
||||
if (lbl) lbl.textContent = state.sortDesc ? 'newest' : 'oldest';
|
||||
if (state.route === 'sessions') renderSessionList();
|
||||
else renderMemoryList();
|
||||
}
|
||||
|
||||
export function switchView() {
|
||||
const showList = state.mode === 'list';
|
||||
const showSessions = state.route === 'sessions';
|
||||
const showUsage = state.route === 'usage';
|
||||
const inSessionDetail = !showList && showSessions;
|
||||
const inSubagent = inSessionDetail && !!state.subagentId;
|
||||
const el = (id, show) => { const e = $(id); if (e) e.style.display = show ? '' : 'none'; };
|
||||
el('#list-wrap', showList && !showSessions && !showUsage);
|
||||
el('#detail-wrap', !showList && !showSessions && !showUsage);
|
||||
el('#session-list-wrap', showList && showSessions);
|
||||
el('#session-detail-wrap', inSessionDetail && !inSubagent);
|
||||
el('#subagent-detail-wrap', inSubagent);
|
||||
el('#usage-wrap', showUsage);
|
||||
el('#search-wrap', showList && !showUsage);
|
||||
el('#sort-toggle', showList && !showUsage);
|
||||
el('#search-msgs-toggle', showList && showSessions);
|
||||
}
|
||||
|
||||
// --- Status bar ---
|
||||
|
||||
export function renderStatus() {
|
||||
const left = $('#status-left');
|
||||
const right = $('#status-right');
|
||||
if (!left || !right) return;
|
||||
|
||||
if (state.route === 'sessions' && state.mode === 'list') {
|
||||
right.innerHTML = `<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 };
|
||||
@@ -0,0 +1,530 @@
|
||||
// Session list and detail rendering module.
|
||||
// Extracted from render.js -- all session/subagent DOM generation.
|
||||
|
||||
import { state } from './state.js';
|
||||
import { loadSessionDetail, loadSubagentDetail, isTextTruncated, loadFullText } from './data.js';
|
||||
import { escapeHTML, highlightPlain, fmtListTime, fmtRelative, fmtClockTime, renderMarkdown, formatProjectLabel, $ } from './utils.js';
|
||||
import { FOLDER_SVG } from './state.js';
|
||||
import registry from './registry.js';
|
||||
|
||||
// --- Session list ---
|
||||
|
||||
export function renderSessionList() {
|
||||
const items = visibleSessions();
|
||||
const list = $('#session-list');
|
||||
if (!list) return;
|
||||
if (!items.length) {
|
||||
list.innerHTML = `<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;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// Sidebar, breadcrumb, and window title rendering.
|
||||
// Extracted from render.js for modularity.
|
||||
|
||||
import { state, FOLDER_SVG } from './state.js';
|
||||
import { $, $$, escapeHTML, formatProjectLabel } from './utils.js';
|
||||
|
||||
// --- Data helpers (sidebar-local) ---
|
||||
|
||||
function projectCountsForCurrentRoute() {
|
||||
const counts = {};
|
||||
if (state.route === 'sessions') {
|
||||
for (const s of state.sessions) if (s.project) counts[s.project] = (counts[s.project] || 0) + 1;
|
||||
} else {
|
||||
for (const m of state.memories) {
|
||||
const matches = state.view === 'archived' ? m.archived : !m.archived;
|
||||
if (!matches) continue;
|
||||
if (m.project) counts[m.project] = (counts[m.project] || 0) + 1;
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
// --- Sidebar ---
|
||||
|
||||
export function renderSidebar() {
|
||||
const activeCount = state.memories.filter(m => !m.archived).length;
|
||||
const archivedCount = state.memories.filter(m => m.archived).length;
|
||||
const el = id => $(id);
|
||||
if (el('#count-sessions')) el('#count-sessions').textContent = state.sessions.length;
|
||||
if (el('#count-memory-total')) el('#count-memory-total').textContent = activeCount + archivedCount;
|
||||
if (el('#count-active')) el('#count-active').textContent = activeCount;
|
||||
if (el('#count-archived')) el('#count-archived').textContent = archivedCount;
|
||||
|
||||
$$('.sidebar-item').forEach(item => {
|
||||
let isActive = false;
|
||||
if (item.dataset.route === 'sessions' && state.route === 'sessions' && state.projectFilter === 'all') isActive = true;
|
||||
else if (item.dataset.route === 'usage' && state.route === 'usage') isActive = true;
|
||||
else if (item.dataset.route === 'memory' && item.dataset.view === state.view && state.projectFilter === 'all') isActive = true;
|
||||
else if (item.dataset.project && item.dataset.project === state.projectFilter) isActive = true;
|
||||
if (item.dataset.route === 'memory' && !item.classList.contains('sub')) isActive = false;
|
||||
item.classList.toggle('active', isActive);
|
||||
});
|
||||
|
||||
const counts = projectCountsForCurrentRoute();
|
||||
let projects = [...new Set(
|
||||
(state.route === 'sessions' ? state.sessions : state.memories)
|
||||
.filter(item => {
|
||||
if (state.route === 'sessions') return true;
|
||||
return state.view === 'archived' ? item.archived : !item.archived;
|
||||
})
|
||||
.map(item => item.project)
|
||||
.filter(Boolean)
|
||||
)];
|
||||
if (state.projectSearch) {
|
||||
const q = state.projectSearch.toLowerCase();
|
||||
projects = projects.filter(p => p.toLowerCase().includes(q));
|
||||
}
|
||||
projects.sort((a, b) => formatProjectLabel(a).localeCompare(formatProjectLabel(b)));
|
||||
const projectsEl = $('#sidebar-projects');
|
||||
if (projectsEl) {
|
||||
projectsEl.innerHTML = projects.map(p => `
|
||||
<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}`;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// App state -- single source of truth for the renderer process.
|
||||
// Loaded collections (memories, sessions, projects) start empty and are
|
||||
// populated from the DB at boot.
|
||||
|
||||
export const state = {
|
||||
memories: [],
|
||||
sessions: [],
|
||||
projects: [],
|
||||
route: 'memory',
|
||||
view: 'active', // 'active' | 'archived'
|
||||
mode: 'list', // 'list' | 'detail'
|
||||
detailId: null,
|
||||
subagentId: null,
|
||||
subagentDescription: null,
|
||||
pendingFocusUuid: null,
|
||||
query: '',
|
||||
projectFilter: 'all',
|
||||
projectSearch: '',
|
||||
sortDesc: true,
|
||||
includeMessageBodies: false,
|
||||
cursorId: null,
|
||||
selection: new Set(),
|
||||
showSource: false,
|
||||
lastArchiveSnapshot: null,
|
||||
undoTimer: null,
|
||||
undoExpires: 0
|
||||
};
|
||||
|
||||
// Platform detection
|
||||
export const IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform);
|
||||
|
||||
// SVG icon constants
|
||||
export const FOLDER_SVG = `<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>`;
|
||||
@@ -0,0 +1,573 @@
|
||||
// Usage rendering module -- heatmap, weekly chart, cumulative chart.
|
||||
// Extracted from render.js with identical logic.
|
||||
|
||||
import { state } from './state.js';
|
||||
import { escapeHTML, fmtDuration, fmtTokens, fmtTooltipDate, positionTooltip, formatProjectLabel, $ } from './utils.js';
|
||||
import registry from './registry.js';
|
||||
|
||||
function navigateToSession(sessionId, focusUuid) {
|
||||
if (registry.navigateToSession) {
|
||||
registry.navigateToSession(sessionId, focusUuid);
|
||||
}
|
||||
}
|
||||
|
||||
export async function renderUsage() {
|
||||
const usage = $('#usage');
|
||||
if (!usage) return;
|
||||
|
||||
const data = await window.obelisk.getUsageStats();
|
||||
const { daily, totalTokens, peakDay, longestTurn } = data;
|
||||
|
||||
// Build heatmap: 52 weeks x 7 days grid
|
||||
const today = new Date();
|
||||
const dayMs = 86400000;
|
||||
// Start from the first Sunday on or after 364 days ago (full weeks only)
|
||||
let startDate = new Date(today.getTime() - 364 * dayMs);
|
||||
startDate.setHours(0, 0, 0, 0);
|
||||
const daysUntilSunday = (7 - startDate.getDay()) % 7;
|
||||
startDate = new Date(startDate.getTime() + daysUntilSunday * dayMs);
|
||||
|
||||
const dailyMap = {};
|
||||
for (const d of daily) dailyMap[d.day] = d.tokens;
|
||||
|
||||
const values = daily.map(d => d.tokens).filter(Boolean);
|
||||
const maxTokens = Math.max(...values, 1);
|
||||
|
||||
// Generate cells (startDate is always a Sunday now)
|
||||
const cells = [];
|
||||
for (let i = 0; i < 371; i++) {
|
||||
const date = new Date(startDate.getTime() + i * dayMs);
|
||||
if (date > today) break;
|
||||
const key = date.toISOString().slice(0, 10);
|
||||
const tokens = dailyMap[key] || 0;
|
||||
const level = tokens === 0 ? 0 : Math.min(4, Math.ceil((tokens / maxTokens) * 4));
|
||||
const col = Math.floor(i / 7);
|
||||
const row = i % 7;
|
||||
cells.push({ key, tokens, level, col, row, date });
|
||||
}
|
||||
|
||||
const maxCol = cells.length ? cells[cells.length - 1].col : 0;
|
||||
const cellSize = 11;
|
||||
const cellGap = 2;
|
||||
const step = cellSize + cellGap;
|
||||
const gridWidth = (maxCol + 1) * step + 20; // extra padding for last month label
|
||||
const gridHeight = 7 * step;
|
||||
|
||||
const cellsHTML = cells.map(c => {
|
||||
const x = c.col * step;
|
||||
const y = c.row * step;
|
||||
return `<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'));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// Utility functions extracted from render.js
|
||||
// Pure helpers with no side-effects on global state (except formatProjectLabel which reads state).
|
||||
|
||||
import { state } from './state.js';
|
||||
|
||||
// --- Time / formatting ---
|
||||
|
||||
export function pad2(n) { return String(n).padStart(2, '0'); }
|
||||
|
||||
export function isSameDay(a, b) {
|
||||
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
||||
}
|
||||
|
||||
export function fmtListTime(ts) {
|
||||
const d = new Date(ts);
|
||||
const now = new Date();
|
||||
const hhmm = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||||
if (isSameDay(d, now)) return hhmm;
|
||||
const mmdd = `${pad2(d.getMonth() + 1)}/${pad2(d.getDate())}`;
|
||||
if (d.getFullYear() === now.getFullYear()) return `${mmdd} ${hhmm}`;
|
||||
return `${d.getFullYear()}/${mmdd} ${hhmm}`;
|
||||
}
|
||||
|
||||
export function fmtRelative(ts) {
|
||||
const diff = Date.now() - ts;
|
||||
const min = 60000, hr = 3600000, day = 86400000;
|
||||
if (diff < 0) return 'in the future';
|
||||
if (diff < min) return 'just now';
|
||||
if (diff < hr) return Math.floor(diff / min) + 'm ago';
|
||||
if (diff < day) return Math.floor(diff / hr) + 'h ago';
|
||||
if (diff < day * 30) return Math.floor(diff / day) + 'd ago';
|
||||
if (diff < day * 365) return Math.floor(diff / (day * 30)) + 'mo ago';
|
||||
return Math.floor(diff / (day * 365)) + 'y ago';
|
||||
}
|
||||
|
||||
export function fmtClockTime(iso) {
|
||||
const d = new Date(iso);
|
||||
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
export function fmtSize(bytes) {
|
||||
if (!bytes) return '-';
|
||||
if (bytes < 1024) return bytes + 'B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'K';
|
||||
return (bytes / 1024 / 1024).toFixed(1) + 'M';
|
||||
}
|
||||
|
||||
// --- HTML / Markdown ---
|
||||
|
||||
export function escapeHTML(s) { return String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||||
|
||||
export function highlightPlain(text, query) {
|
||||
if (!query) return escapeHTML(text);
|
||||
const safe = escapeHTML(text);
|
||||
const q = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return safe.replace(new RegExp(q, 'gi'), m => `<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>`;
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
<script setup>
|
||||
import { computed, watch } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import {
|
||||
state,
|
||||
IS_MAC,
|
||||
FOLDER_SVG,
|
||||
setRoute,
|
||||
setView,
|
||||
setProject,
|
||||
setQuery,
|
||||
setProjectSearch,
|
||||
toggleSort,
|
||||
toggleIncludeMessageBodies
|
||||
} from './store.js';
|
||||
import { formatProjectLabel } from './utils.js';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
// --- Sidebar data ---
|
||||
|
||||
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);
|
||||
const sessionCount = computed(() => state.sessions.length);
|
||||
|
||||
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
|
||||
}));
|
||||
});
|
||||
|
||||
// --- Toolbar visibility ---
|
||||
|
||||
const showToolbar = computed(() => {
|
||||
const r = route.name;
|
||||
return r === 'SessionList' || r === 'MemoryList';
|
||||
});
|
||||
|
||||
const showSearchMsgsToggle = computed(() => {
|
||||
return route.name === 'SessionList';
|
||||
});
|
||||
|
||||
// --- Window title ---
|
||||
|
||||
const windowTitle = computed(() => {
|
||||
const appName = 'Obelisk';
|
||||
let scopeText = '';
|
||||
if (route.name === 'Usage') {
|
||||
scopeText = 'Usage';
|
||||
} else if (route.name?.startsWith('Session')) {
|
||||
if (route.name === 'SessionDetail' || route.name === 'SubagentDetail') {
|
||||
const s = state.sessions.find(x => x.id === route.params.id);
|
||||
scopeText = s ? `Sessions · ${s.title}` : 'Sessions';
|
||||
} else {
|
||||
const proj = state.projectFilter !== 'all' ? ` · ${formatProjectLabel(state.projectFilter)}` : '';
|
||||
scopeText = `Sessions${proj}`;
|
||||
}
|
||||
} else {
|
||||
if (route.name === 'MemoryDetail') {
|
||||
const m = state.memories.find(x => x.id === route.params.id);
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
return { appName, scopeText };
|
||||
});
|
||||
|
||||
watch(() => windowTitle.value.scopeText, (scopeText) => {
|
||||
document.title = `${windowTitle.value.appName} — ${scopeText}`;
|
||||
}, { immediate: true });
|
||||
|
||||
// --- Navigation helpers ---
|
||||
|
||||
function handleSidebarRoute(routeName) {
|
||||
setRoute(routeName);
|
||||
if (routeName === 'sessions') {
|
||||
router.push('/sessions');
|
||||
} else if (routeName === 'usage') {
|
||||
router.push('/usage');
|
||||
} else {
|
||||
router.push('/memory');
|
||||
}
|
||||
}
|
||||
|
||||
function handleSidebarView(view) {
|
||||
setView(view);
|
||||
router.push('/memory');
|
||||
}
|
||||
|
||||
function handleSidebarProject(slug) {
|
||||
setProject(slug);
|
||||
// Stay on current list route
|
||||
if (state.route === 'sessions') router.push('/sessions');
|
||||
else router.push('/memory');
|
||||
}
|
||||
|
||||
function handleProjectSearch(e) {
|
||||
setProjectSearch(e.target.value);
|
||||
}
|
||||
|
||||
// --- Search ---
|
||||
|
||||
let searchTimer = null;
|
||||
function handleSearch(e) {
|
||||
const value = e.target.value;
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
setQuery(value);
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function handleToggleSort() {
|
||||
toggleSort();
|
||||
}
|
||||
|
||||
function handleToggleSearchMsgs() {
|
||||
toggleIncludeMessageBodies();
|
||||
}
|
||||
|
||||
// --- Keep-alive includes ---
|
||||
const keepAliveIncludes = ['SessionDetail'];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<!-- Titlebar (macOS traffic-light region) -->
|
||||
<div class="titlebar" :class="{ mac: IS_MAC }">
|
||||
<div class="titlebar-drag"></div>
|
||||
<div id="titlebar-text" class="titlebar-text">
|
||||
<span class="app-name">{{ windowTitle.appName }}</span>
|
||||
<span class="sep">—</span>
|
||||
<span class="scope">{{ windowTitle.scopeText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Columns: sidebar + main -->
|
||||
<div class="columns">
|
||||
<!-- Sidebar -->
|
||||
<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>
|
||||
|
||||
<!-- Navigation section -->
|
||||
<div class="sidebar-section">
|
||||
<button
|
||||
class="sidebar-item"
|
||||
:class="{ active: state.route === 'sessions' && state.projectFilter === 'all' }"
|
||||
@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>
|
||||
|
||||
<button
|
||||
class="sidebar-item sub"
|
||||
:class="{ active: state.route === 'memory' && state.view === 'active' && state.projectFilter === 'all' }"
|
||||
@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: state.route === 'memory' && state.view === 'archived' && state.projectFilter === 'all' }"
|
||||
@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>
|
||||
|
||||
<button
|
||||
class="sidebar-item"
|
||||
:class="{ active: state.route === 'usage' }"
|
||||
@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">Usage</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Projects section -->
|
||||
<div class="sidebar-section projects">
|
||||
<div class="sidebar-section-title">
|
||||
<span>Projects</span>
|
||||
</div>
|
||||
<div class="sidebar-search">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter..."
|
||||
:value="state.projectSearch"
|
||||
@input="handleProjectSearch"
|
||||
/>
|
||||
</div>
|
||||
<div id="sidebar-projects" class="sidebar-projects-list">
|
||||
<button
|
||||
v-for="p in sidebarProjects"
|
||||
:key="p.slug"
|
||||
class="sidebar-item"
|
||||
:class="{ active: state.projectFilter === 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>
|
||||
|
||||
<!-- Main content area -->
|
||||
<div class="main">
|
||||
<!-- Toolbar with search + sort (only on list views) -->
|
||||
<div v-if="showToolbar" class="toolbar">
|
||||
<div class="breadcrumb">
|
||||
<span class="crumb terminal">
|
||||
{{ state.route === 'sessions' ? 'Sessions' : 'Memory' }}
|
||||
</span>
|
||||
<template v-if="state.projectFilter !== 'all'">
|
||||
<span class="crumb-sep">/</span>
|
||||
<span class="crumb terminal">{{ formatProjectLabel(state.projectFilter) }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<div id="search-wrap" class="search-wrap">
|
||||
<input
|
||||
id="search"
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Search..."
|
||||
@input="handleSearch"
|
||||
/>
|
||||
<button
|
||||
v-if="showSearchMsgsToggle"
|
||||
class="filter-toggle"
|
||||
:class="{ active: state.includeMessageBodies }"
|
||||
@click="handleToggleSearchMsgs"
|
||||
title="Include message bodies in search"
|
||||
>
|
||||
Msgs
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
id="sort-toggle"
|
||||
class="sort-group"
|
||||
:class="{ desc: state.sortDesc, asc: !state.sortDesc }"
|
||||
@click="handleToggleSort"
|
||||
>
|
||||
<span id="sort-label">{{ state.sortDesc ? 'newest' : 'oldest' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar for detail views (breadcrumb only) -->
|
||||
<div v-if="!showToolbar" class="toolbar">
|
||||
<div class="breadcrumb">
|
||||
<router-link class="crumb" to="/sessions" v-if="route.name === 'SessionDetail' || route.name === 'SubagentDetail'">
|
||||
Sessions
|
||||
</router-link>
|
||||
<template v-if="route.name === 'SubagentDetail'">
|
||||
<span class="crumb-sep">/</span>
|
||||
<router-link class="crumb" :to="`/sessions/${route.params.id}`">
|
||||
{{ (state.sessions.find(s => s.id === route.params.id)?.title || '').slice(0, 30) || route.params.id }}
|
||||
</router-link>
|
||||
</template>
|
||||
<template v-if="route.name === 'SessionDetail'">
|
||||
<span class="crumb-sep">/</span>
|
||||
<span class="crumb terminal">
|
||||
{{ state.sessions.find(s => s.id === route.params.id)?.title || route.params.id }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="route.name === 'SubagentDetail'">
|
||||
<span class="crumb-sep">/</span>
|
||||
<span class="crumb terminal">{{ route.params.agentId }}</span>
|
||||
</template>
|
||||
<router-link class="crumb" to="/memory" v-if="route.name === 'MemoryDetail'">
|
||||
Memory
|
||||
</router-link>
|
||||
<template v-if="route.name === 'MemoryDetail'">
|
||||
<span class="crumb-sep">/</span>
|
||||
<span class="crumb terminal filename">
|
||||
{{ (state.memories.find(m => m.id === route.params.id)?.path || '').split('/').pop() }}
|
||||
</span>
|
||||
</template>
|
||||
<span v-if="route.name === 'Usage'" class="crumb terminal">Usage</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Router view with keep-alive for SessionDetail -->
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive :include="keepAliveIncludes">
|
||||
<component :is="Component" />
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status bar -->
|
||||
<div class="statusbar">
|
||||
<div id="status-left" class="status-left"></div>
|
||||
<div id="status-right" class="status-right"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@import '../styles/base.css';
|
||||
@import '../styles/sidebar.css';
|
||||
@import '../styles/toolbar.css';
|
||||
@import '../styles/list.css';
|
||||
@import '../styles/detail.css';
|
||||
@import '../styles/statusbar.css';
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.app-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.titlebar {
|
||||
height: 38px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
-webkit-app-region: drag;
|
||||
background: var(--bg-2);
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
}
|
||||
|
||||
.titlebar.mac {
|
||||
padding-left: 78px;
|
||||
}
|
||||
|
||||
.titlebar-drag {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.titlebar-text {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.titlebar-text .app-name {
|
||||
font-weight: 600;
|
||||
color: var(--fg-2);
|
||||
}
|
||||
|
||||
.titlebar-text .sep {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.columns {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.statusbar {
|
||||
height: 26px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 12px;
|
||||
background: var(--bg-2);
|
||||
border-top: 1px solid var(--hairline);
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.sidebar-empty {
|
||||
padding: 8px 10px;
|
||||
font-size: 11px;
|
||||
color: var(--muted-2);
|
||||
}
|
||||
|
||||
.sidebar-projects-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,308 @@
|
||||
<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 isUsageActive() {
|
||||
return state.route === 'usage';
|
||||
}
|
||||
|
||||
function isProjectActive(slug) {
|
||||
return state.projectFilter === slug;
|
||||
}
|
||||
|
||||
// --- Navigation handlers ---
|
||||
|
||||
function handleSidebarRoute(routeName) {
|
||||
setRoute(routeName);
|
||||
if (routeName === 'sessions') {
|
||||
router.push('/sessions');
|
||||
} else if (routeName === 'usage') {
|
||||
router.push('/usage');
|
||||
} 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: isUsageActive() }"
|
||||
@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">Usage</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>
|
||||
@@ -0,0 +1,379 @@
|
||||
<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 === 'Usage') {
|
||||
crumbs.push({ label: 'Usage', 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>
|
||||
@@ -0,0 +1,376 @@
|
||||
// Data loading layer -- bridges Electron IPC (window.obelisk.*) to reactive store.
|
||||
// All DB access goes through this module.
|
||||
|
||||
import { markRaw } from 'vue';
|
||||
import { state, clearUndo } from './store.js';
|
||||
|
||||
/**
|
||||
* Load initial data from the DB and populate state.memories, state.sessions,
|
||||
* and state.projects.
|
||||
*/
|
||||
export async function loadInitialData() {
|
||||
const [rawMemories, rawSessions, stats, projects] = await Promise.all([
|
||||
window.obelisk.getMemories(),
|
||||
window.obelisk.getSessions(),
|
||||
window.obelisk.getStats(),
|
||||
window.obelisk.getProjects()
|
||||
]);
|
||||
|
||||
// Transform memories: DB records -> render-layer shape
|
||||
state.memories = (rawMemories || []).map(m => ({
|
||||
...m,
|
||||
ts: m.created_at ? new Date(m.created_at).getTime() : 0,
|
||||
archived: !!m.deleted_at,
|
||||
archivedAt: m.deleted_at ? new Date(m.deleted_at).getTime() : null,
|
||||
health: 'ok',
|
||||
anchors: [],
|
||||
markdown: null // loaded on demand via loadMemoryMarkdown
|
||||
}));
|
||||
|
||||
// Sessions: keep DB shape, add empty messages array for on-demand loading
|
||||
state.sessions = (rawSessions || []).map(s => ({
|
||||
...s,
|
||||
messages: []
|
||||
}));
|
||||
|
||||
state.projects = projects || [];
|
||||
state.stats = stats || {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load full detail for a session: messages with inline tool_calls (each with
|
||||
* result), summaries, subagents, and workflow data.
|
||||
*
|
||||
* Returns the assembled session object (also updates state.sessions entry).
|
||||
*/
|
||||
export async function loadSessionDetail(sessionId) {
|
||||
const [messages, toolCalls, toolResults, subagents, workflows, summaries] =
|
||||
await Promise.all([
|
||||
window.obelisk.getSessionMessages(sessionId),
|
||||
window.obelisk.getSessionToolCalls(sessionId),
|
||||
window.obelisk.getSessionToolResults(sessionId),
|
||||
window.obelisk.getSessionSubagents(sessionId),
|
||||
window.obelisk.getSessionWorkflows(sessionId),
|
||||
window.obelisk.getSessionSummaries(sessionId)
|
||||
]);
|
||||
|
||||
// Index tool results by tool_use_id for fast lookup
|
||||
const resultsByCallId = {};
|
||||
for (const r of (toolResults || [])) {
|
||||
resultsByCallId[r.tool_use_id] = r;
|
||||
}
|
||||
|
||||
// Index subagents by parent_tool_use_id
|
||||
const subagentsByCallId = {};
|
||||
for (const sa of (subagents || [])) {
|
||||
if (sa.parent_tool_use_id) {
|
||||
subagentsByCallId[sa.parent_tool_use_id] = sa;
|
||||
}
|
||||
}
|
||||
|
||||
// Group tool_calls by message_uuid, attaching result and subagent inline
|
||||
const callsByMessageUuid = {};
|
||||
for (const tc of (toolCalls || [])) {
|
||||
const call = {
|
||||
id: tc.id,
|
||||
name: tc.name,
|
||||
input_json: tc.input_json,
|
||||
result: resultsByCallId[tc.id] || null
|
||||
};
|
||||
|
||||
// Attach subagent data if present
|
||||
const sa = subagentsByCallId[tc.id];
|
||||
if (sa) {
|
||||
call.subagent = {
|
||||
agent_id: sa.agent_id,
|
||||
agent_type: sa.agent_type,
|
||||
description: sa.description
|
||||
};
|
||||
}
|
||||
|
||||
const msgUuid = tc.message_uuid;
|
||||
if (!callsByMessageUuid[msgUuid]) callsByMessageUuid[msgUuid] = [];
|
||||
callsByMessageUuid[msgUuid].push(call);
|
||||
}
|
||||
|
||||
// Attach workflow data to Workflow tool calls
|
||||
for (const wf of (workflows || [])) {
|
||||
for (const calls of Object.values(callsByMessageUuid)) {
|
||||
for (const call of calls) {
|
||||
if (call.name === 'Workflow' && !call.workflow) {
|
||||
const resultText = call.result?.content || '';
|
||||
if (resultText.includes(wf.run_id) || resultText.includes(wf.workflow_name || '___none___')) {
|
||||
call.workflow = {
|
||||
run_id: wf.run_id,
|
||||
workflow_name: wf.workflow_name,
|
||||
status: wf.status,
|
||||
duration_ms: wf.duration_ms,
|
||||
total_tokens: wf.total_tokens,
|
||||
agent_count: wf.agent_count,
|
||||
agents: (wf.agents || []).map(a => ({
|
||||
agent_id: a.agent_id,
|
||||
phase: a.phase,
|
||||
label: a.label,
|
||||
state: a.state,
|
||||
tokens: a.tokens,
|
||||
duration_ms: a.duration_ms,
|
||||
}))
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Index summaries by session
|
||||
const sessionSummaries = (summaries || []).map(s => ({
|
||||
source: s.source,
|
||||
content: s.content,
|
||||
timestamp: s.timestamp
|
||||
}));
|
||||
|
||||
// Assemble messages with tool_calls inline
|
||||
const rawAssembled = (messages || []).map(msg => {
|
||||
const assembled = {
|
||||
uuid: msg.uuid,
|
||||
type: msg.type || msg.role,
|
||||
timestamp: msg.timestamp,
|
||||
text: msg.text,
|
||||
content_type: msg.content_type || null,
|
||||
is_meta: msg.is_meta || 0
|
||||
};
|
||||
|
||||
const calls = callsByMessageUuid[msg.uuid];
|
||||
if (calls && calls.length > 0) {
|
||||
assembled.tool_calls = calls;
|
||||
}
|
||||
|
||||
return assembled;
|
||||
});
|
||||
|
||||
// Merge adjacent assistant messages:
|
||||
// - tool_result user messages are skipped (results shown inside tool_call panels)
|
||||
// - consecutive tool_use messages (separated by tool_results) merge into one
|
||||
// - thinking messages merge into the next non-thinking assistant message
|
||||
const assembledMessages = [];
|
||||
for (let i = 0; i < rawAssembled.length; i++) {
|
||||
const msg = rawAssembled[i];
|
||||
|
||||
// Skip tool_result user messages
|
||||
if (msg.content_type === 'tool_result') continue;
|
||||
|
||||
// For thinking messages, collect consecutive thinking blocks and attach to the next assistant
|
||||
if (msg.type === 'assistant' && msg.content_type === 'thinking') {
|
||||
const thinkingParts = [msg.text || ''];
|
||||
let j = i + 1;
|
||||
while (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type === 'thinking') {
|
||||
thinkingParts.push(rawAssembled[j].text || '');
|
||||
j++;
|
||||
}
|
||||
if (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type !== 'thinking') {
|
||||
rawAssembled[j]._thinking = thinkingParts.join('\n\n');
|
||||
i = j - 1;
|
||||
continue;
|
||||
}
|
||||
assembledMessages.push({ ...msg, text: thinkingParts.join('\n\n'), content_type: 'thinking' });
|
||||
i = j - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// For tool_use assistant messages, absorb subsequent tool_use (skipping tool_results)
|
||||
if (msg.type === 'assistant' && msg.content_type === 'tool_use') {
|
||||
const merged = { ...msg, tool_calls: [...(msg.tool_calls || [])] };
|
||||
if (msg._thinking) merged._thinking = msg._thinking;
|
||||
let j = i + 1;
|
||||
while (j < rawAssembled.length) {
|
||||
const next = rawAssembled[j];
|
||||
if (next.content_type === 'tool_result') { j++; continue; }
|
||||
if (next.type === 'assistant' && next.content_type === 'tool_use') {
|
||||
if (next.tool_calls) merged.tool_calls.push(...next.tool_calls);
|
||||
if (next.text && !merged.text) merged.text = next.text;
|
||||
j++;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
assembledMessages.push(merged);
|
||||
i = j - 1;
|
||||
} else {
|
||||
const out = { ...msg };
|
||||
if (msg._thinking) out._thinking = msg._thinking;
|
||||
assembledMessages.push(out);
|
||||
}
|
||||
}
|
||||
|
||||
// Attach workflow data if present
|
||||
const workflow = (workflows && workflows.length > 0) ? workflows[0] : null;
|
||||
|
||||
// Build assembled session object
|
||||
const session = state.sessions.find(s => s.id === sessionId);
|
||||
const assembled = {
|
||||
...(session || {}),
|
||||
id: sessionId,
|
||||
messages: assembledMessages
|
||||
};
|
||||
|
||||
if (workflow) {
|
||||
assembled.workflow = workflow;
|
||||
}
|
||||
|
||||
// Update in-place in state.sessions
|
||||
const idx = state.sessions.findIndex(s => s.id === sessionId);
|
||||
if (idx !== -1) {
|
||||
state.sessions[idx] = assembled;
|
||||
}
|
||||
|
||||
return assembled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load full detail for a subagent conversation.
|
||||
* Returns assembled messages with tool_calls inline.
|
||||
*/
|
||||
export async function loadSubagentDetail(agentId) {
|
||||
const [messages, toolCalls, toolResults] = await Promise.all([
|
||||
window.obelisk.getSubagentMessages(agentId),
|
||||
window.obelisk.getSubagentToolCalls(agentId),
|
||||
window.obelisk.getSubagentToolResults(agentId),
|
||||
]);
|
||||
|
||||
const resultsByCallId = {};
|
||||
for (const r of (toolResults || [])) {
|
||||
resultsByCallId[r.tool_use_id] = r;
|
||||
}
|
||||
|
||||
const callsByMessageUuid = {};
|
||||
for (const tc of (toolCalls || [])) {
|
||||
const call = {
|
||||
id: tc.id,
|
||||
name: tc.name,
|
||||
input_json: tc.input_json,
|
||||
result: resultsByCallId[tc.id] || null
|
||||
};
|
||||
const msgUuid = tc.message_uuid;
|
||||
if (!callsByMessageUuid[msgUuid]) callsByMessageUuid[msgUuid] = [];
|
||||
callsByMessageUuid[msgUuid].push(call);
|
||||
}
|
||||
|
||||
const rawAssembled = (messages || []).map(msg => {
|
||||
const assembled = {
|
||||
uuid: msg.uuid,
|
||||
type: msg.type || msg.role,
|
||||
timestamp: msg.timestamp,
|
||||
text: msg.text,
|
||||
content_type: msg.content_type || null,
|
||||
is_meta: msg.is_meta || 0
|
||||
};
|
||||
const calls = callsByMessageUuid[msg.uuid];
|
||||
if (calls && calls.length > 0) assembled.tool_calls = calls;
|
||||
return assembled;
|
||||
});
|
||||
|
||||
// Same merging logic as session detail
|
||||
const assembledMessages = [];
|
||||
for (let i = 0; i < rawAssembled.length; i++) {
|
||||
const msg = rawAssembled[i];
|
||||
if (msg.content_type === 'tool_result') continue;
|
||||
if (msg.type === 'assistant' && msg.content_type === 'thinking') {
|
||||
const thinkingParts = [msg.text || ''];
|
||||
let j = i + 1;
|
||||
while (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type === 'thinking') {
|
||||
thinkingParts.push(rawAssembled[j].text || '');
|
||||
j++;
|
||||
}
|
||||
if (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type !== 'thinking') {
|
||||
rawAssembled[j]._thinking = thinkingParts.join('\n\n');
|
||||
i = j - 1;
|
||||
continue;
|
||||
}
|
||||
assembledMessages.push({ ...msg, text: thinkingParts.join('\n\n'), content_type: 'thinking' });
|
||||
i = j - 1;
|
||||
continue;
|
||||
}
|
||||
if (msg.type === 'assistant' && msg.content_type === 'tool_use') {
|
||||
const merged = { ...msg, tool_calls: [...(msg.tool_calls || [])] };
|
||||
if (msg._thinking) merged._thinking = msg._thinking;
|
||||
let j = i + 1;
|
||||
while (j < rawAssembled.length) {
|
||||
const next = rawAssembled[j];
|
||||
if (next.content_type === 'tool_result') { j++; continue; }
|
||||
if (next.type === 'assistant' && next.content_type === 'tool_use') {
|
||||
if (next.tool_calls) merged.tool_calls.push(...next.tool_calls);
|
||||
if (next.text && !merged.text) merged.text = next.text;
|
||||
j++;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
assembledMessages.push(merged);
|
||||
i = j - 1;
|
||||
} else {
|
||||
const out = { ...msg };
|
||||
if (msg._thinking) out._thinking = msg._thinking;
|
||||
assembledMessages.push(out);
|
||||
}
|
||||
}
|
||||
|
||||
return assembledMessages;
|
||||
}
|
||||
|
||||
const TEXT_LIMIT = 10000;
|
||||
|
||||
/**
|
||||
* Check if a message text was truncated during indexing.
|
||||
*/
|
||||
export function isTextTruncated(text) {
|
||||
return text && text.length >= TEXT_LIMIT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the full untruncated text for a message from its source JSONL.
|
||||
* Returns the full text string or null.
|
||||
*/
|
||||
export async function loadFullText(uuid) {
|
||||
try {
|
||||
return await window.obelisk.getMessageFullText(uuid);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the markdown content of a memory file.
|
||||
* Returns the content string or null on failure.
|
||||
*/
|
||||
export async function loadMemoryMarkdown(memoryPath) {
|
||||
try {
|
||||
const content = await window.obelisk.readMemoryFile(memoryPath);
|
||||
return content || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive a memory by id. Updates state after successful IPC call.
|
||||
*/
|
||||
export async function archiveMemory(id) {
|
||||
await window.obelisk.archiveMemory(id);
|
||||
const mem = state.memories.find(m => m.id === id);
|
||||
if (mem) {
|
||||
mem.archived = true;
|
||||
mem.archivedAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore an archived memory by id. Updates state after successful IPC call.
|
||||
*/
|
||||
export async function restoreMemory(id) {
|
||||
await window.obelisk.restoreMemory(id);
|
||||
const mem = state.memories.find(m => m.id === id);
|
||||
if (mem) {
|
||||
mem.archived = false;
|
||||
mem.archivedAt = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Vue 3 application entry point for Obelisk.
|
||||
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import router from './router.js';
|
||||
import { loadInitialData } from './data.js';
|
||||
|
||||
// Import all original CSS globally
|
||||
import '../styles/base.css';
|
||||
import '../styles/sidebar.css';
|
||||
import '../styles/toolbar.css';
|
||||
import '../styles/list.css';
|
||||
import '../styles/detail.css';
|
||||
import '../styles/statusbar.css';
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(router);
|
||||
|
||||
// Load data before the first render completes
|
||||
router.isReady().then(() => {
|
||||
loadInitialData();
|
||||
});
|
||||
|
||||
app.mount('#app');
|
||||
@@ -0,0 +1,65 @@
|
||||
// Vue Router configuration for Obelisk.
|
||||
// Routes map to the main content views; sidebar navigation drives route changes.
|
||||
|
||||
import { createRouter, createWebHashHistory } from 'vue-router';
|
||||
|
||||
// Lazy-loaded view components (will be created as Vue SFCs later)
|
||||
const SessionList = () => import('./views/SessionList.vue');
|
||||
const SessionDetail = () => import('./views/SessionDetail.vue');
|
||||
const SubagentDetail = () => import('./views/SubagentDetail.vue');
|
||||
const MemoryList = () => import('./views/MemoryList.vue');
|
||||
const MemoryDetail = () => import('./views/MemoryDetail.vue');
|
||||
const Usage = () => import('./views/Usage.vue');
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/sessions',
|
||||
name: 'SessionList',
|
||||
component: SessionList
|
||||
},
|
||||
{
|
||||
path: '/sessions/:id',
|
||||
name: 'SessionDetail',
|
||||
component: SessionDetail,
|
||||
props: true,
|
||||
meta: { keepAlive: true }
|
||||
},
|
||||
{
|
||||
path: '/sessions/:id/agent/:agentId',
|
||||
name: 'SubagentDetail',
|
||||
component: SubagentDetail,
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: '/memory',
|
||||
name: 'MemoryList',
|
||||
component: MemoryList
|
||||
},
|
||||
{
|
||||
path: '/memory/:id',
|
||||
name: 'MemoryDetail',
|
||||
component: MemoryDetail,
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: '/usage',
|
||||
name: 'Usage',
|
||||
component: Usage
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/memory'
|
||||
},
|
||||
{
|
||||
// Catch-all redirect
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/memory'
|
||||
}
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,128 @@
|
||||
// Reactive store -- Vue 3 reactive() replaces the plain object from state.js.
|
||||
// All state fields are ported; action functions mutate the reactive state.
|
||||
|
||||
import { reactive, markRaw } from 'vue';
|
||||
|
||||
export const state = reactive({
|
||||
memories: [],
|
||||
sessions: [],
|
||||
projects: [],
|
||||
stats: {},
|
||||
route: 'memory',
|
||||
view: 'active', // 'active' | 'archived'
|
||||
mode: 'list', // 'list' | 'detail'
|
||||
detailId: null,
|
||||
subagentId: null,
|
||||
subagentDescription: null,
|
||||
pendingFocusUuid: null,
|
||||
query: '',
|
||||
projectFilter: 'all',
|
||||
projectSearch: '',
|
||||
sortDesc: true,
|
||||
includeMessageBodies: false,
|
||||
cursorId: null,
|
||||
selection: markRaw(new Set()),
|
||||
showSource: false,
|
||||
lastArchiveSnapshot: null,
|
||||
undoTimer: null,
|
||||
undoExpires: 0
|
||||
});
|
||||
|
||||
// Platform detection
|
||||
export const IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform);
|
||||
|
||||
// SVG icon constants
|
||||
export const FOLDER_SVG = `<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;
|
||||
state.cursorId = null;
|
||||
state.selection = markRaw(new Set());
|
||||
state.query = '';
|
||||
}
|
||||
|
||||
export function setView(v) {
|
||||
state.route = 'memory';
|
||||
state.view = v;
|
||||
state.mode = 'list';
|
||||
state.detailId = null;
|
||||
state.cursorId = null;
|
||||
state.selection = markRaw(new Set());
|
||||
state.projectFilter = 'all';
|
||||
}
|
||||
|
||||
export function setProject(p) {
|
||||
state.projectFilter = p;
|
||||
state.cursorId = null;
|
||||
state.selection = markRaw(new Set());
|
||||
state.mode = 'list';
|
||||
state.detailId = null;
|
||||
}
|
||||
|
||||
export function toggleSort() {
|
||||
state.sortDesc = !state.sortDesc;
|
||||
}
|
||||
|
||||
export function enterDetail(id) {
|
||||
state.detailId = id;
|
||||
state.mode = 'detail';
|
||||
state.showSource = false;
|
||||
}
|
||||
|
||||
export function exitDetail() {
|
||||
if (state.subagentId) {
|
||||
state.subagentId = null;
|
||||
state.subagentDescription = null;
|
||||
return;
|
||||
}
|
||||
state.mode = 'list';
|
||||
state.detailId = null;
|
||||
state.pendingFocusUuid = null;
|
||||
}
|
||||
|
||||
export function navigateToSession(sessionId, focusUuid) {
|
||||
state.route = 'sessions';
|
||||
state.mode = 'detail';
|
||||
state.detailId = sessionId;
|
||||
state.subagentId = null;
|
||||
state.subagentDescription = null;
|
||||
state.pendingFocusUuid = focusUuid || null;
|
||||
state.query = '';
|
||||
}
|
||||
|
||||
export function navigateToSubagent(agentId, description) {
|
||||
state.subagentId = agentId;
|
||||
state.subagentDescription = description || agentId;
|
||||
}
|
||||
|
||||
export function setCursor(id, opts = {}) {
|
||||
state.cursorId = id;
|
||||
if (!opts.keepSelection) {
|
||||
state.selection = markRaw(new Set());
|
||||
}
|
||||
}
|
||||
|
||||
export function setQuery(q) {
|
||||
state.query = q;
|
||||
}
|
||||
|
||||
export function setProjectSearch(q) {
|
||||
state.projectSearch = q;
|
||||
}
|
||||
|
||||
export function toggleIncludeMessageBodies() {
|
||||
state.includeMessageBodies = !state.includeMessageBodies;
|
||||
}
|
||||
|
||||
export function clearUndo() {
|
||||
state.lastArchiveSnapshot = null;
|
||||
if (state.undoTimer) {
|
||||
clearInterval(state.undoTimer);
|
||||
state.undoTimer = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// Utility functions ported from the vanilla JS utils.js.
|
||||
// Pure helpers with no side-effects on global state (except formatProjectLabel which reads store).
|
||||
|
||||
import { state } from './store.js';
|
||||
|
||||
// --- Time / formatting ---
|
||||
|
||||
export function pad2(n) { return String(n).padStart(2, '0'); }
|
||||
|
||||
export function isSameDay(a, b) {
|
||||
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
||||
}
|
||||
|
||||
export function fmtListTime(ts) {
|
||||
const d = new Date(ts);
|
||||
const now = new Date();
|
||||
const hhmm = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||||
if (isSameDay(d, now)) return hhmm;
|
||||
const mmdd = `${pad2(d.getMonth() + 1)}/${pad2(d.getDate())}`;
|
||||
if (d.getFullYear() === now.getFullYear()) return `${mmdd} ${hhmm}`;
|
||||
return `${d.getFullYear()}/${mmdd} ${hhmm}`;
|
||||
}
|
||||
|
||||
export function fmtRelative(ts) {
|
||||
const diff = Date.now() - ts;
|
||||
const min = 60000, hr = 3600000, day = 86400000;
|
||||
if (diff < 0) return 'in the future';
|
||||
if (diff < min) return 'just now';
|
||||
if (diff < hr) return Math.floor(diff / min) + 'm ago';
|
||||
if (diff < day) return Math.floor(diff / hr) + 'h ago';
|
||||
if (diff < day * 30) return Math.floor(diff / day) + 'd ago';
|
||||
if (diff < day * 365) return Math.floor(diff / (day * 30)) + 'mo ago';
|
||||
return Math.floor(diff / (day * 365)) + 'y ago';
|
||||
}
|
||||
|
||||
export function fmtClockTime(iso) {
|
||||
const d = new Date(iso);
|
||||
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
export function fmtSize(bytes) {
|
||||
if (!bytes) return '-';
|
||||
if (bytes < 1024) return bytes + 'B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'K';
|
||||
return (bytes / 1024 / 1024).toFixed(1) + 'M';
|
||||
}
|
||||
|
||||
// --- HTML / Markdown ---
|
||||
|
||||
export function escapeHTML(s) {
|
||||
return String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
export function highlightPlain(text, query) {
|
||||
if (!query) return escapeHTML(text);
|
||||
const safe = escapeHTML(text);
|
||||
const q = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return safe.replace(new RegExp(q, 'gi'), m => `<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 '';
|
||||
// marked is loaded globally via CDN in index.html
|
||||
const html = sanitizeMarkdown(window.marked.parse(text));
|
||||
const cls = opts.variant === 'msg' ? 'markdown-msg'
|
||||
: opts.variant === 'compact' ? 'markdown-compact'
|
||||
: 'markdown-body';
|
||||
const container = document.createElement('div');
|
||||
container.className = cls;
|
||||
container.innerHTML = html;
|
||||
if (opts.query) highlightTextNodes(container, opts.query.trim());
|
||||
return container.outerHTML;
|
||||
}
|
||||
|
||||
// --- Duration / tokens / tooltip ---
|
||||
|
||||
export function fmtDuration(ms) {
|
||||
if (!ms) return '—';
|
||||
const s = Math.floor(ms / 1000);
|
||||
const d = Math.floor(s / 86400);
|
||||
const h = Math.floor((s % 86400) / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = s % 60;
|
||||
const parts = [];
|
||||
if (d) parts.push(`${d}d`);
|
||||
if (h) parts.push(`${h}h`);
|
||||
if (m) parts.push(`${m}m`);
|
||||
if (sec || !parts.length) parts.push(`${sec}s`);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
export function fmtTokens(n) {
|
||||
if (!n) return '0';
|
||||
if (n >= 1_000_000_000) return (n / 1_000_000_000).toFixed(2) + 'B';
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
|
||||
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K';
|
||||
return String(n);
|
||||
}
|
||||
|
||||
export function fmtTooltipDate(isoDay) {
|
||||
const d = new Date(isoDay + 'T00:00:00');
|
||||
const months = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
||||
const day = d.getDate();
|
||||
const suffix = day === 1 || day === 21 || day === 31 ? 'st' : day === 2 || day === 22 ? 'nd' : day === 3 || day === 23 ? 'rd' : 'th';
|
||||
const thisYear = new Date().getFullYear();
|
||||
if (d.getFullYear() === thisYear) return `${months[d.getMonth()]} ${day}${suffix}`;
|
||||
return `${months[d.getMonth()]} ${day}${suffix}, ${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
export function positionTooltip(el, x, y) {
|
||||
const pad = 12;
|
||||
const rect = el.getBoundingClientRect();
|
||||
let left = x + pad;
|
||||
if (left + rect.width > window.innerWidth - pad) left = x - rect.width - pad;
|
||||
el.style.left = left + 'px';
|
||||
el.style.top = (y - 28) + 'px';
|
||||
}
|
||||
|
||||
// --- Project label ---
|
||||
|
||||
export function formatProjectLabel(slug) {
|
||||
if (!slug) return '(no project)';
|
||||
const session = state.sessions.find(s => s.project === slug && s.project_path);
|
||||
if (session?.project_path) {
|
||||
const parts = session.project_path.split('/');
|
||||
return parts.slice(-2).join('/');
|
||||
}
|
||||
return slug.replace(/^-/, '');
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<script setup>
|
||||
defineOptions({ name: 'MemoryDetail' });
|
||||
defineProps({ id: String });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="view-placeholder">MemoryDetail view for {{ id }} (TODO)</div>
|
||||
</template>
|
||||
@@ -0,0 +1,736 @@
|
||||
<script setup>
|
||||
import { computed, ref, nextTick, onMounted, onUnmounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { state, FOLDER_SVG, clearUndo } from '../store.js';
|
||||
import { highlightPlain, escapeHTML, formatProjectLabel, fmtListTime, fmtRelative, renderMarkdown } from '../utils.js';
|
||||
import { loadMemoryMarkdown, archiveMemory, restoreMemory } from '../data.js';
|
||||
|
||||
defineOptions({ name: 'MemoryList' });
|
||||
|
||||
const router = useRouter();
|
||||
const listWrapRef = ref(null);
|
||||
const undoCountdown = ref(0);
|
||||
|
||||
// --- Filtered/sorted memories ---
|
||||
|
||||
const visibleMemories = computed(() => {
|
||||
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);
|
||||
});
|
||||
|
||||
const showProjectPrefix = computed(() => state.projectFilter === 'all');
|
||||
|
||||
// --- Detail state ---
|
||||
|
||||
const detailMemory = ref(null);
|
||||
const detailMarkdown = ref(null);
|
||||
const showSource = ref(false);
|
||||
const loadingMarkdown = ref(false);
|
||||
|
||||
const showDetail = computed(() => detailMemory.value !== null);
|
||||
|
||||
// --- Row helpers ---
|
||||
|
||||
function dominantRowStatus(m) {
|
||||
if (m.health === 'broken') return 'broken';
|
||||
if (m.health === 'partial') return 'partial';
|
||||
if (m.archived) return 'archived';
|
||||
return null;
|
||||
}
|
||||
|
||||
function statusGlyphs(status) {
|
||||
if (!status) return '';
|
||||
const map = {
|
||||
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 map[status] || '';
|
||||
}
|
||||
|
||||
function pathHTML(m) {
|
||||
return highlightPlain(m.path || '', state.query.trim());
|
||||
}
|
||||
|
||||
function summaryHTML(m) {
|
||||
return highlightPlain(m.summary || '', state.query.trim());
|
||||
}
|
||||
|
||||
function timeLabel(m) {
|
||||
return fmtListTime(m.ts);
|
||||
}
|
||||
|
||||
function projectLabel(m) {
|
||||
return escapeHTML(formatProjectLabel(m.project));
|
||||
}
|
||||
|
||||
// --- Selection ---
|
||||
|
||||
function toggleSelection(id) {
|
||||
const s = new Set(state.selection);
|
||||
if (s.has(id)) s.delete(id);
|
||||
else s.add(id);
|
||||
state.selection = s;
|
||||
}
|
||||
|
||||
// --- Cursor navigation ---
|
||||
|
||||
function moveCursor(direction) {
|
||||
const items = visibleMemories.value;
|
||||
if (!items.length) return;
|
||||
const curIdx = items.findIndex(m => m.id === state.cursorId);
|
||||
let next;
|
||||
if (curIdx === -1) {
|
||||
next = 0;
|
||||
} else {
|
||||
next = curIdx + direction;
|
||||
if (next < 0) next = 0;
|
||||
if (next >= items.length) next = items.length - 1;
|
||||
}
|
||||
state.cursorId = items[next].id;
|
||||
nextTick(() => ensureVisible());
|
||||
}
|
||||
|
||||
function ensureVisible() {
|
||||
if (!listWrapRef.value || !state.cursorId) return;
|
||||
const cursorEl = listWrapRef.value.querySelector(`.row[data-id="${state.cursorId}"]`);
|
||||
if (!cursorEl) return;
|
||||
const elRect = cursorEl.getBoundingClientRect();
|
||||
const wrapRect = listWrapRef.value.getBoundingClientRect();
|
||||
if (elRect.top < wrapRect.top + 30) {
|
||||
listWrapRef.value.scrollTop -= (wrapRect.top + 30 - elRect.top);
|
||||
} else if (elRect.bottom > wrapRect.bottom - 10) {
|
||||
listWrapRef.value.scrollTop += (elRect.bottom - wrapRect.bottom + 10);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Open detail ---
|
||||
|
||||
async function openDetail(m) {
|
||||
detailMemory.value = m;
|
||||
showSource.value = false;
|
||||
loadingMarkdown.value = true;
|
||||
detailMarkdown.value = null;
|
||||
|
||||
if (m.markdown === null && m.path) {
|
||||
m.markdown = await loadMemoryMarkdown(m.path);
|
||||
}
|
||||
detailMarkdown.value = m.markdown;
|
||||
loadingMarkdown.value = false;
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
detailMemory.value = null;
|
||||
detailMarkdown.value = null;
|
||||
showSource.value = false;
|
||||
}
|
||||
|
||||
function toggleSourceView() {
|
||||
showSource.value = !showSource.value;
|
||||
}
|
||||
|
||||
// --- Row click ---
|
||||
|
||||
function onRowClick(m) {
|
||||
state.cursorId = m.id;
|
||||
openDetail(m);
|
||||
}
|
||||
|
||||
// --- Archive/restore with undo ---
|
||||
|
||||
const undoSnapshot = ref(null);
|
||||
let undoTimer = null;
|
||||
|
||||
async function doArchive(ids) {
|
||||
const targets = ids || (state.cursorId ? [state.cursorId] : []);
|
||||
if (!targets.length) return;
|
||||
undoSnapshot.value = { action: 'archive', ids: [...targets] };
|
||||
undoCountdown.value = 5;
|
||||
for (const id of targets) {
|
||||
await archiveMemory(id);
|
||||
}
|
||||
startUndoTimer();
|
||||
// Move cursor if needed
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
async function undoAction() {
|
||||
if (!undoSnapshot.value) return;
|
||||
const { action, ids } = undoSnapshot.value;
|
||||
for (const id of ids) {
|
||||
if (action === 'archive') await restoreMemory(id);
|
||||
else await archiveMemory(id);
|
||||
}
|
||||
undoSnapshot.value = null;
|
||||
undoCountdown.value = 0;
|
||||
if (undoTimer) { clearInterval(undoTimer); undoTimer = null; }
|
||||
}
|
||||
|
||||
function startUndoTimer() {
|
||||
if (undoTimer) clearInterval(undoTimer);
|
||||
undoCountdown.value = 5;
|
||||
undoTimer = setInterval(() => {
|
||||
undoCountdown.value--;
|
||||
if (undoCountdown.value <= 0) {
|
||||
clearInterval(undoTimer);
|
||||
undoTimer = null;
|
||||
undoSnapshot.value = null;
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// --- Detail action ---
|
||||
|
||||
function detailArchiveRestore() {
|
||||
if (!detailMemory.value) return;
|
||||
if (detailMemory.value.archived) {
|
||||
doRestore([detailMemory.value.id]);
|
||||
} else {
|
||||
doArchive([detailMemory.value.id]);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Detail markdown rendering ---
|
||||
|
||||
const renderedMarkdown = computed(() => {
|
||||
if (detailMarkdown.value == null) return null;
|
||||
if (showSource.value) return null; // handled by pre block in template
|
||||
return renderMarkdown(detailMarkdown.value, { variant: 'body' });
|
||||
});
|
||||
|
||||
// --- 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;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
if (undoTimer) { clearInterval(undoTimer); undoTimer = null; }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Detail panel overlay -->
|
||||
<div v-if="showDetail" class="detail-wrap">
|
||||
<div class="detail">
|
||||
<div class="detail-header">
|
||||
<div class="detail-eyebrow">
|
||||
<span class="project-icon" v-html="FOLDER_SVG"></span>
|
||||
<span class="project-name">{{ formatProjectLabel(detailMemory.project) }}</span>
|
||||
<span v-if="detailMemory.archived" class="archived-tag">archived</span>
|
||||
</div>
|
||||
<div class="detail-path">{{ detailMemory.path }}</div>
|
||||
<div class="detail-summary">{{ detailMemory.summary }}</div>
|
||||
<div class="detail-meta">
|
||||
<span>{{ fmtRelative(detailMemory.ts) }}</span>
|
||||
</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="detailMarkdown == null"
|
||||
@click="toggleSourceView"
|
||||
>
|
||||
{{ showSource ? 'Show rendered' : 'Show source' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingMarkdown" class="markdown-loading">Loading...</div>
|
||||
<div v-else-if="detailMarkdown == null" class="markdown-empty">
|
||||
File not found or empty.
|
||||
</div>
|
||||
<pre v-else-if="showSource" class="markdown-source">{{ detailMarkdown }}</pre>
|
||||
<div v-else class="markdown-body" v-html="renderedMarkdown"></div>
|
||||
</div>
|
||||
|
||||
<div class="detail-actions">
|
||||
<button class="btn" @click="closeDetail">
|
||||
Back<span class="kbd">Esc</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn"
|
||||
:class="detailMemory.archived ? 'primary' : 'danger'"
|
||||
@click="detailArchiveRestore"
|
||||
>
|
||||
{{ detailMemory.archived ? 'Restore' : 'Archive' }}<span class="kbd">D</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List panel -->
|
||||
<div v-else ref="listWrapRef" class="list-wrap">
|
||||
<div v-if="!visibleMemories.length" class="empty">
|
||||
No memories{{ state.view === 'archived' ? ' archived' : '' }} here.
|
||||
<span class="hint">{{ state.query ? 'Try a different search term.' : 'Press / to search.' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="memory-list">
|
||||
<div
|
||||
v-for="m in visibleMemories"
|
||||
:key="m.id"
|
||||
class="row"
|
||||
:class="{
|
||||
cursor: state.cursorId === m.id,
|
||||
selected: state.selection.has(m.id),
|
||||
archived: m.archived
|
||||
}"
|
||||
:data-id="m.id"
|
||||
@click="onRowClick(m)"
|
||||
>
|
||||
<button
|
||||
class="row-checkbox"
|
||||
:class="{ checked: state.selection.has(m.id) }"
|
||||
aria-label="Select"
|
||||
@click.stop="toggleSelection(m.id)"
|
||||
>
|
||||
<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">
|
||||
<span
|
||||
v-if="dominantRowStatus(m)"
|
||||
class="row-status"
|
||||
:class="dominantRowStatus(m)"
|
||||
:title="dominantRowStatus(m)"
|
||||
v-html="statusGlyphs(dominantRowStatus(m))"
|
||||
></span>
|
||||
<template v-if="showProjectPrefix">
|
||||
<span class="project-prefix" v-html="projectLabel(m)"></span>
|
||||
<span class="project-prefix-sep">/</span>
|
||||
</template>
|
||||
<span class="path-text" v-html="pathHTML(m)"></span>
|
||||
</div>
|
||||
<div class="row-summary" v-html="summaryHTML(m)"></div>
|
||||
</div>
|
||||
|
||||
<div class="row-right">
|
||||
<div class="row-meta"><span>{{ timeLabel(m) }}</span></div>
|
||||
<div class="row-actions">
|
||||
<button
|
||||
v-if="m.archived"
|
||||
class="row-action restore"
|
||||
@click.stop="doRestore([m.id])"
|
||||
>
|
||||
Restore<span class="kbd">D</span>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="row-action danger"
|
||||
@click.stop="doArchive([m.id])"
|
||||
>
|
||||
Archive<span class="kbd">D</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Undo toast -->
|
||||
<Transition name="undo-fade">
|
||||
<div v-if="undoSnapshot" class="undo-toast" @click="undoAction">
|
||||
{{ undoSnapshot.action === 'archive' ? 'Archived' : 'Restored' }}
|
||||
{{ undoSnapshot.ids.length }} memory{{ undoSnapshot.ids.length > 1 ? 'ies' : '' }}.
|
||||
<button class="undo-btn">Undo ({{ undoCountdown }}s)</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.list-wrap {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.detail-wrap {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.detail {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 32px 60px;
|
||||
}
|
||||
|
||||
.memory-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Row styles */
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 22px 1fr auto;
|
||||
align-items: start;
|
||||
column-gap: 12px;
|
||||
padding: 14px 16px 14px 14px;
|
||||
min-height: var(--row-h, 60px);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
transition: background 0.06s;
|
||||
position: relative;
|
||||
}
|
||||
.row:last-child { border-bottom: 0; }
|
||||
.row:hover { background: rgba(255,255,255,0.025); }
|
||||
.row.cursor { background: var(--surface); }
|
||||
.row.cursor::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--muted-2);
|
||||
}
|
||||
.row.selected { background: var(--accent-soft); }
|
||||
.row.selected::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 12px var(--accent-glow);
|
||||
}
|
||||
.row.cursor.selected { background: rgba(167,139,250,0.16); }
|
||||
|
||||
.row-checkbox {
|
||||
width: 18px; height: 18px; margin-top: 1px;
|
||||
border-radius: 4px;
|
||||
border: 1.5px solid var(--muted-2);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
opacity: 0;
|
||||
transition: all 0.1s;
|
||||
justify-self: center;
|
||||
}
|
||||
.row:hover .row-checkbox,
|
||||
.row.selected .row-checkbox,
|
||||
.row.cursor .row-checkbox { opacity: 1; }
|
||||
.row-checkbox:hover { border-color: var(--accent); }
|
||||
.row-checkbox.checked {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 8px var(--accent-glow);
|
||||
}
|
||||
.row-checkbox svg { width: 10px; height: 10px; color: #0a0b14; opacity: 0; }
|
||||
.row-checkbox.checked svg { opacity: 1; }
|
||||
|
||||
.row-body { min-width: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
|
||||
.row-path {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-md);
|
||||
font-weight: 500;
|
||||
color: var(--fg);
|
||||
line-height: 1.4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.row-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px; height: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.row-status :deep(svg) { width: 100%; height: 100%; }
|
||||
.row-status.broken { color: var(--danger); }
|
||||
.row-status.partial { color: var(--warn); }
|
||||
.row-status.archived { color: var(--muted-2); }
|
||||
|
||||
.row-path .project-prefix { color: var(--muted); font-weight: 400; flex-shrink: 0; }
|
||||
.row-path .project-prefix-sep { color: var(--muted-2); margin: 0 2px; flex-shrink: 0; }
|
||||
.row-path .path-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||
.row-path :deep(mark), .row-summary :deep(mark) {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-2);
|
||||
padding: 0 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.row-summary {
|
||||
font-size: var(--text-base);
|
||||
color: var(--fg-2);
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.row-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
padding-top: 1px;
|
||||
}
|
||||
|
||||
.row-meta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.02em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.row:hover .row-meta { color: var(--muted-2); }
|
||||
|
||||
.row-actions { display: flex; gap: 4px; opacity: 0; transition: opacity 0.1s; }
|
||||
.row:hover .row-actions, .row.cursor .row-actions { opacity: 1; }
|
||||
|
||||
.row-action {
|
||||
height: 24px; padding: 0 8px; border-radius: 4px;
|
||||
color: var(--muted); font-size: var(--text-sm);
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
transition: all 0.1s; border: 1px solid transparent;
|
||||
background: transparent; cursor: pointer;
|
||||
}
|
||||
.row-action:hover { background: var(--surface-hi); color: var(--fg); border-color: var(--hairline-strong); }
|
||||
.row-action.danger:hover { background: var(--danger-soft); color: var(--danger); border-color: rgba(248,113,113,0.3); }
|
||||
.row-action.restore { color: var(--accent-2); }
|
||||
.row-action.restore:hover { background: var(--accent-soft); color: var(--fg); border-color: var(--accent-soft); }
|
||||
.row-action .kbd {
|
||||
font-family: var(--font-mono); font-size: 9.5px; color: var(--muted-2);
|
||||
padding: 0 3px; border: 1px solid var(--hairline); border-radius: 2px; line-height: 1.4;
|
||||
}
|
||||
.row-action:hover .kbd { color: var(--fg-2); border-color: var(--hairline-strong); }
|
||||
|
||||
.row.archived .row-path, .row.archived .row-summary { color: var(--muted); }
|
||||
.row.archived .row-path .project-prefix { color: var(--muted-2); }
|
||||
|
||||
/* Empty state */
|
||||
.empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--muted-2);
|
||||
font-size: var(--text-sm);
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.empty .hint { font-size: 11px; color: var(--muted-2); }
|
||||
|
||||
/* Detail panel styles */
|
||||
.detail-header { margin-bottom: 24px; }
|
||||
.detail-eyebrow {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-size: 11px; color: var(--muted);
|
||||
margin-bottom: 14px; flex-wrap: wrap;
|
||||
}
|
||||
.detail-eyebrow .project-icon { width: 13px; height: 13px; color: var(--muted); display: inline-flex; }
|
||||
.detail-eyebrow .project-icon :deep(svg) { width: 100%; height: 100%; }
|
||||
.detail-eyebrow .project-name { color: var(--fg-2); font-weight: 500; }
|
||||
.detail-eyebrow .archived-tag {
|
||||
color: var(--accent-2);
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.detail-eyebrow .archived-tag::before {
|
||||
content: ''; width: 6px; height: 6px; border-radius: 50%;
|
||||
background: var(--accent); box-shadow: 0 0 6px var(--accent-glow);
|
||||
}
|
||||
.detail-path {
|
||||
font-family: var(--font-mono); font-size: 17px; font-weight: 500;
|
||||
color: var(--fg); line-height: 1.5;
|
||||
word-break: break-all; margin-bottom: 16px;
|
||||
}
|
||||
.detail-summary { font-size: var(--text-md); color: var(--fg-2); line-height: 1.6; margin-bottom: 16px; }
|
||||
.detail-meta {
|
||||
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||
font-family: var(--font-mono); font-size: var(--text-sm);
|
||||
color: var(--muted); font-variant-numeric: tabular-nums;
|
||||
padding-bottom: 16px; border-bottom: 1px solid var(--hairline);
|
||||
}
|
||||
|
||||
.markdown-section { margin: 28px 0 8px; }
|
||||
.markdown-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
||||
.markdown-toolbar-label {
|
||||
font-size: 10.5px; color: var(--muted);
|
||||
font-weight: 500; letter-spacing: 0.04em; flex: 1;
|
||||
}
|
||||
.source-toggle {
|
||||
height: 22px; padding: 0 8px; border-radius: 4px;
|
||||
border: 1px solid var(--hairline-strong); background: var(--surface);
|
||||
color: var(--muted); font-size: var(--text-sm);
|
||||
transition: all 0.1s; cursor: pointer;
|
||||
}
|
||||
.source-toggle:hover { background: var(--surface-strong); color: var(--fg-2); }
|
||||
.source-toggle.active { background: var(--accent-soft); color: var(--accent-2); border-color: var(--accent-soft); }
|
||||
.source-toggle:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
.markdown-loading {
|
||||
color: var(--muted-2); font-style: italic; padding: 20px; text-align: center;
|
||||
}
|
||||
.markdown-empty {
|
||||
color: var(--muted-2); font-style: italic; padding: 20px; text-align: center;
|
||||
border: 1px dashed var(--hairline); border-radius: 6px;
|
||||
}
|
||||
.markdown-source {
|
||||
background: rgba(0,0,0,0.3); border: 1px solid var(--hairline);
|
||||
border-radius: 6px; padding: 14px 16px;
|
||||
font-family: var(--font-mono); font-size: 12px; line-height: 1.55;
|
||||
color: var(--fg-2); white-space: pre-wrap; word-wrap: break-word;
|
||||
}
|
||||
|
||||
.detail-actions { margin-top: 32px; display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.detail-actions .btn {
|
||||
height: 30px; padding: 0 14px; border-radius: 6px;
|
||||
font-size: var(--text-base); font-weight: 500;
|
||||
transition: all 0.1s;
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
border: 1px solid var(--hairline-strong);
|
||||
color: var(--fg-2); background: var(--surface);
|
||||
cursor: pointer;
|
||||
}
|
||||
.detail-actions .btn:hover { background: var(--surface-strong); color: var(--fg); }
|
||||
.detail-actions .btn.danger { color: var(--danger); }
|
||||
.detail-actions .btn.danger:hover { background: var(--danger-soft); border-color: rgba(248,113,113,0.3); }
|
||||
.detail-actions .btn.primary { color: var(--accent-2); }
|
||||
.detail-actions .btn.primary:hover { background: var(--accent-soft); border-color: var(--accent-soft); color: var(--fg); }
|
||||
.detail-actions .btn .kbd {
|
||||
font-family: var(--font-mono); font-size: 10px; color: var(--muted-2);
|
||||
padding: 0 4px; border: 1px solid var(--hairline); border-radius: 3px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Undo toast */
|
||||
.undo-toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--surface-strong);
|
||||
border: 1px solid var(--hairline-strong);
|
||||
border-radius: 8px;
|
||||
padding: 10px 16px;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--fg-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
|
||||
z-index: 100;
|
||||
cursor: pointer;
|
||||
}
|
||||
.undo-btn {
|
||||
background: var(--accent-soft);
|
||||
border: 1px solid rgba(167,139,250,0.3);
|
||||
border-radius: 4px;
|
||||
padding: 3px 10px;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--accent-2);
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.undo-btn:hover { background: rgba(167,139,250,0.25); border-color: var(--accent); }
|
||||
|
||||
.undo-fade-enter-active, .undo-fade-leave-active { transition: opacity 0.2s, transform 0.2s; }
|
||||
.undo-fade-enter-from, .undo-fade-leave-to { opacity: 0; transform: translateX(-50%) translateY(10px); }
|
||||
</style>
|
||||
@@ -0,0 +1,474 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, nextTick, onActivated } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { state, FOLDER_SVG } from '../store.js';
|
||||
import { loadSessionDetail, isTextTruncated, loadFullText } from '../data.js';
|
||||
import {
|
||||
escapeHTML,
|
||||
fmtRelative,
|
||||
fmtClockTime,
|
||||
renderMarkdown,
|
||||
formatProjectLabel
|
||||
} from '../utils.js';
|
||||
|
||||
defineOptions({ name: 'SessionDetail' });
|
||||
const props = defineProps({ id: String });
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// --- Reactive state ---
|
||||
const session = computed(() => state.sessions.find(s => s.id === props.id));
|
||||
const messages = ref([]);
|
||||
const loading = ref(false);
|
||||
const progressPct = ref(0);
|
||||
const showBackToTop = ref(false);
|
||||
|
||||
// DOM refs
|
||||
const wrapRef = ref(null);
|
||||
const detailRef = ref(null);
|
||||
|
||||
// --- Load session on mount ---
|
||||
onMounted(async () => {
|
||||
await loadMessages();
|
||||
});
|
||||
|
||||
// When keep-alive re-activates, re-check if we need data
|
||||
onActivated(async () => {
|
||||
if (messages.value.length === 0 && props.id) {
|
||||
await loadMessages();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadMessages() {
|
||||
if (!props.id) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const s = state.sessions.find(x => x.id === props.id);
|
||||
if (s && (!s.messages || s.messages.length === 0)) {
|
||||
const loaded = await loadSessionDetail(props.id);
|
||||
if (loaded) Object.assign(s, loaded);
|
||||
}
|
||||
messages.value = s?.messages || [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
// Focus pending uuid if any
|
||||
if (state.pendingFocusUuid) {
|
||||
const targetUuid = state.pendingFocusUuid;
|
||||
state.pendingFocusUuid = null;
|
||||
await nextTick();
|
||||
const target = detailRef.value?.querySelector(`.msg[data-uuid="${targetUuid}"]`);
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
target.classList.add('is-focused');
|
||||
setTimeout(() => target.classList.remove('is-focused'), 1200);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Scroll / progress tracking ---
|
||||
function onScroll() {
|
||||
if (!wrapRef.value || !detailRef.value) return;
|
||||
const msgs = detailRef.value.querySelectorAll('.msg, .wf-card');
|
||||
if (!msgs.length) return;
|
||||
const wrapTop = wrapRef.value.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);
|
||||
progressPct.value = pct;
|
||||
showBackToTop.value = wrapRef.value.scrollTop > 300;
|
||||
}
|
||||
|
||||
function scrollToTop() {
|
||||
if (wrapRef.value) wrapRef.value.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
// --- Toggle helpers ---
|
||||
function toggleToolCall(event) {
|
||||
const btn = event.currentTarget;
|
||||
btn.closest('.msg-tool').classList.toggle('open');
|
||||
}
|
||||
|
||||
function toggleSummary(event) {
|
||||
const btn = event.currentTarget;
|
||||
btn.closest('.msg-summary').classList.toggle('open');
|
||||
}
|
||||
|
||||
function toggleThinking(event) {
|
||||
const btn = event.currentTarget;
|
||||
btn.closest('.msg-thinking').classList.toggle('open');
|
||||
}
|
||||
|
||||
function toggleMeta(event) {
|
||||
const btn = event.currentTarget;
|
||||
btn.closest('.msg-meta-collapsed').classList.toggle('open');
|
||||
}
|
||||
|
||||
// --- Full text loading ---
|
||||
async function handleLoadFullText(event, uuid) {
|
||||
const btn = event.currentTarget;
|
||||
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 ---
|
||||
function navigateToSubagent(agentId, description) {
|
||||
router.push({
|
||||
name: 'SubagentDetail',
|
||||
params: { id: props.id, agentId }
|
||||
});
|
||||
}
|
||||
|
||||
// --- Render helpers (produce raw HTML strings like the vanilla version) ---
|
||||
|
||||
function getArgPreview(tc) {
|
||||
try {
|
||||
const j = JSON.parse(tc.input_json || '{}');
|
||||
if (j.file_path) return j.file_path;
|
||||
if (j.command) return j.command;
|
||||
if (j.path) return j.path;
|
||||
if (j.description) return j.description;
|
||||
return JSON.stringify(j).slice(0, 100);
|
||||
} catch {
|
||||
return (tc.input_json || '').slice(0, 100);
|
||||
}
|
||||
}
|
||||
|
||||
function getToolCallParsedInput(tc) {
|
||||
try {
|
||||
return JSON.parse(tc.input_json || '{}');
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="detail-wrap" ref="wrapRef" @scroll="onScroll">
|
||||
<div class="detail" ref="detailRef">
|
||||
<!-- Progress bar -->
|
||||
<div class="session-progress">
|
||||
<div class="session-progress-fill" :style="{ width: progressPct + '%' }"></div>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="empty" style="padding: 60px 0; text-align: center; color: var(--muted);">
|
||||
Loading session...
|
||||
</div>
|
||||
|
||||
<!-- Session header -->
|
||||
<template v-if="session && !loading">
|
||||
<div class="session-header">
|
||||
<div class="session-eyebrow">
|
||||
<span class="project-icon" v-html="FOLDER_SVG"></span>
|
||||
<span class="project-name">{{ formatProjectLabel(session.project) }}</span>
|
||||
<span class="sep">·</span>
|
||||
<span class="project-path">{{ session.project_path || '' }}</span>
|
||||
</div>
|
||||
<div class="session-title">{{ session.title || '(untitled)' }}</div>
|
||||
<div class="session-meta-inline">
|
||||
<span>{{ fmtRelative(new Date(session.started_at || 0).getTime()) }}</span>
|
||||
<span class="dot"></span>
|
||||
<span>{{ session.message_count || 0 }} messages</span>
|
||||
<template v-if="session.git_branch">
|
||||
<span class="dot"></span>
|
||||
<span>{{ session.git_branch }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message timeline -->
|
||||
<div class="timeline">
|
||||
<template v-for="(msg, idx) in messages" :key="msg.uuid || idx">
|
||||
|
||||
<!-- Meta messages: collapsed system indicator -->
|
||||
<template v-if="msg.is_meta === 1">
|
||||
<div class="msg meta" :data-uuid="msg.uuid">
|
||||
<div class="msg-meta-collapsed">
|
||||
<button class="meta-toggle" @click="toggleMeta">
|
||||
<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">{{ (msg.text || '').replace(/<[^>]+>/g, '').slice(0, 80) }}</span>
|
||||
</button>
|
||||
<div class="meta-body">
|
||||
<div v-html="renderMarkdown(msg.text, { variant: 'compact', query: state.query })"></div>
|
||||
<button
|
||||
v-if="isTextTruncated(msg.text)"
|
||||
class="truncated-btn"
|
||||
@click="handleLoadFullText($event, msg.uuid)"
|
||||
>Message truncated — click to load full text</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Workflow card (standalone, outside assistant bubble) -->
|
||||
<template v-else-if="!msg.type || msg.type !== 'user' ? (msg.tool_calls || []).find(tc => tc.name === 'Workflow' && tc.workflow) : false">
|
||||
<template v-if="(() => { const wfCall = (msg.tool_calls || []).find(tc => tc.name === 'Workflow' && tc.workflow); return wfCall && msg.type !== 'user'; })()">
|
||||
<div class="wf-card" :data-uuid="msg.uuid">
|
||||
<div class="wf-card-header">
|
||||
<span class="wf-card-icon">⚙</span>
|
||||
<span class="wf-card-name">{{ ((msg.tool_calls || []).find(tc => tc.name === 'Workflow' && tc.workflow)).workflow.workflow_name || 'Workflow' }}</span>
|
||||
<span class="wf-card-count">{{ ((msg.tool_calls || []).find(tc => tc.name === 'Workflow' && tc.workflow)).workflow.agents?.length || 0 }} agents</span>
|
||||
<span
|
||||
v-if="((msg.tool_calls || []).find(tc => tc.name === 'Workflow' && tc.workflow)).workflow.status"
|
||||
class="wf-card-status"
|
||||
:class="((msg.tool_calls || []).find(tc => tc.name === 'Workflow' && tc.workflow)).workflow.status"
|
||||
>{{ ((msg.tool_calls || []).find(tc => tc.name === 'Workflow' && tc.workflow)).workflow.status }}</span>
|
||||
</div>
|
||||
<div class="wf-card-body">
|
||||
<!-- Group agents by phase -->
|
||||
<template v-for="(phaseAgents, phase) in (() => {
|
||||
const wf = ((msg.tool_calls || []).find(tc => tc.name === 'Workflow' && tc.workflow)).workflow;
|
||||
const phases = {};
|
||||
for (const a of (wf.agents || [])) {
|
||||
const p = a.phase || 'Other';
|
||||
if (!phases[p]) phases[p] = [];
|
||||
phases[p].push(a);
|
||||
}
|
||||
return phases;
|
||||
})()" :key="phase">
|
||||
<div class="wf-card-phase">
|
||||
<div class="wf-card-phase-title">{{ phase }}</div>
|
||||
<button
|
||||
v-for="a in phaseAgents"
|
||||
:key="a.agent_id"
|
||||
class="wf-card-agent"
|
||||
@click="navigateToSubagent(a.agent_id, a.label || '')"
|
||||
>
|
||||
<span class="wf-card-agent-label">{{ a.label || a.agent_id }}</span>
|
||||
<span v-if="a.state === 'error'" class="wf-card-agent-state error">error</span>
|
||||
<span class="wf-card-agent-arrow">→</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Other tool calls (non-workflow) for this message -->
|
||||
<template v-if="(msg.tool_calls || []).filter(tc => !(tc.name === 'Workflow' && tc.workflow)).length > 0">
|
||||
<div class="msg assistant" :data-uuid="msg.uuid + '-tools'">
|
||||
<div class="msg-tools">
|
||||
<template v-for="tc in (msg.tool_calls || []).filter(tc2 => !(tc2.name === 'Workflow' && tc2.workflow))" :key="tc.id">
|
||||
<!-- Render non-workflow tool calls -->
|
||||
<div class="msg-tool" :class="{ 'is-error': tc.result && tc.result.is_error }">
|
||||
<button class="toolcall-toggle" @click="toggleToolCall">
|
||||
<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">{{ tc.name }}</span>
|
||||
<span class="tool-arg">{{ getArgPreview(tc) }}</span>
|
||||
<span v-if="tc.result && tc.result.is_error" class="tool-error">error</span>
|
||||
</button>
|
||||
<div class="toolcall-body">
|
||||
<div class="tc-section">Input</div>
|
||||
<pre>{{ tc.input_json || '' }}</pre>
|
||||
<template v-if="tc.result">
|
||||
<div class="tc-section">{{ tc.result.is_error ? 'Error' : 'Output' }}</div>
|
||||
<pre>{{ tc.result.content || '(empty)' }}</pre>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Standalone thinking message -->
|
||||
<template v-else-if="msg.type === 'assistant' && msg.content_type === 'thinking'">
|
||||
<div class="msg assistant" :data-uuid="msg.uuid">
|
||||
<div class="msg-thinking">
|
||||
<button class="thinking-toggle" @click="toggleThinking">
|
||||
<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" v-html="renderMarkdown(msg.text, { variant: 'msg', query: state.query })"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Normal message (user or assistant) -->
|
||||
<template v-else>
|
||||
<div
|
||||
class="msg"
|
||||
:class="msg.type === 'user' ? 'user' : 'assistant'"
|
||||
:data-uuid="msg.uuid"
|
||||
>
|
||||
<!-- Message header -->
|
||||
<div class="msg-head">
|
||||
<span class="role">{{ msg.type === 'user' ? 'You' : 'Assistant' }}</span>
|
||||
<span class="when">{{ msg.timestamp ? fmtClockTime(msg.timestamp) : '' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Attached thinking block (merged from preceding thinking messages) -->
|
||||
<div v-if="msg._thinking" class="msg-thinking">
|
||||
<button class="thinking-toggle" @click="toggleThinking">
|
||||
<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" v-html="renderMarkdown(msg._thinking, { variant: 'msg', query: state.query })"></div>
|
||||
</div>
|
||||
|
||||
<!-- Message text body -->
|
||||
<template v-if="msg.text">
|
||||
<div v-html="renderMarkdown(msg.text, { variant: 'msg', query: state.query })"></div>
|
||||
<button
|
||||
v-if="isTextTruncated(msg.text)"
|
||||
class="truncated-btn"
|
||||
@click="handleLoadFullText($event, msg.uuid)"
|
||||
>Message truncated — click to load full text</button>
|
||||
</template>
|
||||
<template v-else-if="!(msg.tool_calls && msg.tool_calls.length)">
|
||||
<div class="msg-text empty-text">(no text content)</div>
|
||||
</template>
|
||||
|
||||
<!-- Tool calls -->
|
||||
<div v-if="msg.tool_calls && msg.tool_calls.length" class="msg-tools">
|
||||
<template v-for="tc in msg.tool_calls" :key="tc.id">
|
||||
|
||||
<!-- Agent/Task tool call (subagent) -->
|
||||
<template v-if="tc.name === 'Agent' || tc.name === 'Task'">
|
||||
<div class="msg-tool agent-call">
|
||||
<button class="toolcall-toggle" @click="toggleToolCall">
|
||||
<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">{{ getToolCallParsedInput(tc).subagent_type || getToolCallParsedInput(tc).agentType || 'Agent' }}</span>
|
||||
<span class="tool-arg">{{ getToolCallParsedInput(tc).description || (getToolCallParsedInput(tc).prompt || '').slice(0, 80) }}</span>
|
||||
<span v-if="tc.result && tc.result.is_error" class="tool-error">error</span>
|
||||
<button
|
||||
v-if="tc.subagent?.agent_id"
|
||||
class="agent-nav-btn"
|
||||
@click.stop="navigateToSubagent(tc.subagent.agent_id, getToolCallParsedInput(tc).description || '')"
|
||||
>View conversation →</button>
|
||||
</button>
|
||||
<div class="toolcall-body">
|
||||
<template v-if="getToolCallParsedInput(tc).prompt">
|
||||
<div class="tc-section">Prompt</div>
|
||||
<div class="agent-prompt">{{ (getToolCallParsedInput(tc).prompt || '').slice(0, 500) }}{{ (getToolCallParsedInput(tc).prompt || '').length > 500 ? '...' : '' }}</div>
|
||||
</template>
|
||||
<template v-if="tc.result?.content">
|
||||
<div class="tc-section">Result</div>
|
||||
<div class="agent-result" v-html="renderMarkdown(tc.result.content, { variant: 'compact' })"></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Workflow tool call (inside assistant bubble) -->
|
||||
<template v-else-if="tc.name === 'Workflow'">
|
||||
<div class="msg-tool agent-call">
|
||||
<button class="toolcall-toggle" @click="toggleToolCall">
|
||||
<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">{{ tc.workflow?.workflow_name || getToolCallParsedInput(tc).name || 'Workflow' }}</span>
|
||||
<span
|
||||
v-if="tc.workflow?.status"
|
||||
class="workflow-status"
|
||||
:class="tc.workflow.status"
|
||||
>{{ tc.workflow.status }}</span>
|
||||
<span v-if="tc.result && tc.result.is_error" class="tool-error">error</span>
|
||||
</button>
|
||||
<div class="toolcall-body">
|
||||
<template v-if="tc.workflow?.agents?.length">
|
||||
<div class="tc-section">Agents · {{ tc.workflow.agents.length }}</div>
|
||||
<div class="workflow-agent-list">
|
||||
<template v-for="(phaseAgents, phase) in (() => {
|
||||
const phases = {};
|
||||
for (const a of (tc.workflow.agents || [])) {
|
||||
const p = a.phase || 'Other';
|
||||
if (!phases[p]) phases[p] = [];
|
||||
phases[p].push(a);
|
||||
}
|
||||
return phases;
|
||||
})()" :key="phase">
|
||||
<div class="workflow-phase-group">
|
||||
<div class="workflow-phase-header">{{ phase }}</div>
|
||||
<div class="workflow-phase-agents">
|
||||
<button
|
||||
v-for="a in phaseAgents"
|
||||
:key="a.agent_id"
|
||||
class="workflow-agent-row"
|
||||
@click.stop="navigateToSubagent(a.agent_id, a.label || '')"
|
||||
>
|
||||
<span class="workflow-agent-label">{{ a.label || a.agent_id }}</span>
|
||||
<span class="workflow-agent-state" :class="a.state || ''">{{ a.state || '' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Generic tool call -->
|
||||
<template v-else>
|
||||
<div class="msg-tool" :class="{ 'is-error': tc.result && tc.result.is_error }">
|
||||
<button class="toolcall-toggle" @click="toggleToolCall">
|
||||
<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">{{ tc.name }}</span>
|
||||
<span class="tool-arg">{{ getArgPreview(tc) }}</span>
|
||||
<span v-if="tc.result && tc.result.is_error" class="tool-error">error</span>
|
||||
</button>
|
||||
<div class="toolcall-body">
|
||||
<div class="tc-section">Input</div>
|
||||
<pre>{{ tc.input_json || '' }}</pre>
|
||||
<template v-if="tc.result">
|
||||
<div class="tc-section">{{ tc.result.is_error ? 'Error' : 'Output' }}</div>
|
||||
<pre>{{ tc.result.content || '(empty)' }}</pre>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Summary block -->
|
||||
<div v-if="msg.summary" class="msg-summary">
|
||||
<button class="summary-toggle" @click="toggleSummary">
|
||||
<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">{{ msg.summary.source || '' }}</span>
|
||||
</button>
|
||||
<div class="summary-body" v-html="renderMarkdown(msg.summary.content, { variant: 'compact' })"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Back to top button -->
|
||||
<button
|
||||
class="back-to-top"
|
||||
:class="{ show: showBackToTop }"
|
||||
@click="scrollToTop"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-wrap {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,188 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { state } from '../store.js';
|
||||
import { highlightPlain, escapeHTML, formatProjectLabel, fmtListTime } from '../utils.js';
|
||||
|
||||
defineOptions({ name: 'SessionList' });
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const visibleSessions = computed(() => {
|
||||
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;
|
||||
});
|
||||
});
|
||||
|
||||
const showProjectPrefix = computed(() => state.projectFilter === 'all');
|
||||
|
||||
function titleHTML(session) {
|
||||
return highlightPlain(session.title || '(untitled)', state.query.trim());
|
||||
}
|
||||
|
||||
function projectLabel(session) {
|
||||
return escapeHTML(formatProjectLabel(session.project));
|
||||
}
|
||||
|
||||
function timeLabel(session) {
|
||||
const ts = new Date(session.started_at || 0).getTime();
|
||||
return fmtListTime(ts);
|
||||
}
|
||||
|
||||
function openSession(session) {
|
||||
router.push({ name: 'SessionDetail', params: { id: session.id } });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="session-list-wrap">
|
||||
<div v-if="!visibleSessions.length" class="empty">
|
||||
No sessions here.
|
||||
<span class="hint">{{ state.query ? 'Try a different search term.' : 'Press / to search.' }}</span>
|
||||
</div>
|
||||
<div v-else class="session-list">
|
||||
<div
|
||||
v-for="s in visibleSessions"
|
||||
:key="s.id"
|
||||
class="srow"
|
||||
:class="{ cursor: state.cursorId === s.id }"
|
||||
:data-session-id="s.id"
|
||||
@click="openSession(s)"
|
||||
>
|
||||
<div class="srow-body">
|
||||
<div class="srow-title" v-html="titleHTML(s)"></div>
|
||||
<div class="srow-meta">
|
||||
<template v-if="showProjectPrefix">
|
||||
<span class="project-tag" v-html="projectLabel(s)"></span>
|
||||
<span class="dot"></span>
|
||||
</template>
|
||||
<span>{{ s.message_count || 0 }} msg</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="srow-right">{{ timeLabel(s) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.session-list-wrap {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.srow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: start;
|
||||
column-gap: 12px;
|
||||
padding: 12px 16px;
|
||||
min-height: var(--row-h-session);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
transition: background 0.06s;
|
||||
position: relative;
|
||||
}
|
||||
.srow:hover {
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
.srow.cursor {
|
||||
background: var(--surface);
|
||||
}
|
||||
.srow.cursor::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--muted-2);
|
||||
}
|
||||
|
||||
.srow-body {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.srow-title {
|
||||
font-size: var(--text-md);
|
||||
font-weight: 500;
|
||||
color: var(--fg);
|
||||
line-height: 1.35;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.srow-title :deep(mark) {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-2);
|
||||
padding: 0 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.srow-meta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.srow-meta .project-tag {
|
||||
color: var(--fg-2);
|
||||
font-weight: 500;
|
||||
}
|
||||
.srow-meta .dot {
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
background: var(--muted-2);
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.srow-right {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--fg-2);
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
padding-top: 2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--muted-2);
|
||||
font-size: var(--text-sm);
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.empty .hint {
|
||||
font-size: 11px;
|
||||
color: var(--muted-2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,8 @@
|
||||
<script setup>
|
||||
defineOptions({ name: 'SubagentDetail' });
|
||||
defineProps({ id: String, agentId: String });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="view-placeholder">SubagentDetail view for agent {{ agentId }} in session {{ id }} (TODO)</div>
|
||||
</template>
|
||||
@@ -0,0 +1,770 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue';
|
||||
import { state, navigateToSession } from '../store.js';
|
||||
import { fmtTokens, fmtDuration, fmtTooltipDate, positionTooltip, escapeHTML, formatProjectLabel } from '../utils.js';
|
||||
|
||||
defineOptions({ name: 'Usage' });
|
||||
|
||||
// --- State ---
|
||||
const activeTab = ref('daily');
|
||||
const loading = ref(true);
|
||||
const usageData = reactive({ daily: [], totalTokens: 0, peakDay: null, longestTurn: null });
|
||||
const selectedDayKey = ref(null);
|
||||
const loadedMonths = ref(0);
|
||||
const monthBlocks = ref([]);
|
||||
|
||||
// Tooltip
|
||||
const tooltip = reactive({ text: '', show: false, x: 0, y: 0 });
|
||||
|
||||
// --- Constants ---
|
||||
const DAY_MS = 86400000;
|
||||
const MONTHS_SHORT = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||
const MONTHS_FULL = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
||||
|
||||
// --- Computed: heatmap grid ---
|
||||
const heatmapGrid = computed(() => {
|
||||
const today = new Date();
|
||||
let startDate = new Date(today.getTime() - 364 * DAY_MS);
|
||||
startDate.setHours(0, 0, 0, 0);
|
||||
const daysUntilSunday = (7 - startDate.getDay()) % 7;
|
||||
startDate = new Date(startDate.getTime() + daysUntilSunday * DAY_MS);
|
||||
|
||||
const dailyMap = {};
|
||||
for (const d of usageData.daily) dailyMap[d.day] = d.tokens;
|
||||
|
||||
const values = usageData.daily.map(d => d.tokens).filter(Boolean);
|
||||
const maxTokens = Math.max(...values, 1);
|
||||
|
||||
const cells = [];
|
||||
for (let i = 0; i < 371; i++) {
|
||||
const date = new Date(startDate.getTime() + i * DAY_MS);
|
||||
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;
|
||||
const gridHeight = 7 * step;
|
||||
|
||||
// Month labels
|
||||
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_SHORT[m] });
|
||||
lastMonth = m;
|
||||
}
|
||||
}
|
||||
|
||||
return { cells, monthLabels, gridWidth, gridHeight, cellSize, step };
|
||||
});
|
||||
|
||||
// --- Computed: streaks ---
|
||||
const currentStreak = computed(() => {
|
||||
const today = new Date();
|
||||
const dailyMap = {};
|
||||
for (const d of usageData.daily) dailyMap[d.day] = d.tokens;
|
||||
|
||||
let streak = 0;
|
||||
let startedCounting = false;
|
||||
for (let i = 0; i <= 365; i++) {
|
||||
const d = new Date(today.getTime() - i * DAY_MS).toISOString().slice(0, 10);
|
||||
if (dailyMap[d] && dailyMap[d] > 0) {
|
||||
startedCounting = true;
|
||||
streak++;
|
||||
} else if (startedCounting) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return streak;
|
||||
});
|
||||
|
||||
const longestStreak = computed(() => {
|
||||
const sortedDays = [...usageData.daily]
|
||||
.filter(d => d.tokens > 0)
|
||||
.sort((a, b) => a.day.localeCompare(b.day));
|
||||
|
||||
let longest = 0;
|
||||
let streak = 0;
|
||||
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();
|
||||
streak = (curr - prev === DAY_MS) ? streak + 1 : 1;
|
||||
}
|
||||
if (streak > longest) longest = streak;
|
||||
}
|
||||
return longest;
|
||||
});
|
||||
|
||||
// --- Computed: weekly chart ---
|
||||
const weeklyBars = computed(() => {
|
||||
const today = new Date();
|
||||
let startDate = new Date(today.getTime() - 364 * DAY_MS);
|
||||
startDate.setHours(0, 0, 0, 0);
|
||||
const daysUntilSunday = (7 - startDate.getDay()) % 7;
|
||||
startDate = new Date(startDate.getTime() + daysUntilSunday * DAY_MS);
|
||||
|
||||
const dailyMap = {};
|
||||
for (const d of usageData.daily) dailyMap[d.day] = d.tokens;
|
||||
|
||||
const weeks = [];
|
||||
for (let w = 0; w < 53; w++) {
|
||||
const weekStart = new Date(startDate.getTime() + w * 7 * DAY_MS);
|
||||
if (weekStart > today) break;
|
||||
let tokens = 0;
|
||||
for (let d = 0; d < 7; d++) {
|
||||
const date = new Date(weekStart.getTime() + d * DAY_MS);
|
||||
if (date > today) break;
|
||||
const key = date.toISOString().slice(0, 10);
|
||||
tokens += dailyMap[key] || 0;
|
||||
}
|
||||
weeks.push({ weekStart, tokens, weekKey: weekStart.toISOString().slice(0, 10) });
|
||||
}
|
||||
|
||||
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 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_SHORT[m] }); lastMonth = m; }
|
||||
}
|
||||
|
||||
const bars = weeks.map((w, i) => {
|
||||
const h = maxVal > 0 ? (w.tokens / maxVal) * chartHeight : 0;
|
||||
const x = i * (barWidth + barGap);
|
||||
return { x, y: chartHeight - h, width: barWidth, height: Math.max(h, 0.5), label: `Week of ${w.weekKey}: ${fmtTokens(w.tokens)}` };
|
||||
});
|
||||
|
||||
return { bars, labels, chartWidth, chartHeight, barWidth, barGap };
|
||||
});
|
||||
|
||||
// --- Computed: cumulative chart ---
|
||||
const cumulativeData = computed(() => {
|
||||
const sorted = [...usageData.daily].sort((a, b) => a.day.localeCompare(b.day));
|
||||
if (!sorted.length) return null;
|
||||
|
||||
let cumulative = 0;
|
||||
const points = sorted.map(d => { cumulative += d.tokens; return { day: d.day, total: cumulative }; });
|
||||
const maxVal = points[points.length - 1].total || 1;
|
||||
|
||||
const chartWidth = 700;
|
||||
const chartHeight = 140;
|
||||
|
||||
const xScale = (i) => (i / (points.length - 1)) * chartWidth;
|
||||
const yScale = (v) => chartHeight - (v / maxVal) * chartHeight;
|
||||
|
||||
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`;
|
||||
|
||||
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_SHORT[m] }); lastMonth = m; }
|
||||
}
|
||||
|
||||
const dots = points.map((p, i) => ({
|
||||
cx: xScale(i).toFixed(1),
|
||||
cy: yScale(p.total).toFixed(1),
|
||||
label: `${p.day}: ${fmtTokens(p.total)} total`
|
||||
}));
|
||||
|
||||
return { linePath, areaPath, labels, dots, chartWidth, chartHeight };
|
||||
});
|
||||
|
||||
// --- Computed: day sessions ---
|
||||
const daySessions = computed(() => {
|
||||
if (!selectedDayKey.value) return null;
|
||||
const dateKey = selectedDayKey.value;
|
||||
const dayStart = dateKey + 'T00:00:00';
|
||||
const dayEnd = dateKey + 'T23:59:59';
|
||||
|
||||
const sessions = 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;
|
||||
});
|
||||
|
||||
const classified = sessions.map(s => {
|
||||
const isNew = s.started_at.slice(0, 10) === dateKey;
|
||||
let kind = 'continued';
|
||||
if (isNew) {
|
||||
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 };
|
||||
});
|
||||
|
||||
return {
|
||||
dateKey,
|
||||
dateLabel: fmtTooltipDate(dateKey),
|
||||
newWorkspaces: classified.filter(s => s.kind === 'new-workspace'),
|
||||
newSessions: classified.filter(s => s.kind === 'new-session'),
|
||||
continued: classified.filter(s => s.kind === 'continued'),
|
||||
isEmpty: classified.length === 0
|
||||
};
|
||||
});
|
||||
|
||||
// --- Methods ---
|
||||
function switchTab(view) {
|
||||
activeTab.value = view;
|
||||
}
|
||||
|
||||
function onCellEnter(cell, event) {
|
||||
tooltip.text = `${fmtTokens(cell.tokens)} tokens on ${fmtTooltipDate(cell.key)}`;
|
||||
tooltip.show = true;
|
||||
updateTooltipPos(event);
|
||||
}
|
||||
|
||||
function onCellMove(event) {
|
||||
updateTooltipPos(event);
|
||||
}
|
||||
|
||||
function onCellLeave() {
|
||||
tooltip.show = false;
|
||||
}
|
||||
|
||||
function onCellClick(cell) {
|
||||
selectedDayKey.value = cell.key;
|
||||
}
|
||||
|
||||
function onBarEnter(bar, event) {
|
||||
tooltip.text = bar.label;
|
||||
tooltip.show = true;
|
||||
updateTooltipPos(event);
|
||||
}
|
||||
|
||||
function onDotEnter(dot, event) {
|
||||
tooltip.text = dot.label;
|
||||
tooltip.show = true;
|
||||
updateTooltipPos(event);
|
||||
}
|
||||
|
||||
function updateTooltipPos(event) {
|
||||
const pad = 12;
|
||||
let left = event.clientX + pad;
|
||||
if (left + 200 > window.innerWidth - pad) left = event.clientX - 200 - pad;
|
||||
tooltip.x = left;
|
||||
tooltip.y = event.clientY - 28;
|
||||
}
|
||||
|
||||
function goToSession(sessionId) {
|
||||
navigateToSession(sessionId);
|
||||
}
|
||||
|
||||
function buildMonthBlock(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 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 };
|
||||
});
|
||||
|
||||
return {
|
||||
header: `${MONTHS_FULL[month]} ${year}`,
|
||||
newWorkspaces: classified.filter(s => s.kind === 'new-workspace'),
|
||||
newSessions: classified.filter(s => s.kind === 'new-session'),
|
||||
continued: classified.filter(s => s.kind === 'continued'),
|
||||
isEmpty: classified.length === 0
|
||||
};
|
||||
}
|
||||
|
||||
function showNextMonth() {
|
||||
const today = new Date();
|
||||
const targetDate = new Date(today.getFullYear(), today.getMonth() - loadedMonths.value, 1);
|
||||
const block = buildMonthBlock(targetDate.getFullYear(), targetDate.getMonth());
|
||||
monthBlocks.value.push(block);
|
||||
loadedMonths.value++;
|
||||
}
|
||||
|
||||
function projectLabel(project) {
|
||||
return formatProjectLabel(project);
|
||||
}
|
||||
|
||||
function newSessionProjectCount(sessions) {
|
||||
const projects = new Set(sessions.map(s => s.project || '(none)'));
|
||||
return projects.size;
|
||||
}
|
||||
|
||||
// --- Lifecycle ---
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const data = await window.obelisk.getUsageStats();
|
||||
usageData.daily = data.daily || [];
|
||||
usageData.totalTokens = data.totalTokens || 0;
|
||||
usageData.peakDay = data.peakDay || null;
|
||||
usageData.longestTurn = data.longestTurn || null;
|
||||
} catch (e) {
|
||||
console.error('Failed to load usage stats:', e);
|
||||
}
|
||||
loading.value = false;
|
||||
showNextMonth();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="usage-wrap" v-if="!loading">
|
||||
<div class="detail-wide">
|
||||
<!-- Header with tabs -->
|
||||
<div class="usage-header">
|
||||
<span class="usage-title">Token activity</span>
|
||||
<div class="usage-view-tabs">
|
||||
<button
|
||||
class="usage-tab"
|
||||
:class="{ active: activeTab === 'daily' }"
|
||||
@click="switchTab('daily')"
|
||||
>Daily</button>
|
||||
<button
|
||||
class="usage-tab"
|
||||
:class="{ active: activeTab === 'weekly' }"
|
||||
@click="switchTab('weekly')"
|
||||
>Weekly</button>
|
||||
<button
|
||||
class="usage-tab"
|
||||
:class="{ active: activeTab === 'cumulative' }"
|
||||
@click="switchTab('cumulative')"
|
||||
>Cumulative</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats bar -->
|
||||
<div class="usage-stats">
|
||||
<div class="usage-stat">
|
||||
<span class="usage-stat-value">{{ fmtTokens(usageData.totalTokens) }}</span>
|
||||
<span class="usage-stat-label">Lifetime tokens</span>
|
||||
</div>
|
||||
<div class="usage-stat">
|
||||
<span class="usage-stat-value">{{ usageData.peakDay ? fmtTokens(usageData.peakDay.tokens) : '—' }}</span>
|
||||
<span class="usage-stat-label">Peak tokens</span>
|
||||
</div>
|
||||
<div class="usage-stat">
|
||||
<span class="usage-stat-value">{{ usageData.longestTurn ? fmtDuration(usageData.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>
|
||||
|
||||
<!-- Daily heatmap -->
|
||||
<div class="heatmap-container" v-show="activeTab === 'daily'">
|
||||
<svg
|
||||
class="heatmap"
|
||||
:width="heatmapGrid.gridWidth"
|
||||
:height="heatmapGrid.gridHeight + 20"
|
||||
:viewBox="`0 0 ${heatmapGrid.gridWidth} ${heatmapGrid.gridHeight + 20}`"
|
||||
>
|
||||
<rect
|
||||
v-for="cell in heatmapGrid.cells"
|
||||
:key="cell.key"
|
||||
:x="cell.col * heatmapGrid.step"
|
||||
:y="cell.row * heatmapGrid.step"
|
||||
:width="heatmapGrid.cellSize"
|
||||
:height="heatmapGrid.cellSize"
|
||||
rx="2"
|
||||
:class="['heatmap-cell', `level-${cell.level}`, { selected: selectedDayKey === cell.key }]"
|
||||
@mouseenter="onCellEnter(cell, $event)"
|
||||
@mousemove="onCellMove"
|
||||
@mouseleave="onCellLeave"
|
||||
@click="onCellClick(cell)"
|
||||
/>
|
||||
<text
|
||||
v-for="ml in heatmapGrid.monthLabels"
|
||||
:key="'ml-' + ml.col"
|
||||
:x="ml.col * heatmapGrid.step"
|
||||
:y="heatmapGrid.gridHeight + 14"
|
||||
class="heatmap-month"
|
||||
>{{ ml.label }}</text>
|
||||
</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>
|
||||
|
||||
<!-- Weekly bar chart -->
|
||||
<div class="chart-container" v-show="activeTab === 'weekly'">
|
||||
<svg
|
||||
class="weekly-chart"
|
||||
:viewBox="`0 0 ${weeklyBars.chartWidth + 20} ${weeklyBars.chartHeight + 24}`"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<rect
|
||||
v-for="(bar, i) in weeklyBars.bars"
|
||||
:key="'bar-' + i"
|
||||
:x="bar.x"
|
||||
:y="bar.y"
|
||||
:width="bar.width"
|
||||
:height="bar.height"
|
||||
rx="2"
|
||||
class="bar-fill"
|
||||
@mouseenter="onBarEnter(bar, $event)"
|
||||
@mousemove="onCellMove"
|
||||
@mouseleave="onCellLeave"
|
||||
/>
|
||||
<text
|
||||
v-for="(lbl, i) in weeklyBars.labels"
|
||||
:key="'wlbl-' + i"
|
||||
:x="lbl.i * (weeklyBars.barWidth + weeklyBars.barGap)"
|
||||
:y="weeklyBars.chartHeight + 16"
|
||||
class="heatmap-month"
|
||||
>{{ lbl.label }}</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Cumulative line chart -->
|
||||
<div class="chart-container" v-show="activeTab === 'cumulative'">
|
||||
<template v-if="cumulativeData">
|
||||
<svg
|
||||
:viewBox="`0 0 ${cumulativeData.chartWidth} ${cumulativeData.chartHeight + 24}`"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
class="cumulative-chart"
|
||||
>
|
||||
<path :d="cumulativeData.areaPath" class="cumulative-area"/>
|
||||
<path :d="cumulativeData.linePath" class="cumulative-line"/>
|
||||
<circle
|
||||
v-for="(dot, i) in cumulativeData.dots"
|
||||
:key="'dot-' + i"
|
||||
:cx="dot.cx"
|
||||
:cy="dot.cy"
|
||||
r="6"
|
||||
class="cumulative-dot"
|
||||
@mouseenter="onDotEnter(dot, $event)"
|
||||
@mousemove="onCellMove"
|
||||
@mouseleave="onCellLeave"
|
||||
/>
|
||||
<text
|
||||
v-for="(lbl, i) in cumulativeData.labels"
|
||||
:key="'clbl-' + i"
|
||||
:x="lbl.x"
|
||||
:y="cumulativeData.chartHeight + 16"
|
||||
class="heatmap-month"
|
||||
>{{ lbl.label }}</text>
|
||||
</svg>
|
||||
</template>
|
||||
<div v-else class="empty">No data</div>
|
||||
</div>
|
||||
|
||||
<!-- Day sessions panel (from heatmap click) -->
|
||||
<div class="day-sessions" v-if="daySessions">
|
||||
<div class="day-sessions-header">{{ daySessions.dateLabel }}<template v-if="daySessions.isEmpty"> — no sessions</template></div>
|
||||
<div class="day-activity-timeline" v-if="!daySessions.isEmpty">
|
||||
<!-- New workspaces -->
|
||||
<div class="activity-group" v-if="daySessions.newWorkspaces.length">
|
||||
<div class="activity-group-header">
|
||||
<span class="activity-icon workspace">★</span>
|
||||
<span class="activity-group-title">Created {{ daySessions.newWorkspaces.length }} new workspace{{ daySessions.newWorkspaces.length > 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
<div class="activity-group-items">
|
||||
<button
|
||||
v-for="s in daySessions.newWorkspaces"
|
||||
:key="s.id"
|
||||
class="activity-item"
|
||||
@click="goToSession(s.id)"
|
||||
>
|
||||
<span class="activity-item-name"><span class="activity-item-project">{{ projectLabel(s.project) }}</span> {{ s.title || '(untitled)' }}</span>
|
||||
<span class="activity-item-meta">{{ s.message_count || 0 }} msg</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- New sessions -->
|
||||
<div class="activity-group" v-if="daySessions.newSessions.length">
|
||||
<div class="activity-group-header">
|
||||
<span class="activity-icon new">+</span>
|
||||
<span class="activity-group-title">Started {{ daySessions.newSessions.length }} session{{ daySessions.newSessions.length > 1 ? 's' : '' }} in {{ newSessionProjectCount(daySessions.newSessions) }} project{{ newSessionProjectCount(daySessions.newSessions) > 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
<div class="activity-group-items">
|
||||
<button
|
||||
v-for="s in daySessions.newSessions"
|
||||
:key="s.id"
|
||||
class="activity-item"
|
||||
@click="goToSession(s.id)"
|
||||
>
|
||||
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
|
||||
<span class="activity-item-meta">{{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Continued sessions -->
|
||||
<div class="activity-group continued" v-if="daySessions.continued.length">
|
||||
<div class="activity-group-header">
|
||||
<span class="activity-icon continued">↳</span>
|
||||
<span class="activity-group-title">Continued {{ daySessions.continued.length }} session{{ daySessions.continued.length > 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
<div class="activity-group-items">
|
||||
<button
|
||||
v-for="s in daySessions.continued"
|
||||
:key="s.id"
|
||||
class="activity-item"
|
||||
@click="goToSession(s.id)"
|
||||
>
|
||||
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
|
||||
<span class="activity-item-meta">{{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Monthly activity blocks -->
|
||||
<div class="day-sessions" v-if="!selectedDayKey">
|
||||
<template v-for="(block, bi) in monthBlocks" :key="bi">
|
||||
<div class="day-sessions-header">{{ block.header }}</div>
|
||||
<div class="day-activity-timeline" v-if="!block.isEmpty">
|
||||
<div class="activity-group" v-if="block.newWorkspaces.length">
|
||||
<div class="activity-group-header">
|
||||
<span class="activity-icon workspace">★</span>
|
||||
<span class="activity-group-title">Created {{ block.newWorkspaces.length }} new workspace{{ block.newWorkspaces.length > 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
<div class="activity-group-items">
|
||||
<button
|
||||
v-for="s in block.newWorkspaces"
|
||||
:key="s.id"
|
||||
class="activity-item"
|
||||
@click="goToSession(s.id)"
|
||||
>
|
||||
<span class="activity-item-name"><span class="activity-item-project">{{ projectLabel(s.project) }}</span> {{ s.title || '(untitled)' }}</span>
|
||||
<span class="activity-item-meta">{{ s.message_count || 0 }} msg</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="activity-group" v-if="block.newSessions.length">
|
||||
<div class="activity-group-header">
|
||||
<span class="activity-icon new">+</span>
|
||||
<span class="activity-group-title">Started {{ block.newSessions.length }} session{{ block.newSessions.length > 1 ? 's' : '' }} in {{ newSessionProjectCount(block.newSessions) }} project{{ newSessionProjectCount(block.newSessions) > 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
<div class="activity-group-items">
|
||||
<button
|
||||
v-for="s in block.newSessions"
|
||||
:key="s.id"
|
||||
class="activity-item"
|
||||
@click="goToSession(s.id)"
|
||||
>
|
||||
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
|
||||
<span class="activity-item-meta">{{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="activity-group continued" v-if="block.continued.length">
|
||||
<div class="activity-group-header">
|
||||
<span class="activity-icon continued">↳</span>
|
||||
<span class="activity-group-title">Continued {{ block.continued.length }} session{{ block.continued.length > 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
<div class="activity-group-items">
|
||||
<button
|
||||
v-for="s in block.continued"
|
||||
:key="s.id"
|
||||
class="activity-item"
|
||||
@click="goToSession(s.id)"
|
||||
>
|
||||
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
|
||||
<span class="activity-item-meta">{{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else style="color:var(--muted);font-size:13px;padding:8px 0;">No sessions this month.</div>
|
||||
</template>
|
||||
<button class="show-more-btn" @click="showNextMonth">Show more activity</button>
|
||||
</div>
|
||||
|
||||
<!-- Tooltip -->
|
||||
<div
|
||||
class="chart-tooltip"
|
||||
:class="{ show: tooltip.show }"
|
||||
:style="{ left: tooltip.x + 'px', top: tooltip.y + 'px' }"
|
||||
>{{ tooltip.text }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.usage-wrap { flex: 1; overflow-y: auto; min-height: 0; }
|
||||
.usage-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.usage-title { font-size: 16px; font-weight: 600; color: var(--fg); letter-spacing: -0.01em; }
|
||||
|
||||
.usage-view-tabs { display: flex; gap: 0; }
|
||||
.usage-tab {
|
||||
padding: 5px 12px; font-size: 12px; font-family: var(--font-mono);
|
||||
color: var(--muted); background: transparent;
|
||||
border: 1px solid var(--hairline); cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
.usage-tab:first-child { border-radius: 4px 0 0 4px; }
|
||||
.usage-tab:last-child { border-radius: 0 4px 4px 0; }
|
||||
.usage-tab:not(:first-child) { border-left: 0; }
|
||||
.usage-tab:hover { color: var(--fg-2); background: var(--surface-strong); }
|
||||
.usage-tab.active { color: var(--fg); background: var(--accent-soft); border-color: var(--accent-soft); }
|
||||
|
||||
.usage-stats {
|
||||
display: flex; gap: 0; margin-bottom: 32px;
|
||||
border-radius: 8px;
|
||||
background: var(--surface); border: 1px solid var(--hairline);
|
||||
overflow: hidden;
|
||||
}
|
||||
.usage-stat {
|
||||
flex: 1; display: flex; flex-direction: column; align-items: center;
|
||||
gap: 4px; padding: 16px 12px;
|
||||
border-right: 1px solid var(--hairline);
|
||||
}
|
||||
.usage-stat:last-child { border-right: 0; }
|
||||
.usage-stat-value { font-size: 18px; font-weight: 600; color: var(--fg); font-variant-numeric: tabular-nums; }
|
||||
.usage-stat-label { font-size: 10.5px; color: var(--muted); font-family: var(--font-mono); text-align: center; }
|
||||
|
||||
.heatmap-container { margin-top: 8px; }
|
||||
.heatmap { display: block; width: 100%; height: auto; }
|
||||
.heatmap-cell { transition: opacity 0.08s; cursor: pointer; }
|
||||
.heatmap-cell.level-0 { fill: var(--surface-strong); }
|
||||
.heatmap-cell.level-1 { fill: rgba(99, 102, 241, 0.3); }
|
||||
.heatmap-cell.level-2 { fill: rgba(99, 102, 241, 0.5); }
|
||||
.heatmap-cell.level-3 { fill: rgba(139, 92, 246, 0.7); }
|
||||
.heatmap-cell.level-4 { fill: rgba(168, 85, 247, 0.9); }
|
||||
.heatmap-cell:hover { opacity: 0.7; }
|
||||
.heatmap-cell.selected { stroke: var(--fg); stroke-width: 1.5; }
|
||||
.heatmap-month { font-size: 10px; fill: var(--muted-2); font-family: var(--font-mono); }
|
||||
|
||||
.heatmap-legend {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
margin-top: 12px; justify-content: flex-end;
|
||||
}
|
||||
.heatmap-legend-label { font-size: 10px; color: var(--muted-2); font-family: var(--font-mono); }
|
||||
|
||||
/* Chart container (weekly / cumulative) */
|
||||
.chart-container { margin-top: 8px; overflow-x: auto; }
|
||||
.chart-container svg { display: block; width: 100%; max-height: 160px; }
|
||||
|
||||
.bar-fill { fill: var(--accent); opacity: 0.8; transition: opacity 0.08s; cursor: pointer; }
|
||||
.bar-fill:hover { opacity: 1; }
|
||||
|
||||
.cumulative-area { fill: rgba(99, 102, 241, 0.12); }
|
||||
.cumulative-line { fill: none; stroke: var(--accent); stroke-width: 1.5; }
|
||||
.cumulative-dot { fill: var(--accent); opacity: 0; transition: opacity 0.08s; cursor: pointer; }
|
||||
.cumulative-dot:hover { opacity: 1; }
|
||||
|
||||
/* Chart tooltip */
|
||||
.chart-tooltip {
|
||||
position: fixed; z-index: 200;
|
||||
padding: 5px 10px; border-radius: 4px;
|
||||
background: rgba(30, 35, 50, 0.95);
|
||||
border: 1px solid var(--hairline-strong);
|
||||
color: var(--fg-2);
|
||||
font-family: var(--font-mono); font-size: 11px;
|
||||
pointer-events: none; opacity: 0;
|
||||
white-space: nowrap;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
.chart-tooltip.show { opacity: 1; }
|
||||
|
||||
/* Day sessions panel */
|
||||
.day-sessions { margin-top: 24px; }
|
||||
.day-sessions-header {
|
||||
font-size: 14px; font-weight: 600; color: var(--fg);
|
||||
margin-top: 28px; margin-bottom: 16px; padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
}
|
||||
.day-sessions-header:first-child { margin-top: 0; }
|
||||
|
||||
.day-activity-timeline {
|
||||
display: flex; flex-direction: column; gap: 20px;
|
||||
padding-left: 16px; border-left: 2px solid var(--hairline);
|
||||
}
|
||||
|
||||
.activity-group { position: relative; }
|
||||
.activity-group-header {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
margin-bottom: 8px; font-size: 14px; color: var(--fg);
|
||||
font-weight: 500;
|
||||
}
|
||||
.activity-icon {
|
||||
width: 24px; height: 24px; border-radius: 50%;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 12px; flex-shrink: 0;
|
||||
margin-left: -28px;
|
||||
border: 2px solid var(--bg);
|
||||
}
|
||||
.activity-icon.workspace { background: rgba(245,158,11,0.2); color: #f59e0b; }
|
||||
.activity-icon.new { background: var(--accent-soft); color: var(--accent-2); }
|
||||
.activity-icon.continued { background: var(--surface-strong); color: var(--muted); }
|
||||
|
||||
.activity-group-title { font-size: 13px; }
|
||||
.activity-group.continued .activity-group-title { color: var(--muted); }
|
||||
|
||||
.activity-group-items { display: flex; flex-direction: column; gap: 3px; padding-left: 6px; }
|
||||
.activity-item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 8px 12px; border-radius: 5px;
|
||||
background: transparent; border: 0;
|
||||
cursor: pointer; transition: background 0.08s;
|
||||
text-align: left; width: 100%;
|
||||
font: inherit; color: inherit;
|
||||
}
|
||||
.activity-item:hover { background: var(--surface-strong); }
|
||||
.activity-item-name { font-size: 13px; color: var(--accent-2); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.activity-item-name .activity-item-project { color: var(--muted); font-weight: 400; margin-right: 4px; }
|
||||
.activity-item:hover .activity-item-name { text-decoration: underline; text-underline-offset: 2px; }
|
||||
.activity-item-meta { font-size: 11px; color: var(--muted); font-family: var(--font-mono); flex-shrink: 0; }
|
||||
|
||||
.activity-group.continued .activity-item-name { color: var(--fg-2); }
|
||||
|
||||
.show-more-btn {
|
||||
display: block; width: 100%; margin-top: 20px;
|
||||
padding: 8px; border-radius: 4px;
|
||||
background: var(--accent-soft); border: 1px solid rgba(167,139,250,0.2);
|
||||
color: var(--accent-2); font-size: 12px; font-family: var(--font-mono);
|
||||
cursor: pointer; transition: all 0.1s; text-align: center;
|
||||
}
|
||||
.show-more-btn:hover { background: rgba(167,139,250,0.2); border-color: var(--accent); }
|
||||
|
||||
.empty { color: var(--muted); font-size: 13px; padding: 16px 0; text-align: center; }
|
||||
</style>
|
||||
@@ -0,0 +1,107 @@
|
||||
:root {
|
||||
--bg: #0a0b14;
|
||||
--bg-2: #11131f;
|
||||
--surface: rgba(255,255,255,0.03);
|
||||
--surface-strong: rgba(255,255,255,0.06);
|
||||
--surface-hi: rgba(255,255,255,0.09);
|
||||
--fg: rgba(255,255,255,0.92);
|
||||
--fg-2: rgba(255,255,255,0.72);
|
||||
--muted: rgba(255,255,255,0.48);
|
||||
--muted-2: rgba(255,255,255,0.28);
|
||||
--edge-hi: rgba(255,255,255,0.08);
|
||||
--edge-lo: rgba(0,0,0,0.35);
|
||||
--hairline: rgba(255,255,255,0.05);
|
||||
--hairline-strong: rgba(255,255,255,0.08);
|
||||
--accent: #a78bfa;
|
||||
--accent-2: #c4b5fd;
|
||||
--accent-glow: rgba(167,139,250,0.35);
|
||||
--accent-soft: rgba(167,139,250,0.12);
|
||||
--danger: #f87171;
|
||||
--danger-soft: rgba(248,113,113,0.12);
|
||||
--warn: #fbbf24;
|
||||
--warn-soft: rgba(251,191,36,0.14);
|
||||
--workflow: #f59e0b;
|
||||
--workflow-soft: rgba(245,158,11,0.12);
|
||||
--workflow-strong: rgba(245,158,11,0.28);
|
||||
--user-bubble: rgba(167,139,250,0.08);
|
||||
--user-bubble-border: rgba(167,139,250,0.18);
|
||||
--asst-bubble: rgba(255,255,255,0.025);
|
||||
--asst-bubble-border: rgba(255,255,255,0.06);
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', system-ui, sans-serif;
|
||||
--font-mono: ui-monospace, 'JetBrains Mono', 'IBM Plex Mono', 'SF Mono', Menlo, monospace;
|
||||
--text-xs: 11px;
|
||||
--text-sm: 12px;
|
||||
--text-base: 13px;
|
||||
--text-md: 14px;
|
||||
--row-h: 88px;
|
||||
--row-h-session: 64px;
|
||||
--row-h-compact: 28px;
|
||||
--col-sidebar: 220px;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { height: 100%; overflow: hidden; }
|
||||
body {
|
||||
color: var(--fg);
|
||||
font: var(--text-base)/1.4 var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
background-color: var(--bg);
|
||||
background-image:
|
||||
radial-gradient(80% 60% at 100% 0%, rgba(236, 72, 153, 0.14), transparent 55%),
|
||||
radial-gradient(60% 50% at 50% 40%, rgba(167, 139, 250, 0.12), transparent 60%),
|
||||
radial-gradient(70% 60% at 0% 100%, rgba(99, 102, 241, 0.16), transparent 60%),
|
||||
linear-gradient(to bottom, var(--bg) 0%, var(--bg-2) 100%);
|
||||
}
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed; inset: 0;
|
||||
pointer-events: none; z-index: 1;
|
||||
opacity: 0.3;
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='200' height='200'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/><feColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.05 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
|
||||
mix-blend-mode: overlay;
|
||||
}
|
||||
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; padding: 0; }
|
||||
button:disabled { cursor: not-allowed; }
|
||||
input { font: inherit; color: inherit; }
|
||||
::selection { background: var(--accent-soft); color: var(--fg); }
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 4px; border: 2px solid transparent; background-clip: padding-box; }
|
||||
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.16); background-clip: padding-box; border: 2px solid transparent; }
|
||||
|
||||
.titlebar {
|
||||
height: 32px; width: 100%;
|
||||
-webkit-app-region: drag;
|
||||
background: rgba(0,0,0,0.15);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
flex-shrink: 0; z-index: 100;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 0 16px 0 78px;
|
||||
}
|
||||
.titlebar-text {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.005em;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
max-width: 100%; user-select: none; pointer-events: none;
|
||||
display: inline-block;
|
||||
}
|
||||
.titlebar-text .app-name { color: var(--fg-2); font-weight: 600; }
|
||||
.titlebar-text .sep { margin: 0 6px; color: var(--muted-2); }
|
||||
.titlebar-text .scope { color: var(--muted); }
|
||||
.titlebar-text .scope-leaf { color: var(--fg-2); font-family: var(--font-mono); font-size: 11.5px; }
|
||||
|
||||
button, input, .row, .sidebar-item, .toolbar-btn,
|
||||
.row-action, .row-checkbox, .crumb, .provenance-link,
|
||||
.banner-action, .source-toggle, .anchor-link,
|
||||
.session-link, .msg-tool, .toolcall-toggle,
|
||||
.agent-indicator, .agent-row, .filter-toggle,
|
||||
.summary-toggle {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.app { position: relative; z-index: 2; height: 100vh; display: flex; flex-direction: column; }
|
||||
.columns { flex: 1; display: grid; grid-template-columns: var(--col-sidebar) 1fr; min-height: 0; }
|
||||
@@ -0,0 +1,875 @@
|
||||
.detail { max-width: 720px; margin: 0 auto; padding: 32px 32px 60px; }
|
||||
.detail-wide { max-width: 860px; margin: 0 auto; padding: 28px 32px 60px; }
|
||||
|
||||
/* Usage page */
|
||||
.usage-wrap { flex: 1; overflow-y: auto; min-height: 0; }
|
||||
.usage-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.usage-title { font-size: 16px; font-weight: 600; color: var(--fg); letter-spacing: -0.01em; }
|
||||
.usage-subtitle { font-size: 13px; color: var(--muted); }
|
||||
|
||||
.usage-view-tabs { display: flex; gap: 0; }
|
||||
.usage-tab {
|
||||
padding: 5px 12px; font-size: 12px; font-family: var(--font-mono);
|
||||
color: var(--muted); background: transparent;
|
||||
border: 1px solid var(--hairline); cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
.usage-tab:first-child { border-radius: 4px 0 0 4px; }
|
||||
.usage-tab:last-child { border-radius: 0 4px 4px 0; }
|
||||
.usage-tab:not(:first-child) { border-left: 0; }
|
||||
.usage-tab:hover { color: var(--fg-2); background: var(--surface-strong); }
|
||||
.usage-tab.active { color: var(--fg); background: var(--accent-soft); border-color: var(--accent-soft); }
|
||||
|
||||
.usage-stats {
|
||||
display: flex; gap: 0; margin-bottom: 32px;
|
||||
border-radius: 8px;
|
||||
background: var(--surface); border: 1px solid var(--hairline);
|
||||
overflow: hidden;
|
||||
}
|
||||
.usage-stat {
|
||||
flex: 1; display: flex; flex-direction: column; align-items: center;
|
||||
gap: 4px; padding: 16px 12px;
|
||||
border-right: 1px solid var(--hairline);
|
||||
}
|
||||
.usage-stat:last-child { border-right: 0; }
|
||||
.usage-stat-value { font-size: 18px; font-weight: 600; color: var(--fg); font-variant-numeric: tabular-nums; }
|
||||
.usage-stat-label { font-size: 10.5px; color: var(--muted); font-family: var(--font-mono); text-align: center; }
|
||||
|
||||
.heatmap-container { margin-top: 8px; }
|
||||
.heatmap { display: block; width: 100%; height: auto; }
|
||||
.heatmap-cell { transition: opacity 0.08s; }
|
||||
.heatmap-cell.level-0 { fill: var(--surface-strong); }
|
||||
.heatmap-cell.level-1 { fill: rgba(99, 102, 241, 0.3); }
|
||||
.heatmap-cell.level-2 { fill: rgba(99, 102, 241, 0.5); }
|
||||
.heatmap-cell.level-3 { fill: rgba(139, 92, 246, 0.7); }
|
||||
.heatmap-cell.level-4 { fill: rgba(168, 85, 247, 0.9); }
|
||||
.heatmap-cell:hover { opacity: 0.7; }
|
||||
.heatmap-cell.selected { stroke: var(--fg); stroke-width: 1.5; }
|
||||
.heatmap-month { font-size: 10px; fill: var(--muted-2); font-family: var(--font-mono); }
|
||||
|
||||
/* Day sessions panel (below heatmap on click) */
|
||||
.day-sessions { margin-top: 24px; }
|
||||
.day-sessions-header {
|
||||
font-size: 14px; font-weight: 600; color: var(--fg);
|
||||
margin-top: 28px; margin-bottom: 16px; padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
}
|
||||
.day-sessions-header:first-child { margin-top: 0; }
|
||||
|
||||
.day-activity-timeline {
|
||||
display: flex; flex-direction: column; gap: 20px;
|
||||
padding-left: 16px; border-left: 2px solid var(--hairline);
|
||||
}
|
||||
|
||||
.activity-group { position: relative; }
|
||||
.activity-group-header {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
margin-bottom: 8px; font-size: 14px; color: var(--fg);
|
||||
font-weight: 500;
|
||||
}
|
||||
.activity-icon {
|
||||
width: 24px; height: 24px; border-radius: 50%;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 12px; flex-shrink: 0;
|
||||
margin-left: -28px;
|
||||
border: 2px solid var(--bg);
|
||||
}
|
||||
.activity-icon.workspace { background: rgba(245,158,11,0.2); color: #f59e0b; }
|
||||
.activity-icon.new { background: var(--accent-soft); color: var(--accent-2); }
|
||||
.activity-icon.continued { background: var(--surface-strong); color: var(--muted); }
|
||||
|
||||
.activity-group-title { font-size: 13px; }
|
||||
.activity-group.continued .activity-group-title { color: var(--muted); }
|
||||
|
||||
.activity-group-items { display: flex; flex-direction: column; gap: 3px; padding-left: 6px; }
|
||||
.activity-item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 8px 12px; border-radius: 5px;
|
||||
background: transparent; border: 0;
|
||||
cursor: pointer; transition: background 0.08s;
|
||||
text-align: left; width: 100%;
|
||||
font: inherit; color: inherit;
|
||||
}
|
||||
.activity-item:hover { background: var(--surface-strong); }
|
||||
.activity-item-name { font-size: 13px; color: var(--accent-2); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.activity-item-name .activity-item-project { color: var(--muted); font-weight: 400; margin-right: 4px; }
|
||||
.activity-item:hover .activity-item-name { text-decoration: underline; text-underline-offset: 2px; }
|
||||
.activity-item-meta { font-size: 11px; color: var(--muted); font-family: var(--font-mono); flex-shrink: 0; }
|
||||
|
||||
.activity-group.continued .activity-item-name { color: var(--fg-2); }
|
||||
|
||||
.show-more-btn {
|
||||
display: block; width: 100%; margin-top: 20px;
|
||||
padding: 8px; border-radius: 4px;
|
||||
background: var(--accent-soft); border: 1px solid rgba(167,139,250,0.2);
|
||||
color: var(--accent-2); font-size: 12px; font-family: var(--font-mono);
|
||||
cursor: pointer; transition: all 0.1s; text-align: center;
|
||||
}
|
||||
.show-more-btn:hover { background: rgba(167,139,250,0.2); border-color: var(--accent); }
|
||||
|
||||
.heatmap-legend {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
margin-top: 12px; justify-content: flex-end;
|
||||
}
|
||||
.heatmap-legend-label { font-size: 10px; color: var(--muted-2); font-family: var(--font-mono); }
|
||||
|
||||
/* Chart container (weekly / cumulative) */
|
||||
.chart-container { margin-top: 8px; overflow-x: auto; }
|
||||
.chart-container svg { display: block; width: 100%; max-height: 160px; }
|
||||
|
||||
.bar-fill { fill: var(--accent); opacity: 0.8; transition: opacity 0.08s; }
|
||||
.bar-fill:hover { opacity: 1; }
|
||||
|
||||
.cumulative-area { fill: rgba(99, 102, 241, 0.12); }
|
||||
.cumulative-line { fill: none; stroke: var(--accent); stroke-width: 1.5; }
|
||||
.cumulative-dot { fill: var(--accent); opacity: 0; transition: opacity 0.08s; }
|
||||
.cumulative-dot:hover { opacity: 1; }
|
||||
|
||||
/* Chart tooltip */
|
||||
.chart-tooltip {
|
||||
position: fixed; z-index: 200;
|
||||
padding: 5px 10px; border-radius: 4px;
|
||||
background: rgba(30, 35, 50, 0.95);
|
||||
border: 1px solid var(--hairline-strong);
|
||||
color: var(--fg-2);
|
||||
font-family: var(--font-mono); font-size: 11px;
|
||||
pointer-events: none; opacity: 0;
|
||||
white-space: nowrap;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
.chart-tooltip.show { opacity: 1; }
|
||||
|
||||
/* Session progress bar */
|
||||
.session-progress {
|
||||
position: sticky; top: 0; z-index: 10;
|
||||
height: 2px; background: var(--hairline);
|
||||
margin: 0 -32px 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.session-progress-fill {
|
||||
height: 100%; background: var(--accent);
|
||||
box-shadow: 0 0 6px var(--accent-glow);
|
||||
transition: width 0.15s ease-out;
|
||||
width: 0%;
|
||||
}
|
||||
|
||||
.detail-banner {
|
||||
display: flex; align-items: flex-start; gap: 10px;
|
||||
padding: 10px 12px; border-radius: 6px; margin-bottom: 18px;
|
||||
font-size: var(--text-base); line-height: 1.5;
|
||||
}
|
||||
.detail-banner.broken { background: var(--danger-soft); border: 1px solid rgba(248,113,113,0.25); color: var(--fg); }
|
||||
.detail-banner.partial { background: var(--warn-soft); border: 1px solid rgba(251,191,36,0.25); color: var(--fg); }
|
||||
.detail-banner-icon { width: 16px; height: 16px; flex-shrink: 0; margin-top: 1px; }
|
||||
.detail-banner.broken .detail-banner-icon { color: var(--danger); }
|
||||
.detail-banner.partial .detail-banner-icon { color: var(--warn); }
|
||||
.detail-banner-body { flex: 1; min-width: 0; }
|
||||
.detail-banner-body strong { font-weight: 600; }
|
||||
.detail-banner-body ul { margin-top: 4px; padding-left: 16px; color: var(--fg-2); font-size: var(--text-sm); }
|
||||
.detail-banner-body li { list-style: disc; margin: 2px 0; }
|
||||
.detail-banner-actions { display: flex; gap: 6px; margin-top: 8px; }
|
||||
.banner-action {
|
||||
height: 24px; padding: 0 10px; border-radius: 4px;
|
||||
border: 1px solid var(--hairline-strong);
|
||||
background: var(--surface);
|
||||
color: var(--fg-2); font-size: var(--text-sm);
|
||||
transition: all 0.1s;
|
||||
}
|
||||
.banner-action:hover { background: var(--surface-strong); color: var(--fg); }
|
||||
.banner-action.danger { color: var(--danger); border-color: rgba(248,113,113,0.3); }
|
||||
.banner-action.danger:hover { background: var(--danger-soft); }
|
||||
|
||||
.detail-header { margin-bottom: 24px; }
|
||||
.detail-eyebrow {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-size: 11px; color: var(--muted);
|
||||
margin-bottom: 14px; flex-wrap: wrap;
|
||||
}
|
||||
.detail-eyebrow .project-icon { width: 13px; height: 13px; color: var(--muted); }
|
||||
.detail-eyebrow .project-name { color: var(--fg-2); font-weight: 500; }
|
||||
.detail-eyebrow .project-path { font-family: var(--font-mono); color: var(--muted); }
|
||||
.detail-eyebrow .sep { color: var(--muted-2); margin: 0 2px; }
|
||||
.detail-eyebrow .archived-tag {
|
||||
color: var(--accent-2);
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.detail-eyebrow .archived-tag::before {
|
||||
content: ''; width: 6px; height: 6px; border-radius: 50%;
|
||||
background: var(--accent); box-shadow: 0 0 6px var(--accent-glow);
|
||||
}
|
||||
.detail-path {
|
||||
font-family: var(--font-mono); font-size: 17px; font-weight: 500;
|
||||
color: var(--fg); line-height: 1.5;
|
||||
word-break: break-all; margin-bottom: 16px;
|
||||
}
|
||||
.detail-summary { font-size: var(--text-md); color: var(--fg-2); line-height: 1.6; margin-bottom: 16px; }
|
||||
.detail-meta {
|
||||
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||
font-family: var(--font-mono); font-size: var(--text-sm);
|
||||
color: var(--muted); font-variant-numeric: tabular-nums;
|
||||
padding-bottom: 16px; border-bottom: 1px solid var(--hairline);
|
||||
}
|
||||
.detail-meta .dot { width: 2px; height: 2px; background: var(--muted-2); border-radius: 50%; flex-shrink: 0; }
|
||||
|
||||
.session-link {
|
||||
color: var(--accent-2); border: 0; background: transparent;
|
||||
padding: 2px 5px; margin: -2px 0; border-radius: 3px;
|
||||
font: inherit; cursor: pointer; transition: all 0.1s;
|
||||
text-decoration: underline; text-decoration-color: var(--accent-soft);
|
||||
text-underline-offset: 3px;
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
}
|
||||
.session-link:hover { background: var(--accent-soft); color: var(--accent-2); text-decoration-color: var(--accent-2); }
|
||||
.session-link svg { width: 11px; height: 11px; }
|
||||
|
||||
.markdown-section { margin: 28px 0 8px; }
|
||||
.markdown-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
||||
.markdown-toolbar-label {
|
||||
font-size: 10.5px; color: var(--muted);
|
||||
font-weight: 500; letter-spacing: 0.04em; flex: 1;
|
||||
}
|
||||
.source-toggle {
|
||||
height: 22px; padding: 0 8px; border-radius: 4px;
|
||||
border: 1px solid var(--hairline-strong); background: var(--surface);
|
||||
color: var(--muted); font-size: var(--text-sm);
|
||||
transition: all 0.1s;
|
||||
}
|
||||
.source-toggle:hover { background: var(--surface-strong); color: var(--fg-2); }
|
||||
.source-toggle.active { background: var(--accent-soft); color: var(--accent-2); border-color: var(--accent-soft); }
|
||||
|
||||
.markdown-body { font-size: var(--text-md); line-height: 1.65; color: var(--fg); word-wrap: break-word; }
|
||||
.markdown-body h1, .markdown-body h2, .markdown-body h3 {
|
||||
font-weight: 600; letter-spacing: -0.01em;
|
||||
margin: 1.5em 0 0.5em; line-height: 1.3;
|
||||
}
|
||||
.markdown-body h1:first-child, .markdown-body h2:first-child, .markdown-body h3:first-child { margin-top: 0; }
|
||||
.markdown-body h1 { font-size: 20px; }
|
||||
.markdown-body h2 { font-size: 17px; }
|
||||
.markdown-body h3 { font-size: 15px; }
|
||||
.markdown-body p { margin: 0.6em 0; }
|
||||
.markdown-body ul, .markdown-body ol { margin: 0.6em 0; padding-left: 24px; }
|
||||
.markdown-body li { margin: 0.2em 0; }
|
||||
.markdown-body code {
|
||||
font-family: var(--font-mono); font-size: 12.5px;
|
||||
background: rgba(255,255,255,0.06); padding: 1px 5px;
|
||||
border-radius: 3px; color: var(--accent-2);
|
||||
}
|
||||
.markdown-body pre {
|
||||
background: rgba(0,0,0,0.4);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: 6px; padding: 12px 14px;
|
||||
overflow-x: auto; margin: 0.8em 0;
|
||||
}
|
||||
.markdown-body pre code { background: transparent; padding: 0; color: var(--fg-2); font-size: 12px; line-height: 1.55; }
|
||||
.markdown-body blockquote {
|
||||
margin: 0.8em 0; padding: 0 0 0 14px;
|
||||
border-left: 2px solid var(--accent-soft);
|
||||
color: var(--fg-2); font-style: italic;
|
||||
}
|
||||
.markdown-body a { color: var(--accent-2); text-decoration: underline; text-underline-offset: 2px; }
|
||||
.markdown-body hr { border: 0; border-top: 1px solid var(--hairline); margin: 1.5em 0; }
|
||||
.markdown-body table { border-collapse: collapse; margin: 0.8em 0; font-size: 12.5px; }
|
||||
.markdown-body th, .markdown-body td { border: 1px solid var(--hairline); padding: 6px 10px; text-align: left; }
|
||||
.markdown-body th { background: rgba(255,255,255,0.04); font-weight: 600; }
|
||||
.markdown-source {
|
||||
background: rgba(0,0,0,0.3); border: 1px solid var(--hairline);
|
||||
border-radius: 6px; padding: 14px 16px;
|
||||
font-family: var(--font-mono); font-size: 12px; line-height: 1.55;
|
||||
color: var(--fg-2); white-space: pre-wrap; word-wrap: break-word;
|
||||
}
|
||||
|
||||
.markdown-compact { font-size: var(--text-sm); line-height: 1.55; color: var(--fg-2); word-wrap: break-word; }
|
||||
.markdown-compact h1, .markdown-compact h2, .markdown-compact h3 {
|
||||
font-weight: 600; letter-spacing: -0.01em;
|
||||
margin: 1em 0 0.4em; line-height: 1.3; color: var(--fg);
|
||||
}
|
||||
.markdown-compact h1:first-child, .markdown-compact h2:first-child, .markdown-compact h3:first-child { margin-top: 0; }
|
||||
.markdown-compact h1 { font-size: var(--text-md); }
|
||||
.markdown-compact h2 { font-size: var(--text-base); }
|
||||
.markdown-compact h3 { font-size: var(--text-sm); }
|
||||
.markdown-compact p { margin: 0.5em 0; }
|
||||
.markdown-compact p:first-child { margin-top: 0; }
|
||||
.markdown-compact p:last-child { margin-bottom: 0; }
|
||||
.markdown-compact ul, .markdown-compact ol { margin: 0.5em 0; padding-left: 20px; }
|
||||
.markdown-compact li { margin: 0.15em 0; }
|
||||
.markdown-compact code {
|
||||
font-family: var(--font-mono); font-size: 11.5px;
|
||||
background: rgba(255,255,255,0.06); padding: 1px 4px;
|
||||
border-radius: 3px; color: var(--accent-2);
|
||||
}
|
||||
.markdown-compact pre {
|
||||
background: rgba(0,0,0,0.4); border: 1px solid var(--hairline);
|
||||
border-radius: 4px; padding: 8px 10px;
|
||||
overflow-x: auto; margin: 0.6em 0;
|
||||
}
|
||||
.markdown-compact pre code { background: transparent; padding: 0; color: var(--fg-2); font-size: 11px; line-height: 1.5; }
|
||||
.markdown-compact blockquote {
|
||||
margin: 0.6em 0; padding: 0 0 0 12px;
|
||||
border-left: 2px solid var(--accent-soft);
|
||||
color: var(--muted); font-style: italic;
|
||||
}
|
||||
.markdown-compact strong { color: var(--fg); font-weight: 600; }
|
||||
.markdown-compact a { color: var(--accent-2); text-decoration: underline; text-underline-offset: 2px; }
|
||||
.markdown-compact table { border-collapse: collapse; margin: 0.6em 0; font-size: 11.5px; }
|
||||
.markdown-compact th, .markdown-compact td { border: 1px solid var(--hairline); padding: 4px 8px; text-align: left; }
|
||||
.markdown-compact th { background: rgba(255,255,255,0.04); font-weight: 600; }
|
||||
.markdown-compact mark { background: var(--accent-soft); color: var(--accent-2); padding: 0 2px; border-radius: 2px; }
|
||||
|
||||
.markdown-msg { font-size: var(--text-base); line-height: 1.6; color: var(--fg); word-wrap: break-word; }
|
||||
.markdown-msg h1, .markdown-msg h2, .markdown-msg h3 {
|
||||
font-weight: 600; letter-spacing: -0.01em;
|
||||
margin: 1em 0 0.4em; line-height: 1.3;
|
||||
}
|
||||
.markdown-msg h1:first-child, .markdown-msg h2:first-child, .markdown-msg h3:first-child { margin-top: 0; }
|
||||
.markdown-msg h1 { font-size: 16px; }
|
||||
.markdown-msg h2 { font-size: 15px; }
|
||||
.markdown-msg h3 { font-size: var(--text-md); }
|
||||
.markdown-msg p { margin: 0.5em 0; }
|
||||
.markdown-msg p:first-child { margin-top: 0; }
|
||||
.markdown-msg p:last-child { margin-bottom: 0; }
|
||||
.markdown-msg ul, .markdown-msg ol { margin: 0.5em 0; padding-left: 22px; }
|
||||
.markdown-msg li { margin: 0.18em 0; }
|
||||
.markdown-msg code {
|
||||
font-family: var(--font-mono); font-size: 12px;
|
||||
background: rgba(255,255,255,0.06); padding: 1px 5px;
|
||||
border-radius: 3px; color: var(--accent-2);
|
||||
}
|
||||
.markdown-msg pre {
|
||||
background: rgba(0,0,0,0.4); border: 1px solid var(--hairline);
|
||||
border-radius: 5px; padding: 10px 12px;
|
||||
overflow-x: auto; margin: 0.7em 0;
|
||||
}
|
||||
.markdown-msg pre code { background: transparent; padding: 0; color: var(--fg-2); font-size: 11.5px; line-height: 1.55; }
|
||||
.markdown-msg blockquote {
|
||||
margin: 0.6em 0; padding: 0 0 0 12px;
|
||||
border-left: 2px solid var(--accent-soft);
|
||||
color: var(--fg-2); font-style: italic;
|
||||
}
|
||||
.markdown-msg strong { font-weight: 600; }
|
||||
.markdown-msg a { color: var(--accent-2); text-decoration: underline; text-underline-offset: 2px; }
|
||||
.markdown-msg table { border-collapse: collapse; margin: 0.6em 0; font-size: 12px; }
|
||||
.markdown-msg th, .markdown-msg td { border: 1px solid var(--hairline); padding: 5px 9px; text-align: left; }
|
||||
.markdown-msg th { background: rgba(255,255,255,0.04); font-weight: 600; }
|
||||
.markdown-msg mark { background: var(--accent-soft); color: var(--accent-2); padding: 0 2px; border-radius: 2px; }
|
||||
|
||||
.detail-section-divider {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
margin: 32px 0 14px; color: var(--muted);
|
||||
font-size: 10.5px; font-weight: 500; letter-spacing: 0.04em;
|
||||
}
|
||||
.detail-section-divider::after { content: ''; flex: 1; height: 1px; background: var(--hairline); }
|
||||
.detail-section-divider .count { font-family: var(--font-mono); color: var(--fg-2); }
|
||||
.anchor-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.anchor-link {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 6px 10px; border-radius: 4px;
|
||||
color: var(--fg-2); font-family: var(--font-mono); font-size: 12px;
|
||||
transition: all 0.1s; cursor: pointer;
|
||||
text-align: left; border: 0; background: transparent; width: 100%;
|
||||
}
|
||||
.anchor-link:hover { background: var(--surface-strong); color: var(--fg); }
|
||||
.anchor-link .anchor-icon { width: 12px; height: 12px; color: var(--muted); flex-shrink: 0; }
|
||||
.anchor-link:hover .anchor-icon { color: var(--accent-2); }
|
||||
.anchor-link .anchor-path { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.anchor-link .anchor-line { color: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; }
|
||||
.anchor-link:disabled {
|
||||
color: var(--muted-2);
|
||||
text-decoration: line-through; text-decoration-color: var(--danger); text-decoration-thickness: 1px;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.anchor-link:disabled .anchor-icon { color: var(--danger); }
|
||||
.anchor-link:disabled:hover { background: transparent; color: var(--muted-2); }
|
||||
|
||||
.detail-actions { margin-top: 32px; display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.detail-actions .btn {
|
||||
height: 30px; padding: 0 14px; border-radius: 6px;
|
||||
font-size: var(--text-base); font-weight: 500;
|
||||
transition: all 0.1s;
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
border: 1px solid var(--hairline-strong);
|
||||
color: var(--fg-2); background: var(--surface);
|
||||
}
|
||||
.detail-actions .btn:hover { background: var(--surface-strong); color: var(--fg); }
|
||||
.detail-actions .btn.danger { color: var(--danger); }
|
||||
.detail-actions .btn.danger:hover { background: var(--danger-soft); border-color: rgba(248,113,113,0.3); }
|
||||
.detail-actions .btn.primary { color: var(--accent-2); }
|
||||
.detail-actions .btn.primary:hover { background: var(--accent-soft); border-color: var(--accent-soft); color: var(--fg); }
|
||||
.detail-actions .btn .kbd {
|
||||
font-family: var(--font-mono); font-size: 10px; color: var(--muted-2);
|
||||
padding: 0 4px; border: 1px solid var(--hairline); border-radius: 3px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.session-header {
|
||||
margin-bottom: 28px; padding-bottom: 20px;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
}
|
||||
.session-eyebrow {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-size: 11px; color: var(--muted);
|
||||
margin-bottom: 12px; flex-wrap: wrap;
|
||||
}
|
||||
.session-eyebrow .project-icon { width: 13px; height: 13px; }
|
||||
.session-eyebrow .project-name { color: var(--fg-2); font-weight: 500; }
|
||||
.session-eyebrow .project-path { font-family: var(--font-mono); color: var(--muted); }
|
||||
.session-eyebrow .sep { color: var(--muted-2); margin: 0 2px; }
|
||||
.session-title {
|
||||
font-size: 22px; font-weight: 600; color: var(--fg);
|
||||
line-height: 1.3; margin-bottom: 14px; letter-spacing: -0.01em;
|
||||
}
|
||||
.session-meta-inline {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
font-family: var(--font-mono); font-size: var(--text-sm);
|
||||
color: var(--muted); font-variant-numeric: tabular-nums;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.session-meta-inline .dot { width: 2px; height: 2px; background: var(--muted-2); border-radius: 50%; flex-shrink: 0; }
|
||||
|
||||
.timeline { display: flex; flex-direction: column; gap: 14px; }
|
||||
.msg {
|
||||
border-radius: 8px; padding: 12px 14px;
|
||||
border: 1px solid; position: relative;
|
||||
transition: border-color 0.6s ease-out, box-shadow 0.6s ease-out;
|
||||
}
|
||||
.msg.user { background: var(--user-bubble); border-color: var(--user-bubble-border); }
|
||||
.msg.assistant { background: var(--asst-bubble); border-color: var(--asst-bubble-border); }
|
||||
.msg.is-focused {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 1px var(--accent), 0 0 22px var(--accent-glow);
|
||||
}
|
||||
.msg-head {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-size: 11px; color: var(--muted);
|
||||
margin-bottom: 8px; font-family: var(--font-mono);
|
||||
}
|
||||
.msg-head .role {
|
||||
font-weight: 600; color: var(--fg-2);
|
||||
text-transform: uppercase; font-size: 10px; letter-spacing: 0.06em;
|
||||
}
|
||||
.msg.user .msg-head .role { color: var(--accent-2); }
|
||||
.msg-head .when { margin-left: auto; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
.msg-text {
|
||||
font-size: var(--text-base); line-height: 1.55; color: var(--fg);
|
||||
white-space: pre-wrap; word-wrap: break-word;
|
||||
}
|
||||
.msg-text.empty-text { color: var(--muted-2); font-style: italic; }
|
||||
.msg-text mark { background: var(--accent-soft); color: var(--accent-2); padding: 0 2px; border-radius: 2px; }
|
||||
|
||||
.msg-summary {
|
||||
margin-top: 12px;
|
||||
border: 1px solid var(--hairline);
|
||||
border-left: 3px solid var(--accent-soft);
|
||||
border-radius: 5px;
|
||||
background: rgba(167,139,250,0.04);
|
||||
overflow: hidden;
|
||||
}
|
||||
.summary-toggle {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
width: 100%; padding: 7px 12px;
|
||||
cursor: pointer; transition: background 0.08s;
|
||||
text-align: left; border: 0; background: transparent;
|
||||
color: inherit; font: inherit;
|
||||
}
|
||||
.summary-toggle:hover { background: rgba(167,139,250,0.06); }
|
||||
.summary-toggle .chevron {
|
||||
width: 8px; height: 8px; color: var(--muted);
|
||||
transition: transform 0.15s; flex-shrink: 0;
|
||||
}
|
||||
.msg-summary.open .summary-toggle .chevron { transform: rotate(90deg); color: var(--accent-2); }
|
||||
.summary-toggle .label {
|
||||
font-family: var(--font-mono); font-size: 10.5px;
|
||||
color: var(--accent-2); font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.05em;
|
||||
}
|
||||
.summary-toggle .source { font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); margin-left: 4px; }
|
||||
.summary-body {
|
||||
display: none; padding: 8px 14px 12px;
|
||||
border-top: 1px solid var(--hairline);
|
||||
}
|
||||
.msg-summary.open .summary-body { display: block; }
|
||||
|
||||
.msg-tools { margin-top: 10px; display: flex; flex-direction: column; gap: 5px; }
|
||||
.msg-tool {
|
||||
border: 1px solid var(--hairline); border-radius: 5px;
|
||||
background: rgba(0,0,0,0.2);
|
||||
overflow: hidden; transition: border-color 0.1s;
|
||||
}
|
||||
.msg-tool.is-error { border-color: rgba(248,113,113,0.3); background: var(--danger-soft); }
|
||||
.toolcall-toggle {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
width: 100%; padding: 6px 10px;
|
||||
cursor: pointer; transition: background 0.08s;
|
||||
text-align: left; border: 0; background: transparent;
|
||||
color: inherit; font: inherit;
|
||||
}
|
||||
.toolcall-toggle:hover { background: rgba(255,255,255,0.03); }
|
||||
.toolcall-toggle .chevron {
|
||||
width: 8px; height: 8px; color: var(--muted);
|
||||
transition: transform 0.15s; flex-shrink: 0;
|
||||
}
|
||||
.msg-tool.open .toolcall-toggle .chevron { transform: rotate(90deg); color: var(--accent-2); }
|
||||
.toolcall-toggle .tool-name {
|
||||
font-family: var(--font-mono); font-size: 11px;
|
||||
color: var(--accent-2); font-weight: 600; flex-shrink: 0;
|
||||
}
|
||||
.msg-tool.is-error .toolcall-toggle .tool-name { color: var(--danger); }
|
||||
.toolcall-toggle .tool-arg {
|
||||
font-family: var(--font-mono); font-size: 11px; color: var(--fg-2);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
flex: 1; min-width: 0;
|
||||
}
|
||||
.toolcall-toggle .tool-error {
|
||||
font-size: 10px; color: var(--danger);
|
||||
padding: 1px 6px; background: rgba(248,113,113,0.18); border-radius: 3px;
|
||||
flex-shrink: 0; text-transform: uppercase;
|
||||
letter-spacing: 0.04em; font-weight: 500;
|
||||
}
|
||||
.toolcall-body {
|
||||
display: none; border-top: 1px solid var(--hairline);
|
||||
background: rgba(0,0,0,0.35);
|
||||
padding: 10px 12px; max-height: 300px; overflow: auto;
|
||||
}
|
||||
.msg-tool.open .toolcall-body { display: block; }
|
||||
.toolcall-body .tc-section {
|
||||
font-family: var(--font-mono); font-size: 10.5px; color: var(--muted);
|
||||
letter-spacing: 0.04em; text-transform: uppercase;
|
||||
margin: 4px 0 4px; font-weight: 500;
|
||||
}
|
||||
.toolcall-body .tc-section:first-child { margin-top: 0; }
|
||||
.toolcall-body pre {
|
||||
font-family: var(--font-mono); font-size: 11.5px; line-height: 1.5;
|
||||
color: var(--fg-2); white-space: pre-wrap; word-wrap: break-word;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tc-subagent {
|
||||
margin-top: 8px; padding: 8px 10px;
|
||||
border: 1px solid var(--workflow-soft);
|
||||
border-left: 3px solid var(--workflow);
|
||||
border-radius: 4px; background: var(--workflow-soft);
|
||||
}
|
||||
.tc-subagent-head {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-family: var(--font-mono); font-size: 10.5px;
|
||||
color: var(--workflow); margin-bottom: 6px;
|
||||
}
|
||||
.tc-subagent-head svg { width: 11px; height: 11px; flex-shrink: 0; }
|
||||
.tc-subagent-head .label {
|
||||
font-weight: 600; text-transform: uppercase;
|
||||
font-size: 9.5px; letter-spacing: 0.06em;
|
||||
}
|
||||
.tc-subagent-head .type {
|
||||
color: var(--fg-2); font-weight: 500;
|
||||
text-transform: none; letter-spacing: 0;
|
||||
}
|
||||
|
||||
.agent-indicator {
|
||||
margin-top: 10px; display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 5px 10px; border-radius: 5px;
|
||||
border: 1px solid var(--workflow-soft);
|
||||
background: var(--workflow-soft);
|
||||
font-family: var(--font-mono); font-size: 11px;
|
||||
color: var(--workflow);
|
||||
cursor: pointer; transition: all 0.1s; text-align: left;
|
||||
}
|
||||
.agent-indicator:hover { background: var(--workflow-strong); border-color: var(--workflow); }
|
||||
.agent-indicator svg { width: 11px; height: 11px; flex-shrink: 0; }
|
||||
.agent-indicator .chevron { transition: transform 0.15s; }
|
||||
.agent-indicator.open .chevron { transform: rotate(90deg); }
|
||||
.agent-indicator .label { color: var(--fg-2); }
|
||||
.agent-indicator .meta { color: var(--muted); margin-left: 4px; }
|
||||
|
||||
.workflow-card {
|
||||
margin-top: 8px;
|
||||
border: 1px solid var(--workflow-soft);
|
||||
border-left: 3px solid var(--workflow);
|
||||
border-radius: 6px;
|
||||
background: rgba(0,0,0,0.18);
|
||||
overflow: hidden; display: none;
|
||||
}
|
||||
.workflow-card.show { display: block; }
|
||||
.workflow-card-head {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||
font-family: var(--font-mono); font-size: 11px;
|
||||
}
|
||||
.workflow-card-head .title {
|
||||
color: var(--fg); font-weight: 600;
|
||||
font-size: var(--text-sm); font-family: var(--font-sans);
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
.workflow-card-head .status {
|
||||
padding: 1px 6px; border-radius: 3px;
|
||||
font-size: 10px; text-transform: uppercase;
|
||||
letter-spacing: 0.04em; font-weight: 500;
|
||||
}
|
||||
.workflow-card-head .status.completed { background: rgba(74,222,128,0.14); color: #4ade80; }
|
||||
.workflow-card-head .status.failed { background: var(--danger-soft); color: var(--danger); }
|
||||
.workflow-card-head .meta { margin-left: auto; color: var(--muted); }
|
||||
.agent-list { display: flex; flex-direction: column; }
|
||||
.agent-row {
|
||||
display: grid; grid-template-columns: 80px 1fr auto; gap: 10px;
|
||||
align-items: center; padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
transition: background 0.08s; cursor: pointer;
|
||||
text-align: left;
|
||||
border-left: 0; border-right: 0; border-top: 0;
|
||||
background: transparent; color: inherit; font: inherit; width: 100%;
|
||||
}
|
||||
.agent-row:last-child { border-bottom: 0; }
|
||||
.agent-row:hover { background: rgba(255,255,255,0.03); }
|
||||
.agent-row .phase {
|
||||
font-family: var(--font-mono); font-size: 10px;
|
||||
color: var(--workflow);
|
||||
text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600;
|
||||
padding: 2px 6px; background: var(--workflow-soft);
|
||||
border-radius: 3px; text-align: center;
|
||||
}
|
||||
.agent-row .agent-body { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
.agent-row .agent-label {
|
||||
font-size: var(--text-sm); color: var(--fg);
|
||||
font-weight: 500;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.agent-row .agent-meta {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-family: var(--font-mono); font-size: 10.5px; color: var(--muted);
|
||||
}
|
||||
.agent-row .agent-state {
|
||||
padding: 1px 5px; border-radius: 3px;
|
||||
font-size: 9.5px; text-transform: uppercase;
|
||||
letter-spacing: 0.04em; font-weight: 500;
|
||||
}
|
||||
.agent-row .agent-state.done { background: rgba(74,222,128,0.14); color: #4ade80; }
|
||||
.agent-row .agent-state.error { background: var(--danger-soft); color: var(--danger); }
|
||||
.agent-row .chevron-r {
|
||||
width: 8px; height: 8px; color: var(--muted-2);
|
||||
transition: transform 0.15s; flex-shrink: 0;
|
||||
}
|
||||
.agent-row.open .chevron-r { transform: rotate(90deg); color: var(--workflow); }
|
||||
.agent-detail {
|
||||
display: none; padding: 10px 14px 14px 102px;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
background: rgba(0,0,0,0.25);
|
||||
}
|
||||
.agent-detail.show { display: block; }
|
||||
|
||||
/* Muted messages (all except first) - no opacity change */
|
||||
|
||||
/* Thinking messages - collapsed by default */
|
||||
.msg-thinking {
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: 5px;
|
||||
background: rgba(0,0,0,0.15);
|
||||
overflow: hidden;
|
||||
}
|
||||
.thinking-toggle {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
width: 100%; padding: 7px 10px;
|
||||
cursor: pointer; transition: background 0.08s;
|
||||
text-align: left; border: 0; background: transparent;
|
||||
color: inherit; font: inherit;
|
||||
}
|
||||
.thinking-toggle:hover { background: rgba(255,255,255,0.03); }
|
||||
.thinking-toggle .chevron {
|
||||
width: 8px; height: 8px; color: var(--muted);
|
||||
transition: transform 0.15s; flex-shrink: 0;
|
||||
}
|
||||
.msg-thinking.open .thinking-toggle .chevron { transform: rotate(90deg); color: var(--accent-2); }
|
||||
.thinking-toggle .thinking-label {
|
||||
font-family: var(--font-mono); font-size: 10.5px;
|
||||
color: var(--muted); font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.05em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.thinking-toggle .thinking-preview {
|
||||
font-size: 11px; color: var(--muted-2);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
flex: 1; min-width: 0;
|
||||
}
|
||||
.thinking-body {
|
||||
display: none; padding: 8px 14px 12px;
|
||||
border-top: 1px solid var(--hairline);
|
||||
}
|
||||
.msg-thinking.open .thinking-body { display: block; }
|
||||
|
||||
/* Truncated message button */
|
||||
.truncated-btn {
|
||||
display: block; width: 100%;
|
||||
margin-top: 8px; padding: 6px 12px;
|
||||
font-family: var(--font-mono); font-size: 11px;
|
||||
color: var(--accent-2); background: var(--accent-soft);
|
||||
border: 1px solid rgba(167,139,250,0.2);
|
||||
border-radius: 4px; cursor: pointer;
|
||||
text-align: center; transition: all 0.1s;
|
||||
}
|
||||
.truncated-btn:hover { background: rgba(167,139,250,0.2); border-color: var(--accent); }
|
||||
|
||||
/* Meta (system) messages - collapsed by default */
|
||||
.msg.meta {
|
||||
border-color: var(--hairline);
|
||||
background: transparent;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
.msg-meta-collapsed {
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: 4px;
|
||||
background: rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
.meta-toggle {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
width: 100%; padding: 5px 10px;
|
||||
cursor: pointer; transition: background 0.08s;
|
||||
text-align: left; border: 0; background: transparent;
|
||||
color: inherit; font: inherit;
|
||||
}
|
||||
.meta-toggle:hover { background: rgba(255,255,255,0.03); }
|
||||
.meta-toggle .chevron {
|
||||
width: 8px; height: 8px; color: var(--muted-2);
|
||||
transition: transform 0.15s; flex-shrink: 0;
|
||||
}
|
||||
.msg-meta-collapsed.open .meta-toggle .chevron { transform: rotate(90deg); color: var(--muted); }
|
||||
.meta-toggle .meta-label {
|
||||
font-family: var(--font-mono); font-size: 10px;
|
||||
color: var(--muted-2); font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.05em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.meta-toggle .meta-preview {
|
||||
font-size: 11px; color: var(--muted-2);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
flex: 1; min-width: 0;
|
||||
}
|
||||
.meta-body {
|
||||
display: none; padding: 6px 12px 10px;
|
||||
border-top: 1px solid var(--hairline);
|
||||
}
|
||||
.msg-meta-collapsed.open .meta-body { display: block; }
|
||||
|
||||
/* Agent tool call special styling */
|
||||
.msg-tool.agent-call { border-color: var(--workflow-soft); border-left: 2px solid var(--workflow); }
|
||||
.msg-tool.agent-call .tool-name { color: var(--workflow); }
|
||||
.agent-prompt {
|
||||
font-size: 12px; color: var(--fg-2); line-height: 1.5;
|
||||
padding: 8px 10px; margin-bottom: 8px;
|
||||
background: rgba(0,0,0,0.2); border-radius: 4px;
|
||||
white-space: pre-wrap; word-wrap: break-word;
|
||||
}
|
||||
.agent-result { max-height: 400px; overflow-y: auto; }
|
||||
.agent-nav-btn {
|
||||
margin-left: auto; padding: 2px 8px;
|
||||
font-family: var(--font-mono); font-size: 10.5px;
|
||||
color: var(--workflow); background: var(--workflow-soft);
|
||||
border: 1px solid rgba(245,158,11,0.25); border-radius: 3px;
|
||||
cursor: pointer; transition: all 0.1s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.agent-nav-btn:hover { background: rgba(245,158,11,0.25); border-color: var(--workflow); }
|
||||
|
||||
/* Workflow agent list inside tool call */
|
||||
.workflow-agent-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
.workflow-phase-group {}
|
||||
.workflow-phase-header {
|
||||
font-family: var(--font-mono); font-size: 11px;
|
||||
color: var(--muted); text-transform: uppercase;
|
||||
letter-spacing: 0.04em; font-weight: 500;
|
||||
padding: 4px 0 4px; margin-bottom: 2px;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
}
|
||||
.workflow-phase-agents { display: flex; flex-direction: column; gap: 1px; }
|
||||
.workflow-agent-row {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 6px 10px; border-radius: 4px;
|
||||
background: transparent; border: 0;
|
||||
cursor: pointer; transition: background 0.08s;
|
||||
text-align: left; width: 100%;
|
||||
font: inherit; color: inherit;
|
||||
}
|
||||
.workflow-agent-row:hover { background: rgba(255,255,255,0.04); }
|
||||
.workflow-agent-label { font-size: 12px; color: var(--fg-2); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.workflow-agent-row:hover .workflow-agent-label { color: var(--accent-2); text-decoration: underline; text-underline-offset: 2px; }
|
||||
.workflow-agent-state { font-family: var(--font-mono); font-size: 10px; padding: 1px 5px; border-radius: 3px; flex-shrink: 0; }
|
||||
.workflow-agent-state.done { background: rgba(74,222,128,0.14); color: #4ade80; }
|
||||
.workflow-agent-state.error { background: var(--danger-soft); color: var(--danger); }
|
||||
.workflow-status { font-family: var(--font-mono); font-size: 10px; padding: 1px 6px; border-radius: 3px; margin-left: auto; }
|
||||
.workflow-status.completed { background: rgba(74,222,128,0.14); color: #4ade80; }
|
||||
.workflow-status.failed { background: var(--danger-soft); color: var(--danger); }
|
||||
|
||||
/* Standalone workflow card (outside assistant bubble) */
|
||||
.wf-card {
|
||||
border: 1px solid var(--workflow-soft);
|
||||
border-left: 3px solid var(--workflow);
|
||||
border-radius: 8px;
|
||||
background: rgba(245, 158, 11, 0.04);
|
||||
padding: 0; overflow: hidden;
|
||||
}
|
||||
.wf-card-header {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--workflow-soft);
|
||||
background: rgba(245, 158, 11, 0.06);
|
||||
}
|
||||
.wf-card-icon { font-size: 14px; }
|
||||
.wf-card-name { font-size: 14px; font-weight: 600; color: var(--fg); font-family: var(--font-mono); }
|
||||
.wf-card-count { font-size: 11px; color: var(--muted); font-family: var(--font-mono); }
|
||||
.wf-card-status { font-family: var(--font-mono); font-size: 10px; padding: 2px 8px; border-radius: 3px; margin-left: auto; text-transform: uppercase; letter-spacing: 0.04em; font-weight: 500; }
|
||||
.wf-card-status.completed { background: rgba(74,222,128,0.14); color: #4ade80; }
|
||||
.wf-card-status.failed { background: var(--danger-soft); color: var(--danger); }
|
||||
.wf-card-body { padding: 12px 16px; }
|
||||
.wf-card-phase { margin-bottom: 12px; }
|
||||
.wf-card-phase:last-child { margin-bottom: 0; }
|
||||
.wf-card-phase-title {
|
||||
font-family: var(--font-mono); font-size: 11px;
|
||||
color: var(--workflow); text-transform: uppercase;
|
||||
letter-spacing: 0.05em; font-weight: 600;
|
||||
margin-bottom: 4px; padding-bottom: 4px;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
}
|
||||
.wf-card-agent {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 7px 10px; border-radius: 4px;
|
||||
background: transparent; border: 0;
|
||||
cursor: pointer; transition: background 0.08s;
|
||||
text-align: left; width: 100%;
|
||||
font: inherit; color: inherit;
|
||||
}
|
||||
.wf-card-agent:hover { background: rgba(245, 158, 11, 0.08); }
|
||||
.wf-card-agent-label { font-size: 13px; color: var(--fg-2); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.wf-card-agent:hover .wf-card-agent-label { color: var(--accent-2); text-decoration: underline; text-underline-offset: 2px; }
|
||||
.wf-card-agent-state { font-family: var(--font-mono); font-size: 10px; padding: 2px 6px; border-radius: 3px; flex-shrink: 0; }
|
||||
.wf-card-agent-state.error { background: var(--danger-soft); color: var(--danger); }
|
||||
.wf-card-agent-arrow { color: var(--muted-2); font-size: 12px; flex-shrink: 0; transition: color 0.08s, transform 0.08s; }
|
||||
.wf-card-agent:hover .wf-card-agent-arrow { color: var(--accent-2); transform: translateX(2px); }
|
||||
|
||||
/* Back to parent session link */
|
||||
.back-to-bar {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 4px 0; margin-bottom: 12px;
|
||||
font-size: 12px; font-family: var(--font-mono);
|
||||
color: var(--accent-2); background: none;
|
||||
border: none; cursor: pointer;
|
||||
transition: color 0.1s;
|
||||
}
|
||||
.back-to-bar:hover { color: var(--fg); text-decoration: underline; text-underline-offset: 2px; }
|
||||
|
||||
/* Back to top floating button */
|
||||
.back-to-top {
|
||||
position: sticky; bottom: 20px; float: right;
|
||||
margin-right: 8px; margin-top: -40px;
|
||||
width: 32px; height: 32px; border-radius: 50%;
|
||||
background: var(--surface-strong); border: 1px solid var(--hairline-strong);
|
||||
color: var(--muted); cursor: pointer;
|
||||
display: grid; place-items: center;
|
||||
opacity: 0; transition: opacity 0.15s, background 0.1s;
|
||||
pointer-events: none; z-index: 10;
|
||||
}
|
||||
.back-to-top.show { opacity: 1; pointer-events: auto; }
|
||||
.back-to-top:hover { background: var(--accent-soft); color: var(--accent-2); border-color: var(--accent-soft); }
|
||||
.back-to-top svg { width: 14px; height: 14px; }
|
||||
@@ -0,0 +1,157 @@
|
||||
.list-wrap, .detail-wrap, .session-list-wrap, .session-detail-wrap {
|
||||
flex: 1; overflow-y: auto; min-height: 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid; grid-template-columns: 22px 1fr auto;
|
||||
align-items: start; column-gap: 12px;
|
||||
padding: 14px 16px 14px 14px;
|
||||
min-height: var(--row-h);
|
||||
cursor: pointer; user-select: none;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
transition: background 0.06s; position: relative;
|
||||
}
|
||||
.row:last-child { border-bottom: 0; }
|
||||
.row:hover { background: rgba(255,255,255,0.025); }
|
||||
.row.cursor { background: var(--surface); }
|
||||
.row.cursor::before {
|
||||
content: ''; position: absolute; left: 0; top: 0; bottom: 0;
|
||||
width: 2px; background: var(--muted-2);
|
||||
}
|
||||
.row.selected { background: var(--accent-soft); }
|
||||
.row.selected::before {
|
||||
content: ''; position: absolute; left: 0; top: 0; bottom: 0;
|
||||
width: 2px; background: var(--accent);
|
||||
box-shadow: 0 0 12px var(--accent-glow);
|
||||
}
|
||||
.row.cursor.selected { background: rgba(167,139,250,0.16); }
|
||||
.row-checkbox {
|
||||
width: 18px; height: 18px; margin-top: 1px;
|
||||
border-radius: 4px; border: 1.5px solid var(--muted-2);
|
||||
background: transparent; cursor: pointer;
|
||||
display: grid; place-items: center;
|
||||
opacity: 0; transition: all 0.1s;
|
||||
justify-self: center;
|
||||
}
|
||||
.row:hover .row-checkbox,
|
||||
.row.selected .row-checkbox,
|
||||
.row.cursor .row-checkbox { opacity: 1; }
|
||||
.row-checkbox:hover { border-color: var(--accent); }
|
||||
.row-checkbox.checked {
|
||||
background: var(--accent); border-color: var(--accent);
|
||||
box-shadow: 0 0 8px var(--accent-glow);
|
||||
}
|
||||
.row-checkbox svg { width: 10px; height: 10px; color: #0a0b14; opacity: 0; }
|
||||
.row-checkbox.checked svg { opacity: 1; }
|
||||
.row-body { min-width: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.row-path {
|
||||
font-family: var(--font-mono); font-size: var(--text-md);
|
||||
font-weight: 500; color: var(--fg); line-height: 1.4;
|
||||
display: flex; align-items: center; gap: 6px; min-width: 0;
|
||||
}
|
||||
.row-status {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 14px; height: 14px; flex-shrink: 0;
|
||||
}
|
||||
.row-status svg { width: 100%; height: 100%; }
|
||||
.row-status.broken { color: var(--danger); }
|
||||
.row-status.partial { color: var(--warn); }
|
||||
.row-status.archived { color: var(--muted-2); }
|
||||
.row-path .project-prefix { color: var(--muted); font-weight: 400; flex-shrink: 0; }
|
||||
.row-path .project-prefix-sep { color: var(--muted-2); margin: 0 2px; flex-shrink: 0; }
|
||||
.row-path .path-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||
.row-path mark, .row-summary mark, .srow-title mark, .srow-snippet mark {
|
||||
background: var(--accent-soft); color: var(--accent-2);
|
||||
padding: 0 2px; border-radius: 2px;
|
||||
}
|
||||
.row-summary {
|
||||
font-size: var(--text-base); color: var(--fg-2);
|
||||
line-height: 1.5;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||
overflow: hidden; word-break: break-word;
|
||||
}
|
||||
.row-right {
|
||||
display: flex; flex-direction: column; align-items: flex-end;
|
||||
gap: 10px; flex-shrink: 0; padding-top: 1px;
|
||||
}
|
||||
.row-meta {
|
||||
font-family: var(--font-mono); font-size: 10.5px;
|
||||
color: var(--muted); letter-spacing: 0.02em;
|
||||
font-variant-numeric: tabular-nums; white-space: nowrap;
|
||||
display: flex; gap: 8px;
|
||||
}
|
||||
.row:hover .row-meta { color: var(--muted-2); }
|
||||
.row-actions { display: flex; gap: 4px; opacity: 0; transition: opacity 0.1s; }
|
||||
.row:hover .row-actions, .row.cursor .row-actions { opacity: 1; }
|
||||
.row-action {
|
||||
height: 24px; padding: 0 8px; border-radius: 4px;
|
||||
color: var(--muted); font-size: var(--text-sm);
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
transition: all 0.1s; border: 1px solid transparent;
|
||||
}
|
||||
.row-action:hover { background: var(--surface-hi); color: var(--fg); border-color: var(--hairline-strong); }
|
||||
.row-action.danger:hover { background: var(--danger-soft); color: var(--danger); border-color: rgba(248,113,113,0.3); }
|
||||
.row-action.restore { color: var(--accent-2); }
|
||||
.row-action.restore:hover { background: var(--accent-soft); color: var(--fg); border-color: var(--accent-soft); }
|
||||
.row-action .kbd {
|
||||
font-family: var(--font-mono); font-size: 9.5px; color: var(--muted-2);
|
||||
padding: 0 3px; border: 1px solid var(--hairline); border-radius: 2px; line-height: 1.4;
|
||||
}
|
||||
.row-action:hover .kbd { color: var(--fg-2); border-color: var(--hairline-strong); }
|
||||
.row.archived .row-path, .row.archived .row-summary { color: var(--muted); }
|
||||
.row.archived .row-path .project-prefix { color: var(--muted-2); }
|
||||
|
||||
.srow {
|
||||
display: grid; grid-template-columns: 1fr auto;
|
||||
align-items: start; column-gap: 12px;
|
||||
padding: 12px 16px;
|
||||
min-height: var(--row-h-session);
|
||||
cursor: pointer; user-select: none;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
transition: background 0.06s; position: relative;
|
||||
}
|
||||
.srow:hover { background: rgba(255,255,255,0.025); }
|
||||
.srow.cursor { background: var(--surface); }
|
||||
.srow.cursor::before {
|
||||
content: ''; position: absolute; left: 0; top: 0; bottom: 0;
|
||||
width: 2px; background: var(--muted-2);
|
||||
}
|
||||
.srow-body { min-width: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||
.srow-title {
|
||||
font-size: var(--text-md); font-weight: 500; color: var(--fg);
|
||||
line-height: 1.35;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.srow-meta {
|
||||
font-family: var(--font-mono); font-size: 11px;
|
||||
color: var(--muted);
|
||||
display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
|
||||
}
|
||||
.srow-meta .project-tag { color: var(--fg-2); font-weight: 500; }
|
||||
.srow-meta .dot { width: 2px; height: 2px; background: var(--muted-2); border-radius: 50%; flex-shrink: 0; }
|
||||
.srow-snippet {
|
||||
margin-top: 4px;
|
||||
font-size: var(--text-sm); color: var(--fg-2); line-height: 1.4;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
padding-left: 14px; border-left: 2px solid var(--accent-soft);
|
||||
}
|
||||
.srow-snippet .snippet-label {
|
||||
font-family: var(--font-mono); font-size: 9.5px;
|
||||
color: var(--accent-2); letter-spacing: 0.04em;
|
||||
text-transform: uppercase; margin-right: 6px;
|
||||
}
|
||||
.srow-right {
|
||||
font-family: var(--font-mono); font-size: 11px;
|
||||
color: var(--fg-2); text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0; padding-top: 2px; white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty {
|
||||
flex: 1; display: flex; align-items: center; justify-content: center;
|
||||
color: var(--muted-2); font-size: var(--text-sm);
|
||||
padding: 60px 20px; text-align: center;
|
||||
flex-direction: column; gap: 8px;
|
||||
}
|
||||
.empty .hint { font-size: 11px; color: var(--muted-2); }
|
||||
@@ -0,0 +1,74 @@
|
||||
.sidebar {
|
||||
border-right: 1px solid var(--hairline-strong);
|
||||
background: rgba(0,0,0,0.2);
|
||||
display: flex; flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.sidebar-brand {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 0 14px; height: 36px;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sidebar-brand svg { width: 18px; height: 18px; filter: drop-shadow(0 0 8px rgba(167,139,250,0.4)); }
|
||||
.sidebar-brand .name { font-size: var(--text-base); font-weight: 600; color: var(--fg-2); }
|
||||
.sidebar-section { padding: 8px 6px; flex-shrink: 0; }
|
||||
.sidebar-section.projects { flex: 1; min-height: 0; display: flex; flex-direction: column; padding-bottom: 0; }
|
||||
.sidebar-section + .sidebar-section { border-top: 1px solid var(--hairline); }
|
||||
.sidebar-section-title {
|
||||
padding: 4px 10px 6px;
|
||||
font-size: 10.5px; color: var(--muted);
|
||||
font-weight: 500; letter-spacing: 0.04em;
|
||||
display: flex; justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sidebar-search { position: relative; padding: 0 6px 6px; flex-shrink: 0; }
|
||||
.sidebar-search input {
|
||||
width: 100%; height: 24px;
|
||||
padding: 0 8px 0 24px;
|
||||
border: 1px solid var(--hairline); border-radius: 4px;
|
||||
background: var(--surface);
|
||||
font-size: var(--text-sm); color: var(--fg);
|
||||
transition: all 0.1s;
|
||||
}
|
||||
.sidebar-search input::placeholder { color: var(--muted-2); }
|
||||
.sidebar-search input:focus { outline: 0; border-color: var(--accent); background: var(--surface-strong); }
|
||||
.sidebar-search-icon {
|
||||
position: absolute; left: 14px; top: 50%; transform: translateY(-50%);
|
||||
width: 11px; height: 11px; color: var(--muted); pointer-events: none;
|
||||
}
|
||||
.sidebar-list { overflow-y: auto; padding: 2px 0 8px; flex: 1; min-height: 0; }
|
||||
.sidebar-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 0 10px; height: var(--row-h-compact);
|
||||
border-radius: 5px;
|
||||
color: var(--fg-2); font-size: var(--text-base);
|
||||
cursor: pointer; user-select: none;
|
||||
transition: background 0.08s; position: relative;
|
||||
width: 100%; text-align: left;
|
||||
}
|
||||
.sidebar-item:hover { background: var(--surface-strong); color: var(--fg); }
|
||||
.sidebar-item.active { background: var(--accent-soft); color: var(--fg); }
|
||||
.sidebar-item.active::before {
|
||||
content: ''; position: absolute; left: -6px; top: 4px; bottom: 4px;
|
||||
width: 2px; background: var(--accent); border-radius: 1px;
|
||||
box-shadow: 0 0 8px var(--accent-glow);
|
||||
}
|
||||
.sidebar-item .icon { width: 14px; height: 14px; color: var(--muted); flex-shrink: 0; transition: all 0.08s; }
|
||||
.sidebar-item.active .icon { color: var(--accent-2); filter: drop-shadow(0 0 4px var(--accent-glow)); }
|
||||
.sidebar-item.warning .icon { color: var(--danger); }
|
||||
.sidebar-item .label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sidebar-item .badge {
|
||||
font-family: var(--font-mono); font-size: 10.5px;
|
||||
color: var(--muted); font-variant-numeric: tabular-nums;
|
||||
line-height: 1; min-width: 22px; text-align: right;
|
||||
flex-shrink: 0; padding: 2px 0;
|
||||
}
|
||||
.sidebar-item.active .badge { color: var(--fg-2); }
|
||||
.sidebar-item.warning .badge {
|
||||
color: var(--danger); background: var(--danger-soft);
|
||||
padding: 2px 6px; border-radius: 8px;
|
||||
margin-right: -6px; min-width: 22px;
|
||||
}
|
||||
.sidebar-item.sub { padding-left: 30px; height: 26px; font-size: var(--text-sm); }
|
||||
.sidebar-item.sub .icon { width: 12px; height: 12px; }
|
||||
@@ -0,0 +1,29 @@
|
||||
.statusbar {
|
||||
height: 24px; flex-shrink: 0;
|
||||
border-top: 1px solid var(--hairline-strong);
|
||||
background: rgba(0,0,0,0.3);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 0 12px;
|
||||
font-size: 10.5px; color: var(--muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.statusbar .status-left { display: flex; gap: 8px; flex: 1; }
|
||||
.statusbar .status-right { display: flex; gap: 8px; }
|
||||
.statusbar .kbd-hint { display: inline-flex; align-items: center; gap: 4px; transition: opacity 0.15s; }
|
||||
.statusbar .kbd-hint.secondary { opacity: 0; }
|
||||
.statusbar:hover .kbd-hint.secondary { opacity: 1; }
|
||||
.statusbar .kbd {
|
||||
color: var(--fg-2); padding: 0 4px;
|
||||
border: 1px solid var(--hairline-strong); border-radius: 3px; line-height: 1.4;
|
||||
}
|
||||
.status-pending { color: var(--accent-2); display: flex; align-items: center; gap: 8px; }
|
||||
.status-pending strong { color: var(--fg); font-weight: 500; }
|
||||
.status-pending .undo-btn {
|
||||
color: var(--accent-2); padding: 0 6px;
|
||||
border: 1px solid var(--accent-soft); border-radius: 3px;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
.status-pending .undo-btn:hover { background: var(--accent-soft); color: var(--fg); }
|
||||
.status-pending .timer { color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
@@ -0,0 +1,81 @@
|
||||
.main { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
|
||||
.toolbar {
|
||||
height: 44px; flex-shrink: 0;
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 0 14px;
|
||||
border-bottom: 1px solid var(--hairline-strong);
|
||||
background: rgba(0,0,0,0.15);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
}
|
||||
.breadcrumb { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
||||
.crumb {
|
||||
font-size: var(--text-md); color: var(--muted);
|
||||
padding: 4px 6px; border-radius: 4px;
|
||||
cursor: pointer; transition: all 0.1s;
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
line-height: 1; border: 0; background: transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.crumb:hover { background: var(--surface-strong); color: var(--fg-2); }
|
||||
.crumb.terminal { color: var(--fg); font-weight: 600; cursor: default; }
|
||||
.crumb.terminal:hover { background: transparent; }
|
||||
.crumb svg { width: 13px; height: 13px; color: var(--muted); }
|
||||
.crumb.filename {
|
||||
font-family: var(--font-mono); font-weight: 500; color: var(--fg);
|
||||
overflow: hidden; text-overflow: ellipsis; min-width: 0;
|
||||
}
|
||||
.crumb-sep { color: var(--muted-2); font-size: var(--text-md); user-select: none; }
|
||||
.toolbar-spacer { flex: 1; }
|
||||
.toolbar-search { width: 220px; position: relative; }
|
||||
.toolbar-search input {
|
||||
width: 100%; height: 26px;
|
||||
padding: 0 30px 0 26px;
|
||||
border: 1px solid var(--hairline); border-radius: 5px;
|
||||
background: var(--surface);
|
||||
font-size: var(--text-base); color: var(--fg);
|
||||
transition: all 0.12s;
|
||||
}
|
||||
.toolbar-search input::placeholder { color: var(--muted-2); }
|
||||
.toolbar-search input:focus {
|
||||
outline: 0; border-color: var(--accent);
|
||||
background: var(--surface-strong);
|
||||
box-shadow: 0 0 0 2px var(--accent-soft);
|
||||
}
|
||||
.toolbar-search-icon {
|
||||
position: absolute; left: 8px; top: 50%; transform: translateY(-50%);
|
||||
width: 12px; height: 12px; color: var(--muted); pointer-events: none;
|
||||
}
|
||||
.toolbar-search-kbd {
|
||||
position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
|
||||
font-family: var(--font-mono); font-size: 10px; color: var(--muted-2);
|
||||
padding: 1px 5px;
|
||||
border: 1px solid var(--hairline); border-radius: 3px;
|
||||
pointer-events: none; line-height: 1.2;
|
||||
}
|
||||
.toolbar-search input:focus ~ .toolbar-search-kbd,
|
||||
.toolbar-search input:not(:placeholder-shown) ~ .toolbar-search-kbd { opacity: 0; }
|
||||
.filter-toggle {
|
||||
height: 26px; width: 26px; border-radius: 5px;
|
||||
color: var(--muted); display: inline-grid; place-items: center;
|
||||
transition: all 0.1s; border: 1px solid transparent;
|
||||
}
|
||||
.filter-toggle:hover { color: var(--fg-2); background: var(--surface-strong); }
|
||||
.filter-toggle.active { color: var(--accent-2); background: var(--accent-soft); border-color: var(--accent-soft); }
|
||||
.filter-toggle svg { width: 13px; height: 13px; }
|
||||
|
||||
.sort-group {
|
||||
display: inline-flex; align-items: center; gap: 2px;
|
||||
height: 26px; padding: 0 4px 0 8px;
|
||||
border-radius: 5px; cursor: pointer;
|
||||
color: var(--muted); font-size: var(--text-sm);
|
||||
transition: background 0.1s, color 0.1s;
|
||||
}
|
||||
.sort-group:hover { background: var(--surface-strong); color: var(--fg-2); }
|
||||
.sort-group .label { font-family: var(--font-mono); letter-spacing: 0.02em; }
|
||||
.sort-group svg { width: 13px; height: 13px; }
|
||||
.sort-group .arrow-up, .sort-group .arrow-down { transition: opacity 0.12s; }
|
||||
.sort-group.desc .arrow-up { opacity: 0.25; }
|
||||
.sort-group.desc .arrow-down { opacity: 1; }
|
||||
.sort-group.asc .arrow-up { opacity: 1; }
|
||||
.sort-group.asc .arrow-down { opacity: 0.25; }
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
base: './',
|
||||
build: {
|
||||
outDir: '../dist-renderer',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user