feat(app): add Settings view, configurable claude dir, and recap docs refactor
Introduce a Settings view for configuring the Claude data directory (with WSL auto-detection on Windows), sidebar project grouping module, and an empty-state onboarding screen for SessionList. Refactor recap-patterns.md into per-card reference files under references/recap/ with separate retrieval and writing guides. Remove the legacy panel.html. On the data layer: incremental indexing via changedPaths, per-session live-update IPC (obelisk:session-updated), and session dirty-tracking in the renderer.
This commit is contained in:
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
+128
-9
@@ -89,7 +89,7 @@ function extractContentType(content) {
|
||||
return !sawUnknown && types.size === 1 ? [...types][0] : 'unknown';
|
||||
}
|
||||
|
||||
const COMMAND_ENVELOPE_RE = /^\s*(<command-name>[^<]+<\/command-name>|<task-notification>|<local-command-caveat>|<local-command-stdout>)/;
|
||||
const COMMAND_ENVELOPE_RE = /^\s*(<command-name>[^<]+<\/command-name>|<(?:task-notification|system-reminder)\b|<local-command(?:\b|-))/;
|
||||
|
||||
function extractMessageIsMeta(record, text = extractText(record?.message?.content)) {
|
||||
const msg = record?.message || {};
|
||||
@@ -141,7 +141,77 @@ function inferProjectPath(project, observedCwds = []) {
|
||||
return best?.path || legacyProjectPathFromSlug(project);
|
||||
}
|
||||
|
||||
function discoverJsonlFiles({ projectsDir = DEFAULT_PROJECTS_DIR } = {}) {
|
||||
function discoverJsonlFiles({ projectsDir = DEFAULT_PROJECTS_DIR, changedPaths = undefined } = {}) {
|
||||
if (Array.isArray(changedPaths) && changedPaths.length) {
|
||||
const changedFiles = discoverJsonlFilesForChanges({ projectsDir, changedPaths });
|
||||
if (changedFiles.length) return changedFiles;
|
||||
}
|
||||
return discoverJsonlFilesFull({ projectsDir });
|
||||
}
|
||||
|
||||
function normalizeChangedPath(projectsDir, changedPath) {
|
||||
if (!changedPath) return null;
|
||||
const raw = String(changedPath);
|
||||
return path.isAbsolute(raw) ? path.normalize(raw) : path.normalize(path.join(projectsDir, raw));
|
||||
}
|
||||
|
||||
function jsonlFileInfoFromPath(projectsDir, changedPath) {
|
||||
const fp = normalizeChangedPath(projectsDir, changedPath);
|
||||
if (!fp || !fp.endsWith('.jsonl')) return null;
|
||||
const rel = path.relative(projectsDir, fp);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
||||
const parts = rel.split(path.sep);
|
||||
const project = parts[0];
|
||||
if (!project) return null;
|
||||
if (parts.length === 2) {
|
||||
const filename = parts[1];
|
||||
return { path: fp, sessionId: filename.slice(0, -6), project, isSubagent: false };
|
||||
}
|
||||
if (parts.length === 4 && parts[2] === 'subagents') {
|
||||
const filename = parts[3];
|
||||
return { path: fp, sessionId: parts[1], project, isSubagent: true, agentId: filename.slice(0, -6) };
|
||||
}
|
||||
if (parts.length === 6 && parts[2] === 'subagents' && parts[3] === 'workflows') {
|
||||
const filename = parts[5];
|
||||
return {
|
||||
path: fp,
|
||||
sessionId: parts[1],
|
||||
project,
|
||||
isSubagent: true,
|
||||
agentId: filename.slice(0, -6),
|
||||
workflowRunId: parts[4],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function sessionIdFromChangedPath(projectsDir, changedPath) {
|
||||
const fp = normalizeChangedPath(projectsDir, changedPath);
|
||||
if (!fp) return null;
|
||||
const rel = path.relative(projectsDir, fp);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
||||
const parts = rel.split(path.sep);
|
||||
if (parts.length === 2 && parts[1].endsWith('.jsonl')) return parts[1].slice(0, -6);
|
||||
if (parts.length >= 3) return parts[1] || null;
|
||||
return null;
|
||||
}
|
||||
|
||||
function dedupeFileInfos(files) {
|
||||
const byPath = new Map();
|
||||
for (const file of files) byPath.set(file.path, file);
|
||||
return [...byPath.values()];
|
||||
}
|
||||
|
||||
function discoverJsonlFilesForChanges({ projectsDir = DEFAULT_PROJECTS_DIR, changedPaths = [] } = {}) {
|
||||
const files = [];
|
||||
for (const changedPath of changedPaths) {
|
||||
const info = jsonlFileInfoFromPath(projectsDir, changedPath);
|
||||
if (info) files.push(info);
|
||||
}
|
||||
return dedupeFileInfos(files);
|
||||
}
|
||||
|
||||
function discoverJsonlFilesFull({ projectsDir = DEFAULT_PROJECTS_DIR } = {}) {
|
||||
const files = [];
|
||||
if (!fs.existsSync(projectsDir)) return files;
|
||||
let projects;
|
||||
@@ -195,7 +265,26 @@ function indexJsonl(db, fi) {
|
||||
if (!needed) return;
|
||||
const ins = {
|
||||
ses: db.prepare('INSERT OR REPLACE INTO sessions (id,title,project,project_path,started_at,ended_at,git_branch,version,message_count,jsonl_path) VALUES (?,?,?,?,?,?,?,?,?,?)'),
|
||||
msg: db.prepare('INSERT OR REPLACE INTO messages (uuid,session_id,type,parent_uuid,timestamp,role,text,content_type,is_meta,model,is_sidechain,agent_id,input_tokens,output_tokens,cwd,skill) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)'),
|
||||
msg: db.prepare(`
|
||||
INSERT INTO messages (uuid,session_id,type,parent_uuid,timestamp,role,text,content_type,is_meta,model,is_sidechain,agent_id,input_tokens,output_tokens,cwd,skill)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(uuid) DO UPDATE SET
|
||||
session_id=excluded.session_id,
|
||||
type=excluded.type,
|
||||
parent_uuid=excluded.parent_uuid,
|
||||
timestamp=excluded.timestamp,
|
||||
role=excluded.role,
|
||||
text=excluded.text,
|
||||
content_type=excluded.content_type,
|
||||
is_meta=excluded.is_meta,
|
||||
model=excluded.model,
|
||||
is_sidechain=excluded.is_sidechain,
|
||||
agent_id=excluded.agent_id,
|
||||
input_tokens=excluded.input_tokens,
|
||||
output_tokens=excluded.output_tokens,
|
||||
cwd=excluded.cwd,
|
||||
skill=excluded.skill
|
||||
`),
|
||||
tc: db.prepare('INSERT OR REPLACE INTO tool_calls (id,message_uuid,session_id,name,input_json,file_path) VALUES (?,?,?,?,?,?)'),
|
||||
tr: db.prepare('INSERT OR REPLACE INTO tool_results (tool_use_id,message_uuid,session_id,content,file_path,is_error) VALUES (?,?,?,?,?,?)'),
|
||||
sum: db.prepare('INSERT OR REPLACE INTO summaries (id,session_id,timestamp,source,content) VALUES (?,?,?,?,?)'),
|
||||
@@ -208,7 +297,7 @@ function indexJsonl(db, fi) {
|
||||
git_branch: existing?.git_branch || null,
|
||||
version: existing?.version || null,
|
||||
title: existing?.title || null,
|
||||
n: existing?.message_count || 0,
|
||||
n: skip > 0 ? (existing?.message_count || 0) : 0,
|
||||
cwds: [],
|
||||
};
|
||||
|
||||
@@ -270,6 +359,7 @@ function indexJsonl(db, fi) {
|
||||
ins.ses.run(fi.sessionId, sm.title, fi.project, pp, sm.started_at, sm.ended_at, sm.git_branch, sm.version, sm.n, fi.path);
|
||||
}
|
||||
ins.idx.run(fi.path, mtime, lineNum);
|
||||
return { sessionId: fi.sessionId, path: fi.path };
|
||||
}
|
||||
|
||||
function refreshSessionProjectPaths(db) {
|
||||
@@ -362,6 +452,15 @@ function rebuildFts(db) {
|
||||
db.exec("INSERT INTO memories_fts(memories_fts) VALUES('rebuild')");
|
||||
}
|
||||
|
||||
function ensureFtsReady(db, { force = false } = {}) {
|
||||
const marker = '__fts_triggers_ready__';
|
||||
const ready = db.prepare('SELECT jsonl_path FROM index_state WHERE jsonl_path = ?').get(marker);
|
||||
if (ready && !force) return false;
|
||||
rebuildFts(db);
|
||||
writeIndexMarker(db, marker);
|
||||
return true;
|
||||
}
|
||||
|
||||
function writeIndexMarker(db, key, value = Date.now()) {
|
||||
db.prepare('INSERT OR REPLACE INTO index_state (jsonl_path, mtime, lines_processed) VALUES (?, ?, 0)').run(key, value);
|
||||
}
|
||||
@@ -384,9 +483,10 @@ function buildIndex({
|
||||
schemaPath = resolveSchemaPath(),
|
||||
DatabaseImpl = Database,
|
||||
force = false,
|
||||
changedPaths = undefined,
|
||||
} = {}) {
|
||||
const db = openIndexDb({ dbPath, schemaPath, DatabaseImpl });
|
||||
const files = discoverJsonlFiles({ projectsDir });
|
||||
const files = discoverJsonlFiles({ projectsDir, changedPaths: force ? undefined : changedPaths });
|
||||
const latestSourceMtime = files.reduce((latest, file) => {
|
||||
try {
|
||||
return Math.max(latest, fs.statSync(file.path).mtimeMs);
|
||||
@@ -396,11 +496,29 @@ function buildIndex({
|
||||
}, 0);
|
||||
|
||||
try {
|
||||
if (force) db.prepare("DELETE FROM index_state WHERE jsonl_path NOT LIKE '__%'").run();
|
||||
if (force) {
|
||||
db.prepare("DELETE FROM index_state WHERE substr(jsonl_path, 1, 2) != '__'").run();
|
||||
db.prepare("DELETE FROM messages").run();
|
||||
db.prepare("DELETE FROM tool_calls").run();
|
||||
db.prepare("DELETE FROM tool_results").run();
|
||||
db.prepare("DELETE FROM sessions").run();
|
||||
db.prepare("DELETE FROM summaries").run();
|
||||
db.prepare("DELETE FROM subagents").run();
|
||||
db.prepare("DELETE FROM workflows").run();
|
||||
db.prepare("DELETE FROM workflow_agents").run();
|
||||
}
|
||||
const affectedSessionIds = new Set();
|
||||
if (Array.isArray(changedPaths)) {
|
||||
for (const changedPath of changedPaths) {
|
||||
const sessionId = sessionIdFromChangedPath(projectsDir, changedPath);
|
||||
if (sessionId) affectedSessionIds.add(sessionId);
|
||||
}
|
||||
}
|
||||
for (const file of files) {
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
indexJsonl(db, file);
|
||||
const indexed = indexJsonl(db, file);
|
||||
if (indexed?.sessionId) affectedSessionIds.add(indexed.sessionId);
|
||||
indexSubagentMeta(db, file);
|
||||
db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
@@ -409,11 +527,12 @@ function buildIndex({
|
||||
}
|
||||
}
|
||||
db.exec('BEGIN');
|
||||
let ftsRebuilt = false;
|
||||
try {
|
||||
indexWorkflows(db, { projectsDir });
|
||||
refreshSessionProjectPaths(db);
|
||||
indexHistory(db, { historyPath });
|
||||
rebuildFts(db);
|
||||
ftsRebuilt = ensureFtsReady(db, { force });
|
||||
writeIndexMarker(db, '__last_build__');
|
||||
writeIndexMarker(db, '__app_heartbeat__');
|
||||
writeIndexMarker(db, '__app_last_successful_build__');
|
||||
@@ -424,7 +543,7 @@ function buildIndex({
|
||||
db.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
return { files: files.length, latestSourceMtime };
|
||||
return { files: files.length, latestSourceMtime, affectedSessionIds: [...affectedSessionIds], ftsRebuilt };
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
+248
-29
@@ -6,42 +6,133 @@ const Database = require('better-sqlite3');
|
||||
const { writeHeartbeat } = require('./indexer');
|
||||
const { createIndexerService } = require('./indexer-service');
|
||||
const { createWorkerBuildIndex } = require('./indexer-worker-client');
|
||||
const { buildRecapExportQuery } = require('./recap-capture-query');
|
||||
|
||||
const DB_PATH = path.join(os.homedir(), '.claude', 'obelisk.sqlite');
|
||||
function detectClaudeDir() {
|
||||
// macOS / Linux: ~/.claude
|
||||
if (process.platform !== 'win32') {
|
||||
return path.join(os.homedir(), '.claude');
|
||||
}
|
||||
// Windows: Claude Code runs in WSL, data lives at \\wsl.localhost\<distro>\home\<user>\.claude
|
||||
const distros = ['Ubuntu', 'Ubuntu-24.04', 'Ubuntu-22.04', 'Debian', 'openSUSE-Leap', 'kali-linux'];
|
||||
for (const distro of distros) {
|
||||
const homePath = path.join('\\\\wsl.localhost', distro, 'home');
|
||||
if (!fs.existsSync(homePath)) continue;
|
||||
try {
|
||||
const users = fs.readdirSync(homePath);
|
||||
for (const user of users) {
|
||||
const claudeDir = path.join(homePath, user, '.claude');
|
||||
if (fs.existsSync(claudeDir)) return claudeDir;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
// Fallback: native Windows path (for future native Claude Code on Windows)
|
||||
return path.join(os.homedir(), '.claude');
|
||||
}
|
||||
|
||||
const DEFAULT_CLAUDE_DIR = detectClaudeDir();
|
||||
|
||||
let db;
|
||||
let indexerService;
|
||||
let indexerWorker;
|
||||
|
||||
function openDb() {
|
||||
if (!fs.existsSync(DB_PATH)) return null;
|
||||
function getConfiguredClaudeDir() {
|
||||
const persisted = loadPersistedSettings();
|
||||
return persisted.claudeDir || DEFAULT_CLAUDE_DIR;
|
||||
}
|
||||
|
||||
function getPathsForClaudeDir(claudeDir = getConfiguredClaudeDir()) {
|
||||
return {
|
||||
claudeDir,
|
||||
dbPath: path.join(claudeDir, 'obelisk.sqlite'),
|
||||
projectsDir: path.join(claudeDir, 'projects'),
|
||||
};
|
||||
}
|
||||
|
||||
function closeDb() {
|
||||
if (db) db.close();
|
||||
db = new Database(DB_PATH, { readonly: false });
|
||||
db = null;
|
||||
}
|
||||
|
||||
function openDb(dbPath = getPathsForClaudeDir().dbPath) {
|
||||
closeDb();
|
||||
if (!fs.existsSync(dbPath)) return null;
|
||||
db = new Database(dbPath, { readonly: false });
|
||||
db.pragma('journal_mode = WAL');
|
||||
return db;
|
||||
}
|
||||
|
||||
function notifyIndexUpdated() {
|
||||
function notifyIndexUpdated(result = {}) {
|
||||
const affectedSessionIds = Array.isArray(result.affectedSessionIds)
|
||||
? [...new Set(result.affectedSessionIds.filter(Boolean))]
|
||||
: [];
|
||||
const payload = { affectedSessionIds };
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send('obelisk:index-updated');
|
||||
win.webContents.send('obelisk:index-updated', payload);
|
||||
for (const sessionId of affectedSessionIds) {
|
||||
win.webContents.send('obelisk:session-updated', { sessionId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startIndexerService() {
|
||||
function startIndexerService({ buildOnStart = false } = {}) {
|
||||
const paths = getPathsForClaudeDir();
|
||||
indexerService = createIndexerService({
|
||||
buildIndex: async ({ reason }) => {
|
||||
const result = await indexerWorker.buildIndex({ reason });
|
||||
openDb();
|
||||
notifyIndexUpdated();
|
||||
projectsDir: paths.projectsDir,
|
||||
buildIndex: async ({ reason, changedPaths }) => {
|
||||
const result = await indexerWorker.buildIndex({
|
||||
reason,
|
||||
changedPaths,
|
||||
claudeDir: paths.claudeDir,
|
||||
projectsDir: paths.projectsDir,
|
||||
dbPath: paths.dbPath,
|
||||
});
|
||||
openDb(paths.dbPath);
|
||||
notifyIndexUpdated(result);
|
||||
return result;
|
||||
},
|
||||
writeHeartbeat,
|
||||
writeHeartbeat: () => writeHeartbeat({ dbPath: paths.dbPath }),
|
||||
});
|
||||
indexerService.start({ buildOnStart: false });
|
||||
indexerService.start({ buildOnStart });
|
||||
return indexerService;
|
||||
}
|
||||
|
||||
function startBackgroundResources({ runStartupBuild = false } = {}) {
|
||||
if (!indexerWorker) indexerWorker = createWorkerBuildIndex();
|
||||
openDb();
|
||||
if (!indexerService) {
|
||||
const service = startIndexerService({ buildOnStart: false });
|
||||
if (runStartupBuild) service.runBuildNow('startup');
|
||||
}
|
||||
if (!obeliskWatcher) startObeliskWatcher();
|
||||
}
|
||||
|
||||
async function stopIndexerServiceAndWait() {
|
||||
const service = indexerService;
|
||||
if (!service) return;
|
||||
service.stop();
|
||||
if (typeof service.idle === 'function') await service.idle();
|
||||
if (indexerService === service) indexerService = null;
|
||||
}
|
||||
|
||||
async function stopBackgroundResources({ stopWorker = false } = {}) {
|
||||
await stopIndexerServiceAndWait();
|
||||
if (stopWorker && indexerWorker) {
|
||||
indexerWorker.stop();
|
||||
indexerWorker = null;
|
||||
}
|
||||
if (obeliskWatcher) {
|
||||
const watcher = obeliskWatcher;
|
||||
obeliskWatcher = null;
|
||||
if (typeof watcher.close === 'function') await Promise.resolve(watcher.close());
|
||||
}
|
||||
closeDb();
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const isDev = process.argv.includes('--dev');
|
||||
const shouldOpenDevTools = process.argv.includes('--devtools');
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
@@ -54,6 +145,7 @@ function createWindow() {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
devTools: isDev || shouldOpenDevTools,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -64,10 +156,11 @@ function createWindow() {
|
||||
}
|
||||
});
|
||||
|
||||
const isDev = process.argv.includes('--dev');
|
||||
if (isDev) {
|
||||
win.loadURL('http://localhost:5173');
|
||||
win.webContents.openDevTools();
|
||||
if (shouldOpenDevTools) {
|
||||
win.webContents.openDevTools();
|
||||
}
|
||||
} else {
|
||||
win.loadFile(path.join(__dirname, 'dist-renderer', 'index.html'));
|
||||
}
|
||||
@@ -78,6 +171,7 @@ const RECAP_DIR = path.join(OBELISK_DIR, 'recap');
|
||||
let obeliskWatcher = null;
|
||||
|
||||
function startObeliskWatcher() {
|
||||
if (obeliskWatcher) return obeliskWatcher;
|
||||
const chokidar = require('chokidar');
|
||||
if (!fs.existsSync(OBELISK_DIR)) {
|
||||
fs.mkdirSync(OBELISK_DIR, { recursive: true });
|
||||
@@ -94,6 +188,7 @@ function startObeliskWatcher() {
|
||||
obeliskWatcher.on('add', onObeliskChange);
|
||||
obeliskWatcher.on('change', onObeliskChange);
|
||||
obeliskWatcher.on('unlink', onObeliskChange);
|
||||
return obeliskWatcher;
|
||||
}
|
||||
|
||||
function onObeliskChange(filePath) {
|
||||
@@ -105,25 +200,23 @@ function onObeliskChange(filePath) {
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
indexerWorker = createWorkerBuildIndex();
|
||||
openDb();
|
||||
startBackgroundResources({ runStartupBuild: true });
|
||||
createWindow();
|
||||
startIndexerService().runBuildNow('startup');
|
||||
startObeliskWatcher();
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
startBackgroundResources({ runStartupBuild: true });
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
if (indexerService) indexerService.stop();
|
||||
if (indexerWorker) indexerWorker.stop();
|
||||
if (obeliskWatcher) obeliskWatcher.close();
|
||||
void stopBackgroundResources({ stopWorker: true });
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (db) db.close();
|
||||
void stopBackgroundResources({ stopWorker: true });
|
||||
if (process.platform !== 'darwin') app.quit();
|
||||
});
|
||||
|
||||
@@ -366,7 +459,7 @@ async function createExportCapture(parentWin, query) {
|
||||
: `file://${path.join(__dirname, 'dist-renderer', 'index.html')}#/recap-export?${query}`;
|
||||
|
||||
await exportWin.loadURL(url);
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
await waitForExportReady(exportWin.webContents);
|
||||
|
||||
const image = await exportWin.webContents.capturePage({
|
||||
x: 0, y: 0, width: EXPORT_WIDTH, height: EXPORT_HEIGHT,
|
||||
@@ -375,10 +468,22 @@ async function createExportCapture(parentWin, query) {
|
||||
return image;
|
||||
}
|
||||
|
||||
ipcMain.handle('capture:export', async (event, { cardIdx, archetype }) => {
|
||||
async function waitForExportReady(webContents, timeoutMs = 2500) {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
try {
|
||||
const ready = await webContents.executeJavaScript('window.__OBELISK_RECAP_EXPORT_READY__ === true', true);
|
||||
if (ready) return true;
|
||||
} catch {}
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
ipcMain.handle('capture:export', async (event, { cardIdx, archetype, filename } = {}) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
if (!win) return null;
|
||||
const query = `card=${cardIdx}&arch=${archetype}`;
|
||||
const query = buildRecapExportQuery({ cardIdx, archetype, filename });
|
||||
const image = await createExportCapture(win, query);
|
||||
const { filePath } = await dialog.showSaveDialog(win, {
|
||||
defaultPath: `obelisk-recap-${cardIdx + 1}.png`,
|
||||
@@ -389,10 +494,10 @@ ipcMain.handle('capture:export', async (event, { cardIdx, archetype }) => {
|
||||
return filePath;
|
||||
});
|
||||
|
||||
ipcMain.handle('capture:copy', async (event, { cardIdx, archetype }) => {
|
||||
ipcMain.handle('capture:copy', async (event, { cardIdx, archetype, filename } = {}) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
if (!win) return false;
|
||||
const query = `card=${cardIdx}&arch=${archetype}`;
|
||||
const query = buildRecapExportQuery({ cardIdx, archetype, filename });
|
||||
const image = await createExportCapture(win, query);
|
||||
clipboard.writeImage(image);
|
||||
return true;
|
||||
@@ -415,3 +520,117 @@ ipcMain.handle('recap:read', (_, filename) => {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
} catch { return null; }
|
||||
});
|
||||
|
||||
// --- Settings ---
|
||||
|
||||
const SETTINGS_PATH = path.join(OBELISK_DIR, 'settings.json');
|
||||
|
||||
function loadPersistedSettings() {
|
||||
try {
|
||||
if (fs.existsSync(SETTINGS_PATH)) return JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf-8'));
|
||||
} catch {}
|
||||
return {};
|
||||
}
|
||||
|
||||
function savePersistedSettings(settings) {
|
||||
if (!fs.existsSync(OBELISK_DIR)) fs.mkdirSync(OBELISK_DIR, { recursive: true });
|
||||
fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2));
|
||||
}
|
||||
|
||||
ipcMain.handle('settings:get', () => {
|
||||
const persisted = loadPersistedSettings();
|
||||
const { claudeDir, dbPath: dbFile } = getPathsForClaudeDir(persisted.claudeDir || DEFAULT_CLAUDE_DIR);
|
||||
const recapDir = persisted.recapDir || RECAP_DIR;
|
||||
const exists = fs.existsSync(claudeDir);
|
||||
let sessionCount = 0;
|
||||
let memoryCount = 0;
|
||||
let lastIndexed = '';
|
||||
|
||||
if (db) {
|
||||
try {
|
||||
sessionCount = db.prepare('SELECT COUNT(*) as c FROM sessions').get()?.c || 0;
|
||||
memoryCount = db.prepare('SELECT COUNT(*) as c FROM memories WHERE deleted_at IS NULL').get()?.c || 0;
|
||||
const latest = db.prepare('SELECT MAX(started_at) as t FROM sessions').get();
|
||||
lastIndexed = latest?.t || '';
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return {
|
||||
claudeDir,
|
||||
dbPath: dbFile,
|
||||
recapDir,
|
||||
autoRefresh: persisted.autoRefresh !== false,
|
||||
sessionCount,
|
||||
memoryCount,
|
||||
lastIndexed,
|
||||
status: exists ? 'ok' : 'error',
|
||||
statusText: exists ? 'Connected' : 'Folder not found',
|
||||
};
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:set', async (_, key, value) => {
|
||||
const persisted = loadPersistedSettings();
|
||||
if (value === null) {
|
||||
delete persisted[key];
|
||||
} else {
|
||||
persisted[key] = value;
|
||||
}
|
||||
savePersistedSettings(persisted);
|
||||
|
||||
if (key === 'autoRefresh') {
|
||||
if (value === false && indexerService) {
|
||||
await stopIndexerServiceAndWait();
|
||||
} else if (value !== false && indexerService) {
|
||||
await stopIndexerServiceAndWait();
|
||||
startIndexerService({ buildOnStart: false });
|
||||
}
|
||||
}
|
||||
|
||||
if (key === 'claudeDir') {
|
||||
await stopIndexerServiceAndWait();
|
||||
openDb();
|
||||
if (persisted.autoRefresh !== false) {
|
||||
startIndexerService({ buildOnStart: true });
|
||||
}
|
||||
notifyIndexUpdated();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:browseFolder', async (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
const { filePaths } = await dialog.showOpenDialog(win, {
|
||||
properties: ['openDirectory'],
|
||||
title: 'Select Claude Code data folder',
|
||||
});
|
||||
if (filePaths && filePaths[0]) return filePaths[0];
|
||||
return null;
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:revealPath', (_, p) => {
|
||||
const { shell } = require('electron');
|
||||
if (fs.existsSync(p)) shell.showItemInFolder(p);
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:rebuildIndex', async () => {
|
||||
if (!indexerWorker) return null;
|
||||
const persisted = loadPersistedSettings();
|
||||
const paths = getPathsForClaudeDir(persisted.claudeDir || DEFAULT_CLAUDE_DIR);
|
||||
const shouldRestartWatcher = persisted.autoRefresh !== false;
|
||||
await stopIndexerServiceAndWait();
|
||||
closeDb();
|
||||
try {
|
||||
const result = await indexerWorker.buildIndex({
|
||||
reason: 'manual-rebuild',
|
||||
force: true,
|
||||
claudeDir: paths.claudeDir,
|
||||
projectsDir: paths.projectsDir,
|
||||
dbPath: paths.dbPath,
|
||||
});
|
||||
openDb(paths.dbPath);
|
||||
notifyIndexUpdated(result);
|
||||
return result;
|
||||
} finally {
|
||||
if (shouldRestartWatcher) startIndexerService({ buildOnStart: false });
|
||||
}
|
||||
});
|
||||
|
||||
+31
-6
@@ -8,26 +8,51 @@
|
||||
"dev": "electron . --dev",
|
||||
"dev:renderer": "vite renderer",
|
||||
"build:renderer": "vite build renderer",
|
||||
"build": "electron-builder"
|
||||
"build": "npm run build:renderer && electron-builder",
|
||||
"pack": "npm run build:renderer && electron-builder --dir",
|
||||
"dist": "npm run build:renderer && electron-builder --mac --win --linux",
|
||||
"dist:mac": "npm run build:renderer && electron-builder --mac",
|
||||
"dist:win": "npm run build:renderer && electron-builder --win",
|
||||
"dist:linux": "npm run build:renderer && electron-builder --linux"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.obelisk.app",
|
||||
"productName": "Obelisk",
|
||||
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"mac": {
|
||||
"target": "dmg",
|
||||
"icon": "build/icon.icns",
|
||||
"target": [
|
||||
"dmg",
|
||||
"zip"
|
||||
],
|
||||
"category": "public.app-category.developer-tools"
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
"nsis",
|
||||
"portable"
|
||||
]
|
||||
},
|
||||
"linux": {
|
||||
"icon": "build/icon.png",
|
||||
"target": [
|
||||
"AppImage",
|
||||
"deb"
|
||||
],
|
||||
"category": "Development"
|
||||
},
|
||||
"files": [
|
||||
"main.js",
|
||||
"preload.js",
|
||||
"recap-capture-query.js",
|
||||
"indexer.js",
|
||||
"indexer-service.js",
|
||||
"indexer-worker.js",
|
||||
"indexer-worker-client.js",
|
||||
"dist-renderer/**/*",
|
||||
"node_modules/better-sqlite3/**/*",
|
||||
"node_modules/chokidar/**/*",
|
||||
"node_modules/readdirp/**/*"
|
||||
"dist-renderer/**/*"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
|
||||
+11
-1
@@ -20,10 +20,15 @@ contextBridge.exposeInMainWorld('obelisk', {
|
||||
getStats: () => ipcRenderer.invoke('db:getStats'),
|
||||
getUsageStats: () => ipcRenderer.invoke('db:getUsageStats'),
|
||||
onIndexUpdated: (callback) => {
|
||||
const listener = () => callback();
|
||||
const listener = (_, payload) => callback(payload);
|
||||
ipcRenderer.on('obelisk:index-updated', listener);
|
||||
return () => ipcRenderer.removeListener('obelisk:index-updated', listener);
|
||||
},
|
||||
onSessionUpdated: (callback) => {
|
||||
const listener = (_, payload) => callback(payload);
|
||||
ipcRenderer.on('obelisk:session-updated', listener);
|
||||
return () => ipcRenderer.removeListener('obelisk:session-updated', listener);
|
||||
},
|
||||
captureExport: (opts) => ipcRenderer.invoke('capture:export', opts),
|
||||
copyImage: (opts) => ipcRenderer.invoke('capture:copy', opts),
|
||||
recapList: () => ipcRenderer.invoke('recap:list'),
|
||||
@@ -33,4 +38,9 @@ contextBridge.exposeInMainWorld('obelisk', {
|
||||
ipcRenderer.on('obelisk:recap-updated', listener);
|
||||
return () => ipcRenderer.removeListener('obelisk:recap-updated', listener);
|
||||
},
|
||||
getSettings: () => ipcRenderer.invoke('settings:get'),
|
||||
browseFolder: () => ipcRenderer.invoke('settings:browseFolder'),
|
||||
setSetting: (key, value) => ipcRenderer.invoke('settings:set', key, value),
|
||||
revealPath: (p) => ipcRenderer.invoke('settings:revealPath', p),
|
||||
rebuildIndex: () => ipcRenderer.invoke('settings:rebuildIndex'),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
const path = require('path');
|
||||
|
||||
function cleanRecapFilename(filename) {
|
||||
if (!filename) return '';
|
||||
return path.basename(String(filename));
|
||||
}
|
||||
|
||||
function buildRecapExportQuery({ cardIdx = 0, archetype = '', filename = '' } = {}) {
|
||||
const params = new URLSearchParams();
|
||||
const cardNumber = Number(cardIdx);
|
||||
params.set('card', Number.isFinite(cardNumber) ? String(cardNumber) : '0');
|
||||
if (archetype) params.set('arch', String(archetype));
|
||||
const safeFilename = cleanRecapFilename(filename);
|
||||
if (safeFilename) params.set('file', safeFilename);
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
module.exports = { buildRecapExportQuery, cleanRecapFilename };
|
||||
+35
-32
@@ -14,6 +14,7 @@ import {
|
||||
toggleIncludeMessageBodies
|
||||
} from './store.js';
|
||||
import { formatProjectLabel } from './utils.js';
|
||||
import { buildSidebarProjects } from './sidebar-projects.mjs';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
@@ -30,38 +31,24 @@ const currentRouteType = computed(() => {
|
||||
if (name === 'SessionList' || name === 'SessionDetail' || name === 'SubagentDetail') return 'sessions';
|
||||
if (name === 'Activity') return 'activity';
|
||||
if (name === 'Recap' || name === 'RecapDetail') return 'recap';
|
||||
if (name === 'Settings') return 'settings';
|
||||
return 'memory';
|
||||
});
|
||||
|
||||
const sidebarProjects = computed(() => {
|
||||
const items = currentRouteType.value === 'sessions' ? state.sessions : state.memories;
|
||||
const filtered = items.filter(item => {
|
||||
if (currentRouteType.value === '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 => formatProjectLabel(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
|
||||
}));
|
||||
const sidebarProjectsForCurrentScope = (search = state.projectSearch) => buildSidebarProjects({
|
||||
routeType: currentRouteType.value,
|
||||
sessions: state.sessions,
|
||||
memories: state.memories,
|
||||
projects: state.projects,
|
||||
view: state.view,
|
||||
search,
|
||||
formatProjectLabel,
|
||||
});
|
||||
|
||||
const sidebarProjects = computed(() => sidebarProjectsForCurrentScope());
|
||||
|
||||
const totalProjectCount = computed(() => {
|
||||
const items = currentRouteType.value === 'sessions' ? state.sessions : state.memories;
|
||||
return new Set(items.map(i => i.project).filter(Boolean)).size;
|
||||
return sidebarProjectsForCurrentScope('').length;
|
||||
});
|
||||
|
||||
// --- Toolbar visibility ---
|
||||
@@ -86,6 +73,8 @@ const windowTitle = computed(() => {
|
||||
scopeText = 'Recap';
|
||||
} else if (route.name === 'RecapDetail') {
|
||||
scopeText = `Recap · ${route.params.id}`;
|
||||
} else if (route.name === 'Settings') {
|
||||
scopeText = 'Settings';
|
||||
} else if (route.name?.startsWith('Session')) {
|
||||
if (route.name === 'SessionDetail' || route.name === 'SubagentDetail') {
|
||||
const s = state.sessions.find(x => x.id === route.params.id);
|
||||
@@ -220,7 +209,7 @@ provide('recapGenerateOpen', recapGenerateOpen);
|
||||
<div class="sidebar-section-title"><span>Library</span></div>
|
||||
<button
|
||||
class="sidebar-item"
|
||||
:class="{ active: state.route === 'sessions' && state.projectFilter === 'all' }"
|
||||
:class="{ active: currentRouteType === 'sessions' && state.projectFilter === 'all' }"
|
||||
@click="handleSidebarRoute('sessions')"
|
||||
>
|
||||
<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
@@ -324,6 +313,24 @@ provide('recapGenerateOpen', recapGenerateOpen);
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section sidebar-bottom">
|
||||
<button
|
||||
class="sidebar-item"
|
||||
:class="{ active: route.name === 'Settings' }"
|
||||
@click="router.push('/settings')"
|
||||
>
|
||||
<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
||||
<line x1="3" y1="4" x2="13" y2="4"/>
|
||||
<line x1="3" y1="8" x2="13" y2="8"/>
|
||||
<line x1="3" y1="12" x2="13" y2="12"/>
|
||||
<circle cx="9.5" cy="4" r="1.7" fill="var(--bg)"/>
|
||||
<circle cx="5.5" cy="8" r="1.7" fill="var(--bg)"/>
|
||||
<circle cx="11" cy="12" r="1.7" fill="var(--bg)"/>
|
||||
</svg>
|
||||
<span class="label">Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
@@ -374,6 +381,7 @@ provide('recapGenerateOpen', recapGenerateOpen);
|
||||
</template>
|
||||
<span v-if="route.name === 'Activity'" class="crumb terminal">Activity</span>
|
||||
<span v-if="route.name === 'Recap'" class="crumb terminal">Recap</span>
|
||||
<span v-if="route.name === 'Settings'" class="crumb terminal">Settings</span>
|
||||
<router-link v-if="route.name === 'RecapDetail'" class="crumb" to="/recap">Recap</router-link>
|
||||
<template v-if="route.name === 'RecapDetail'">
|
||||
<span class="crumb-sep">/</span>
|
||||
@@ -433,10 +441,5 @@ provide('recapGenerateOpen', recapGenerateOpen);
|
||||
</router-view>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="statusbar">
|
||||
<div class="status-left" id="status-left"></div>
|
||||
<div class="status-right" id="status-right"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
headline: String,
|
||||
receipts: Array,
|
||||
stats: Array,
|
||||
mostSaidPhrase: String,
|
||||
signoff: String,
|
||||
@@ -21,8 +22,8 @@ defineProps({
|
||||
<div class="closing-body">
|
||||
<div class="closing-headline">{{ headline }}</div>
|
||||
|
||||
<div class="closing-stats" v-if="stats">
|
||||
<div v-for="(line, i) in stats" :key="i">{{ line }}</div>
|
||||
<div class="closing-stats" v-if="receipts || stats">
|
||||
<div v-for="(line, i) in (receipts || stats || [])" :key="i">{{ line }}</div>
|
||||
</div>
|
||||
|
||||
<div class="closing-quote" v-if="mostSaidPhrase">
|
||||
|
||||
@@ -6,6 +6,7 @@ const props = defineProps({
|
||||
archKey: String,
|
||||
badge: String,
|
||||
title: String,
|
||||
claim: String,
|
||||
subtitle: String,
|
||||
activity: Array,
|
||||
footer: String,
|
||||
@@ -28,7 +29,7 @@ const sealSvg = computed(() => CORNER_SEALS[props.archKey] || '');
|
||||
<div class="cover-seal-corner" v-html="sealSvg"></div>
|
||||
<div class="cover-body">
|
||||
<div class="cover-archetype">{{ title }}</div>
|
||||
<div class="cover-subtitle">{{ subtitle }}</div>
|
||||
<div class="cover-subtitle">{{ claim || subtitle }}</div>
|
||||
|
||||
<div class="cover-activity">
|
||||
<div class="cover-activity-row">
|
||||
|
||||
@@ -24,7 +24,7 @@ defineProps({
|
||||
<div class="tl-day">{{ item.day }}</div>
|
||||
<div class="tl-prompt">{{ item.prompt }}</div>
|
||||
<div class="tl-outcome">
|
||||
<span>{{ item.outcome }}</span>
|
||||
<span>{{ item.turn || item.outcome }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: String,
|
||||
voiceLines: Array,
|
||||
observations: Array,
|
||||
meter: Object,
|
||||
quote: Object,
|
||||
@@ -23,7 +24,7 @@ defineProps({
|
||||
<div class="vibe-section">
|
||||
<div class="section-label">Things you kept saying</div>
|
||||
<div class="vibe-observations">
|
||||
<div v-for="(obs, i) in observations" :key="i" class="vibe-obs">
|
||||
<div v-for="(obs, i) in (voiceLines || observations || [])" :key="i" class="vibe-obs">
|
||||
<div class="vibe-obs-text">{{ obs.text }}</div>
|
||||
<div class="vibe-obs-meta">
|
||||
<template v-if="obs.count">×{{ obs.count }} · </template>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: String,
|
||||
deck: String,
|
||||
summary: String,
|
||||
stats: String,
|
||||
items: Array,
|
||||
@@ -19,7 +20,7 @@ defineProps({
|
||||
<span class="slot">{{ String(idx).padStart(2, '0') }} · {{ String(total).padStart(2, '0') }}</span>
|
||||
</div>
|
||||
<div class="card-title">{{ title }}</div>
|
||||
<div class="card-deck-text" v-if="summary">{{ summary }}</div>
|
||||
<div class="card-deck-text" v-if="deck || summary">{{ deck || summary }}</div>
|
||||
|
||||
<div class="wf-content">
|
||||
<div class="wf-stats" v-if="stats">{{ stats }}</div>
|
||||
@@ -27,7 +28,7 @@ defineProps({
|
||||
<div class="wf-list">
|
||||
<div v-for="(item, i) in items" :key="i" class="wf-item">
|
||||
<div class="wf-item-name">{{ item.name }}</div>
|
||||
<div class="wf-item-outcome">{{ item.outcome }}</div>
|
||||
<div class="wf-item-reaction">{{ item.reaction || item.outcome }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -67,12 +68,12 @@ defineProps({
|
||||
font-family: var(--font-mono); font-size: 13px; font-weight: 500;
|
||||
color: var(--fg); letter-spacing: -0.005em;
|
||||
}
|
||||
.wf-item-outcome {
|
||||
.wf-item-reaction {
|
||||
font-family: var(--font-serif); font-style: italic;
|
||||
font-size: 16px; color: var(--fg-2); line-height: 1.4;
|
||||
}
|
||||
.wf-item-outcome::before { content: '\201C'; color: var(--muted-2); }
|
||||
.wf-item-outcome::after { content: '\201D'; color: var(--muted-2); }
|
||||
.wf-item-reaction::before { content: '\201C'; color: var(--muted-2); }
|
||||
.wf-item-reaction::after { content: '\201D'; color: var(--muted-2); }
|
||||
|
||||
.wf-verdict {
|
||||
margin-top: auto; padding: 16px 18px;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import router from './router.js';
|
||||
import { loadInitialData } from './data.js';
|
||||
import { noteSessionUpdated, sessionLiveState } from './session-live.mjs';
|
||||
|
||||
// Import all original CSS globally
|
||||
import '../styles/base.css';
|
||||
@@ -11,7 +12,6 @@ import '../styles/sidebar.css';
|
||||
import '../styles/toolbar.css';
|
||||
import '../styles/list.css';
|
||||
import '../styles/detail.css';
|
||||
import '../styles/statusbar.css';
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
@@ -33,4 +33,10 @@ window.obelisk?.onIndexUpdated?.(() => {
|
||||
loadInitialData();
|
||||
});
|
||||
|
||||
window.obelisk?.onSessionUpdated?.(({ sessionId } = {}) => {
|
||||
const route = router.currentRoute.value;
|
||||
const currentSessionId = route.name === 'SessionDetail' ? String(route.params.id || '') : null;
|
||||
noteSessionUpdated(sessionLiveState, sessionId, currentSessionId);
|
||||
});
|
||||
|
||||
app.mount('#app');
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"persona": {
|
||||
"archetype": "architect",
|
||||
"title": "The Architect",
|
||||
"subtitle": "从零设计了一个完整的 memory 系统。",
|
||||
"claim": "从零设计了一个完整的 memory 系统。",
|
||||
"tone": "affectionate_teasing"
|
||||
},
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"type": "cover",
|
||||
"badge": "Week 24",
|
||||
"title": "The Architect",
|
||||
"subtitle": "从零设计了一个完整的 memory 系统。",
|
||||
"claim": "从零设计了一个完整的 memory 系统。",
|
||||
"activity": [0.85, 0.92, 0.72, 0.45, 0.88, 0.30, 0],
|
||||
"footer": "12 sessions · 2.4M tokens"
|
||||
},
|
||||
@@ -47,17 +47,17 @@
|
||||
"type": "thinking_path",
|
||||
"title": "Five questions, five turns.",
|
||||
"items": [
|
||||
{ "day": "Mon", "prompt": "为什么要把 session 编译成 wiki?", "outcome": "raw SQLite, no wiki" },
|
||||
{ "day": "Tue", "prompt": "buildWhere 是什么", "outcome": "unified filter opts, not DSL" },
|
||||
{ "day": "Wed", "prompt": "failures() 90% 误报", "outcome": "is_error in JSONL" },
|
||||
{ "day": "Thu", "prompt": "memory 层需要清理机制吗", "outcome": "soft-delete, human-only" },
|
||||
{ "day": "Fri", "prompt": "热力图不选中默认显示本月", "outcome": "GitHub-style activity timeline" }
|
||||
{ "day": "Mon", "prompt": "为什么要把 session 编译成 wiki?", "turn": "raw SQLite, no wiki" },
|
||||
{ "day": "Tue", "prompt": "buildWhere 是什么", "turn": "unified filter opts, not DSL" },
|
||||
{ "day": "Wed", "prompt": "failures() 90% 误报", "turn": "is_error in JSONL" },
|
||||
{ "day": "Thu", "prompt": "memory 层需要清理机制吗", "turn": "soft-delete, human-only" },
|
||||
{ "day": "Fri", "prompt": "热力图不选中默认显示本月", "turn": "GitHub-style activity timeline" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "vibe",
|
||||
"title": "A short character study.",
|
||||
"observations": [
|
||||
"voice_lines": [
|
||||
{ "label": "catchphrase", "text": "这太丑了", "count": 4 },
|
||||
{ "label": "highest praise", "text": "可以" },
|
||||
{ "label": "late night", "text": "你在干什么", "time": "02:47 AM" }
|
||||
@@ -75,19 +75,19 @@
|
||||
{
|
||||
"type": "workflow",
|
||||
"title": "Three workflows. Forty-two agents.",
|
||||
"summary": "你召唤了机器军团。结果各有不同。",
|
||||
"deck": "你召唤了机器军团。结果各有不同。",
|
||||
"stats": "3 workflows · 42 agents",
|
||||
"items": [
|
||||
{ "name": "hono-plugin-review", "outcome": "完美" },
|
||||
{ "name": "vue-migration", "outcome": "你这页面完全和之前的不一样…" },
|
||||
{ "name": "split-render-js", "outcome": "可以" }
|
||||
{ "name": "hono-plugin-review", "reaction": "完美" },
|
||||
{ "name": "vue-migration", "reaction": "你这页面完全和之前的不一样…" },
|
||||
{ "name": "split-render-js", "reaction": "可以" }
|
||||
],
|
||||
"verdict": "Mostly tolerated."
|
||||
},
|
||||
{
|
||||
"type": "closing",
|
||||
"headline": "19 days",
|
||||
"stats": ["847 messages exchanged", "12 corrections · 47 approvals"],
|
||||
"receipts": ["847 messages exchanged", "12 corrections · 47 approvals"],
|
||||
"most_said_phrase": "好的开始做吧",
|
||||
"signoff": "See you next week."
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ const Activity = () => import('./views/Activity.vue');
|
||||
const Recap = () => import('./views/RecapList.vue');
|
||||
const RecapDetail = () => import('./views/RecapDetail.vue');
|
||||
const RecapExport = () => import('./views/RecapExport.vue');
|
||||
const Settings = () => import('./views/Settings.vue');
|
||||
|
||||
const routes = [
|
||||
{
|
||||
@@ -65,6 +66,11 @@ const routes = [
|
||||
name: 'RecapExport',
|
||||
component: RecapExport
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'Settings',
|
||||
component: Settings
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/memory'
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
export function createSessionLiveState() {
|
||||
return {
|
||||
dirtySessions: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
export const sessionLiveState = createSessionLiveState();
|
||||
|
||||
export function noteSessionUpdated(live, sessionId, currentSessionId = null) {
|
||||
if (!sessionId) return { reload: false, sessionId: null };
|
||||
if (sessionId === currentSessionId) {
|
||||
live.dirtySessions.delete(sessionId);
|
||||
return { reload: true, sessionId };
|
||||
}
|
||||
live.dirtySessions.add(sessionId);
|
||||
return { reload: false, sessionId };
|
||||
}
|
||||
|
||||
export function clearSessionDirty(sessionId, live = sessionLiveState) {
|
||||
if (sessionId) live.dirtySessions.delete(sessionId);
|
||||
}
|
||||
|
||||
export function consumeSessionDirty(live, sessionId) {
|
||||
if (!sessionId || !live.dirtySessions.has(sessionId)) return false;
|
||||
live.dirtySessions.delete(sessionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function consumeGlobalSessionDirty(sessionId) {
|
||||
return consumeSessionDirty(sessionLiveState, sessionId);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
function countByProject(items) {
|
||||
const counts = {};
|
||||
for (const item of items) {
|
||||
if (item.project) counts[item.project] = (counts[item.project] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function orderedProjectSlugs(projectCounts, projects, formatProjectLabel) {
|
||||
const seen = new Set();
|
||||
const ordered = [];
|
||||
|
||||
for (const project of projects || []) {
|
||||
const slug = project?.project;
|
||||
if (!slug || !projectCounts[slug] || seen.has(slug)) continue;
|
||||
seen.add(slug);
|
||||
ordered.push(slug);
|
||||
}
|
||||
|
||||
const missing = Object.keys(projectCounts)
|
||||
.filter(slug => !seen.has(slug))
|
||||
.sort((a, b) => formatProjectLabel(a).localeCompare(formatProjectLabel(b)));
|
||||
|
||||
return ordered.concat(missing);
|
||||
}
|
||||
|
||||
export function buildSidebarProjects({
|
||||
routeType,
|
||||
sessions = [],
|
||||
memories = [],
|
||||
projects = [],
|
||||
view = 'active',
|
||||
search = '',
|
||||
formatProjectLabel = slug => slug,
|
||||
} = {}) {
|
||||
const items = routeType === 'sessions'
|
||||
? sessions
|
||||
: memories.filter(memory => view === 'archived' ? memory.archived : !memory.archived);
|
||||
const counts = countByProject(items);
|
||||
const q = search.trim().toLowerCase();
|
||||
|
||||
return orderedProjectSlugs(counts, projects, formatProjectLabel)
|
||||
.filter(slug => {
|
||||
if (!q) return true;
|
||||
return formatProjectLabel(slug).toLowerCase().includes(q);
|
||||
})
|
||||
.map(slug => ({
|
||||
slug,
|
||||
label: formatProjectLabel(slug),
|
||||
count: counts[slug] || 0,
|
||||
}));
|
||||
}
|
||||
@@ -18,6 +18,13 @@ const tooltip = reactive({ text: '', show: false, x: 0, y: 0 });
|
||||
|
||||
// --- Constants ---
|
||||
const DAY_MS = 86400000;
|
||||
|
||||
function localDateStr(d) {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
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'];
|
||||
|
||||
@@ -111,10 +118,13 @@ const longestStreak = computed(() => {
|
||||
// --- Computed: weekly chart ---
|
||||
const weeklyBars = computed(() => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
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);
|
||||
// Align to Monday (ISO week start)
|
||||
const dayOfWeek = startDate.getDay(); // 0=Sun, 1=Mon...
|
||||
const daysUntilMonday = dayOfWeek === 0 ? 1 : (dayOfWeek === 1 ? 0 : 8 - dayOfWeek);
|
||||
startDate = new Date(startDate.getTime() + daysUntilMonday * DAY_MS);
|
||||
|
||||
const dailyMap = {};
|
||||
for (const d of usageData.daily) dailyMap[d.day] = d.tokens;
|
||||
@@ -130,7 +140,7 @@ const weeklyBars = computed(() => {
|
||||
const key = date.toISOString().slice(0, 10);
|
||||
tokens += dailyMap[key] || 0;
|
||||
}
|
||||
weeks.push({ weekStart, tokens, weekKey: weekStart.toISOString().slice(0, 10) });
|
||||
weeks.push({ weekStart, tokens, weekKey: localDateStr(weekStart) });
|
||||
}
|
||||
|
||||
const maxVal = Math.max(...weeks.map(w => w.tokens), 1);
|
||||
|
||||
@@ -56,7 +56,19 @@ function statusGlyphs(status) {
|
||||
}
|
||||
|
||||
function pathHTML(m) {
|
||||
return highlightPlain(m.path || '', state.query.trim());
|
||||
const full = m.path || '';
|
||||
const filename = full.split('/').pop() || full;
|
||||
return highlightPlain(filename, state.query.trim());
|
||||
}
|
||||
|
||||
function relativePath(m) {
|
||||
const full = m.path || '';
|
||||
if (!m.project) return full;
|
||||
const projectDir = '/' + m.project.replace(/^-/, '').replace(/-/g, '/');
|
||||
if (full.startsWith(projectDir)) {
|
||||
return full.slice(projectDir.length + 1);
|
||||
}
|
||||
return full.split('/').slice(-3).join('/');
|
||||
}
|
||||
|
||||
function summaryHTML(m) {
|
||||
@@ -301,7 +313,7 @@ onUnmounted(() => {
|
||||
<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-path">{{ relativePath(detailMemory) }}</div>
|
||||
<div class="detail-summary">{{ detailMemory.summary }}</div>
|
||||
<div class="detail-meta">
|
||||
<span>{{ fmtRelative(detailMemory.ts) }}</span>
|
||||
|
||||
@@ -15,6 +15,7 @@ const route = useRoute();
|
||||
const recapData = ref(mockJson);
|
||||
const currentArch = ref(mockJson.persona.archetype);
|
||||
const currentIdx = ref(0);
|
||||
const recapFilename = computed(() => String(route.params.id || ''));
|
||||
|
||||
const palette = computed(() => PALETTES[currentArch.value] || PALETTES.architect);
|
||||
const CARD_LABELS = ['Cover', 'Path', 'Vibe', 'Workflow', 'Closing'];
|
||||
@@ -59,10 +60,18 @@ onUnmounted(() => { unsubRecap?.(); });
|
||||
watch(() => route.params.id, (id) => { if (id) loadRecap(id); });
|
||||
|
||||
async function exportImage() {
|
||||
await window.obelisk.captureExport({ cardIdx: currentIdx.value, archetype: currentArch.value });
|
||||
await window.obelisk.captureExport({
|
||||
cardIdx: currentIdx.value,
|
||||
archetype: currentArch.value,
|
||||
filename: recapFilename.value,
|
||||
});
|
||||
}
|
||||
async function copyImage() {
|
||||
await window.obelisk.copyImage({ cardIdx: currentIdx.value, archetype: currentArch.value });
|
||||
await window.obelisk.copyImage({
|
||||
cardIdx: currentIdx.value,
|
||||
archetype: currentArch.value,
|
||||
filename: recapFilename.value,
|
||||
});
|
||||
}
|
||||
|
||||
function goTo(idx) {
|
||||
@@ -91,6 +100,7 @@ function onKeydown(e) {
|
||||
:arch-key="currentArch"
|
||||
:badge="cover.badge"
|
||||
:title="cover.title"
|
||||
:claim="cover.claim || cover.subtitle"
|
||||
:subtitle="cover.subtitle"
|
||||
:activity="cover.activity"
|
||||
:footer="cover.footer"
|
||||
@@ -107,6 +117,7 @@ function onKeydown(e) {
|
||||
<div class="card-slot" :class="{ active: currentIdx === 2, prev: currentIdx > 2 }">
|
||||
<VibeCard
|
||||
:title="vibe.title"
|
||||
:voice-lines="vibe.voice_lines || vibe.observations"
|
||||
:observations="vibe.observations"
|
||||
:meter="vibe.meter"
|
||||
:quote="vibe.quote"
|
||||
@@ -116,6 +127,7 @@ function onKeydown(e) {
|
||||
<div class="card-slot" :class="{ active: currentIdx === 3, prev: currentIdx > 3 }">
|
||||
<WorkflowCard
|
||||
:title="workflow.title"
|
||||
:deck="workflow.deck || workflow.summary"
|
||||
:summary="workflow.summary"
|
||||
:stats="workflow.stats"
|
||||
:items="workflow.items"
|
||||
@@ -126,6 +138,7 @@ function onKeydown(e) {
|
||||
<div class="card-slot" :class="{ active: currentIdx === 4, prev: currentIdx > 4 }">
|
||||
<ClosingCard
|
||||
:headline="closing.headline"
|
||||
:receipts="closing.receipts || closing.stats"
|
||||
:stats="closing.stats"
|
||||
:most-said-phrase="closing.most_said_phrase"
|
||||
:signoff="closing.signoff"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import CoverCard from '../components/recap/CoverCard.vue';
|
||||
import PathCard from '../components/recap/PathCard.vue';
|
||||
@@ -10,15 +10,19 @@ import { PALETTES, ARCH_KEYS } from '../components/recap/archetypes.js';
|
||||
import recapJson from '../mock/recap-2026-W24.json';
|
||||
|
||||
const route = useRoute();
|
||||
const recapData = ref(recapJson);
|
||||
window.__OBELISK_RECAP_EXPORT_READY__ = false;
|
||||
const cardIdx = computed(() => parseInt(route.query.card) || 0);
|
||||
const archKey = computed(() => route.query.arch || recapJson.persona.archetype);
|
||||
const exportFilename = computed(() => typeof route.query.file === 'string' ? route.query.file : '');
|
||||
const archKey = computed(() => route.query.arch || recapData.value.persona?.archetype || recapJson.persona.archetype);
|
||||
const palette = computed(() => PALETTES[archKey.value] || PALETTES.architect);
|
||||
const total = computed(() => recapData.value.cards?.length || 5);
|
||||
|
||||
const cover = recapJson.cards[0];
|
||||
const path = recapJson.cards[1];
|
||||
const vibe = recapJson.cards[2];
|
||||
const workflow = recapJson.cards[3];
|
||||
const closing = recapJson.cards[4];
|
||||
const cover = computed(() => recapData.value.cards?.[0] || recapJson.cards[0]);
|
||||
const path = computed(() => recapData.value.cards?.[1] || recapJson.cards[1]);
|
||||
const vibe = computed(() => recapData.value.cards?.[2] || recapJson.cards[2]);
|
||||
const workflow = computed(() => recapData.value.cards?.[3] || recapJson.cards[3]);
|
||||
const closing = computed(() => recapData.value.cards?.[4] || recapJson.cards[4]);
|
||||
|
||||
const cssVars = computed(() => ({
|
||||
'--tc': palette.value.tc,
|
||||
@@ -28,33 +32,70 @@ const cssVars = computed(() => ({
|
||||
'--tg-soft': palette.value.soft,
|
||||
'--tg-edge': palette.value.soft,
|
||||
}));
|
||||
|
||||
function setExportReady(value) {
|
||||
window.__OBELISK_RECAP_EXPORT_READY__ = value;
|
||||
}
|
||||
|
||||
async function markExportReady() {
|
||||
await nextTick();
|
||||
await new Promise(resolve => requestAnimationFrame(() => resolve()));
|
||||
setExportReady(true);
|
||||
}
|
||||
|
||||
let loadSeq = 0;
|
||||
async function loadExportRecap(filename) {
|
||||
const seq = ++loadSeq;
|
||||
setExportReady(false);
|
||||
try {
|
||||
if (filename && window.obelisk?.recapRead) {
|
||||
const data = await window.obelisk.recapRead(filename);
|
||||
if (seq === loadSeq && data?.cards?.length) {
|
||||
recapData.value = data;
|
||||
} else if (seq === loadSeq) {
|
||||
recapData.value = recapJson;
|
||||
}
|
||||
} else if (seq === loadSeq) {
|
||||
recapData.value = recapJson;
|
||||
}
|
||||
} finally {
|
||||
if (seq === loadSeq) await markExportReady();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => loadExportRecap(exportFilename.value));
|
||||
watch(exportFilename, (filename) => loadExportRecap(filename));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="export-wrap" :style="cssVars">
|
||||
<CoverCard v-if="cardIdx === 0"
|
||||
:arch-key="archKey" :badge="cover.badge" :title="cover.title"
|
||||
:claim="cover.claim || cover.subtitle"
|
||||
:subtitle="cover.subtitle" :activity="cover.activity" :footer="cover.footer"
|
||||
:idx="1" :total="5"
|
||||
:idx="1" :total="total"
|
||||
/>
|
||||
<PathCard v-else-if="cardIdx === 1"
|
||||
:title="path.title" :items="path.items"
|
||||
:idx="2" :total="5"
|
||||
:idx="2" :total="total"
|
||||
/>
|
||||
<VibeCard v-else-if="cardIdx === 2"
|
||||
:title="vibe.title" :observations="vibe.observations"
|
||||
:title="vibe.title" :voice-lines="vibe.voice_lines || vibe.observations"
|
||||
:observations="vibe.observations"
|
||||
:meter="vibe.meter" :quote="vibe.quote"
|
||||
:idx="3" :total="5"
|
||||
:idx="3" :total="total"
|
||||
/>
|
||||
<WorkflowCard v-else-if="cardIdx === 3"
|
||||
:title="workflow.title" :summary="workflow.summary"
|
||||
:title="workflow.title" :deck="workflow.deck || workflow.summary"
|
||||
:summary="workflow.summary"
|
||||
:stats="workflow.stats" :items="workflow.items" :verdict="workflow.verdict"
|
||||
:idx="4" :total="5"
|
||||
:idx="4" :total="total"
|
||||
/>
|
||||
<ClosingCard v-else-if="cardIdx === 4"
|
||||
:headline="closing.headline" :stats="closing.stats"
|
||||
:headline="closing.headline" :receipts="closing.receipts || closing.stats"
|
||||
:stats="closing.stats"
|
||||
:most-said-phrase="closing.most_said_phrase" :signoff="closing.signoff"
|
||||
:idx="5" :total="5"
|
||||
:idx="5" :total="total"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { ref, computed, onMounted, onUnmounted, inject } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { PALETTES, ARCH_KEYS } from '../components/recap/archetypes.js';
|
||||
import { MINI_SEALS } from '../components/recap/seals.js';
|
||||
import { CORNER_SEALS } from '../components/recap/seals.js';
|
||||
|
||||
defineOptions({ name: 'RecapList' });
|
||||
|
||||
@@ -27,7 +27,7 @@ function glowColor(arch) {
|
||||
return PALETTES[arch]?.glow || PALETTES.architect.glow;
|
||||
}
|
||||
function sealSvg(arch) {
|
||||
return MINI_SEALS[arch] || MINI_SEALS.architect;
|
||||
return CORNER_SEALS[arch] || CORNER_SEALS.architect;
|
||||
}
|
||||
function formatDateRange(r) {
|
||||
if (!r.period) return '';
|
||||
@@ -116,7 +116,7 @@ onUnmounted(() => { unsub?.(); });
|
||||
<span>{{ formatDateRange(r) }}</span>
|
||||
</div>
|
||||
<div class="recap-archetype">{{ r.persona?.title }}</div>
|
||||
<div class="recap-subtitle">{{ r.persona?.subtitle }}</div>
|
||||
<div class="recap-subtitle">{{ r.persona?.claim || r.persona?.subtitle }}</div>
|
||||
<div class="recap-stats">
|
||||
<span>{{ r.metrics?.sessions || 0 }} sessions</span>
|
||||
<span class="sep">·</span>
|
||||
@@ -266,7 +266,7 @@ onUnmounted(() => { unsub?.(); });
|
||||
.timeline { position: relative; }
|
||||
.timeline::before {
|
||||
content: ''; position: absolute;
|
||||
left: 15px; top: 15px; bottom: 15px;
|
||||
left: 32px; top: 32px; bottom: 32px;
|
||||
width: 1px; margin-left: -0.5px;
|
||||
background: linear-gradient(to bottom,
|
||||
rgba(167,139,250,0.55) 0%, rgba(167,139,250,0.35) 8%,
|
||||
@@ -276,19 +276,19 @@ onUnmounted(() => { unsub?.(); });
|
||||
|
||||
.recap-row {
|
||||
position: relative; display: grid;
|
||||
grid-template-columns: 30px 1fr;
|
||||
column-gap: 28px; align-items: center;
|
||||
grid-template-columns: 64px 1fr;
|
||||
column-gap: 18px; align-items: center;
|
||||
padding: 12px 0; cursor: pointer;
|
||||
transition: transform 0.12s;
|
||||
}
|
||||
.recap-row:hover { transform: translateX(2px); }
|
||||
|
||||
.recap-node {
|
||||
width: 30px; height: 30px;
|
||||
width: 64px; height: 64px;
|
||||
position: relative; z-index: 2;
|
||||
}
|
||||
.recap-node::before {
|
||||
content: ''; position: absolute; inset: -3px;
|
||||
content: ''; position: absolute; inset: -2px;
|
||||
border-radius: 50%; background: var(--bg); z-index: -1;
|
||||
}
|
||||
.recap-node :deep(svg) {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, onActivated, watch } from 'vue';
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, onActivated, onDeactivated, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { state, FOLDER_SVG } from '../store.js';
|
||||
import { loadSessionDetail, isTextTruncated, loadFullText } from '../data.js';
|
||||
import { clearSessionDirty, consumeGlobalSessionDirty } from '../session-live.mjs';
|
||||
import {
|
||||
escapeHTML,
|
||||
fmtRelative,
|
||||
@@ -21,6 +22,9 @@ const session = computed(() => state.sessions.find(s => s.id === props.id));
|
||||
const messages = ref([]);
|
||||
const loading = ref(false);
|
||||
const progressPct = ref(0);
|
||||
const active = ref(false);
|
||||
let removeSessionUpdated = null;
|
||||
let keydownAttached = false;
|
||||
|
||||
// DOM refs
|
||||
const wrapRef = ref(null);
|
||||
@@ -58,51 +62,91 @@ function handleZoom(e) {
|
||||
}
|
||||
}
|
||||
|
||||
function attachKeydown() {
|
||||
if (keydownAttached) return;
|
||||
window.addEventListener('keydown', handleZoom);
|
||||
keydownAttached = true;
|
||||
}
|
||||
|
||||
function detachKeydown() {
|
||||
if (!keydownAttached) return;
|
||||
window.removeEventListener('keydown', handleZoom);
|
||||
keydownAttached = false;
|
||||
}
|
||||
|
||||
const HINT_KEY = 'obelisk:font-hint-shown';
|
||||
const showFontHint = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('keydown', handleZoom);
|
||||
active.value = true;
|
||||
attachKeydown();
|
||||
removeSessionUpdated = window.obelisk?.onSessionUpdated?.(async ({ sessionId } = {}) => {
|
||||
if (!active.value || !props.id || sessionId !== props.id) return;
|
||||
clearSessionDirty(props.id);
|
||||
await loadMessages({ force: true });
|
||||
}) || null;
|
||||
if (!localStorage.getItem(HINT_KEY)) {
|
||||
showFontHint.value = true;
|
||||
localStorage.setItem(HINT_KEY, '1');
|
||||
setTimeout(() => { showFontHint.value = false; }, 4000);
|
||||
}
|
||||
await loadMessages();
|
||||
await loadMessages({ force: consumeGlobalSessionDirty(props.id) });
|
||||
});
|
||||
|
||||
onActivated(async () => {
|
||||
window.addEventListener('keydown', handleZoom);
|
||||
if (messages.value.length === 0 && props.id) {
|
||||
await loadMessages();
|
||||
active.value = true;
|
||||
attachKeydown();
|
||||
if (props.id && (messages.value.length === 0 || consumeGlobalSessionDirty(props.id))) {
|
||||
await loadMessages({ force: true });
|
||||
}
|
||||
});
|
||||
|
||||
onDeactivated(() => {
|
||||
active.value = false;
|
||||
detachKeydown();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleZoom);
|
||||
active.value = false;
|
||||
detachKeydown();
|
||||
removeSessionUpdated?.();
|
||||
removeSessionUpdated = null;
|
||||
});
|
||||
|
||||
watch(() => props.id, async (newId, oldId) => {
|
||||
if (newId && newId !== oldId) {
|
||||
messages.value = [];
|
||||
await loadMessages();
|
||||
await loadMessages({ force: consumeGlobalSessionDirty(newId) });
|
||||
}
|
||||
});
|
||||
|
||||
async function loadMessages() {
|
||||
async function loadMessages({ force = false } = {}) {
|
||||
if (!props.id) return;
|
||||
const wasAtBottom = wrapRef.value && (wrapRef.value.scrollHeight - wrapRef.value.scrollTop - wrapRef.value.clientHeight) < 50;
|
||||
const prevScrollTop = wrapRef.value?.scrollTop || 0;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const s = state.sessions.find(x => x.id === props.id);
|
||||
if (s && (!s.messages || s.messages.length === 0)) {
|
||||
if (s && (force || !s.messages || s.messages.length === 0)) {
|
||||
const loaded = await loadSessionDetail(props.id);
|
||||
if (loaded) Object.assign(s, loaded);
|
||||
}
|
||||
messages.value = s?.messages || [];
|
||||
const latest = state.sessions.find(x => x.id === props.id);
|
||||
messages.value = latest?.messages || [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
if (!wrapRef.value) return;
|
||||
if (wasAtBottom) {
|
||||
wrapRef.value.scrollTop = wrapRef.value.scrollHeight;
|
||||
} else {
|
||||
wrapRef.value.scrollTop = prevScrollTop;
|
||||
}
|
||||
});
|
||||
|
||||
// Focus pending uuid if any
|
||||
if (state.pendingFocusUuid) {
|
||||
const targetUuid = state.pendingFocusUuid;
|
||||
@@ -120,20 +164,25 @@ async function loadMessages() {
|
||||
// --- Scroll / progress tracking ---
|
||||
const currentMsgIdx = ref(0);
|
||||
const totalMsgs = ref(0);
|
||||
let navLock = false;
|
||||
|
||||
function onScroll() {
|
||||
if (navLock) return;
|
||||
if (!wrapRef.value || !detailRef.value) return;
|
||||
const msgs = detailRef.value.querySelectorAll('.msg, .wf-card, .skill-card');
|
||||
if (!msgs.length) return;
|
||||
totalMsgs.value = msgs.length;
|
||||
const wrapTop = wrapRef.value.getBoundingClientRect().top;
|
||||
let topMsgIdx = 0;
|
||||
|
||||
const el = wrapRef.value;
|
||||
const navHeight = 52;
|
||||
const bottomLine = el.getBoundingClientRect().bottom - navHeight;
|
||||
let bottomMsgIdx = 0;
|
||||
for (let i = 0; i < msgs.length; i++) {
|
||||
if (msgs[i].getBoundingClientRect().top <= wrapTop + 50) topMsgIdx = i;
|
||||
if (msgs[i].getBoundingClientRect().bottom <= bottomLine) bottomMsgIdx = i;
|
||||
else break;
|
||||
}
|
||||
currentMsgIdx.value = topMsgIdx;
|
||||
const pct = msgs.length <= 1 ? 100 : Math.round((topMsgIdx / (msgs.length - 1)) * 100);
|
||||
currentMsgIdx.value = bottomMsgIdx;
|
||||
const pct = msgs.length <= 1 ? 100 : Math.round((bottomMsgIdx / (msgs.length - 1)) * 100);
|
||||
progressPct.value = pct;
|
||||
}
|
||||
|
||||
@@ -147,8 +196,16 @@ function navTo(target) {
|
||||
else if (target === 'prev') idx = Math.max(0, currentMsgIdx.value - 1);
|
||||
else if (target === 'next') idx = Math.min(msgs.length - 1, currentMsgIdx.value + 1);
|
||||
else return;
|
||||
const isClose = Math.abs(idx - currentMsgIdx.value) <= 3;
|
||||
msgs[idx]?.scrollIntoView({ behavior: isClose ? 'smooth' : 'instant', block: 'start' });
|
||||
currentMsgIdx.value = idx;
|
||||
navLock = true;
|
||||
const navHeight = 52;
|
||||
const el = wrapRef.value;
|
||||
const msgEl = msgs[idx];
|
||||
if (!msgEl) return;
|
||||
const msgBottom = msgEl.offsetTop + msgEl.offsetHeight;
|
||||
const scrollTarget = msgBottom - el.clientHeight + navHeight;
|
||||
el.scrollTo({ top: Math.max(0, scrollTarget), behavior: 'instant' });
|
||||
setTimeout(() => { navLock = false; }, 50);
|
||||
}
|
||||
|
||||
// --- Toggle helpers ---
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { state } from '../store.js';
|
||||
import { highlightPlain, escapeHTML, formatProjectLabel, fmtListTime, fmtRelative } from '../utils.js';
|
||||
@@ -7,6 +7,17 @@ import { highlightPlain, escapeHTML, formatProjectLabel, fmtListTime, fmtRelativ
|
||||
defineOptions({ name: 'SessionList' });
|
||||
|
||||
const router = useRouter();
|
||||
const debugEmpty = ref(false);
|
||||
|
||||
function onKeydown(e) {
|
||||
if (e.key === 'm' && !e.metaKey && !e.ctrlKey && e.target.tagName !== 'INPUT') {
|
||||
debugEmpty.value = !debugEmpty.value;
|
||||
}
|
||||
}
|
||||
onMounted(() => window.addEventListener('keydown', onKeydown));
|
||||
onUnmounted(() => window.removeEventListener('keydown', onKeydown));
|
||||
|
||||
const homePath = (typeof process !== 'undefined' && process.env?.HOME) || '~';
|
||||
|
||||
const visibleSessions = computed(() => {
|
||||
const q = state.query.trim().toLowerCase();
|
||||
@@ -81,10 +92,47 @@ function obeliskStyle(session) {
|
||||
|
||||
<template>
|
||||
<div class="session-list-wrap">
|
||||
<div v-if="!visibleSessions.length" class="empty">
|
||||
<!-- Empty state: no data source / debug toggle -->
|
||||
<div v-if="debugEmpty || (!visibleSessions.length && !state.query)" class="empty-content">
|
||||
<div class="empty-eyebrow">
|
||||
<span class="diamond"></span>
|
||||
<span>No data source connected</span>
|
||||
</div>
|
||||
<div class="empty-title">Obelisk reads your Claude Code session history.</div>
|
||||
<div class="empty-body">
|
||||
We didn't find <code>~/.claude</code> on this machine. If you've already used
|
||||
Claude Code, point Obelisk at where its data lives in
|
||||
<button class="inline-link" @click="router.push('/settings')">Settings</button>. If you haven't,
|
||||
<strong>install Claude Code first</strong> — Obelisk has nothing to read until
|
||||
sessions exist.
|
||||
</div>
|
||||
<div class="empty-actions">
|
||||
<button class="toolbar-action primary" @click="router.push('/settings')">
|
||||
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round">
|
||||
<path d="M2.5 3.5h3.5l1.2 1.2h4.3a1 1 0 0 1 1 1V11a1 1 0 0 1-1 1H2.5a1 1 0 0 1-1-1V4.5a1 1 0 0 1 1-1z"/>
|
||||
</svg>
|
||||
Choose folder…
|
||||
</button>
|
||||
</div>
|
||||
<div class="empty-divider"></div>
|
||||
<div class="empty-help">
|
||||
<div class="help-row">
|
||||
<span class="label">expected</span>
|
||||
<code>~/.claude</code>
|
||||
</div>
|
||||
<div class="help-row">
|
||||
<span class="label">searched</span>
|
||||
<code>{{ homePath }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state: search returned nothing -->
|
||||
<div v-else-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"
|
||||
@@ -116,6 +164,8 @@ function obeliskStyle(session) {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.srow {
|
||||
@@ -217,4 +267,70 @@ function obeliskStyle(session) {
|
||||
font-size: 11px;
|
||||
color: var(--muted-2);
|
||||
}
|
||||
|
||||
/* Onboarding empty state */
|
||||
.empty-content {
|
||||
flex: 1;
|
||||
display: flex; flex-direction: column; gap: 16px;
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
}
|
||||
.empty-eyebrow {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-family: var(--font-mono); font-size: 11px;
|
||||
color: var(--muted); letter-spacing: 0.04em;
|
||||
}
|
||||
.empty-eyebrow .diamond {
|
||||
width: 6px; height: 6px;
|
||||
background: var(--accent, #a78bfa); transform: rotate(45deg);
|
||||
box-shadow: 0 0 6px rgba(167,139,250,0.4); flex-shrink: 0;
|
||||
}
|
||||
.empty-title {
|
||||
font-family: var(--font-serif, Georgia); font-size: 22px;
|
||||
font-weight: 500; color: var(--fg);
|
||||
letter-spacing: -0.015em; line-height: 1.2;
|
||||
}
|
||||
.empty-body {
|
||||
font-family: var(--font-serif, Georgia); font-style: italic;
|
||||
font-size: 14px; color: var(--fg-3); line-height: 1.6; max-width: 460px;
|
||||
}
|
||||
.empty-body code {
|
||||
font-family: var(--font-mono); font-style: normal; font-size: 12.5px;
|
||||
color: var(--accent-2, #c4b5fd); background: rgba(167,139,250,0.12);
|
||||
padding: 1px 6px; border-radius: 3px;
|
||||
}
|
||||
.empty-body strong { color: var(--fg); font-weight: 600; font-style: normal; }
|
||||
.empty-body .inline-link {
|
||||
color: var(--accent-2, #c4b5fd); background: none;
|
||||
border: none; border-bottom: 1px solid rgba(167,139,250,0.4);
|
||||
padding: 0 0 1px; font: inherit; cursor: pointer; transition: all 0.12s;
|
||||
}
|
||||
.empty-body .inline-link:hover { color: var(--accent, #a78bfa); border-bottom-color: var(--accent); }
|
||||
.empty-actions { display: flex; gap: 8px; margin-top: 6px; }
|
||||
.empty-actions .toolbar-action {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
height: 32px; padding: 0 14px; border-radius: 5px;
|
||||
font-size: 12px; font-weight: 500; cursor: pointer; transition: all 0.12s;
|
||||
}
|
||||
.empty-actions .toolbar-action.primary {
|
||||
border: 1px solid rgba(167,139,250,0.35); background: rgba(167,139,250,0.12); color: #c4b5fd;
|
||||
}
|
||||
.empty-actions .toolbar-action.primary:hover {
|
||||
background: rgba(167,139,250,0.18); border-color: #a78bfa; color: var(--fg);
|
||||
box-shadow: 0 0 12px rgba(167,139,250,0.2);
|
||||
}
|
||||
.empty-actions .toolbar-action svg { width: 13px; height: 13px; }
|
||||
.empty-divider { width: 100%; height: 1px; background: var(--hairline); margin: 6px 0; }
|
||||
.empty-help {
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
font-family: var(--font-mono); font-size: 11px; color: var(--muted);
|
||||
}
|
||||
.empty-help .help-row { display: flex; align-items: baseline; gap: 8px; }
|
||||
.empty-help .help-row .label { color: var(--muted-2); letter-spacing: 0.04em; width: 76px; flex-shrink: 0; }
|
||||
.empty-help code {
|
||||
font-family: var(--font-mono); color: var(--fg-2);
|
||||
background: rgba(0,0,0,0.3); padding: 1px 6px; border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
|
||||
defineOptions({ name: 'Settings' });
|
||||
|
||||
const claudePath = ref('');
|
||||
const dbPath = ref('');
|
||||
const recapPath = ref('');
|
||||
const autoRefresh = ref(true);
|
||||
const status = ref('ok');
|
||||
const statusText = ref('Connected');
|
||||
const sessionCount = ref(0);
|
||||
const memoryCount = ref(0);
|
||||
const lastIndexed = ref('');
|
||||
const rebuilding = ref(false);
|
||||
const version = ref('0.1.0');
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSettings();
|
||||
});
|
||||
|
||||
async function loadSettings() {
|
||||
if (!window.obelisk?.getSettings) return;
|
||||
const s = await window.obelisk.getSettings();
|
||||
claudePath.value = s.claudeDir || '~/.claude';
|
||||
dbPath.value = s.dbPath || '';
|
||||
recapPath.value = s.recapDir || '~/.obelisk/recap';
|
||||
autoRefresh.value = s.autoRefresh !== false;
|
||||
sessionCount.value = s.sessionCount || 0;
|
||||
memoryCount.value = s.memoryCount || 0;
|
||||
lastIndexed.value = s.lastIndexed || '';
|
||||
status.value = s.status || 'ok';
|
||||
statusText.value = s.statusText || 'Connected';
|
||||
}
|
||||
|
||||
async function browsePath() {
|
||||
if (!window.obelisk?.browseFolder) return;
|
||||
const result = await window.obelisk.browseFolder();
|
||||
if (result) {
|
||||
claudePath.value = result;
|
||||
await saveSetting('claudeDir', result);
|
||||
await loadSettings();
|
||||
}
|
||||
}
|
||||
|
||||
async function browseRecapPath() {
|
||||
if (!window.obelisk?.browseFolder) return;
|
||||
const result = await window.obelisk.browseFolder();
|
||||
if (result) {
|
||||
recapPath.value = result;
|
||||
await saveSetting('recapDir', result);
|
||||
}
|
||||
}
|
||||
|
||||
async function resetPath() {
|
||||
await saveSetting('claudeDir', null);
|
||||
await loadSettings();
|
||||
}
|
||||
|
||||
async function toggleAutoRefresh() {
|
||||
autoRefresh.value = !autoRefresh.value;
|
||||
await saveSetting('autoRefresh', autoRefresh.value);
|
||||
}
|
||||
|
||||
async function saveSetting(key, value) {
|
||||
if (window.obelisk?.setSetting) {
|
||||
await window.obelisk.setSetting(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
async function commitClaudePath() {
|
||||
await saveSetting('claudeDir', claudePath.value);
|
||||
await loadSettings();
|
||||
}
|
||||
|
||||
async function commitRecapPath() {
|
||||
await saveSetting('recapDir', recapPath.value);
|
||||
}
|
||||
|
||||
async function rebuildIndex() {
|
||||
if (rebuilding.value || !window.obelisk?.rebuildIndex) return;
|
||||
rebuilding.value = true;
|
||||
statusText.value = 'Rebuilding…';
|
||||
try {
|
||||
await window.obelisk.rebuildIndex();
|
||||
await loadSettings();
|
||||
} finally {
|
||||
rebuilding.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function revealDb() {
|
||||
if (window.obelisk?.revealPath) {
|
||||
window.obelisk.revealPath(dbPath.value);
|
||||
}
|
||||
}
|
||||
|
||||
function fmtRelative(iso) {
|
||||
if (!iso) return '';
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const min = Math.floor(diff / 60000);
|
||||
if (min < 1) return 'just now';
|
||||
if (min < 60) return `${min}m ago`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `${hr}h ago`;
|
||||
return `${Math.floor(hr / 24)}d ago`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="settings-wrap">
|
||||
<div class="settings-content">
|
||||
|
||||
<!-- Data Source -->
|
||||
<section class="settings-section">
|
||||
<div class="settings-section-head">
|
||||
<h2>Data Source</h2>
|
||||
<p>Where Obelisk reads your Claude Code session history.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div>
|
||||
<div class="form-label">Claude Code path</div>
|
||||
<div class="form-label-hint">Default <code>~/.claude</code> on macOS & Linux.</div>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<div class="path-input">
|
||||
<input
|
||||
class="path-field"
|
||||
:class="{ error: status === 'error' }"
|
||||
type="text"
|
||||
v-model="claudePath"
|
||||
spellcheck="false"
|
||||
@keydown.enter="commitClaudePath"
|
||||
@blur="commitClaudePath"
|
||||
/>
|
||||
<button class="btn" @click="browsePath">
|
||||
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round">
|
||||
<path d="M2.5 3.5h3.5l1.2 1.2h4.3a1 1 0 0 1 1 1V11a1 1 0 0 1-1 1H2.5a1 1 0 0 1-1-1V4.5a1 1 0 0 1 1-1z"/>
|
||||
</svg>
|
||||
Browse…
|
||||
</button>
|
||||
<button class="btn subtle" @click="resetPath" title="Reset to default">
|
||||
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12.5 6.5A5 5 0 1 0 12 9.5"/>
|
||||
<path d="M12.5 2v4.5h-4.5"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="status-row" :class="status">
|
||||
<span class="status-dot" :class="status"></span>
|
||||
<span class="status-text">{{ statusText }}</span>
|
||||
<div class="status-meta" v-if="sessionCount || lastIndexed">
|
||||
<template v-if="lastIndexed">
|
||||
<span>last read <strong>{{ fmtRelative(lastIndexed) }}</strong></span>
|
||||
<span class="sep">·</span>
|
||||
</template>
|
||||
<span><strong>{{ sessionCount }}</strong> sessions</span>
|
||||
<span class="sep">·</span>
|
||||
<span><strong>{{ memoryCount }}</strong> memories</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div>
|
||||
<div class="form-label">Index location</div>
|
||||
<div class="form-label-hint">SQLite database where Obelisk caches the session index.</div>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<div class="path-input">
|
||||
<input class="path-field" type="text" :value="dbPath" spellcheck="false" readonly/>
|
||||
<button class="btn" @click="revealDb">Reveal</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div>
|
||||
<div class="form-label">Auto-refresh</div>
|
||||
<div class="form-label-hint">Obelisk re-reads when new session files appear.</div>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="toggle-label" @click.prevent="toggleAutoRefresh">
|
||||
<span class="toggle-track" :class="{ on: autoRefresh }">
|
||||
<span class="toggle-thumb"></span>
|
||||
</span>
|
||||
<span class="toggle-text">Watch <code>.claude</code> for changes</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Recap -->
|
||||
<section class="settings-section">
|
||||
<div class="settings-section-head">
|
||||
<h2>Recap</h2>
|
||||
<p>Where generated weekly and monthly recap files live.</p>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div>
|
||||
<div class="form-label">Recap output directory</div>
|
||||
<div class="form-label-hint">Watched by Obelisk for new <code>recap-*.json</code> files.</div>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<div class="path-input">
|
||||
<input
|
||||
class="path-field"
|
||||
type="text"
|
||||
v-model="recapPath"
|
||||
spellcheck="false"
|
||||
@keydown.enter="commitRecapPath"
|
||||
@blur="commitRecapPath"
|
||||
/>
|
||||
<button class="btn" @click="browseRecapPath">Browse…</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- About -->
|
||||
<section class="settings-section last">
|
||||
<div class="settings-section-head">
|
||||
<h2>About</h2>
|
||||
<p>The kind of details you don't usually need.</p>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-label">Version</div>
|
||||
<div class="form-control version-text">
|
||||
Obelisk {{ version }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-label">Reset</div>
|
||||
<div class="form-control">
|
||||
<div class="reset-actions">
|
||||
<button class="btn" :disabled="rebuilding" @click="rebuildIndex">
|
||||
{{ rebuilding ? 'Rebuilding…' : 'Rebuild index' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="reset-hint">
|
||||
Rebuilding only re-reads your Claude Code data. It does not delete memories or recaps.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-wrap { flex: 1; overflow-y: auto; min-height: 0; }
|
||||
.settings-content { max-width: 720px; margin: 0 auto; padding: 36px 32px 80px; }
|
||||
|
||||
.settings-section { margin-bottom: 44px; }
|
||||
.settings-section.last { margin-bottom: 0; }
|
||||
.settings-section-head {
|
||||
margin-bottom: 16px; padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
}
|
||||
.settings-section-head h2 {
|
||||
font-size: 18px; font-weight: 600;
|
||||
color: var(--fg); letter-spacing: -0.01em; margin-bottom: 2px;
|
||||
}
|
||||
.settings-section-head p {
|
||||
font-size: 13px; color: var(--muted);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid; grid-template-columns: 180px 1fr;
|
||||
gap: 24px; padding: 14px 0; align-items: start;
|
||||
}
|
||||
.form-row + .form-row { border-top: 1px solid var(--hairline); }
|
||||
.form-label { font-size: 13px; color: var(--fg-2); font-weight: 500; padding-top: 6px; }
|
||||
.form-label-hint {
|
||||
font-size: 11.5px; color: var(--muted); margin-top: 4px; font-weight: 400;
|
||||
}
|
||||
.form-label-hint code {
|
||||
font-family: var(--font-mono); font-style: normal; font-size: 10.5px;
|
||||
padding: 1px 4px; background: rgba(0,0,0,0.3); border-radius: 3px; color: var(--muted);
|
||||
}
|
||||
.form-control { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
.path-input { display: flex; gap: 6px; }
|
||||
.path-field {
|
||||
flex: 1; height: 28px; padding: 0 10px;
|
||||
background: rgba(0,0,0,0.3); border: 1px solid var(--hairline-strong);
|
||||
border-radius: 5px; font-family: var(--font-mono); font-size: 12px;
|
||||
color: var(--fg); min-width: 0; transition: all 0.12s;
|
||||
}
|
||||
.path-field:focus { outline: 0; border-color: var(--accent); background: rgba(0,0,0,0.4); box-shadow: 0 0 0 2px rgba(167,139,250,0.12); }
|
||||
.path-field.error { border-color: rgba(248,113,113,0.4); }
|
||||
.path-field.error:focus { border-color: #f87171; box-shadow: 0 0 0 2px rgba(248,113,113,0.12); }
|
||||
.tz-field { max-width: 240px; }
|
||||
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
height: 28px; padding: 0 12px;
|
||||
border: 1px solid var(--hairline-strong); border-radius: 5px;
|
||||
background: var(--surface); color: var(--fg-2);
|
||||
font-size: 12px; font-weight: 500; cursor: pointer;
|
||||
transition: all 0.12s; white-space: nowrap;
|
||||
}
|
||||
.btn:hover { background: var(--surface-strong); color: var(--fg); border-color: var(--hairline-vivid); }
|
||||
.btn:disabled { opacity: 0.4; cursor: default; }
|
||||
.btn.subtle { background: transparent; border-color: transparent; color: var(--muted); }
|
||||
.btn.subtle:hover { background: var(--surface); color: var(--fg-2); }
|
||||
.btn svg { width: 13px; height: 13px; }
|
||||
|
||||
.status-row {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding: 8px 12px; background: rgba(0,0,0,0.2);
|
||||
border: 1px solid var(--hairline); border-radius: 5px;
|
||||
font-family: var(--font-mono); font-size: 11.5px; flex-wrap: wrap;
|
||||
}
|
||||
.status-row.ok { border-color: rgba(52,211,153,0.20); background: rgba(52,211,153,0.04); }
|
||||
.status-row.warn { border-color: rgba(251,191,36,0.20); background: rgba(251,191,36,0.04); }
|
||||
.status-row.error { border-color: rgba(248,113,113,0.20); background: rgba(248,113,113,0.04); }
|
||||
|
||||
.status-dot {
|
||||
width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
.status-dot.ok { background: #34d399; box-shadow: 0 0 6px rgba(52,211,153,0.5); }
|
||||
.status-dot.warn { background: #fbbf24; box-shadow: 0 0 6px rgba(251,191,36,0.5); }
|
||||
.status-dot.error { background: #f87171; box-shadow: 0 0 6px rgba(248,113,113,0.5); }
|
||||
.status-dot.ok::before {
|
||||
content: ''; position: absolute; inset: -3px;
|
||||
border-radius: 50%; border: 1px solid #34d399; opacity: 0.5;
|
||||
animation: pulse 1.6s ease-out infinite;
|
||||
}
|
||||
@keyframes pulse { 0% { transform: scale(0.8); opacity: 0.5; } 100% { transform: scale(1.6); opacity: 0; } }
|
||||
.status-text { color: var(--fg-2); font-weight: 500; }
|
||||
.status-text.error { color: #f87171; }
|
||||
.status-meta { display: flex; gap: 6px; color: var(--muted); align-items: center; flex-wrap: wrap; }
|
||||
.status-meta strong { color: var(--fg-2); font-weight: 500; }
|
||||
.status-meta .sep { color: var(--muted-2); }
|
||||
|
||||
.toggle-label { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; }
|
||||
.toggle-input { position: absolute; opacity: 0; width: 0; height: 0; }
|
||||
.toggle-track {
|
||||
position: relative; width: 30px; height: 16px;
|
||||
background: var(--surface-strong); border: 1px solid var(--hairline-strong);
|
||||
border-radius: 8px; transition: all 0.15s;
|
||||
}
|
||||
.toggle-track.on { background: rgba(167,139,250,0.12); border-color: rgba(167,139,250,0.5); }
|
||||
.toggle-thumb {
|
||||
position: absolute; top: 2px; left: 2px;
|
||||
width: 10px; height: 10px; border-radius: 50%;
|
||||
background: var(--muted); transition: all 0.15s;
|
||||
}
|
||||
.toggle-track.on .toggle-thumb {
|
||||
left: 16px; background: #c4b5fd;
|
||||
box-shadow: 0 0 6px rgba(167,139,250,0.5);
|
||||
}
|
||||
.toggle-text { font-size: 12.5px; color: var(--fg-2); }
|
||||
.toggle-text code {
|
||||
font-family: var(--font-mono); font-size: 11px;
|
||||
padding: 1px 4px; background: rgba(0,0,0,0.3); border-radius: 3px;
|
||||
}
|
||||
|
||||
.version-text {
|
||||
font-family: var(--font-mono); font-size: 12px; color: var(--fg-2); padding-top: 6px;
|
||||
}
|
||||
.reset-actions { display: flex; gap: 8px; }
|
||||
.reset-hint {
|
||||
font-size: 11.5px; color: var(--muted); margin-top: 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -1165,7 +1165,7 @@
|
||||
/* Back to top floating button */
|
||||
/* Message pagination nav */
|
||||
.msg-nav {
|
||||
position: fixed; bottom: 40px;
|
||||
position: fixed; bottom: 16px;
|
||||
left: 50%; transform: translateX(-50%);
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
padding: 5px 8px;
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
.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-spacer { flex: 1; min-height: 0; }
|
||||
.sidebar-bottom { margin-top: auto; }
|
||||
.sidebar-section-title {
|
||||
padding: 4px 10px 6px;
|
||||
font-size: 10.5px; color: var(--muted);
|
||||
|
||||
Reference in New Issue
Block a user