From 55e091386756b1b21e89d196a7cddde2579b6d2a Mon Sep 17 00:00:00 2001 From: Anykeyev Date: Thu, 9 Apr 2026 23:00:20 +0300 Subject: [PATCH 1/4] Add files via upload --- README.md | 248 +++++++++++++++++++++++++++--------------------------- 1 file changed, 125 insertions(+), 123 deletions(-) diff --git a/README.md b/README.md index 2956888..5a81f7a 100644 --- a/README.md +++ b/README.md @@ -1,123 +1,125 @@ -# CodeDash - -Dashboard + CLI for AI coding agent sessions. View, search, resume, convert, and hand off between 5 agents. - -[Russian / Русский](docs/README_RU.md) | [Chinese / 中文](docs/README_ZH.md) - -https://github.com/user-attachments/assets/15c45659-365b-49f8-86a3-9005fa155ca6 - -![npm](https://img.shields.io/npm/v/codedash-app?style=flat-square) ![Node](https://img.shields.io/badge/node-%3E%3D18-green?style=flat-square) ![License](https://img.shields.io/badge/license-MIT-blue?style=flat-square) ![Zero deps](https://img.shields.io/badge/dependencies-0-blue?style=flat-square) - -## Quick Start - -```bash -npm i -g codedash-app -codedash run -``` - -## Supported Agents - -| Agent | Sessions | Preview | Search | Live Status | Convert | Handoff | Launch | -|-------|----------|---------|--------|-------------|---------|---------|--------| -| Claude Code | JSONL | Yes | Yes | Yes | Yes | Yes | Terminal / cmux | -| Codex CLI | JSONL | Yes | Yes | Yes | Yes | Yes | Terminal | -| Cursor | JSONL | Yes | Yes | Yes | - | Yes | Open in Cursor | -| OpenCode | SQLite | Yes | Yes | Yes | - | Yes | Terminal | -| Kiro CLI | SQLite | Yes | Yes | Yes | - | Yes | Terminal | - -Also detects Claude Code running inside Cursor (via `claude-vscode` entrypoint). - -## Features - -**Browser Dashboard** -- Grid and List view with project grouping -- Trigram fuzzy search + full-text deep search across all messages -- Filter by agent, tags, date range -- Star/pin sessions, tag with labels -- GitHub-style SVG activity heatmap with streak stats -- Session Replay with timeline slider and play/pause -- Hover preview + expandable cards -- Themes: Dark, Light, System - -**Live Monitoring** -- LIVE/WAITING badges on all 5 agent types -- Animated border on active session cards -- Running view with CPU, Memory, PID, Uptime -- Focus Terminal / Open in Cursor buttons -- Polling every 5 seconds - -**Cost Analytics** -- Real cost from actual token usage (input, output, cache) -- Per-model pricing: Opus, Sonnet, Haiku, Codex, GPT-5 -- Daily cost chart, cost by project, most expensive sessions - -**Cross-Agent** -- Convert sessions between Claude Code and Codex -- Handoff: generate context document to continue in any agent -- Install Agents: one-click install commands for all 5 agents - -**CLI** -```bash -codedash run [--port=N] [--no-browser] -codedash search -codedash show -codedash handoff [target] [--verbosity=full] [--out=file.md] -codedash convert claude|codex -codedash list [limit] -codedash stats -codedash export [file.tar.gz] -codedash import -codedash update -codedash restart -codedash stop -``` - -**Keyboard Shortcuts**: `/` search, `j/k` navigate, `Enter` open, `x` star, `d` delete, `s` select, `g` group, `r` refresh, `Esc` close - -## Data Sources - -``` -~/.claude/ Claude Code sessions + PID tracking -~/.codex/ Codex CLI sessions -~/.cursor/projects/*/agent-transcripts/ Cursor agent sessions -~/.local/share/opencode/opencode.db OpenCode (SQLite) -~/Library/Application Support/kiro-cli/ Kiro CLI (SQLite) -``` - -Zero dependencies. Everything runs on `localhost`. - -## Install Agents - -```bash -curl -fsSL https://claude.ai/install.sh | bash # Claude Code -npm i -g @openai/codex # Codex CLI -curl -fsSL https://cli.kiro.dev/install | bash # Kiro CLI -curl -fsSL https://opencode.ai/install | bash # OpenCode -``` - -## Requirements - -- Node.js >= 18 -- At least one AI coding agent installed -- macOS / Linux / Windows - -## Contributing - -`main` is protected. All changes go through feature branches and pull requests. - -```bash -git checkout -b fix/my-fix -# make changes -git push -u origin fix/my-fix -gh pr create -``` - -- **Branch naming:** `feat/`, `fix/`, `chore/`, `release/` -- **1 approval** required to merge -- Keep PRs small and focused - -See [ARCHITECTURE.md](docs/ARCHITECTURE.md) for codebase details. - -## License - -MIT +# CodeDash + +Dashboard + CLI for AI coding agent sessions. View, search, resume, convert, and hand off between 6 agents. + +[Russian / Русский](docs/README_RU.md) | [Chinese / 中文](docs/README_ZH.md) + +https://github.com/user-attachments/assets/15c45659-365b-49f8-86a3-9005fa155ca6 + +![npm](https://img.shields.io/npm/v/codedash-app?style=flat-square) ![Node](https://img.shields.io/badge/node-%3E%3D18-green?style=flat-square) ![License](https://img.shields.io/badge/license-MIT-blue?style=flat-square) ![Zero deps](https://img.shields.io/badge/dependencies-0-blue?style=flat-square) + +## Quick Start + +```bash +npm i -g codedash-app +codedash run +``` + +## Supported Agents + +| Agent | Sessions | Preview | Search | Live Status | Convert | Handoff | Launch | +|-------|----------|---------|--------|-------------|---------|---------|--------| +| Claude Code | JSONL | Yes | Yes | Yes | Yes | Yes | Terminal / cmux | +| Codex CLI | JSONL | Yes | Yes | Yes | Yes | Yes | Terminal | +| Cursor | JSONL | Yes | Yes | Yes | - | Yes | Open in Cursor | +| GitHub Copilot (VS Code) | JSONL | Yes | Yes | No | - | Yes | Open in VS Code | +| OpenCode | SQLite | Yes | Yes | Yes | - | Yes | Terminal | +| Kiro CLI | SQLite | Yes | Yes | Yes | - | Yes | Terminal | + +Also detects Claude Code running inside Cursor (via `claude-vscode` entrypoint). + +## Features + +**Browser Dashboard** +- Grid and List view with project grouping +- Trigram fuzzy search + full-text deep search across all messages +- Filter by agent, tags, date range +- Star/pin sessions, tag with labels +- GitHub-style SVG activity heatmap with streak stats +- Session Replay with timeline slider and play/pause +- Hover preview + expandable cards +- Themes: Dark, Light, System + +**Live Monitoring** +- LIVE/WAITING badges on local terminal/IDE agent processes +- Animated border on active session cards +- Running view with CPU, Memory, PID, Uptime +- Focus Terminal / Open in Cursor / Open in VS Code buttons +- Polling every 5 seconds + +**Cost Analytics** +- Real cost from actual token usage (input, output, cache) +- Per-model pricing: Opus, Sonnet, Haiku, Codex, GPT-5 +- Daily cost chart, cost by project, most expensive sessions + +**Cross-Agent** +- Convert sessions between Claude Code and Codex +- Handoff: generate context document to continue in any agent +- Install Agents: one-click install commands for Claude, Codex, Kiro, OpenCode + +**CLI** +```bash +codedash run [--port=N] [--no-browser] +codedash search +codedash show +codedash handoff [target] [--verbosity=full] [--out=file.md] +codedash convert claude|codex +codedash list [limit] +codedash stats +codedash export [file.tar.gz] +codedash import +codedash update +codedash restart +codedash stop +``` + +**Keyboard Shortcuts**: `/` search, `j/k` navigate, `Enter` open, `x` star, `d` delete, `s` select, `g` group, `r` refresh, `Esc` close + +## Data Sources + +``` +~/.claude/ Claude Code sessions + PID tracking +~/.codex/ Codex CLI sessions +~/.cursor/projects/*/agent-transcripts/ Cursor agent sessions +%APPDATA%/Code/User/workspaceStorage/*/chatSessions/ GitHub Copilot chat sessions +~/.local/share/opencode/opencode.db OpenCode (SQLite) +~/Library/Application Support/kiro-cli/ Kiro CLI (SQLite) +``` + +Zero dependencies. Everything runs on `localhost`. + +## Install Agents + +```bash +curl -fsSL https://claude.ai/install.sh | bash # Claude Code +npm i -g @openai/codex # Codex CLI +curl -fsSL https://cli.kiro.dev/install | bash # Kiro CLI +curl -fsSL https://opencode.ai/install | bash # OpenCode +``` + +## Requirements + +- Node.js >= 18 +- At least one AI coding agent installed +- macOS / Linux / Windows + +## Contributing + +`main` is protected. All changes go through feature branches and pull requests. + +```bash +git checkout -b fix/my-fix +# make changes +git push -u origin fix/my-fix +gh pr create +``` + +- **Branch naming:** `feat/`, `fix/`, `chore/`, `release/` +- **1 approval** required to merge +- Keep PRs small and focused + +See [ARCHITECTURE.md](docs/ARCHITECTURE.md) for codebase details. + +## License + +MIT From 4f2f9adb3af4cc23d54858f2c36139a4792e5b57 Mon Sep 17 00:00:00 2001 From: Anykeyev Date: Thu, 9 Apr 2026 23:01:08 +0300 Subject: [PATCH 2/4] Add files via upload --- src/data.js | 6532 +++++++++++++++++++++++++++------------------------ 1 file changed, 3416 insertions(+), 3116 deletions(-) diff --git a/src/data.js b/src/data.js index 2786234..774c875 100644 --- a/src/data.js +++ b/src/data.js @@ -1,3116 +1,3416 @@ -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const { execSync, execFileSync } = require('child_process'); - -// ── Constants ────────────────────────────────────────────── - -// Detect WSL and find Windows user home for cross-OS data access -function detectHomes() { - const homes = [os.homedir()]; - // WSL: also check Windows-side home dirs - if (process.platform === 'linux' && fs.existsSync('/mnt/c/Users')) { - try { - const winUser = execSync('cmd.exe /C "echo %USERPROFILE%" 2>/dev/null', { encoding: 'utf8', timeout: 3000 }).trim(); - if (winUser && winUser.includes('\\')) { - // Convert C:\Users\foo to /mnt/c/Users/foo - const drive = winUser[0].toLowerCase(); - const winPath = '/mnt/' + drive + winUser.slice(2).replace(/\\/g, '/'); - if (fs.existsSync(winPath) && !homes.includes(winPath)) { - homes.push(winPath); - } - } - } catch { - // Fallback: scan /mnt/c/Users/ for directories with .claude - try { - for (const u of fs.readdirSync('/mnt/c/Users')) { - const candidate = '/mnt/c/Users/' + u; - if (fs.existsSync(path.join(candidate, '.claude'))) { - if (!homes.includes(candidate)) homes.push(candidate); - } - } - } catch {} - } - } - return homes; -} - -const ALL_HOMES = detectHomes(); -const IS_WSL = ALL_HOMES.length > 1; - -const CLAUDE_DIR = path.join(ALL_HOMES[0], '.claude'); -const CODEX_DIR = path.join(ALL_HOMES[0], '.codex'); -const OPENCODE_DB = path.join(ALL_HOMES[0], '.local', 'share', 'opencode', 'opencode.db'); -const KIRO_DB = path.join(ALL_HOMES[0], 'Library', 'Application Support', 'kiro-cli', 'data.sqlite3'); -const CURSOR_DIR = path.join(ALL_HOMES[0], '.cursor'); -const CURSOR_PROJECTS = path.join(CURSOR_DIR, 'projects'); -const CURSOR_CHATS = path.join(CURSOR_DIR, 'chats'); -// Cursor global DB path varies by OS: macOS ~/Library/Application Support, Linux ~/.config, Windows %APPDATA% -const CURSOR_APP_DATA = process.platform === 'darwin' - ? path.join(ALL_HOMES[0], 'Library', 'Application Support', 'Cursor') - : process.platform === 'win32' - ? path.join(ALL_HOMES[0], 'AppData', 'Roaming', 'Cursor') - : path.join(ALL_HOMES[0], '.config', 'Cursor'); -const CURSOR_GLOBAL_DB = path.join(CURSOR_APP_DATA, 'User', 'globalStorage', 'state.vscdb'); -const CURSOR_WORKSPACE_STORAGE = path.join(CURSOR_APP_DATA, 'User', 'workspaceStorage'); -const HISTORY_FILE = path.join(CLAUDE_DIR, 'history.jsonl'); -const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects'); - -// On WSL, collect all alternative data dirs -const EXTRA_CLAUDE_DIRS = ALL_HOMES.slice(1).map(h => path.join(h, '.claude')).filter(d => fs.existsSync(d)); -const EXTRA_CODEX_DIRS = ALL_HOMES.slice(1).map(h => path.join(h, '.codex')).filter(d => fs.existsSync(d)); -const EXTRA_CURSOR_DIRS = ALL_HOMES.slice(1).map(h => path.join(h, '.cursor')).filter(d => fs.existsSync(d)); - -// Extra OpenCode/Kiro DBs on Windows side -const EXTRA_OPENCODE_DBS = ALL_HOMES.slice(1).map(h => path.join(h, 'AppData', 'Local', 'opencode', 'opencode.db')).filter(d => fs.existsSync(d)); -const EXTRA_KIRO_DBS = ALL_HOMES.slice(1).map(h => path.join(h, 'AppData', 'Roaming', 'kiro-cli', 'data.sqlite3')).filter(d => fs.existsSync(d)); - -if (IS_WSL) { - console.log(' \x1b[36m[WSL]\x1b[0m Detected Windows homes:', ALL_HOMES.slice(1).join(', ')); - if (EXTRA_CLAUDE_DIRS.length) console.log(' \x1b[36m[WSL]\x1b[0m Extra Claude dirs:', EXTRA_CLAUDE_DIRS.join(', ')); - if (EXTRA_CODEX_DIRS.length) console.log(' \x1b[36m[WSL]\x1b[0m Extra Codex dirs:', EXTRA_CODEX_DIRS.join(', ')); - if (EXTRA_CURSOR_DIRS.length) console.log(' \x1b[36m[WSL]\x1b[0m Extra Cursor dirs:', EXTRA_CURSOR_DIRS.join(', ')); -} - -// ── Helpers ──────────────────────────────────────────────── - -// Read file lines handling \r\n (Windows/WSL) -function readLines(filePath) { - return fs.readFileSync(filePath, 'utf8').split('\n').map(l => l.replace(/\r$/, '')).filter(Boolean); -} - -// OpenCode built-in tools that should NOT be treated as MCP servers -const OPENCODE_BUILTIN_TOOLS = new Set([ - 'read', 'write', 'edit', 'bash', 'glob', 'grep', 'task', 'todowrite', - 'delegate_task', 'apply_patch', 'webfetch', 'websearch', 'slashcommand', - 'question', 'background_task', 'background_output', 'background_cancel', - 'lsp_diagnostics', 'ast_grep_search', 'ast_grep_replace', 'session_read', - 'skill', 'skill_mcp', 'call_omo_agent', -]); - -// OpenCode tool names like "chrome-devtools_take_screenshot" → server "chrome-devtools" -// Returns null if it's a built-in tool, otherwise the server name (first segment). -function parseOpenCodeMcpServer(toolName) { - if (!toolName || OPENCODE_BUILTIN_TOOLS.has(toolName)) return null; - // Match server_tool or server-with-dashes_tool - const idx = toolName.indexOf('_'); - if (idx <= 0) return null; - return toolName.slice(0, idx); -} - -// Disk cache for parsed Claude session files (keyed by path + mtime + size) -const PARSED_CACHE_FILE = path.join(os.tmpdir(), 'codedash-parsed-cache.json'); -let _parsedDiskCache = null; -let _parsedDiskCacheDirty = false; -// Reverse index: file path -> cache key (avoids repeated fs.statSync) -const _fileCacheKeyIndex = {}; - -function _loadParsedDiskCache() { - if (_parsedDiskCache) return; - try { - if (fs.existsSync(PARSED_CACHE_FILE)) { - _parsedDiskCache = JSON.parse(fs.readFileSync(PARSED_CACHE_FILE, 'utf8')); - } - } catch {} - if (!_parsedDiskCache) _parsedDiskCache = {}; -} - -function _saveParsedDiskCache() { - if (!_parsedDiskCacheDirty || !_parsedDiskCache) return; - try { - fs.writeFileSync(PARSED_CACHE_FILE, JSON.stringify(_parsedDiskCache)); - _parsedDiskCacheDirty = false; - } catch {} -} - -function parseClaudeSessionFile(sessionFile) { - if (!fs.existsSync(sessionFile)) return null; - - let stat; - try { - stat = fs.statSync(sessionFile); - } catch { - return null; - } - - // Check disk cache (keyed by file path + mtime + size) - _loadParsedDiskCache(); - const cacheKey = sessionFile + '|' + stat.mtimeMs + '|' + stat.size; - _fileCacheKeyIndex[sessionFile] = cacheKey; - if (_parsedDiskCache[cacheKey]) return _parsedDiskCache[cacheKey]; - - let lines; - try { - lines = readLines(sessionFile); - } catch { - return null; - } - let projectPath = ''; - let tool = 'claude'; - let msgCount = 0; - let firstMsg = ''; - let customTitle = ''; - let firstTs = stat.mtimeMs; - let lastTs = stat.mtimeMs; - let userMsgCount = 0; - let entrypointFound = false; - let worktreeOriginalCwd = ''; - const mcpSet = new Set(); - const skillSet = new Set(); - - for (const line of lines) { - try { - const entry = JSON.parse(line); - if (entry.type === 'user' || entry.type === 'assistant') msgCount++; - if (entry.type === 'user') userMsgCount++; - if (entry.timestamp) { - if (entry.timestamp < firstTs) firstTs = entry.timestamp; - if (entry.timestamp > lastTs) lastTs = entry.timestamp; - } - if (!projectPath && entry.type === 'user' && entry.cwd) { - projectPath = entry.cwd; - } - // worktree-state is written by Claude Code when a session runs inside a git worktree. - // originalCwd is the main checkout directory — safe to use in containers (no git needed). - if (!worktreeOriginalCwd && entry.type === 'worktree-state' && entry.worktreeSession && entry.worktreeSession.originalCwd) { - worktreeOriginalCwd = entry.worktreeSession.originalCwd; - } - if (!entrypointFound && entry.type === 'user' && entry.entrypoint) { - entrypointFound = true; - if (entry.entrypoint !== 'cli') tool = 'claude-ext'; - } - if (entry.type === 'custom-title' && typeof entry.customTitle === 'string') { - const title = entry.customTitle.trim(); - if (title) customTitle = title.slice(0, 200); - } - if (!firstMsg && entry.type === 'user' && entry.message && entry.message.content) { - const content = extractContent(entry.message.content).trim(); - if (content) firstMsg = content.slice(0, 200); - } - // MCP/Skill extraction from assistant tool_use blocks - if (entry.type === 'assistant') { - const aContent = (entry.message || {}).content; - if (Array.isArray(aContent)) { - for (const block of aContent) { - if (!block || block.type !== 'tool_use') continue; - const name = block.name || ''; - if (name.startsWith('mcp__')) { - const parts = name.split('__'); - if (parts.length >= 3) mcpSet.add(parts[1]); - } else if (name === 'Skill') { - const sk = (block.input || {}).skill; - if (sk) skillSet.add(sk.includes(':') ? sk.split(':')[0] : sk); - } - } - } - } - } catch {} - } - - const result = { - projectPath, - tool, - msgCount, - userMsgCount, - firstMsg, - customTitle, - firstTs, - lastTs, - fileSize: stat.size, - worktreeOriginalCwd, - mcpServers: Array.from(mcpSet), - skills: Array.from(skillSet), - }; - - // Cache to disk - _parsedDiskCache[cacheKey] = result; - _parsedDiskCacheDirty = true; - return result; -} - -function mergeClaudeSessionDetail(session, summary, sessionFile) { - if (!session || !summary) return; - - session.tool = summary.tool || session.tool; - session.has_detail = true; - session.file_size = summary.fileSize; - session.detail_messages = summary.msgCount; - session.user_messages = summary.userMsgCount || 0; - session._session_file = sessionFile; - session.mcp_servers = summary.mcpServers || []; - session.skills = summary.skills || []; - - if (!session.project && summary.projectPath) { - session.project = summary.projectPath; - session.project_short = summary.projectPath.replace(os.homedir(), '~'); - } - - if (summary.worktreeOriginalCwd) { - session.worktree_original_cwd = summary.worktreeOriginalCwd; - } - - if (summary.customTitle) { - session.first_message = summary.customTitle; - } -} - -function parseCodexSessionIndex(codexDir) { - const titles = {}; - const titleMeta = {}; - const indexFile = path.join(codexDir, 'session_index.jsonl'); - if (!fs.existsSync(indexFile)) return titles; - - const parseUpdatedAt = (value) => { - if (typeof value === 'number' && Number.isFinite(value)) return value; - if (typeof value === 'string') { - const trimmed = value.trim(); - if (!trimmed) return NaN; - if (/^\d+$/.test(trimmed)) return Number(trimmed); - return Date.parse(trimmed); - } - return NaN; - }; - - let lines; - try { - lines = readLines(indexFile); - } catch { - return titles; - } - - for (const line of lines) { - try { - const entry = JSON.parse(line); - const sid = entry.id || entry.session_id || entry.sessionId; - if (!sid || typeof entry.thread_name !== 'string') continue; - const title = entry.thread_name.trim(); - if (!title) continue; - - const updatedAt = parseUpdatedAt(entry.updated_at); - const hasUpdatedAt = Number.isFinite(updatedAt); - const existing = titleMeta[sid]; - - if (!existing) { - titles[sid] = title.slice(0, 200); - titleMeta[sid] = { updatedAt, hasUpdatedAt }; - continue; - } - - if ( - (hasUpdatedAt && !existing.hasUpdatedAt) || - (hasUpdatedAt && existing.hasUpdatedAt && updatedAt >= existing.updatedAt) || - (!hasUpdatedAt && !existing.hasUpdatedAt) - ) { - titles[sid] = title.slice(0, 200); - titleMeta[sid] = { updatedAt, hasUpdatedAt }; - } - } catch {} - } - - return titles; -} - -function scanOpenCodeSessions() { - const sessions = []; - if (!fs.existsSync(OPENCODE_DB)) return sessions; - - try { - // Use sqlite3 CLI with tab separator — session titles can contain pipes - // (e.g. "review changes [commit|branch|pr]") which break the default | separator - const rows = execFileSync('sqlite3', [ - '-separator', '\t', - OPENCODE_DB, - 'SELECT s.id, s.title, s.directory, s.time_created, s.time_updated, COUNT(m.id) as msg_count FROM session s LEFT JOIN message m ON m.session_id = s.id GROUP BY s.id ORDER BY s.time_updated DESC' - ], { encoding: 'utf8', timeout: 5000, windowsHide: true }).trim(); - - if (!rows) return sessions; - - // Get MCP/Skills usage per session in one query - const sessionMcp = {}; - const sessionSkills = {}; - try { - const toolRows = execFileSync('sqlite3', [ - '-separator', '\t', - OPENCODE_DB, - "SELECT session_id, json_extract(data, '$.tool'), json_extract(data, '$.state.input.name') FROM part WHERE json_extract(data, '$.type') = 'tool'" - ], { encoding: 'utf8', timeout: 10000, maxBuffer: 50 * 1024 * 1024, windowsHide: true }).trim(); - if (toolRows) { - for (const tr of toolRows.split('\n')) { - const cols = tr.split('\t'); - if (cols.length < 2) continue; - const sid = cols[0]; - const toolName = cols[1]; - const skillName = cols[2]; - if (!sid || !toolName) continue; - // Skill tool: collect skill name - if (toolName === 'skill' || toolName === 'skill_mcp') { - if (skillName) { - if (!sessionSkills[sid]) sessionSkills[sid] = new Set(); - // Plugin prefix: "superpowers:writing-plans" -> "superpowers" - // For OpenCode keep full name (e.g. "openspec-propose", "chrome-devtools") - const sk = skillName.includes(':') ? skillName.split(':')[0] : skillName; - sessionSkills[sid].add(sk); - } - continue; - } - // MCP tool: extract server name - const server = parseOpenCodeMcpServer(toolName); - if (server) { - if (!sessionMcp[sid]) sessionMcp[sid] = new Set(); - sessionMcp[sid].add(server); - } - } - } - } catch {} - - for (const row of rows.split('\n')) { - const parts = row.split('\t'); - if (parts.length < 6) continue; - const [id, title, directory, timeCreated, timeUpdated, msgCount] = parts; - - sessions.push({ - id: id, - tool: 'opencode', - project: directory || '', - project_short: (directory || '').replace(os.homedir(), '~'), - first_ts: parseInt(timeCreated) || Date.now(), - last_ts: parseInt(timeUpdated) || Date.now(), - messages: parseInt(msgCount) || 0, - first_message: title || '', - has_detail: true, - file_size: 0, - detail_messages: parseInt(msgCount) || 0, - mcp_servers: sessionMcp[id] ? Array.from(sessionMcp[id]) : [], - skills: sessionSkills[id] ? Array.from(sessionSkills[id]) : [], - }); - } - } catch {} - - return sessions; -} - -function loadOpenCodeDetail(sessionId) { - if (!fs.existsSync(OPENCODE_DB)) return { messages: [] }; - - try { - // Get messages with parts joined - const rows = execFileSync('sqlite3', [ - OPENCODE_DB, - `SELECT m.data, GROUP_CONCAT(p.data, '|||') FROM message m LEFT JOIN part p ON p.message_id = m.id WHERE m.session_id = '${sessionId.replace(/'/g, "''")}' GROUP BY m.id ORDER BY m.time_created` - ], { encoding: 'utf8', timeout: 10000, windowsHide: true }).trim(); - - if (!rows) return { messages: [] }; - - const messages = []; - for (const row of rows.split('\n')) { - const sepIdx = row.indexOf('|'); - if (sepIdx < 0) continue; - - // Parse message data (first column) - // Find the JSON boundary - message data ends where part data starts - let msgJson, partsRaw; - try { - // Try to find where message JSON ends - let braceCount = 0; - let jsonEnd = 0; - for (let i = 0; i < row.length; i++) { - if (row[i] === '{') braceCount++; - if (row[i] === '}') { braceCount--; if (braceCount === 0) { jsonEnd = i + 1; break; } } - } - msgJson = row.slice(0, jsonEnd); - partsRaw = row.slice(jsonEnd + 1); // skip | - } catch { continue; } - - let msgData; - try { msgData = JSON.parse(msgJson); } catch { continue; } - - const role = msgData.role; - if (role !== 'user' && role !== 'assistant') continue; - - // Extract text + tools from parts - let content = ''; - const tools = []; - const toolSeen = new Set(); - if (partsRaw) { - for (const partStr of partsRaw.split('|||')) { - try { - const part = JSON.parse(partStr); - if (part.type === 'text' && part.text) { - content += part.text + '\n'; - } else if (part.type === 'tool' && part.tool) { - const toolName = part.tool; - if (toolName === 'skill' || toolName === 'skill_mcp') { - const skillRaw = part.state && part.state.input && part.state.input.name; - if (skillRaw) { - const sk = skillRaw.includes(':') ? skillRaw.split(':')[0] : skillRaw; - const key = 'skill:' + sk; - if (!toolSeen.has(key)) { - toolSeen.add(key); - tools.push({ type: 'skill', skill: sk }); - } - } - } else { - const server = parseOpenCodeMcpServer(toolName); - if (server) { - const tool = toolName.slice(server.length + 1); - const key = 'mcp:' + server + ':' + tool; - if (!toolSeen.has(key)) { - toolSeen.add(key); - tools.push({ type: 'mcp', server: server, tool: tool }); - } - } - } - } - } catch {} - } - } - - content = content.trim(); - if (!content) continue; - - const tokens = msgData.tokens || {}; - - const msg = { - role: role, - content: content.slice(0, 2000), - uuid: '', - model: msgData.modelID || msgData.model?.modelID || '', - tokens: tokens, - }; - if (tools.length > 0) msg.tools = tools; - messages.push(msg); - } - - return { messages: messages.slice(0, 200) }; - } catch { - return { messages: [] }; - } -} - -function scanKiroSessions() { - const sessions = []; - if (!fs.existsSync(KIRO_DB)) return sessions; - - try { - const rows = execFileSync('sqlite3', [ - '-separator', '\t', - KIRO_DB, - 'SELECT key, conversation_id, created_at, updated_at, substr(value, 1, 500), length(value) FROM conversations_v2 ORDER BY updated_at DESC' - ], { encoding: 'utf8', timeout: 5000, windowsHide: true }).trim(); - - if (!rows) return sessions; - - for (const row of rows.split('\n')) { - const parts = row.split('\t'); - if (parts.length < 5) continue; - const [directory, convId, createdAt, updatedAt, valuePeek, valueLen] = parts; - - // Extract first user prompt and estimate message count from JSON peek - let firstMsg = ''; - let msgCount = 0; - try { - const promptMatch = valuePeek.match(/"prompt":"([^"]{1,100})"/); - if (promptMatch) firstMsg = promptMatch[1]; - // Count "prompt" occurrences as rough message estimate (each turn has user+assistant) - const promptCount = (valuePeek.match(/"prompt"/g) || []).length; - msgCount = promptCount * 2; // user + assistant per turn - if (msgCount === 0 && parseInt(valueLen) > 100) msgCount = Math.max(2, Math.floor(parseInt(valueLen) / 2000)); - } catch {} - - sessions.push({ - id: convId, - tool: 'kiro', - project: directory || '', - project_short: (directory || '').replace(os.homedir(), '~'), - first_ts: parseInt(createdAt) || Date.now(), - last_ts: parseInt(updatedAt) || Date.now(), - messages: msgCount, - first_message: firstMsg, - has_detail: true, - file_size: parseInt(valueLen) || 0, - detail_messages: msgCount, - }); - } - } catch {} - - return sessions; -} - -function loadKiroDetail(conversationId) { - if (!fs.existsSync(KIRO_DB)) return { messages: [] }; - - try { - const raw = execFileSync('sqlite3', [ - KIRO_DB, - `SELECT value FROM conversations_v2 WHERE conversation_id = '${conversationId.replace(/'/g, "''")}';` - ], { encoding: 'utf8', timeout: 10000, windowsHide: true }).trim(); - - if (!raw) return { messages: [] }; - - const data = JSON.parse(raw); - const messages = []; - - for (const entry of (data.history || [])) { - if (entry.user) { - const prompt = (entry.user.content || {}).Prompt || {}; - const text = prompt.prompt || ''; - if (text) messages.push({ role: 'user', content: text.slice(0, 2000), uuid: '' }); - } - if (entry.assistant) { - const resp = entry.assistant.Response || entry.assistant.response || {}; - const text = resp.content || ''; - if (text) messages.push({ role: 'assistant', content: text.slice(0, 2000), uuid: resp.message_id || '' }); - } - } - - return { messages: messages.slice(0, 200) }; - } catch { - return { messages: [] }; - } -} - -// Cursor stores each workspace under ~/.cursor/projects// where is the -// absolute path with / and . replaced by -. Hyphens inside a directory name are -// preserved, so splitting on "-" cannot recover the path. Decode by -// greedily matching the longest real child directory name at each level. -function decodeCursorProjectFolderKey(proj) { - if (!proj) return ''; - let enc = proj; - let cwd = ''; - while (enc.length > 0) { - const parent = cwd || '/'; - let dirs; - try { - dirs = fs.readdirSync(parent, { withFileTypes: true }) - .filter(function (e) { return e.isDirectory(); }) - .map(function (e) { return e.name; }); - } catch { - return cwd || ('/' + proj.replace(/-/g, '/')); - } - dirs.sort(function (a, b) { return b.length - a.length; }); - var matched = null; - for (var j = 0; j < dirs.length; j++) { - var d = dirs[j]; - // Cursor encodes both / and . as -, so compare against encoded dir name - var encoded = d.replace(/[^a-zA-Z0-9-]/g, '-'); - if (enc === encoded || (enc.startsWith(encoded) && (enc.length === encoded.length || enc[encoded.length] === '-'))) { - matched = d; - break; - } - } - if (!matched) { - var idx = enc.indexOf('-'); - var part = idx === -1 ? enc : enc.slice(0, idx); - var next = cwd ? path.join(cwd, part) : path.join('/', part); - if (fs.existsSync(next)) { - cwd = next; - enc = idx === -1 ? '' : enc.slice(idx + 1); - } else { - return cwd || ('/' + proj.replace(/-/g, '/')); - } - continue; - } - cwd = cwd ? path.join(cwd, matched) : path.join('/', matched); - enc = enc.length === matched.length ? '' : enc.slice(matched.length + 1); - } - return cwd; -} - -// Build composerId -> project path mapping from Cursor workspace storage -// Uses disk cache to avoid querying 190+ SQLite files on every startup -let _cursorWsMapCache = null; -const CURSOR_WS_MAP_CACHE_FILE = path.join(os.tmpdir(), 'codedash-cursor-ws-map.json'); -const CURSOR_WS_MAP_TTL = 600000; // 10 minutes - -function buildCursorWorkspaceMap() { - if (_cursorWsMapCache) return _cursorWsMapCache; - - // Try loading from disk cache first (~1ms vs ~1500ms full rebuild) - try { - if (fs.existsSync(CURSOR_WS_MAP_CACHE_FILE)) { - const cached = JSON.parse(fs.readFileSync(CURSOR_WS_MAP_CACHE_FILE, 'utf8')); - if (cached._ts && (Date.now() - cached._ts) < CURSOR_WS_MAP_TTL) { - delete cached._ts; - _cursorWsMapCache = cached; - return cached; - } - } - } catch {} - - const map = {}; // composerId -> projectPath - if (!fs.existsSync(CURSOR_WORKSPACE_STORAGE)) return map; - - try { - // Step 1: Read all workspace.json files (fast fs reads, ~10ms) - const hashToFolder = {}; - for (const hash of fs.readdirSync(CURSOR_WORKSPACE_STORAGE)) { - const wsJson = path.join(CURSOR_WORKSPACE_STORAGE, hash, 'workspace.json'); - try { - const wsData = JSON.parse(fs.readFileSync(wsJson, 'utf8')); - let folder = wsData.folder || ''; - if (folder.startsWith('file://')) { - folder = decodeURIComponent(folder.replace('file://', '')); - } else if (folder.startsWith('vscode-remote://')) { - const m = folder.match(/vscode-remote:\/\/[^/]+(\/.*)/); - folder = m ? decodeURIComponent(m[1]) : ''; - } - if (folder) hashToFolder[hash] = folder; - } catch {} - } - - // Step 2: Query workspace state.vscdb files for composer IDs - for (const hash of Object.keys(hashToFolder)) { - const wsDb = path.join(CURSOR_WORKSPACE_STORAGE, hash, 'state.vscdb'); - if (!fs.existsSync(wsDb)) continue; - try { - const raw = execFileSync('sqlite3', [ - wsDb, - "SELECT value FROM ItemTable WHERE key = 'composer.composerData'" - ], { encoding: 'utf8', timeout: 2000, windowsHide: true }).trim(); - if (!raw) continue; - const data = JSON.parse(raw); - for (const c of (data.allComposers || [])) { - if (c.composerId) map[c.composerId] = hashToFolder[hash]; - } - } catch {} - } - } catch {} - - _cursorWsMapCache = map; - - // Save to disk cache for fast startup next time - try { - fs.writeFileSync(CURSOR_WS_MAP_CACHE_FILE, JSON.stringify(Object.assign({ _ts: Date.now() }, map))); - } catch {} - - return map; -} - -function scanCursorSessions() { - const sessions = []; - const seenIds = new Set(); - - // Scan ~/.cursor/projects/*/agent-transcripts/*/*.jsonl - if (fs.existsSync(CURSOR_PROJECTS)) { - try { - for (const proj of fs.readdirSync(CURSOR_PROJECTS)) { - const transcriptsDir = path.join(CURSOR_PROJECTS, proj, 'agent-transcripts'); - if (!fs.existsSync(transcriptsDir)) continue; - - const projectPath = decodeCursorProjectFolderKey(proj) || ('/' + proj.replace(/-/g, '/')); - - for (const sessDir of fs.readdirSync(transcriptsDir)) { - const sessFile = path.join(transcriptsDir, sessDir, sessDir + '.jsonl'); - if (!fs.existsSync(sessFile)) continue; - - const stat = fs.statSync(sessFile); - let firstMsg = ''; - let msgCount = 0; - try { - const firstLine = fs.readFileSync(sessFile, 'utf8').split('\n')[0].replace(/\r$/, ''); - const d = JSON.parse(firstLine); - const content = (d.message || {}).content; - if (Array.isArray(content)) { - for (const part of content) { - if (part.type === 'text' && part.text) { - // Strip wrapper - firstMsg = part.text.replace(/<\/?user_query>/g, '').trim().slice(0, 200); - break; - } - } - } - // Count lines - msgCount = readLines(sessFile).length; - } catch {} - - seenIds.add(sessDir); - sessions.push({ - id: sessDir, - tool: 'cursor', - project: projectPath, - project_short: projectPath.replace(os.homedir(), '~'), - first_ts: stat.mtimeMs - (msgCount * 60000), // rough estimate - last_ts: stat.mtimeMs, - messages: msgCount, - first_message: firstMsg, - has_detail: true, - file_size: stat.size, - detail_messages: msgCount, - _file: sessFile, - }); - } - } - } catch {} - } - - // Also scan ~/.cursor/chats/*/ (Linux format) - if (fs.existsSync(CURSOR_CHATS)) { - try { - for (const chatDir of fs.readdirSync(CURSOR_CHATS)) { - const fullDir = path.join(CURSOR_CHATS, chatDir); - if (!fs.statSync(fullDir).isDirectory()) continue; - - // Look for .jsonl or .json inside - for (const f of fs.readdirSync(fullDir)) { - if (!f.endsWith('.jsonl') && !f.endsWith('.json')) continue; - const filePath = path.join(fullDir, f); - const stat = fs.statSync(filePath); - - let firstMsg = ''; - let msgCount = 0; - try { - const firstLine = fs.readFileSync(filePath, 'utf8').split('\n')[0].replace(/\r$/, ''); - const d = JSON.parse(firstLine); - if (d.role === 'user') { - const content = (d.message || {}).content || d.content; - if (typeof content === 'string') firstMsg = content.slice(0, 200); - else if (Array.isArray(content)) { - for (const p of content) { - if (p.text) { firstMsg = p.text.replace(/<\/?user_query>/g, '').trim().slice(0, 200); break; } - } - } - } - msgCount = readLines(filePath).length; - } catch {} - - seenIds.add(chatDir); - sessions.push({ - id: chatDir, - tool: 'cursor', - project: '', - project_short: '', - first_ts: stat.mtimeMs - (msgCount * 60000), - last_ts: stat.mtimeMs, - messages: msgCount, - first_message: firstMsg, - has_detail: true, - file_size: stat.size, - detail_messages: msgCount, - _file: filePath, - }); - break; // one file per chat dir - } - } - } catch {} - } - - // Cursor vscdb sessions are loaded via background task (see _loadCursorVscdbInBackground) - // and merged into loadSessions() result when ready - - return sessions; -} - -function loadCursorDetail(sessionId) { - // Find the file - let filePath = null; - - // Search in projects - if (fs.existsSync(CURSOR_PROJECTS)) { - for (const proj of fs.readdirSync(CURSOR_PROJECTS)) { - const f = path.join(CURSOR_PROJECTS, proj, 'agent-transcripts', sessionId, sessionId + '.jsonl'); - if (fs.existsSync(f)) { filePath = f; break; } - } - } - - // Search in chats - if (!filePath && fs.existsSync(CURSOR_CHATS)) { - const chatDir = path.join(CURSOR_CHATS, sessionId); - if (fs.existsSync(chatDir)) { - for (const f of fs.readdirSync(chatDir)) { - if (f.endsWith('.jsonl') || f.endsWith('.json')) { - filePath = path.join(chatDir, f); - break; - } - } - } - } - - // Try loading from global vscdb (Cursor stores most sessions here) - if (!filePath && fs.existsSync(CURSOR_GLOBAL_DB)) { - return loadCursorVscdbDetail(sessionId); - } - - if (!filePath) return { messages: [] }; - - const messages = []; - const lines = readLines(filePath); - - for (const line of lines) { - try { - const d = JSON.parse(line); - const role = d.role; - if (role !== 'user' && role !== 'assistant') continue; - - const content = (d.message || {}).content || d.content || ''; - let text = ''; - if (typeof content === 'string') { - text = content; - } else if (Array.isArray(content)) { - text = content - .filter(function(p) { return p.type === 'text' && p.text; }) - .map(function(p) { return p.text; }) - .join('\n'); - } - - // Strip Cursor wrappers - text = text.replace(/<\/?user_query>/g, '').replace(/<\/?tool_call>/g, '').trim(); - if (!text) continue; - - messages.push({ role: role, content: text.slice(0, 2000), uuid: '' }); - } catch {} - } - - return { messages: messages.slice(0, 200) }; -} - -// Load Cursor session detail from global state.vscdb (composerData + bubbleId entries) -function loadCursorVscdbDetail(sessionId) { - const messages = []; - - try { - // Get bubble order from composerData - const cleanId = sessionId.replace(/'/g, "''"); - const composerRaw = execFileSync('sqlite3', [ - CURSOR_GLOBAL_DB, - "SELECT value FROM cursorDiskKV WHERE key = 'composerData:" + cleanId + "'" - ], { encoding: 'utf8', timeout: 5000, windowsHide: true }).trim(); - - if (!composerRaw) return { messages: [] }; - - const composer = JSON.parse(composerRaw); - const bubbleHeaders = composer.fullConversationHeadersOnly || []; - if (bubbleHeaders.length === 0) return { messages: [] }; - - // Query all bubbles for this composer in one go - const bubbleRows = execFileSync('sqlite3', [ - '-separator', '\t', - CURSOR_GLOBAL_DB, - "SELECT key, value FROM cursorDiskKV WHERE key LIKE 'bubbleId:" + cleanId + ":%'" - ], { encoding: 'utf8', timeout: 10000, maxBuffer: 50 * 1024 * 1024, windowsHide: true }).trim(); - - if (!bubbleRows) return { messages: [] }; - - // Build bubbleId -> data map - const bubbleMap = {}; - for (const row of bubbleRows.split('\n')) { - const tabIdx = row.indexOf('\t'); - if (tabIdx < 0) continue; - const key = row.slice(0, tabIdx); - const value = row.slice(tabIdx + 1); - // key format: bubbleId:: - const parts = key.split(':'); - const bubbleId = parts[2]; - if (!bubbleId) continue; - try { - bubbleMap[bubbleId] = JSON.parse(value); - } catch {} - } - - // Iterate in conversation order - for (const header of bubbleHeaders) { - const bubble = bubbleMap[header.bubbleId]; - if (!bubble) continue; - - // type 1 = user, type 2 = assistant - const bType = bubble.type; - if (bType !== 1 && bType !== 2) continue; - - const role = bType === 1 ? 'user' : 'assistant'; - let text = bubble.text || ''; - text = text.replace(/<\/?user_query>/g, '').replace(/<\/?tool_call>/g, '').trim(); - if (!text) continue; - - messages.push({ role: role, content: text.slice(0, 2000), uuid: '' }); - } - } catch {} - - return { messages: messages.slice(0, 200) }; -} - -function parseCodexSessionFile(sessionFile) { - if (!fs.existsSync(sessionFile)) return null; - - let stat; - let lines; - try { - stat = fs.statSync(sessionFile); - lines = readLines(sessionFile); - } catch { - return null; - } - - const parseTimestamp = (value) => { - if (typeof value === 'number' && Number.isFinite(value)) return value; - if (typeof value === 'string') { - const trimmed = value.trim(); - if (!trimmed) return NaN; - if (/^\d+$/.test(trimmed)) return Number(trimmed); - return Date.parse(trimmed); - } - return NaN; - }; - - let projectPath = ''; - let msgCount = 0; - let userMsgCount = 0; - let firstMsg = ''; - let firstTs = stat.mtimeMs; - let lastTs = stat.mtimeMs; - const mcpSet = new Set(); - - for (const line of lines) { - try { - const entry = JSON.parse(line); - const ts = parseTimestamp(entry.timestamp || entry.ts); - if (Number.isFinite(ts)) { - if (ts < firstTs) firstTs = ts; - if (ts > lastTs) lastTs = ts; - } - - if (entry.type === 'session_meta' && entry.payload && entry.payload.cwd && !projectPath) { - projectPath = entry.payload.cwd; - continue; - } - - if (entry.type !== 'response_item' || !entry.payload) continue; - - // MCP function_call extraction - if (entry.payload.type === 'function_call') { - const name = entry.payload.name || ''; - if (name.startsWith('mcp__')) { - const parts = name.split('__'); - if (parts.length >= 3) mcpSet.add(parts[1]); - } - continue; - } - - const role = entry.payload.role; - if (role !== 'user' && role !== 'assistant') continue; - - const content = extractContent(entry.payload.content); - if (!content || isSystemMessage(content)) continue; - - msgCount++; - if (role === 'user') userMsgCount++; - if (!firstMsg) firstMsg = content.slice(0, 200); - } catch {} - } - - return { - projectPath, - msgCount, - userMsgCount, - firstMsg, - firstTs, - lastTs, - fileSize: stat.size, - mcpServers: Array.from(mcpSet), - }; -} - -function scanCodexSessions() { - const sessions = []; - const codexTitles = parseCodexSessionIndex(CODEX_DIR); - const codexHistory = path.join(CODEX_DIR, 'history.jsonl'); - if (fs.existsSync(codexHistory)) { - const lines = readLines(codexHistory); - for (const line of lines) { - try { - const d = JSON.parse(line); - // Codex uses session_id, ts (seconds), text - const sid = d.session_id || d.sessionId || d.id; - if (!sid) continue; - const ts = d.ts ? d.ts * 1000 : (d.timestamp || Date.now()); - if (!sessions.find(s => s.id === sid)) { - sessions.push({ - id: sid, - tool: 'codex', - project: d.project || d.cwd || '', - project_short: (d.project || d.cwd || '').replace(os.homedir(), '~'), - first_ts: ts, - last_ts: ts, - messages: 1, - first_message: codexTitles[sid] || d.text || d.display || d.prompt || '', - has_detail: false, - file_size: 0, - detail_messages: 0, - }); - } - } catch {} - } - } - - // Enrich with session files from ~/.codex/sessions/ - const codexSessionsDir = path.join(CODEX_DIR, 'sessions'); - if (fs.existsSync(codexSessionsDir)) { - try { - // Walk year/month/day directories - const files = []; - const walkDir = (dir) => { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) walkDir(full); - else if (entry.name.endsWith('.jsonl')) files.push(full); - } - }; - walkDir(codexSessionsDir); - - for (const f of files) { - // Extract session ID from filename (rollout-DATE-UUID.jsonl) - const basename = path.basename(f, '.jsonl'); - const uuidMatch = basename.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/); - if (!uuidMatch) continue; - const sid = uuidMatch[1]; - const summary = parseCodexSessionFile(f); - if (!summary) continue; - - const existing = sessions.find(s => s.id === sid); - if (existing) { - existing.has_detail = true; - existing.file_size = summary.fileSize; - existing.messages = summary.msgCount; - existing.detail_messages = summary.msgCount; - existing.user_messages = summary.userMsgCount || 0; - if (codexTitles[sid]) { - existing.first_message = codexTitles[sid]; - } else if (summary.firstMsg && !existing.first_message) { - existing.first_message = summary.firstMsg; - } - if (summary.projectPath && !existing.project) { - existing.project = summary.projectPath; - existing.project_short = summary.projectPath.replace(os.homedir(), '~'); - } - existing.first_ts = Math.min(existing.first_ts, summary.firstTs); - existing.last_ts = Math.max(existing.last_ts, summary.lastTs); - if (summary.mcpServers && summary.mcpServers.length > 0) { - existing.mcp_servers = summary.mcpServers; - } - } else { - sessions.push({ - id: sid, - tool: 'codex', - project: summary.projectPath, - project_short: summary.projectPath ? summary.projectPath.replace(os.homedir(), '~') : '', - first_ts: summary.firstTs, - last_ts: summary.lastTs, - messages: summary.msgCount, - first_message: codexTitles[sid] || summary.firstMsg || '', - has_detail: true, - file_size: summary.fileSize, - detail_messages: summary.msgCount, - user_messages: summary.userMsgCount || 0, - mcp_servers: summary.mcpServers || [], - skills: [], - }); - } - } - } catch {} - } - - return sessions; -} - -// ── Git root resolver ─────────────────────────────────────── -// -// Priority order for determining the git root of a session: -// 1. worktree-state.originalCwd — written by Claude Code into the JSONL when -// the session runs inside a git worktree. Container-safe: no git required. -// 2. git rev-parse --show-toplevel — resolves the root at runtime. Fails -// gracefully (returns '') in containerized setups where git repos are not -// mounted; the try/catch ensures it never crashes the server. -// 3. Path heuristic in the frontend (getGitProjectName) — parses /.claude/worktrees/ -// from the session cwd string. Works without git for standard worktree layouts. - -const _gitRootCache = {}; -const GIT_ROOT_CACHE_FILE = path.join(os.tmpdir(), 'codedash-gitroot-cache.json'); -let _gitRootDiskCache = null; - -function _loadGitRootDiskCache() { - if (_gitRootDiskCache) return; - try { - if (fs.existsSync(GIT_ROOT_CACHE_FILE)) { - _gitRootDiskCache = JSON.parse(fs.readFileSync(GIT_ROOT_CACHE_FILE, 'utf8')); - // Pre-fill memory cache from disk - Object.assign(_gitRootCache, _gitRootDiskCache); - } - } catch {} - if (!_gitRootDiskCache) _gitRootDiskCache = {}; -} - -function _saveGitRootDiskCache() { - try { - fs.writeFileSync(GIT_ROOT_CACHE_FILE, JSON.stringify(_gitRootCache)); - } catch {} -} - -function resolveGitRoot(projectPath) { - if (!projectPath) return ''; - _loadGitRootDiskCache(); - if (_gitRootCache[projectPath] !== undefined) return _gitRootCache[projectPath]; - // Skip remote/non-existent paths - if (!fs.existsSync(projectPath)) { - _gitRootCache[projectPath] = ''; - return ''; - } - try { - const root = execFileSync('git', ['-C', projectPath, 'rev-parse', '--show-toplevel'], { - encoding: 'utf8', timeout: 2000, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] - }).trim(); - _gitRootCache[projectPath] = root; - return root; - } catch { - _gitRootCache[projectPath] = ''; - return ''; - } -} - -const _gitInfoCache = {}; -const GIT_INFO_CACHE_TTL = 30000; // 30 seconds - -function getProjectGitInfo(projectPath) { - if (!projectPath || !fs.existsSync(projectPath)) return null; - if (process.platform === 'win32') return null; - - const now = Date.now(); - const cached = _gitInfoCache[projectPath]; - if (cached && (now - cached._ts) < GIT_INFO_CACHE_TTL) return cached; - - const gitRoot = resolveGitRoot(projectPath); - if (!gitRoot) return null; - - const cwd = gitRoot; - const opts = { encoding: 'utf8', timeout: 3000, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }; - const info = { gitRoot, branch: '', remoteUrl: '', lastCommit: '', lastCommitDate: '', isDirty: false, _ts: now }; - - try { info.branch = execFileSync('git', ['-C', cwd, 'rev-parse', '--abbrev-ref', 'HEAD'], opts).trim(); } catch {} - try { info.remoteUrl = execFileSync('git', ['-C', cwd, 'config', '--get', 'remote.origin.url'], opts).trim(); } catch {} - try { - const log = execFileSync('git', ['-C', cwd, 'log', '-1', '--format=%h %s'], opts).trim(); - if (log) { - const sp = log.indexOf(' '); - info.lastCommit = sp > 0 ? log.slice(sp + 1).slice(0, 80) : log; - info.lastCommitHash = sp > 0 ? log.slice(0, sp) : ''; - } - } catch {} - try { info.lastCommitDate = execFileSync('git', ['-C', cwd, 'log', '-1', '--format=%ci'], opts).trim(); } catch {} - try { - const status = execFileSync('git', ['-C', cwd, 'status', '--porcelain'], opts).trim(); - info.isDirty = status.length > 0; - } catch {} - - _gitInfoCache[projectPath] = info; - return info; -} - -// ── Public API ───────────────────────────────────────────── - -let _sessionsCache = null; -let _sessionsCacheTs = 0; -const SESSIONS_CACHE_TTL = 60000; // 60 seconds — hot cache, invalidated by file changes - -// Track file mtimes for smart invalidation -let _historyMtime = 0; -let _historySize = 0; -let _projectsDirMtime = 0; - -function _sessionsNeedRescan() { - // Check if history.jsonl or projects dir changed since last scan - try { - if (fs.existsSync(HISTORY_FILE)) { - const st = fs.statSync(HISTORY_FILE); - if (st.mtimeMs !== _historyMtime || st.size !== _historySize) return true; - } - if (fs.existsSync(PROJECTS_DIR)) { - const st = fs.statSync(PROJECTS_DIR); - if (st.mtimeMs !== _projectsDirMtime) return true; - } - } catch {} - return false; -} - -function _updateScanMarkers() { - try { - if (fs.existsSync(HISTORY_FILE)) { - const st = fs.statSync(HISTORY_FILE); - _historyMtime = st.mtimeMs; - _historySize = st.size; - } - if (fs.existsSync(PROJECTS_DIR)) { - _projectsDirMtime = fs.statSync(PROJECTS_DIR).mtimeMs; - } - } catch {} -} - -// Progressive loading: cursor vscdb sessions load in background -let _cursorVscdbSessions = null; -let _cursorVscdbLoading = false; - -function _loadCursorVscdbInBackground() { - if (_cursorVscdbLoading || _cursorVscdbSessions) return; - if (!fs.existsSync(CURSOR_GLOBAL_DB)) { _cursorVscdbSessions = []; return; } - _cursorVscdbLoading = true; - - // Workspace map from disk cache is instant (~1ms), only global DB query is slow - const wsMap = buildCursorWorkspaceMap(); - const homedir = os.homedir(); - - // Async sqlite3 queries — do NOT block the event loop - // Query 1: session metadata, Query 2: exact user bubble count per composer - const query = "SELECT json_extract(value, '$.composerId'), json_extract(value, '$.name'), json_extract(value, '$.createdAt'), json_extract(value, '$.lastUpdatedAt'), json_array_length(json_extract(value, '$.fullConversationHeadersOnly')) FROM cursorDiskKV WHERE key LIKE 'composerData:%'"; - - const cp = require('child_process'); - cp.execFile('sqlite3', [ - '-separator', '\t', CURSOR_GLOBAL_DB, query - ], { encoding: 'utf8', timeout: 15000, maxBuffer: 10 * 1024 * 1024, windowsHide: true }, - function(err, stdout) { - // Query 2: user bubble counts + token totals per composer (combined for efficiency) - const statsQuery = "SELECT substr(key, 10, 36) as cid, " + - "sum(CASE WHEN json_extract(value, '$.type') = 1 THEN 1 ELSE 0 END), " + - "sum(CASE WHEN json_extract(value, '$.tokenCount.inputTokens') > 0 THEN json_extract(value, '$.tokenCount.inputTokens') ELSE 0 END), " + - "sum(CASE WHEN json_extract(value, '$.tokenCount.outputTokens') > 0 THEN json_extract(value, '$.tokenCount.outputTokens') ELSE 0 END) " + - "FROM cursorDiskKV WHERE key LIKE 'bubbleId:%' GROUP BY cid"; - - cp.execFile('sqlite3', [ - '-separator', '\t', CURSOR_GLOBAL_DB, statsQuery - ], { encoding: 'utf8', timeout: 30000, maxBuffer: 10 * 1024 * 1024, windowsHide: true }, - function(err2, stdout2) { - // Build per-composer stats from query 2 - const composerStats = {}; // { userCount, inputTokens, outputTokens } - if (stdout2) { - for (const row of stdout2.trim().split('\n')) { - const cols = row.split('\t'); - if (cols.length < 4) continue; - composerStats[cols[0]] = { - userCount: parseInt(cols[1]) || 0, - inputTokens: parseInt(cols[2]) || 0, - outputTokens: parseInt(cols[3]) || 0, - }; - } - } - - // Build model map from composerData (query 1 already has this via the main query) - // We need to add model to the main query — for now extract from sessions metadata - // Query 3: models per composer (lightweight) - const modelQuery = "SELECT json_extract(value, '$.composerId'), json_extract(value, '$.modelConfig.modelName') FROM cursorDiskKV WHERE key LIKE 'composerData:%'"; - - cp.execFile('sqlite3', [ - '-separator', '\t', CURSOR_GLOBAL_DB, modelQuery - ], { encoding: 'utf8', timeout: 10000, maxBuffer: 5 * 1024 * 1024, windowsHide: true }, - function(err3, stdout3) { - const composerModels = {}; - if (stdout3) { - for (const row of stdout3.trim().split('\n')) { - const tabIdx = row.indexOf('\t'); - if (tabIdx > 0) composerModels[row.slice(0, tabIdx)] = row.slice(tabIdx + 1) || ''; - } - } - - try { - const results = []; - const rows = (stdout || '').trim(); - if (rows) { - for (const row of rows.split('\n')) { - const cols = row.split('\t'); - if (cols.length < 5) continue; - const composerId = cols[0]; - if (!composerId) continue; - const msgCount = parseInt(cols[4]) || 0; - if (msgCount === 0) continue; - const projectPath = wsMap[composerId] || ''; - const stats = composerStats[composerId] || {}; - results.push({ - id: composerId, - tool: 'cursor', - project: projectPath, - project_short: projectPath ? projectPath.replace(homedir, '~') : '', - first_ts: parseInt(cols[2]) || 0, - last_ts: parseInt(cols[3]) || parseInt(cols[2]) || 0, - messages: msgCount, - first_message: (cols[1] || '').slice(0, 200), - has_detail: true, - file_size: 0, - detail_messages: msgCount, - user_messages: stats.userCount || 0, - _cursor_vscdb: true, - _cursor_input_tokens: stats.inputTokens || 0, - _cursor_output_tokens: stats.outputTokens || 0, - _cursor_model: composerModels[composerId] || '', - }); - } - } - _cursorVscdbSessions = results; - } catch { - _cursorVscdbSessions = []; - } - _cursorVscdbLoading = false; - // Merge into existing cache instead of full invalidation - if (_sessionsCache && _cursorVscdbSessions && _cursorVscdbSessions.length > 0) { - const existingIds = new Set(_sessionsCache.map(function(s) { return s.id; })); - const newSessions = []; - for (var i = 0; i < _cursorVscdbSessions.length; i++) { - var cs = _cursorVscdbSessions[i]; - if (existingIds.has(cs.id)) continue; - cs.first_time = new Date(cs.first_ts).toLocaleString('sv-SE').slice(0, 16); - cs.last_time = new Date(cs.last_ts).toLocaleString('sv-SE').slice(0, 16); - var dt = new Date(cs.last_ts); - cs.date = dt.getFullYear() + '-' + String(dt.getMonth()+1).padStart(2,'0') + '-' + String(dt.getDate()).padStart(2,'0'); - cs.git_root = ''; - if (!cs.mcp_servers) cs.mcp_servers = []; - if (!cs.skills) cs.skills = []; - newSessions.push(cs); - } - if (newSessions.length > 0) { - _sessionsCache = _sessionsCache.concat(newSessions).sort(function(a, b) { return b.last_ts - a.last_ts; }); - // Keep the same cache timestamp — no full rebuild needed - } - } else { - _sessionsCache = null; - _sessionsCacheTs = 0; - } - }); // end execFile query 3 (models) - }); // end execFile query 2 (stats) - }); // end execFile query 1 (sessions) -} - -function loadSessions() { - const now = Date.now(); - if (_sessionsCache) { - // Hot cache: return immediately if within TTL and no file changes - if ((now - _sessionsCacheTs) < SESSIONS_CACHE_TTL) return _sessionsCache; - // Extended cache: even after TTL, only rescan if files actually changed - if (!_sessionsNeedRescan()) { - _sessionsCacheTs = now; // extend TTL - return _sessionsCache; - } - } - const sessions = {}; - - // Load Claude Code sessions - if (fs.existsSync(HISTORY_FILE)) { - const lines = readLines(HISTORY_FILE); - for (const line of lines) { - try { - const d = JSON.parse(line); - const sid = d.sessionId; - if (!sid) continue; - - if (!sessions[sid]) { - sessions[sid] = { - id: sid, - tool: 'claude', - project: d.project || '', - project_short: (d.project || '').replace(os.homedir(), '~'), - first_ts: d.timestamp, - last_ts: d.timestamp, - messages: 0, - first_message: '', - _claude_dir: CLAUDE_DIR, - }; - } - - const s = sessions[sid]; - s.last_ts = Math.max(s.last_ts, d.timestamp); - s.first_ts = Math.min(s.first_ts, d.timestamp); - s.messages++; - - if (d.display && d.display !== 'exit' && !s.first_message) { - s.first_message = d.display.slice(0, 200); - } - } catch {} - } - } - - // Load Codex sessions - if (fs.existsSync(CODEX_DIR)) { - try { - const codexSessions = scanCodexSessions(); - for (const cs of codexSessions) { - sessions[cs.id] = cs; - } - } catch {} - } - - // Load OpenCode sessions - try { - const opencodeSessions = scanOpenCodeSessions(); - for (const ocs of opencodeSessions) { - sessions[ocs.id] = ocs; - } - } catch {} - - // Load Cursor sessions - try { - const cursorSessions = scanCursorSessions(); - for (const cs of cursorSessions) { - sessions[cs.id] = cs; - } - } catch {} - - // Load Kiro sessions - try { - const kiroSessions = scanKiroSessions(); - for (const ks of kiroSessions) { - sessions[ks.id] = ks; - } - } catch {} - - // WSL: also load from Windows-side dirs - for (const extraClaudeDir of EXTRA_CLAUDE_DIRS) { - try { - const extraHistory = path.join(extraClaudeDir, 'history.jsonl'); - if (fs.existsSync(extraHistory)) { - const lines = readLines(extraHistory); - for (const line of lines) { - let d; - try { - d = JSON.parse(line); - const sid = d.sessionId; - if (!sid) continue; - if (!sessions[sid]) { - sessions[sid] = { - id: sid, tool: 'claude', - project: d.project || '', project_short: (d.project || '').replace(os.homedir(), '~'), - first_ts: d.timestamp, last_ts: d.timestamp, - messages: 0, first_message: '', - _claude_dir: extraClaudeDir, - }; - } - } catch {} - if (!d || !d.sessionId) continue; - const s = sessions[d.sessionId]; - if (s) { s.last_ts = Math.max(s.last_ts, d.timestamp); s.first_ts = Math.min(s.first_ts, d.timestamp); s.messages++; if (d.display && d.display !== 'exit' && !s.first_message) s.first_message = d.display.slice(0, 200); } - } - } - // Scan extra projects dirs - const extraProjects = path.join(extraClaudeDir, 'projects'); - if (fs.existsSync(extraProjects)) { - for (const proj of fs.readdirSync(extraProjects)) { - const projDir = path.join(extraProjects, proj); - if (!fs.statSync(projDir).isDirectory()) continue; - for (const file of fs.readdirSync(projDir)) { - if (!file.endsWith('.jsonl')) continue; - const sid = file.replace('.jsonl', ''); - const fp = path.join(projDir, file); - if (sessions[sid]) { - const summary = parseClaudeSessionFile(fp); - if (summary) mergeClaudeSessionDetail(sessions[sid], summary, fp); - else if (!sessions[sid].has_detail) { - sessions[sid].has_detail = true; - sessions[sid].file_size = fs.statSync(fp).size; - sessions[sid]._session_file = fp; - } - continue; - } - const summary = parseClaudeSessionFile(fp); - if (!summary) continue; - sessions[sid] = { - id: sid, - tool: summary.tool, - project: summary.projectPath, - project_short: summary.projectPath.replace(os.homedir(), '~'), - first_ts: summary.firstTs, - last_ts: summary.lastTs, - messages: summary.msgCount, - first_message: summary.customTitle || summary.firstMsg, - has_detail: true, - file_size: summary.fileSize, - detail_messages: summary.msgCount, - _claude_dir: extraClaudeDir, - _session_file: fp, - worktree_original_cwd: summary.worktreeOriginalCwd || '', - }; - } - } - } - } catch {} - } - - // Enrich Claude sessions with detail file info - // Build file index once to avoid O(sessions*projects) existsSync scans - _buildSessionFileIndex(); - for (const [sid, s] of Object.entries(sessions)) { - if (s.tool !== 'claude' && s.tool !== 'claude-ext') continue; - let sessionFile = ''; - if (s._session_file) { - sessionFile = s._session_file; - } else { - // Use pre-built index instead of scanning dirs - const indexed = _sessionFileIndex[sid]; - if (indexed && indexed.format === 'claude') sessionFile = indexed.file; - } - - if (sessionFile) { - const summary = parseClaudeSessionFile(sessionFile); - if (summary) mergeClaudeSessionDetail(s, summary, sessionFile); - else { - s.has_detail = true; - try { s.file_size = fs.statSync(sessionFile).size; } catch { s.file_size = 0; } - s._session_file = sessionFile; - } - } else if (!s.has_detail) { - s.has_detail = false; - s.file_size = 0; - s.detail_messages = 0; - s.mcp_servers = []; - s.skills = []; - } - } - - // Scan project dirs for orphan sessions (e.g. Claude Extension sessions not in history.jsonl) - if (fs.existsSync(PROJECTS_DIR)) { - try { - for (const proj of fs.readdirSync(PROJECTS_DIR)) { - const projDir = path.join(PROJECTS_DIR, proj); - if (!fs.statSync(projDir).isDirectory()) continue; - for (const file of fs.readdirSync(projDir)) { - if (!file.endsWith('.jsonl')) continue; - const sid = file.replace('.jsonl', ''); - const filePath = path.join(projDir, file); - if (sessions[sid]) { - const summary = parseClaudeSessionFile(filePath); - if (summary) mergeClaudeSessionDetail(sessions[sid], summary, filePath); - continue; - } - const summary = parseClaudeSessionFile(filePath); - if (!summary) continue; - sessions[sid] = { - id: sid, - tool: summary.tool, - project: summary.projectPath, - project_short: summary.projectPath.replace(os.homedir(), '~'), - first_ts: summary.firstTs, - last_ts: summary.lastTs, - messages: summary.msgCount, - first_message: summary.customTitle || summary.firstMsg, - has_detail: true, - file_size: summary.fileSize, - detail_messages: summary.msgCount, - mcp_servers: summary.mcpServers, - skills: summary.skills, - _claude_dir: CLAUDE_DIR, - _session_file: filePath, - worktree_original_cwd: summary.worktreeOriginalCwd || '', - }; - } - } - } catch {} - } - - // Ensure all sessions have mcp_servers/skills (defaults for non-Claude) - for (const s of Object.values(sessions)) { - if (!s.mcp_servers) s.mcp_servers = []; - if (!s.skills) s.skills = []; - } - - // Merge background-loaded Cursor vscdb sessions (progressive loading) - const existingIds = new Set(Object.keys(sessions)); - if (_cursorVscdbSessions) { - for (const cs of _cursorVscdbSessions) { - if (!existingIds.has(cs.id)) sessions[cs.id] = cs; - } - } else { - // Kick off background loading if not started yet - _loadCursorVscdbInBackground(); - } - - const result = Object.values(sessions).sort((a, b) => b.last_ts - a.last_ts); - - // Collect unique project paths and resolve git roots in one pass - const uniquePaths = [...new Set(result.map(s => s.project).filter(Boolean))]; - for (const p of uniquePaths) resolveGitRoot(p); - - for (const s of result) { - s.first_time = new Date(s.first_ts).toLocaleString('sv-SE').slice(0, 16); - s.last_time = new Date(s.last_ts).toLocaleString('sv-SE').slice(0, 16); - const dt = new Date(s.last_ts); - s.date = dt.getFullYear() + '-' + String(dt.getMonth()+1).padStart(2,'0') + '-' + String(dt.getDate()).padStart(2,'0'); - // Priority: worktree-state.originalCwd (container-safe) > git rev-parse > path heuristic (frontend) - s.git_root = s.worktree_original_cwd || (s.project ? (_gitRootCache[s.project] || '') : ''); - } - - // Flag for frontend: true = cursor vscdb still loading, will have more data soon - result._loading = !_cursorVscdbSessions && _cursorVscdbLoading; - - // Flush disk caches - _saveParsedDiskCache(); - _saveGitRootDiskCache(); - _updateScanMarkers(); - - _sessionsCache = result; - _sessionsCacheTs = Date.now(); - return result; -} - -function loadSessionDetail(sessionId, project) { - const found = findSessionFile(sessionId, project); - if (!found) return { error: 'Session file not found', messages: [] }; - - // OpenCode uses SQLite - if (found.format === 'opencode') { - return loadOpenCodeDetail(sessionId); - } - - // Cursor - if (found.format === 'cursor') { - return loadCursorDetail(sessionId); - } - - // Kiro uses SQLite - if (found.format === 'kiro') { - return loadKiroDetail(sessionId); - } - - const messages = []; - const lines = readLines(found.file); - - for (const line of lines) { - try { - const entry = JSON.parse(line); - - if (found.format === 'claude') { - if (entry.type === 'user' || entry.type === 'assistant') { - const content = extractContent((entry.message || {}).content); - if (content) { - const msg = { role: entry.type, content: content.slice(0, 2000), uuid: entry.uuid || '' }; - if (entry.type === 'assistant') { - const rawContent = (entry.message || {}).content; - if (Array.isArray(rawContent)) { - const tools = extractTools(rawContent); - if (tools.length > 0) msg.tools = tools; - } - } - messages.push(msg); - } - } - } else { - // Codex format: response_item with payload - if (entry.type === 'response_item' && entry.payload) { - const pType = entry.payload.type; - const role = entry.payload.role; - if (role === 'user' || role === 'assistant') { - const content = extractContent(entry.payload.content); - if (content && !isSystemMessage(content)) { - messages.push({ role: role, content: content.slice(0, 2000), uuid: '' }); - } - } - // Codex function_call → attach as tool to last assistant message - if (pType === 'function_call') { - const name = entry.payload.name || ''; - if (name.startsWith('mcp__')) { - const parts = name.split('__'); - if (parts.length >= 3) { - const lastMsg = messages.length > 0 ? messages[messages.length - 1] : null; - if (lastMsg && lastMsg.role === 'assistant') { - if (!lastMsg.tools) lastMsg.tools = []; - if (!lastMsg._toolSeen) lastMsg._toolSeen = new Set(); - const tool = parts.slice(2).join('__'); - const key = 'mcp:' + parts[1] + ':' + tool; - if (!lastMsg._toolSeen.has(key)) { - lastMsg._toolSeen.add(key); - lastMsg.tools.push({ type: 'mcp', server: parts[1], tool: tool }); - } - } - } - } - } - } - } - } catch {} - } - - // Clean up internal markers from Codex - for (const m of messages) { - if (m._toolSeen) delete m._toolSeen; - } - - return { messages: messages.slice(0, 200) }; -} - -function deleteSession(sessionId, project) { - const deleted = []; - - // 1. Remove session JSONL file from project dir - const projectKey = project.replace(/[^a-zA-Z0-9-]/g, '-'); - const sessionFile = path.join(PROJECTS_DIR, projectKey, `${sessionId}.jsonl`); - if (fs.existsSync(sessionFile)) { - fs.unlinkSync(sessionFile); - deleted.push('session file'); - } - - // Also remove companion directory if exists (some sessions have one) - const sessionDir = path.join(PROJECTS_DIR, projectKey, sessionId); - if (fs.existsSync(sessionDir) && fs.statSync(sessionDir).isDirectory()) { - fs.rmSync(sessionDir, { recursive: true }); - deleted.push('session dir'); - } - - // 2. Remove entries from history.jsonl - if (fs.existsSync(HISTORY_FILE)) { - const lines = readLines(HISTORY_FILE); - const filtered = lines.filter(line => { - try { - const d = JSON.parse(line); - return d.sessionId !== sessionId; - } catch { return true; } - }); - if (filtered.length < lines.length) { - fs.writeFileSync(HISTORY_FILE, filtered.join('\n') + '\n'); - deleted.push(`${lines.length - filtered.length} history entries`); - } - } - - // 3. Remove session-env file if exists - const envFile = path.join(CLAUDE_DIR, 'session-env', `${sessionId}.json`); - if (fs.existsSync(envFile)) { - fs.unlinkSync(envFile); - deleted.push('env file'); - } - - return deleted; -} - -function getGitCommits(projectDir, fromTs, toTs) { - try { - if (!projectDir || !fs.existsSync(projectDir)) { - return []; - } - - const afterDate = new Date(fromTs).toISOString(); - const beforeDate = new Date(toTs).toISOString(); - - const output = execFileSync('git', [ - 'log', '--oneline', `--after=${afterDate}`, `--before=${beforeDate}` - ], { cwd: projectDir, encoding: 'utf8', timeout: 5000, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }).trim(); - - if (!output) return []; - - return output.split('\n').map(line => { - const spaceIdx = line.indexOf(' '); - if (spaceIdx === -1) return { hash: line, message: '' }; - return { - hash: line.slice(0, spaceIdx), - message: line.slice(spaceIdx + 1), - }; - }); - } catch { - return []; - } -} - -function exportSessionMarkdown(sessionId, project) { - const found = findSessionFile(sessionId, project); - - // For non-Claude formats, use the detail loader for markdown export - if (found && found.format !== 'claude') { - const detail = - found.format === 'cursor' ? loadCursorDetail(sessionId) : - found.format === 'opencode' ? loadOpenCodeDetail(sessionId) : - found.format === 'kiro' ? loadKiroDetail(sessionId) : - null; - if (detail && detail.messages && detail.messages.length > 0) { - const parts = [`# Session ${sessionId}\n\n**Project:** ${project || '(none)'}\n`]; - for (const msg of detail.messages) { - const header = msg.role === 'user' ? '## User' : '## Assistant'; - parts.push(`\n${header}\n\n${msg.content}\n`); - } - return parts.join(''); - } - } - - if (!found || found.format !== 'claude' || !fs.existsSync(found.file)) { - return `# Session ${sessionId}\n\nSession file not found.\n`; - } - - const sessionFile = found.file; - const summary = parseClaudeSessionFile(sessionFile); - const lines = readLines(sessionFile); - const projectLabel = project || (summary && summary.projectPath) || '(none)'; - const parts = [`# Session ${sessionId}\n\n**Project:** ${projectLabel}\n`]; - - for (const line of lines) { - try { - const entry = JSON.parse(line); - if (entry.type === 'user' || entry.type === 'assistant') { - const msg = entry.message || {}; - let content = msg.content || ''; - if (Array.isArray(content)) { - content = content - .map(b => (typeof b === 'string' ? b : (b.type === 'text' ? b.text : ''))) - .filter(Boolean) - .join('\n'); - } - const header = entry.type === 'user' ? '## User' : '## Assistant'; - parts.push(`\n${header}\n\n${content}\n`); - } - } catch {} - } - - return parts.join(''); -} - -// ── Session Preview (first N messages, lightweight) ──────── - -// Session file index: sessionId -> file path (built once, avoids O(sessions*projects) scans) -let _sessionFileIndex = null; -let _sessionFileIndexTs = 0; -const SESSION_FILE_INDEX_TTL = 120000; // 2 minutes — dirs rarely change - -function _buildSessionFileIndex() { - const now = Date.now(); - if (_sessionFileIndex && (now - _sessionFileIndexTs) < SESSION_FILE_INDEX_TTL) return; - - _sessionFileIndex = {}; - // Index Claude project files - const allProjectDirs = [PROJECTS_DIR]; - for (const extraDir of EXTRA_CLAUDE_DIRS) { - allProjectDirs.push(path.join(extraDir, 'projects')); - } - for (const projDir of allProjectDirs) { - if (!fs.existsSync(projDir)) continue; - try { - for (const proj of fs.readdirSync(projDir)) { - const dir = path.join(projDir, proj); - try { - if (!fs.statSync(dir).isDirectory()) continue; - for (const file of fs.readdirSync(dir)) { - if (!file.endsWith('.jsonl')) continue; - const sid = file.replace('.jsonl', ''); - if (!_sessionFileIndex[sid]) { - _sessionFileIndex[sid] = { file: path.join(dir, file), format: 'claude' }; - } - } - } catch {} - } - } catch {} - } - - // Index Cursor transcript files - if (fs.existsSync(CURSOR_PROJECTS)) { - try { - for (const proj of fs.readdirSync(CURSOR_PROJECTS)) { - const transcriptsDir = path.join(CURSOR_PROJECTS, proj, 'agent-transcripts'); - if (!fs.existsSync(transcriptsDir)) continue; - try { - for (const sessDir of fs.readdirSync(transcriptsDir)) { - const f = path.join(transcriptsDir, sessDir, sessDir + '.jsonl'); - if (fs.existsSync(f)) _sessionFileIndex[sessDir] = { file: f, format: 'cursor' }; - } - } catch {} - } - } catch {} - } - - // Index Cursor chat files - if (fs.existsSync(CURSOR_CHATS)) { - try { - for (const chatDir of fs.readdirSync(CURSOR_CHATS)) { - const fullDir = path.join(CURSOR_CHATS, chatDir); - try { - if (!fs.statSync(fullDir).isDirectory()) continue; - for (const f of fs.readdirSync(fullDir)) { - if (f.endsWith('.jsonl') || f.endsWith('.json')) { - _sessionFileIndex[chatDir] = { file: path.join(fullDir, f), format: 'cursor' }; - break; - } - } - } catch {} - } - } catch {} - } - - _sessionFileIndexTs = now; -} - -function findSessionFile(sessionId, project) { - _buildSessionFileIndex(); - - // Fast index lookup - if (_sessionFileIndex[sessionId]) return _sessionFileIndex[sessionId]; - - // Try Claude projects dir (direct path if project known) - if (project) { - const projectKey = project.replace(/[^a-zA-Z0-9-]/g, '-'); - const claudeFile = path.join(PROJECTS_DIR, projectKey, `${sessionId}.jsonl`); - if (fs.existsSync(claudeFile)) return { file: claudeFile, format: 'claude' }; - } - - // Extra Claude dirs and Cursor files are already in the index. - // Only Codex (date tree) and SQLite agents need fallback lookup. - - // Try Codex sessions dir (walk year/month/day) - const codexSessionsDir = path.join(CODEX_DIR, 'sessions'); - if (fs.existsSync(codexSessionsDir)) { - const walkDir = (dir) => { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) { - const result = walkDir(full); - if (result) return result; - } else if (entry.name.includes(sessionId) && entry.name.endsWith('.jsonl')) { - return full; - } - } - return null; - }; - const codexFile = walkDir(codexSessionsDir); - if (codexFile) return { file: codexFile, format: 'codex' }; - } - - // Try OpenCode (SQLite — return special marker) - if (fs.existsSync(OPENCODE_DB) && sessionId.startsWith('ses_')) { - return { file: OPENCODE_DB, format: 'opencode', sessionId: sessionId }; - } - - // Cursor JSONL files are already in the index. Only check vscdb fallback. - - // Try Cursor global vscdb - if (fs.existsSync(CURSOR_GLOBAL_DB)) { - try { - const cleanId = sessionId.replace(/'/g, "''"); - const check = execFileSync('sqlite3', [ - CURSOR_GLOBAL_DB, - "SELECT COUNT(*) FROM cursorDiskKV WHERE key = 'composerData:" + cleanId + "'" - ], { encoding: 'utf8', timeout: 3000, windowsHide: true }).trim(); - if (parseInt(check) > 0) { - return { file: CURSOR_GLOBAL_DB, format: 'cursor', sessionId: sessionId }; - } - } catch {} - } - - // Try Kiro (SQLite) - if (fs.existsSync(KIRO_DB)) { - try { - const check = execFileSync('sqlite3', [ - KIRO_DB, - `SELECT COUNT(*) FROM conversations_v2 WHERE conversation_id = '${sessionId.replace(/'/g, "''")}';` - ], { encoding: 'utf8', timeout: 3000, windowsHide: true }).trim(); - if (parseInt(check) > 0) { - return { file: KIRO_DB, format: 'kiro', sessionId: sessionId }; - } - } catch {} - } - - return null; -} - -function isSystemMessage(text) { - if (!text) return true; - var t = text.trim(); - if (t === 'exit' || t === 'quit' || t === '/exit') return true; - if (t.startsWith('')) return true; - // Codex developer role system prompts - if (t.startsWith('You are Codex')) return true; - if (t.startsWith('Filesystem sandboxing')) return true; - return false; -} - -function extractContent(raw) { - if (!raw) return ''; - if (typeof raw === 'string') return raw; - if (Array.isArray(raw)) { - return raw - .map(b => (typeof b === 'string' ? b : (b.text || b.input_text || ''))) - .filter(Boolean) - .join('\n'); - } - return String(raw); -} - -// Extract MCP/Skill tool_use blocks from a Claude assistant message content array. -// Returns deduplicated array of { type, server, tool } or { type, skill }. -function extractTools(contentBlocks) { - if (!Array.isArray(contentBlocks)) return []; - const tools = []; - const seen = new Set(); - for (const block of contentBlocks) { - if (!block || block.type !== 'tool_use') continue; - const name = block.name || ''; - if (name.startsWith('mcp__')) { - const parts = name.split('__'); - if (parts.length >= 3) { - const tool = parts.slice(2).join('__'); - const key = 'mcp:' + parts[1] + ':' + tool; - if (!seen.has(key)) { - seen.add(key); - tools.push({ type: 'mcp', server: parts[1], tool: tool }); - } - } - } else if (name === 'Skill') { - const skillRaw = (block.input || {}).skill; - if (skillRaw) { - // Use plugin name only (e.g. "superpowers:writing-plans" -> "superpowers") - const skill = skillRaw.includes(':') ? skillRaw.split(':')[0] : skillRaw; - const key = 'skill:' + skill; - if (!seen.has(key)) { - seen.add(key); - tools.push({ type: 'skill', skill: skill }); - } - } - } - } - return tools; -} - -function getSessionPreview(sessionId, project, limit) { - limit = limit || 10; - const found = findSessionFile(sessionId, project); - if (!found) return []; - - // Cursor - if (found.format === 'cursor') { - var detail = loadCursorDetail(sessionId); - return detail.messages.slice(0, limit).map(function(m) { - return { role: m.role, content: m.content.slice(0, 300) }; - }); - } - - // Kiro: use loadKiroDetail and slice - if (found.format === 'kiro') { - var detail = loadKiroDetail(sessionId); - return detail.messages.slice(0, limit).map(function(m) { - return { role: m.role, content: m.content.slice(0, 300) }; - }); - } - - // OpenCode: use loadOpenCodeDetail and slice - if (found.format === 'opencode') { - const detail = loadOpenCodeDetail(sessionId); - return detail.messages.slice(0, limit).map(function(m) { - return { role: m.role, content: m.content.slice(0, 300) }; - }); - } - - const messages = []; - const lines = readLines(found.file); - - for (const line of lines) { - if (messages.length >= limit) break; - try { - const entry = JSON.parse(line); - - if (found.format === 'claude') { - // Claude: {type: "user"|"assistant", message: {content: ...}} - if (entry.type === 'user' || entry.type === 'assistant') { - const content = extractContent((entry.message || {}).content); - if (content) { - messages.push({ role: entry.type, content: content.slice(0, 300) }); - } - } - } else { - // Codex: {type: "response_item", payload: {role: "user"|"assistant", content: [...]}} - if (entry.type === 'response_item' && entry.payload) { - const role = entry.payload.role; - if (role === 'user' || role === 'assistant') { - const content = extractContent(entry.payload.content); - // Skip system-like messages - if (content && !isSystemMessage(content)) { - messages.push({ role: role, content: content.slice(0, 300) }); - } - } - } - } - } catch {} - } - - return messages; -} - -// ── Full-text search index ───────────────────────────────── -// -// Built once on first search, then cached in memory. -// Each entry: { sessionId, texts: [{role, content}] } -// Total text is kept lowercase for fast substring matching. - -let searchIndex = null; -let searchIndexBuiltAt = 0; -const INDEX_TTL = 60000; // rebuild every 60s - -function buildSearchIndex(sessions) { - const startMs = Date.now(); - const index = []; - - for (const s of sessions) { - if (!s.has_detail) continue; - - const found = findSessionFile(s.id, s.project); - if (!found) continue; - - try { - const lines = readLines(found.file); - const texts = []; - - for (const line of lines) { - try { - const entry = JSON.parse(line); - let role, content; - - if (found.format === 'claude') { - if (entry.type !== 'user' && entry.type !== 'assistant') continue; - role = entry.type; - content = extractContent((entry.message || {}).content); - } else { - if (entry.type !== 'response_item' || !entry.payload) continue; - role = entry.payload.role; - if (role !== 'user' && role !== 'assistant') continue; - content = extractContent(entry.payload.content); - } - - if (content && !isSystemMessage(content)) { - texts.push({ role, content: content.slice(0, 500) }); - } - } catch {} - } - - if (texts.length > 0) { - // Pre-compute lowercase full text for fast matching - const fullText = texts.map(t => t.content).join(' ').toLowerCase(); - index.push({ sessionId: s.id, texts, fullText }); - } - } catch {} - } - - const elapsed = Date.now() - startMs; - console.log(` \x1b[2mSearch index: ${index.length} sessions, ${elapsed}ms\x1b[0m`); - return index; -} - -function getSearchIndex(sessions) { - const now = Date.now(); - if (!searchIndex || (now - searchIndexBuiltAt) > INDEX_TTL) { - searchIndex = buildSearchIndex(sessions); - searchIndexBuiltAt = now; - } - return searchIndex; -} - -function searchFullText(query, sessions) { - if (!query || query.length < 2) return []; - const q = query.toLowerCase(); - const index = getSearchIndex(sessions); - const results = []; - - for (const entry of index) { - if (entry.fullText.indexOf(q) === -1) continue; - - // Find matching messages with snippets - const matches = []; - for (const t of entry.texts) { - if (matches.length >= 3) break; - const idx = t.content.toLowerCase().indexOf(q); - if (idx >= 0) { - const start = Math.max(0, idx - 50); - const end = Math.min(t.content.length, idx + q.length + 50); - matches.push({ - role: t.role, - snippet: (start > 0 ? '...' : '') + t.content.slice(start, end) + (end < t.content.length ? '...' : ''), - }); - } - } - - if (matches.length > 0) { - results.push({ sessionId: entry.sessionId, matches }); - } - } - - return results; -} - -// ── Exports ──────────────────────────────────────────────── - -// ── Session replay data (with timestamps) ───────────────── - -function getSessionReplay(sessionId, project) { - const found = findSessionFile(sessionId, project); - if (!found) return { messages: [], duration: 0 }; - - const messages = []; - const lines = readLines(found.file); - - for (const line of lines) { - try { - const entry = JSON.parse(line); - let role, content, ts; - - if (found.format === 'claude') { - if (entry.type !== 'user' && entry.type !== 'assistant') continue; - role = entry.type; - content = extractContent((entry.message || {}).content); - ts = entry.timestamp || ''; - } else { - if (entry.type !== 'response_item' || !entry.payload) continue; - role = entry.payload.role; - if (role !== 'user' && role !== 'assistant') continue; - content = extractContent(entry.payload.content); - ts = entry.timestamp || ''; - } - - if (!content || isSystemMessage(content)) continue; - - messages.push({ - role, - content: content.slice(0, 3000), - timestamp: ts, - ms: ts ? new Date(ts).getTime() : 0, - }); - } catch {} - } - - // Calculate duration - const startMs = messages.length > 0 ? messages[0].ms : 0; - const endMs = messages.length > 0 ? messages[messages.length - 1].ms : 0; - - return { - messages, - startMs, - endMs, - duration: endMs - startMs, - }; -} - -const CONTEXT_WINDOW = 200_000; // Claude's max context window (tokens) - -// ── Pricing per model (per token, April 2026) ───────────── - -const MODEL_PRICING = { - 'claude-opus-4-6': { input: 5.00 / 1e6, output: 25.00 / 1e6, cache_read: 0.50 / 1e6, cache_create: 6.25 / 1e6 }, - 'claude-opus-4-5': { input: 5.00 / 1e6, output: 25.00 / 1e6, cache_read: 0.50 / 1e6, cache_create: 6.25 / 1e6 }, - 'claude-sonnet-4-6': { input: 3.00 / 1e6, output: 15.00 / 1e6, cache_read: 0.30 / 1e6, cache_create: 3.75 / 1e6 }, - 'claude-sonnet-4-5': { input: 3.00 / 1e6, output: 15.00 / 1e6, cache_read: 0.30 / 1e6, cache_create: 3.75 / 1e6 }, - 'claude-haiku-4-5': { input: 1.00 / 1e6, output: 5.00 / 1e6, cache_read: 0.10 / 1e6, cache_create: 1.25 / 1e6 }, - 'codex-mini-latest': { input: 1.50 / 1e6, output: 6.00 / 1e6, cache_read: 0.375 / 1e6, cache_create: 1.875 / 1e6 }, - 'gpt-5': { input: 1.25 / 1e6, output: 10.00 / 1e6, cache_read: 0.625 / 1e6, cache_create: 1.25 / 1e6 }, -}; - -function getModelPricing(model) { - if (!model) return MODEL_PRICING['claude-sonnet-4-6']; // default - for (const key in MODEL_PRICING) { - if (model.includes(key) || model.startsWith(key)) return MODEL_PRICING[key]; - } - // Fallback: try partial match - if (model.includes('opus')) return MODEL_PRICING['claude-opus-4-6']; - if (model.includes('haiku')) return MODEL_PRICING['claude-haiku-4-5']; - if (model.includes('sonnet')) return MODEL_PRICING['claude-sonnet-4-6']; - if (model.includes('codex')) return MODEL_PRICING['codex-mini-latest']; - return MODEL_PRICING['claude-sonnet-4-6']; -} - -// ── Compute real cost from session file token usage ──────── - -// Disk cache for computed session costs -const COST_CACHE_FILE = path.join(os.tmpdir(), 'codedash-cost-cache.json'); -let _costDiskCache = null; - -function _loadCostDiskCache() { - if (_costDiskCache) return; - try { - if (fs.existsSync(COST_CACHE_FILE)) { - _costDiskCache = JSON.parse(fs.readFileSync(COST_CACHE_FILE, 'utf8')); - } - } catch {} - if (!_costDiskCache) _costDiskCache = {}; -} - -function _saveCostDiskCache() { - if (!_costDiskCache) return; - try { - fs.writeFileSync(COST_CACHE_FILE, JSON.stringify(_costDiskCache)); - } catch {} -} - -const EMPTY_COST = { cost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, contextPctSum: 0, contextTurnCount: 0, model: '' }; - -// In-memory cost cache (reset when sessions cache resets) -const _costMemCache = {}; - -function computeSessionCost(sessionId, project) { - // Fast in-memory cache (same session never changes within request cycle) - if (_costMemCache[sessionId] !== undefined) return _costMemCache[sessionId]; - - const found = findSessionFile(sessionId, project); - if (!found) { _costMemCache[sessionId] = EMPTY_COST; return EMPTY_COST; } - - // Skip formats that never have cost data - if (found.format === 'cursor' || found.format === 'kiro') { _costMemCache[sessionId] = EMPTY_COST; return EMPTY_COST; } - - // Check disk cache (keyed by file path + mtime + size for JSONL, sessionId for SQLite) - _loadCostDiskCache(); - let cacheKey = ''; - if (found.format === 'opencode') { - cacheKey = 'opencode:' + sessionId; - } else if (found.file) { - // Use file stat lookup (reuse from parsed cache index if available) - const cached = _fileCacheKeyIndex[found.file]; - if (cached) { - cacheKey = cached; - } else { - try { - const stat = fs.statSync(found.file); - cacheKey = found.file + '|' + stat.mtimeMs + '|' + stat.size; - _fileCacheKeyIndex[found.file] = cacheKey; - } catch {} - } - } - if (cacheKey && _costDiskCache[cacheKey]) return _costDiskCache[cacheKey]; - - let totalCost = 0; - let totalInput = 0; - let totalOutput = 0; - let totalCacheRead = 0; - let totalCacheCreate = 0; - let contextPctSum = 0; - let contextTurnCount = 0; - let model = ''; - - // OpenCode: query SQLite directly for token data - if (found.format === 'opencode') { - const safeId = /^[a-zA-Z0-9_-]+$/.test(found.sessionId) ? found.sessionId : ''; - if (!safeId) return { cost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, contextPctSum: 0, contextTurnCount: 0, model: '' }; - try { - const rows = execFileSync('sqlite3', [ - OPENCODE_DB, - `SELECT data FROM message WHERE session_id = '${safeId}' AND json_extract(data, '$.role') = 'assistant' ORDER BY time_created` - ], { encoding: 'utf8', timeout: 10000, windowsHide: true }).trim(); - if (rows) { - for (const row of rows.split('\n')) { - try { - const msgData = JSON.parse(row); - const t = msgData.tokens || {}; - if (!model && msgData.modelID) model = msgData.modelID; - const inp = t.input || 0; - const out = (t.output || 0) + (t.reasoning || 0); - const cacheRead = (t.cache && t.cache.read) || 0; - const cacheCreate = (t.cache && t.cache.write) || 0; - if (inp === 0 && out === 0) continue; - - const pricing = getModelPricing(msgData.modelID || model); - totalInput += inp; - totalOutput += out; - totalCacheRead += cacheRead; - totalCacheCreate += cacheCreate; - totalCost += inp * pricing.input - + cacheCreate * pricing.cache_create - + cacheRead * pricing.cache_read - + out * pricing.output; - - const contextThisTurn = inp + cacheCreate + cacheRead; - if (contextThisTurn > 0) { - contextPctSum += (contextThisTurn / CONTEXT_WINDOW) * 100; - contextTurnCount++; - } - } catch {} - } - } - } catch {} - return { cost: totalCost, inputTokens: totalInput, outputTokens: totalOutput, cacheReadTokens: totalCacheRead, cacheCreateTokens: totalCacheCreate, contextPctSum, contextTurnCount, model }; - } - - try { - const lines = readLines(found.file); - for (const line of lines) { - try { - const entry = JSON.parse(line); - if (found.format === 'claude' && entry.type === 'assistant') { - const msg = entry.message || {}; - if (!model && msg.model) model = msg.model; - const u = msg.usage; - if (!u) continue; - - const pricing = getModelPricing(msg.model || model); - const inp = u.input_tokens || 0; - const cacheCreate = u.cache_creation_input_tokens || 0; - const cacheRead = u.cache_read_input_tokens || 0; - const out = u.output_tokens || 0; - - totalInput += inp; - totalOutput += out; - totalCacheRead += cacheRead; - totalCacheCreate += cacheCreate; - totalCost += inp * pricing.input - + cacheCreate * pricing.cache_create - + cacheRead * pricing.cache_read - + out * pricing.output; - - // Track per-turn context window usage (average, not peak) - const contextThisTurn = inp + cacheCreate + cacheRead; - if (contextThisTurn > 0) { - contextPctSum += (contextThisTurn / CONTEXT_WINDOW) * 100; - contextTurnCount++; - } - } - // Codex: estimate from file size (no token usage in session files) - } catch {} - } - } catch {} - - // Fallback for Codex or sessions without usage data - if (totalCost === 0 && found.format === 'codex') { - try { - const size = fs.statSync(found.file).size; - const tokens = size / 4; - const pricing = MODEL_PRICING['codex-mini-latest']; - totalInput = Math.round(tokens * 0.3); - totalOutput = Math.round(tokens * 0.7); - totalCost = totalInput * pricing.input + totalOutput * pricing.output; - } catch {} - } - - const result = { cost: totalCost, inputTokens: totalInput, outputTokens: totalOutput, cacheReadTokens: totalCacheRead, cacheCreateTokens: totalCacheCreate, contextPctSum, contextTurnCount, model }; - if (cacheKey) _costDiskCache[cacheKey] = result; - _costMemCache[sessionId] = result; - return result; -} - -// ── Cost analytics ──────────────────────────────────────── - -// Analytics result cache — avoids recomputing 31k sessions every request -const ANALYTICS_CACHE_FILE = path.join(os.tmpdir(), 'codedash-analytics-cache.json'); -let _analyticsCacheResult = null; -let _analyticsCacheKey = null; - -function _analyticsKey(sessions) { - // Key: session count + newest session mtime - let newest = 0; - for (const s of sessions) { - if (s.last_ts > newest) newest = s.last_ts; - } - return sessions.length + ':' + newest; -} - -function getCostAnalytics(sessions) { - // Fast cache check — if sessions haven't changed, return cached result - const key = _analyticsKey(sessions); - if (_analyticsCacheResult && _analyticsCacheKey === key) return _analyticsCacheResult; - - // Try disk cache - if (!_analyticsCacheResult) { - try { - if (fs.existsSync(ANALYTICS_CACHE_FILE)) { - const cached = JSON.parse(fs.readFileSync(ANALYTICS_CACHE_FILE, 'utf8')); - if (cached._key === key) { - _analyticsCacheResult = cached.data; - _analyticsCacheKey = key; - return cached.data; - } - } - } catch {} - } - - const result = _computeCostAnalytics(sessions); - - // Save to cache - _analyticsCacheResult = result; - _analyticsCacheKey = key; - try { fs.writeFileSync(ANALYTICS_CACHE_FILE, JSON.stringify({ _key: key, data: result })); } catch {} - - return result; -} - -function _computeCostAnalytics(sessions) { - const byDay = {}; - const byProject = {}; - const byWeek = {}; - const byAgent = {}; - let totalCost = 0; - let totalTokens = 0; - let totalInputTokens = 0; - let totalOutputTokens = 0; - let totalCacheReadTokens = 0; - let totalCacheCreateTokens = 0; - let globalContextPctSum = 0; - let globalContextTurnCount = 0; - let firstDate = null; - let lastDate = null; - let sessionsWithData = 0; - const agentNoCostData = {}; - for (const s of sessions) { - if (!byAgent[s.tool]) byAgent[s.tool] = { cost: 0, sessions: 0, tokens: 0, estimated: false }; - } - const sessionCosts = []; - - // Pre-compute OpenCode costs in one batch query (avoids O(n) execSync calls) - const opencodeCostCache = {}; - const opencodeSessions = sessions.filter(s => s.tool === 'opencode'); - if (opencodeSessions.length > 0 && fs.existsSync(OPENCODE_DB)) { - try { - const batchRows = execFileSync('sqlite3', [ - OPENCODE_DB, - `SELECT session_id, data FROM message WHERE json_extract(data, '$.role') = 'assistant' ORDER BY time_created` - ], { encoding: 'utf8', timeout: 30000, windowsHide: true }).trim(); - if (batchRows) { - for (const row of batchRows.split('\n')) { - const sepIdx = row.indexOf('|'); - if (sepIdx < 0) continue; - const sessId = row.slice(0, sepIdx); - const jsonStr = row.slice(sepIdx + 1); - try { - const msgData = JSON.parse(jsonStr); - const t = msgData.tokens || {}; - const inp = t.input || 0; - const out = (t.output || 0) + (t.reasoning || 0); - const cacheRead = (t.cache && t.cache.read) || 0; - const cacheCreate = (t.cache && t.cache.write) || 0; - if (inp === 0 && out === 0) continue; - if (!opencodeCostCache[sessId]) opencodeCostCache[sessId] = { cost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, contextPctSum: 0, contextTurnCount: 0, model: '' }; - const c = opencodeCostCache[sessId]; - if (!c.model && msgData.modelID) c.model = msgData.modelID; - const pricing = getModelPricing(msgData.modelID || c.model); - c.inputTokens += inp; - c.outputTokens += out; - c.cacheReadTokens += cacheRead; - c.cacheCreateTokens += cacheCreate; - c.cost += inp * pricing.input + cacheCreate * pricing.cache_create + cacheRead * pricing.cache_read + out * pricing.output; - const ctx = inp + cacheCreate + cacheRead; - if (ctx > 0) { c.contextPctSum += (ctx / CONTEXT_WINDOW) * 100; c.contextTurnCount++; } - } catch {} - } - } - } catch {} - } - - for (const s of sessions) { - let costData; - if (s.tool === 'opencode' && opencodeCostCache[s.id]) { - costData = opencodeCostCache[s.id]; - } else if (s.tool === 'cursor') { - // Use real token data from Cursor vscdb if available - const inp = s._cursor_input_tokens || 0; - const out = s._cursor_output_tokens || 0; - if (inp > 0 || out > 0) { - const model = s._cursor_model || ''; - const pricing = getModelPricing(model); - costData = { cost: inp * pricing.input + out * pricing.output, inputTokens: inp, outputTokens: out, cacheReadTokens: 0, cacheCreateTokens: 0, contextPctSum: 0, contextTurnCount: 0, model: model }; - } else if (s.user_messages > 0 || s.messages > 0) { - // Fallback: estimate from user prompt count - const userMsgs = s.user_messages || Math.ceil((s.messages || 0) * 0.07); - const model = s._cursor_model || 'claude-sonnet'; - const pricing = getModelPricing(model); - const estInput = userMsgs * 2000; - const estOutput = userMsgs * 1000; - costData = { cost: estInput * pricing.input + estOutput * pricing.output, inputTokens: estInput, outputTokens: estOutput, cacheReadTokens: 0, cacheCreateTokens: 0, contextPctSum: 0, contextTurnCount: 0, model: model + '-estimated' }; - } else { - costData = EMPTY_COST; - } - } else { - costData = computeSessionCost(s.id, s.project); - } - const cost = costData.cost; - const tokens = costData.inputTokens + costData.outputTokens + costData.cacheReadTokens + costData.cacheCreateTokens; - if (cost === 0 && tokens === 0) { - if (!agentNoCostData[s.tool]) agentNoCostData[s.tool] = 0; - agentNoCostData[s.tool]++; - continue; - } - sessionsWithData++; - totalCost += cost; - totalTokens += tokens; - totalInputTokens += costData.inputTokens; - totalOutputTokens += costData.outputTokens; - totalCacheReadTokens += costData.cacheReadTokens; - totalCacheCreateTokens += costData.cacheCreateTokens; - - // Per-agent breakdown - const agent = s.tool || 'unknown'; - if (!byAgent[agent]) byAgent[agent] = { cost: 0, sessions: 0, tokens: 0, estimated: false }; - byAgent[agent].cost += cost; - byAgent[agent].sessions++; - byAgent[agent].tokens += tokens; - if (agent === 'codex') byAgent[agent].estimated = true; - if (agent === 'cursor' && costData.model && costData.model.includes('-estimated')) byAgent[agent].estimated = true; - if (agent === 'opencode' && !costData.model) byAgent[agent].estimated = true; - - // Context % across all turns - globalContextPctSum += costData.contextPctSum; - globalContextTurnCount += costData.contextTurnCount; - - // Date range - const day = s.date || 'unknown'; - if (s.date) { - if (!firstDate || s.date < firstDate) firstDate = s.date; - if (!lastDate || s.date > lastDate) lastDate = s.date; - } - if (!byDay[day]) byDay[day] = { cost: 0, sessions: 0, tokens: 0 }; - byDay[day].cost += cost; - byDay[day].sessions++; - byDay[day].tokens += tokens; - - // By week - if (s.date) { - const d = new Date(s.date); - const weekStart = new Date(d); - weekStart.setDate(d.getDate() - d.getDay()); - const weekKey = weekStart.toISOString().slice(0, 10); - if (!byWeek[weekKey]) byWeek[weekKey] = { cost: 0, sessions: 0 }; - byWeek[weekKey].cost += cost; - byWeek[weekKey].sessions++; - } - - // By project - const proj = s.project_short || s.project || 'unknown'; - if (!byProject[proj]) byProject[proj] = { cost: 0, sessions: 0, tokens: 0 }; - byProject[proj].cost += cost; - byProject[proj].sessions++; - byProject[proj].tokens += tokens; - - sessionCosts.push({ id: s.id, cost, project: proj, date: s.date, last_ts: s.last_ts || 0 }); - } - - // Sort top sessions by cost - sessionCosts.sort((a, b) => b.cost - a.cost); - - const days = firstDate && lastDate - ? Math.max(1, Math.round((new Date(lastDate) - new Date(firstDate)) / 86400000) + 1) - : 1; - - // Burn rate: derived from already-computed sessionCosts — no extra IO - const now = Date.now(); - const todayStr = new Date().toISOString().slice(0, 10); - const hoursElapsedToday = (now - new Date(todayStr).getTime()) / 3600000; - let last1hCost = 0; - let todayCost = 0; - for (const sc of sessionCosts) { - if (sc.last_ts >= now - 3600000) last1hCost += sc.cost; - if (sc.date === todayStr) todayCost += sc.cost; - } - - _saveCostDiskCache(); - - return { - totalCost, - totalTokens, - totalInputTokens, - totalOutputTokens, - totalCacheReadTokens, - totalCacheCreateTokens, - avgContextPct: globalContextTurnCount > 0 ? Math.round(globalContextPctSum / globalContextTurnCount) : 0, - dailyRate: totalCost / days, - firstDate, - lastDate, - days, - totalSessions: sessionsWithData, - totalSessionsAll: sessions.length, - byDay, - byWeek, - byProject, - topSessions: sessionCosts.slice(0, 10), - byAgent, - agentNoCostData, - last1hCost, - todayCost, - hoursElapsedToday: Math.max(1, hoursElapsedToday), - }; -} - -// ── Active sessions detection ───────────────────────────── - -function getActiveSessions() { - const active = []; - const seenPids = new Set(); - - // 1. Claude Code — read PID files for session ID mapping - const sessionsDir = path.join(CLAUDE_DIR, 'sessions'); - const claudePidMap = {}; // pid → {sessionId, cwd, startedAt} - if (fs.existsSync(sessionsDir)) { - for (const file of fs.readdirSync(sessionsDir)) { - if (!file.endsWith('.json')) continue; - try { - const data = JSON.parse(fs.readFileSync(path.join(sessionsDir, file), 'utf8')); - if (data.pid) claudePidMap[data.pid] = data; - } catch {} - } - } - - // 2. Scan ALL agent processes via ps - const agentPatterns = [ - { pattern: 'claude', tool: 'claude', match: /\/claude\s|^claude\s|\bclaude\b/ }, - { pattern: 'codex', tool: 'codex', match: /\/codex\s|^codex\s|codex app-server|\bcodex\b/ }, - { pattern: 'opencode', tool: 'opencode', match: /\/opencode\s|^opencode\s|\bopencode\b/ }, - { pattern: 'kiro', tool: 'kiro', match: /kiro-cli/ }, - { pattern: 'cursor-agent', tool: 'cursor', match: /cursor-agent/ }, - ]; - - // Skip process scanning on Windows (no ps/grep) - if (process.platform === 'win32') return active; - - try { - const psOut = execSync( - 'ps aux 2>/dev/null | grep -E "claude|codex|opencode|kiro-cli|cursor-agent" | grep -v grep || true', - { encoding: 'utf8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] } - ); - - for (const line of psOut.split('\n').filter(Boolean)) { - const parts = line.trim().split(/\s+/); - if (parts.length < 11) continue; - - const pid = parseInt(parts[1]); - if (seenPids.has(pid)) continue; - - const cpu = parseFloat(parts[2]) || 0; - const rss = parseInt(parts[5]) || 0; - const stat = parts[7] || ''; - const cmd = parts.slice(10).join(' '); - - // Determine tool - let tool = ''; - for (const ap of agentPatterns) { - if (ap.match.test(cmd)) { tool = ap.tool; break; } - } - if (!tool) continue; - - // Skip node/npm/shell wrappers, MCP servers, plugins — only main agent processes - if (cmd.includes('node bin/cli') || cmd.includes('npm') || cmd.includes('grep')) continue; - if (cmd.includes('mcp-server') || cmd.includes('mcp_server') || cmd.includes('/mcp/') || cmd.includes('/mcp-servers/')) continue; - if (cmd.includes('/plugins/') || cmd.includes('plugin-') || cmd.includes('app-server-broker')) continue; - if (cmd.includes('.claude/') && !cmd.includes('claude ') && tool === 'claude') continue; - if (cmd.includes('.codex/') && !cmd.includes('codex ') && tool === 'codex') continue; - - seenPids.add(pid); - - // Get session ID from Claude PID files - let sessionId = ''; - let cwd = ''; - let startedAt = 0; - let sessionSource = ''; - if (claudePidMap[pid]) { - sessionId = claudePidMap[pid].sessionId || ''; - cwd = claudePidMap[pid].cwd || ''; - startedAt = claudePidMap[pid].startedAt || 0; - if (sessionId) sessionSource = 'pid-file'; - } - - // Try to get cwd from lsof if not from PID file - if (!cwd) { - try { - const lsofOut = execSync(`lsof -d cwd -p ${pid} -Fn 2>/dev/null`, { encoding: 'utf8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'] }); - const match = lsofOut.match(/\nn(\/[^\n]+)/); - if (match) cwd = match[1]; - } catch {} - } - - // Try to find session ID by matching cwd + tool to loaded sessions - if (!sessionId) { - const allS = loadSessions(); - const match = allS.find(s => s.tool === tool && s.project === cwd); - if (match) { - sessionId = match.id; - sessionSource = 'cwd-match'; - } - // If still no match, find latest session of this tool - if (!sessionId) { - const latest = allS.filter(s => s.tool === tool).sort((a,b) => b.last_ts - a.last_ts)[0]; - if (latest) { - sessionId = latest.id; - sessionSource = 'fallback-latest'; - } - } - } - - const status = cpu < 1 && (stat.includes('S') || stat.includes('T')) ? 'waiting' : 'active'; - - active.push({ - pid: pid, - sessionId: sessionId, - cwd: cwd, - startedAt: startedAt, - kind: tool, - entrypoint: tool, - status: status, - cpu: cpu, - memoryMB: Math.round(rss / 1024), - _sessionSource: sessionSource, - }); - } - } catch {} - - return active; -} - -// ── Leaderboard stats ───────────────────────────────────── - -const ANON_NAMES_ADJ = ['brave','swift','calm','bold','keen','wise','cool','fast','wild','epic','rare','pure','warm','dark','deep','fair','free','glad','gold','iron']; -const ANON_NAMES_NOUN = ['fox','owl','cat','wolf','bear','hawk','lion','deer','hare','crow','lynx','moth','seal','wren','dove','frog','newt','crab','swan','kite']; - -function getOrCreateAnonId() { - const configDir = path.join(os.homedir(), '.codedash'); - const idFile = path.join(configDir, 'anon-id.json'); - try { - const data = JSON.parse(fs.readFileSync(idFile, 'utf8')); - if (data.id && data.name) return data; - } catch {} - // Generate new - const id = require('crypto').randomUUID(); - const adj = ANON_NAMES_ADJ[Math.floor(Math.random() * ANON_NAMES_ADJ.length)]; - const noun = ANON_NAMES_NOUN[Math.floor(Math.random() * ANON_NAMES_NOUN.length)]; - const num = Math.floor(Math.random() * 100); - const name = adj + '-' + noun + '-' + num; - const data = { id, name, createdAt: new Date().toISOString() }; - if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, { recursive: true }); - fs.writeFileSync(idFile, JSON.stringify(data, null, 2)); - return data; -} - -const fmtLocalDay = (ts) => { - const d = new Date(ts); - return d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0') + '-' + String(d.getDate()).padStart(2,'0'); -}; - -// Disk cache for per-session daily message breakdown -const DAILY_STATS_CACHE_FILE = path.join(os.tmpdir(), 'codedash-daily-stats-cache.json'); -let _dailyStatsDiskCache = null; - -function _loadDailyStatsDiskCache() { - if (_dailyStatsDiskCache) return; - try { - if (fs.existsSync(DAILY_STATS_CACHE_FILE)) { - _dailyStatsDiskCache = JSON.parse(fs.readFileSync(DAILY_STATS_CACHE_FILE, 'utf8')); - } - } catch {} - if (!_dailyStatsDiskCache) _dailyStatsDiskCache = {}; -} - -function _saveDailyStatsDiskCache() { - if (!_dailyStatsDiskCache) return; - try { - fs.writeFileSync(DAILY_STATS_CACHE_FILE, JSON.stringify(_dailyStatsDiskCache)); - } catch {} -} - -function _computeSessionDailyBreakdown(s, found) { - const msgsByDay = {}; - const tsByDay = {}; - try { - const lines = readLines(found.file); - for (const line of lines) { - try { - const entry = JSON.parse(line); - let isUser = false; - let hasText = false; - let ts = 0; - - if (found.format === 'claude') { - if (entry.type !== 'user') continue; - isUser = true; - if (entry.timestamp) ts = typeof entry.timestamp === 'number' ? entry.timestamp : new Date(entry.timestamp).getTime(); - const c = entry.message && entry.message.content; - if (typeof c === 'string' && c.trim()) hasText = true; - else if (Array.isArray(c)) { for (const p of c) { if (p.type === 'text' && p.text && p.text.trim()) { hasText = true; break; } } } - } else if (found.format === 'cursor') { - if (entry.role !== 'user') continue; - isUser = true; - ts = s.first_ts; - const c = (entry.message || {}).content; - if (Array.isArray(c)) { for (const p of c) { if (p.type === 'text' && p.text && p.text.replace(/<\/?user_query>/g,'').trim()) { hasText = true; break; } } } - else if (typeof c === 'string' && c.trim()) hasText = true; - } else if (found.format === 'codex') { - if (entry.type === 'response_item' && entry.payload && entry.payload.role === 'user') { - isUser = true; - ts = s.first_ts; - const c = entry.payload.content; - if (Array.isArray(c)) { for (const p of c) { if ((p.text || '').trim()) { hasText = true; break; } } } - } else continue; - } - - if (!isUser || !hasText) continue; - if (!ts || ts < 1000000000000) ts = s.first_ts; - const day = (found.format === 'claude' && ts) ? fmtLocalDay(ts) : (s.date || fmtLocalDay(s.last_ts)); - msgsByDay[day] = (msgsByDay[day] || 0) + 1; - if (!tsByDay[day]) tsByDay[day] = { first: ts, last: ts }; - if (ts < tsByDay[day].first) tsByDay[day].first = ts; - if (ts > tsByDay[day].last) tsByDay[day].last = ts; - } catch {} - } - } catch {} - return { msgsByDay, tsByDay }; -} - -// Daily stats result cache -const DAILY_RESULT_CACHE_FILE = path.join(os.tmpdir(), 'codedash-daily-result-cache.json'); -let _dailyResultCache = null; -let _dailyResultCacheKey = null; - -function getDailyStats(sessions) { - const key = _analyticsKey(sessions); - if (_dailyResultCache && _dailyResultCacheKey === key) return _dailyResultCache; - - // Try disk cache - if (!_dailyResultCache) { - try { - if (fs.existsSync(DAILY_RESULT_CACHE_FILE)) { - const cached = JSON.parse(fs.readFileSync(DAILY_RESULT_CACHE_FILE, 'utf8')); - if (cached._key === key) { - _dailyResultCache = cached.data; - _dailyResultCacheKey = key; - return cached.data; - } - } - } catch {} - } - - const result = _computeDailyStats(sessions); - _dailyResultCache = result; - _dailyResultCacheKey = key; - try { fs.writeFileSync(DAILY_RESULT_CACHE_FILE, JSON.stringify({ _key: key, data: result })); } catch {} - return result; -} - -function _computeDailyStats(sessions) { - const byDay = {}; - const ensureDay = (date) => { - if (!byDay[date]) byDay[date] = { date, sessions: 0, messages: 0, hours: 0, cost: 0, agents: {} }; - return byDay[date]; - }; - - _loadDailyStatsDiskCache(); - - for (const s of sessions) { - if (!s.first_ts || !s.last_ts) continue; - const tool = s.tool || 'unknown'; - - // Cost per session - const costData = computeSessionCost(s.id, s.project); - const sessionCost = (costData && costData.cost) || 0; - - // For sessions with detail files — read actual message timestamps - const found = s.has_detail ? findSessionFile(s.id, s.project) : null; - if (found && found.format !== 'opencode' && found.format !== 'kiro' && found.format !== 'cursor' && fs.existsSync(found.file)) { - // Check disk cache for daily breakdown - let breakdown; - let dailyCacheKey = ''; - try { - const stat = fs.statSync(found.file); - dailyCacheKey = found.file + '|' + stat.mtimeMs + '|' + stat.size; - } catch {} - if (dailyCacheKey && _dailyStatsDiskCache[dailyCacheKey]) { - breakdown = _dailyStatsDiskCache[dailyCacheKey]; - } else { - breakdown = _computeSessionDailyBreakdown(s, found); - if (dailyCacheKey) _dailyStatsDiskCache[dailyCacheKey] = breakdown; - } - const { msgsByDay, tsByDay } = breakdown; - - const dayKeys = Object.keys(msgsByDay); - if (dayKeys.length > 0) { - const totalMsgs = dayKeys.reduce((a, k) => a + msgsByDay[k], 0) || 1; - for (const day of dayKeys) { - const d = ensureDay(day); - d.sessions++; - d.messages += msgsByDay[day]; - const dayHours = tsByDay[day] ? Math.min((tsByDay[day].last - tsByDay[day].first) / 3600000, 16) : 0; - d.hours += dayHours; - d.cost += sessionCost * (msgsByDay[day] / totalMsgs); // cost proportional to messages - d.agents[tool] = (d.agents[tool] || 0) + 1; - } - continue; // done with this session - } - } - - // Fallback for non-Claude or sessions without detail: single-day attribution - const day = s.date || fmtLocalDay(s.last_ts); - const d = ensureDay(day); - d.sessions++; - // Use exact user_messages count if available, otherwise estimate - if (s.user_messages > 0) { - d.messages += s.user_messages; - } else { - const totalMsgEst = s.detail_messages || s.messages || 0; - d.messages += Math.ceil(totalMsgEst * 0.5); - } - d.hours += Math.min((s.last_ts - s.first_ts) / 3600000, 16); - d.cost += sessionCost; - d.agents[tool] = (d.agents[tool] || 0) + 1; - } - - // Round - for (const d of Object.values(byDay)) { - d.hours = Math.round(d.hours * 10) / 10; - d.cost = Math.round(d.cost * 100) / 100; - } - _saveDailyStatsDiskCache(); - return Object.values(byDay).sort((a, b) => b.date.localeCompare(a.date)); -} - -let _lbCache = null; -let _lbCacheTs = 0; -const LB_CACHE_TTL = 60000; // 60 seconds - -function getLeaderboardStats() { - const now = Date.now(); - if (_lbCache && (now - _lbCacheTs) < LB_CACHE_TTL) return _lbCache; - - const sessions = loadSessions(); - const anon = getOrCreateAnonId(); - const daily = getDailyStats(sessions); - - // Totals - let totalMessages = 0, totalHours = 0, totalCost = 0, totalSessions = sessions.length; - const agentTotals = {}; - for (const d of daily) { - totalMessages += d.messages; - totalHours += d.hours; - totalCost += d.cost; - for (const [agent, count] of Object.entries(d.agents)) { - agentTotals[agent] = (agentTotals[agent] || 0) + count; - } - } - - // Today - const today = new Date().toISOString().slice(0, 10); - const todayStats = daily.find(d => d.date === today) || { sessions: 0, messages: 0, hours: 0, cost: 0, agents: {} }; - - // Streak (consecutive days with sessions) - let streak = 0; - const dt = new Date(); - for (let i = 0; i < 365; i++) { - const day = dt.toISOString().slice(0, 10); - if (daily.find(d => d.date === day)) { - streak++; - dt.setDate(dt.getDate() - 1); - } else { - break; - } - } - - const result = { - anon, - today: todayStats, - totals: { sessions: totalSessions, messages: totalMessages, hours: Math.round(totalHours * 10) / 10, cost: Math.round(totalCost * 100) / 100 }, - agents: agentTotals, - streak, - daily: daily.slice(0, 30), // last 30 days - activeDays: daily.length, - }; - _lbCache = result; - _lbCacheTs = Date.now(); - return result; -} - -module.exports = { - loadSessions, - loadSessionDetail, - getProjectGitInfo, - getLeaderboardStats, - getOrCreateAnonId, - deleteSession, - getGitCommits, - exportSessionMarkdown, - getSessionPreview, - searchFullText, - getActiveSessions, - getSessionReplay, - getCostAnalytics, - computeSessionCost, - MODEL_PRICING, - findSessionFile, - extractContent, - isSystemMessage, - loadOpenCodeDetail, - CLAUDE_DIR, - CODEX_DIR, - OPENCODE_DB, - KIRO_DB, - HISTORY_FILE, - PROJECTS_DIR, -}; +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { execSync, execFileSync } = require('child_process'); + +// ── Constants ────────────────────────────────────────────── + +// Detect WSL and find Windows user home for cross-OS data access +function detectHomes() { + const homes = [os.homedir()]; + // WSL: also check Windows-side home dirs + if (process.platform === 'linux' && fs.existsSync('/mnt/c/Users')) { + try { + const winUser = execSync('cmd.exe /C "echo %USERPROFILE%" 2>/dev/null', { encoding: 'utf8', timeout: 3000 }).trim(); + if (winUser && winUser.includes('\\')) { + // Convert C:\Users\foo to /mnt/c/Users/foo + const drive = winUser[0].toLowerCase(); + const winPath = '/mnt/' + drive + winUser.slice(2).replace(/\\/g, '/'); + if (fs.existsSync(winPath) && !homes.includes(winPath)) { + homes.push(winPath); + } + } + } catch { + // Fallback: scan /mnt/c/Users/ for directories with .claude + try { + for (const u of fs.readdirSync('/mnt/c/Users')) { + const candidate = '/mnt/c/Users/' + u; + if (fs.existsSync(path.join(candidate, '.claude'))) { + if (!homes.includes(candidate)) homes.push(candidate); + } + } + } catch {} + } + } + return homes; +} + +const ALL_HOMES = detectHomes(); +const IS_WSL = ALL_HOMES.length > 1; + +const CLAUDE_DIR = path.join(ALL_HOMES[0], '.claude'); +const CODEX_DIR = path.join(ALL_HOMES[0], '.codex'); +const OPENCODE_DB = path.join(ALL_HOMES[0], '.local', 'share', 'opencode', 'opencode.db'); +const KIRO_DB = path.join(ALL_HOMES[0], 'Library', 'Application Support', 'kiro-cli', 'data.sqlite3'); +const CURSOR_DIR = path.join(ALL_HOMES[0], '.cursor'); +const CURSOR_PROJECTS = path.join(CURSOR_DIR, 'projects'); +const CURSOR_CHATS = path.join(CURSOR_DIR, 'chats'); +// Cursor global DB path varies by OS: macOS ~/Library/Application Support, Linux ~/.config, Windows %APPDATA% +const CURSOR_APP_DATA = process.platform === 'darwin' + ? path.join(ALL_HOMES[0], 'Library', 'Application Support', 'Cursor') + : process.platform === 'win32' + ? path.join(ALL_HOMES[0], 'AppData', 'Roaming', 'Cursor') + : path.join(ALL_HOMES[0], '.config', 'Cursor'); +const CURSOR_GLOBAL_DB = path.join(CURSOR_APP_DATA, 'User', 'globalStorage', 'state.vscdb'); +const CURSOR_WORKSPACE_STORAGE = path.join(CURSOR_APP_DATA, 'User', 'workspaceStorage'); +const VSCODE_APP_DATA = process.platform === 'darwin' + ? path.join(ALL_HOMES[0], 'Library', 'Application Support', 'Code') + : process.platform === 'win32' + ? path.join(ALL_HOMES[0], 'AppData', 'Roaming', 'Code') + : path.join(ALL_HOMES[0], '.config', 'Code'); +const COPILOT_WORKSPACE_STORAGE = path.join(VSCODE_APP_DATA, 'User', 'workspaceStorage'); +const HISTORY_FILE = path.join(CLAUDE_DIR, 'history.jsonl'); +const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects'); + +// On WSL, collect all alternative data dirs +const EXTRA_CLAUDE_DIRS = ALL_HOMES.slice(1).map(h => path.join(h, '.claude')).filter(d => fs.existsSync(d)); +const EXTRA_CODEX_DIRS = ALL_HOMES.slice(1).map(h => path.join(h, '.codex')).filter(d => fs.existsSync(d)); +const EXTRA_CURSOR_DIRS = ALL_HOMES.slice(1).map(h => path.join(h, '.cursor')).filter(d => fs.existsSync(d)); +const EXTRA_COPILOT_WS_STORAGE = ALL_HOMES.slice(1) + .map(h => path.join(h, 'AppData', 'Roaming', 'Code', 'User', 'workspaceStorage')) + .filter(d => fs.existsSync(d)); + +// Extra OpenCode/Kiro DBs on Windows side +const EXTRA_OPENCODE_DBS = ALL_HOMES.slice(1).map(h => path.join(h, 'AppData', 'Local', 'opencode', 'opencode.db')).filter(d => fs.existsSync(d)); +const EXTRA_KIRO_DBS = ALL_HOMES.slice(1).map(h => path.join(h, 'AppData', 'Roaming', 'kiro-cli', 'data.sqlite3')).filter(d => fs.existsSync(d)); + +if (IS_WSL) { + console.log(' \x1b[36m[WSL]\x1b[0m Detected Windows homes:', ALL_HOMES.slice(1).join(', ')); + if (EXTRA_CLAUDE_DIRS.length) console.log(' \x1b[36m[WSL]\x1b[0m Extra Claude dirs:', EXTRA_CLAUDE_DIRS.join(', ')); + if (EXTRA_CODEX_DIRS.length) console.log(' \x1b[36m[WSL]\x1b[0m Extra Codex dirs:', EXTRA_CODEX_DIRS.join(', ')); + if (EXTRA_CURSOR_DIRS.length) console.log(' \x1b[36m[WSL]\x1b[0m Extra Cursor dirs:', EXTRA_CURSOR_DIRS.join(', ')); + if (EXTRA_COPILOT_WS_STORAGE.length) console.log(' \x1b[36m[WSL]\x1b[0m Extra Copilot WS dirs:', EXTRA_COPILOT_WS_STORAGE.join(', ')); +} + +// ── Helpers ──────────────────────────────────────────────── + +// Read file lines handling \r\n (Windows/WSL) +function readLines(filePath) { + return fs.readFileSync(filePath, 'utf8').split('\n').map(l => l.replace(/\r$/, '')).filter(Boolean); +} + +// OpenCode built-in tools that should NOT be treated as MCP servers +const OPENCODE_BUILTIN_TOOLS = new Set([ + 'read', 'write', 'edit', 'bash', 'glob', 'grep', 'task', 'todowrite', + 'delegate_task', 'apply_patch', 'webfetch', 'websearch', 'slashcommand', + 'question', 'background_task', 'background_output', 'background_cancel', + 'lsp_diagnostics', 'ast_grep_search', 'ast_grep_replace', 'session_read', + 'skill', 'skill_mcp', 'call_omo_agent', +]); + +// OpenCode tool names like "chrome-devtools_take_screenshot" → server "chrome-devtools" +// Returns null if it's a built-in tool, otherwise the server name (first segment). +function parseOpenCodeMcpServer(toolName) { + if (!toolName || OPENCODE_BUILTIN_TOOLS.has(toolName)) return null; + // Match server_tool or server-with-dashes_tool + const idx = toolName.indexOf('_'); + if (idx <= 0) return null; + return toolName.slice(0, idx); +} + +// Disk cache for parsed Claude session files (keyed by path + mtime + size) +const PARSED_CACHE_FILE = path.join(os.tmpdir(), 'codedash-parsed-cache.json'); +let _parsedDiskCache = null; +let _parsedDiskCacheDirty = false; +// Reverse index: file path -> cache key (avoids repeated fs.statSync) +const _fileCacheKeyIndex = {}; + +function _loadParsedDiskCache() { + if (_parsedDiskCache) return; + try { + if (fs.existsSync(PARSED_CACHE_FILE)) { + _parsedDiskCache = JSON.parse(fs.readFileSync(PARSED_CACHE_FILE, 'utf8')); + } + } catch {} + if (!_parsedDiskCache) _parsedDiskCache = {}; +} + +function _saveParsedDiskCache() { + if (!_parsedDiskCacheDirty || !_parsedDiskCache) return; + try { + fs.writeFileSync(PARSED_CACHE_FILE, JSON.stringify(_parsedDiskCache)); + _parsedDiskCacheDirty = false; + } catch {} +} + +function parseClaudeSessionFile(sessionFile) { + if (!fs.existsSync(sessionFile)) return null; + + let stat; + try { + stat = fs.statSync(sessionFile); + } catch { + return null; + } + + // Check disk cache (keyed by file path + mtime + size) + _loadParsedDiskCache(); + const cacheKey = sessionFile + '|' + stat.mtimeMs + '|' + stat.size; + _fileCacheKeyIndex[sessionFile] = cacheKey; + if (_parsedDiskCache[cacheKey]) return _parsedDiskCache[cacheKey]; + + let lines; + try { + lines = readLines(sessionFile); + } catch { + return null; + } + let projectPath = ''; + let tool = 'claude'; + let msgCount = 0; + let firstMsg = ''; + let customTitle = ''; + let firstTs = stat.mtimeMs; + let lastTs = stat.mtimeMs; + let userMsgCount = 0; + let entrypointFound = false; + let worktreeOriginalCwd = ''; + const mcpSet = new Set(); + const skillSet = new Set(); + + for (const line of lines) { + try { + const entry = JSON.parse(line); + if (entry.type === 'user' || entry.type === 'assistant') msgCount++; + if (entry.type === 'user') userMsgCount++; + if (entry.timestamp) { + if (entry.timestamp < firstTs) firstTs = entry.timestamp; + if (entry.timestamp > lastTs) lastTs = entry.timestamp; + } + if (!projectPath && entry.type === 'user' && entry.cwd) { + projectPath = entry.cwd; + } + // worktree-state is written by Claude Code when a session runs inside a git worktree. + // originalCwd is the main checkout directory — safe to use in containers (no git needed). + if (!worktreeOriginalCwd && entry.type === 'worktree-state' && entry.worktreeSession && entry.worktreeSession.originalCwd) { + worktreeOriginalCwd = entry.worktreeSession.originalCwd; + } + if (!entrypointFound && entry.type === 'user' && entry.entrypoint) { + entrypointFound = true; + if (entry.entrypoint !== 'cli') tool = 'claude-ext'; + } + if (entry.type === 'custom-title' && typeof entry.customTitle === 'string') { + const title = entry.customTitle.trim(); + if (title) customTitle = title.slice(0, 200); + } + if (!firstMsg && entry.type === 'user' && entry.message && entry.message.content) { + const content = extractContent(entry.message.content).trim(); + if (content) firstMsg = content.slice(0, 200); + } + // MCP/Skill extraction from assistant tool_use blocks + if (entry.type === 'assistant') { + const aContent = (entry.message || {}).content; + if (Array.isArray(aContent)) { + for (const block of aContent) { + if (!block || block.type !== 'tool_use') continue; + const name = block.name || ''; + if (name.startsWith('mcp__')) { + const parts = name.split('__'); + if (parts.length >= 3) mcpSet.add(parts[1]); + } else if (name === 'Skill') { + const sk = (block.input || {}).skill; + if (sk) skillSet.add(sk.includes(':') ? sk.split(':')[0] : sk); + } + } + } + } + } catch {} + } + + const result = { + projectPath, + tool, + msgCount, + userMsgCount, + firstMsg, + customTitle, + firstTs, + lastTs, + fileSize: stat.size, + worktreeOriginalCwd, + mcpServers: Array.from(mcpSet), + skills: Array.from(skillSet), + }; + + // Cache to disk + _parsedDiskCache[cacheKey] = result; + _parsedDiskCacheDirty = true; + return result; +} + +function mergeClaudeSessionDetail(session, summary, sessionFile) { + if (!session || !summary) return; + + session.tool = summary.tool || session.tool; + session.has_detail = true; + session.file_size = summary.fileSize; + session.detail_messages = summary.msgCount; + session.user_messages = summary.userMsgCount || 0; + session._session_file = sessionFile; + session.mcp_servers = summary.mcpServers || []; + session.skills = summary.skills || []; + + if (!session.project && summary.projectPath) { + session.project = summary.projectPath; + session.project_short = summary.projectPath.replace(os.homedir(), '~'); + } + + if (summary.worktreeOriginalCwd) { + session.worktree_original_cwd = summary.worktreeOriginalCwd; + } + + if (summary.customTitle) { + session.first_message = summary.customTitle; + } +} + +function parseCodexSessionIndex(codexDir) { + const titles = {}; + const titleMeta = {}; + const indexFile = path.join(codexDir, 'session_index.jsonl'); + if (!fs.existsSync(indexFile)) return titles; + + const parseUpdatedAt = (value) => { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) return NaN; + if (/^\d+$/.test(trimmed)) return Number(trimmed); + return Date.parse(trimmed); + } + return NaN; + }; + + let lines; + try { + lines = readLines(indexFile); + } catch { + return titles; + } + + for (const line of lines) { + try { + const entry = JSON.parse(line); + const sid = entry.id || entry.session_id || entry.sessionId; + if (!sid || typeof entry.thread_name !== 'string') continue; + const title = entry.thread_name.trim(); + if (!title) continue; + + const updatedAt = parseUpdatedAt(entry.updated_at); + const hasUpdatedAt = Number.isFinite(updatedAt); + const existing = titleMeta[sid]; + + if (!existing) { + titles[sid] = title.slice(0, 200); + titleMeta[sid] = { updatedAt, hasUpdatedAt }; + continue; + } + + if ( + (hasUpdatedAt && !existing.hasUpdatedAt) || + (hasUpdatedAt && existing.hasUpdatedAt && updatedAt >= existing.updatedAt) || + (!hasUpdatedAt && !existing.hasUpdatedAt) + ) { + titles[sid] = title.slice(0, 200); + titleMeta[sid] = { updatedAt, hasUpdatedAt }; + } + } catch {} + } + + return titles; +} + +function scanOpenCodeSessions() { + const sessions = []; + if (!fs.existsSync(OPENCODE_DB)) return sessions; + + try { + // Use sqlite3 CLI with tab separator — session titles can contain pipes + // (e.g. "review changes [commit|branch|pr]") which break the default | separator + const rows = execFileSync('sqlite3', [ + '-separator', '\t', + OPENCODE_DB, + 'SELECT s.id, s.title, s.directory, s.time_created, s.time_updated, COUNT(m.id) as msg_count FROM session s LEFT JOIN message m ON m.session_id = s.id GROUP BY s.id ORDER BY s.time_updated DESC' + ], { encoding: 'utf8', timeout: 5000, windowsHide: true }).trim(); + + if (!rows) return sessions; + + // Get MCP/Skills usage per session in one query + const sessionMcp = {}; + const sessionSkills = {}; + try { + const toolRows = execFileSync('sqlite3', [ + '-separator', '\t', + OPENCODE_DB, + "SELECT session_id, json_extract(data, '$.tool'), json_extract(data, '$.state.input.name') FROM part WHERE json_extract(data, '$.type') = 'tool'" + ], { encoding: 'utf8', timeout: 10000, maxBuffer: 50 * 1024 * 1024, windowsHide: true }).trim(); + if (toolRows) { + for (const tr of toolRows.split('\n')) { + const cols = tr.split('\t'); + if (cols.length < 2) continue; + const sid = cols[0]; + const toolName = cols[1]; + const skillName = cols[2]; + if (!sid || !toolName) continue; + // Skill tool: collect skill name + if (toolName === 'skill' || toolName === 'skill_mcp') { + if (skillName) { + if (!sessionSkills[sid]) sessionSkills[sid] = new Set(); + // Plugin prefix: "superpowers:writing-plans" -> "superpowers" + // For OpenCode keep full name (e.g. "openspec-propose", "chrome-devtools") + const sk = skillName.includes(':') ? skillName.split(':')[0] : skillName; + sessionSkills[sid].add(sk); + } + continue; + } + // MCP tool: extract server name + const server = parseOpenCodeMcpServer(toolName); + if (server) { + if (!sessionMcp[sid]) sessionMcp[sid] = new Set(); + sessionMcp[sid].add(server); + } + } + } + } catch {} + + for (const row of rows.split('\n')) { + const parts = row.split('\t'); + if (parts.length < 6) continue; + const [id, title, directory, timeCreated, timeUpdated, msgCount] = parts; + + sessions.push({ + id: id, + tool: 'opencode', + project: directory || '', + project_short: (directory || '').replace(os.homedir(), '~'), + first_ts: parseInt(timeCreated) || Date.now(), + last_ts: parseInt(timeUpdated) || Date.now(), + messages: parseInt(msgCount) || 0, + first_message: title || '', + has_detail: true, + file_size: 0, + detail_messages: parseInt(msgCount) || 0, + mcp_servers: sessionMcp[id] ? Array.from(sessionMcp[id]) : [], + skills: sessionSkills[id] ? Array.from(sessionSkills[id]) : [], + }); + } + } catch {} + + return sessions; +} + +function loadOpenCodeDetail(sessionId) { + if (!fs.existsSync(OPENCODE_DB)) return { messages: [] }; + + try { + // Get messages with parts joined + const rows = execFileSync('sqlite3', [ + OPENCODE_DB, + `SELECT m.data, GROUP_CONCAT(p.data, '|||') FROM message m LEFT JOIN part p ON p.message_id = m.id WHERE m.session_id = '${sessionId.replace(/'/g, "''")}' GROUP BY m.id ORDER BY m.time_created` + ], { encoding: 'utf8', timeout: 10000, windowsHide: true }).trim(); + + if (!rows) return { messages: [] }; + + const messages = []; + for (const row of rows.split('\n')) { + const sepIdx = row.indexOf('|'); + if (sepIdx < 0) continue; + + // Parse message data (first column) + // Find the JSON boundary - message data ends where part data starts + let msgJson, partsRaw; + try { + // Try to find where message JSON ends + let braceCount = 0; + let jsonEnd = 0; + for (let i = 0; i < row.length; i++) { + if (row[i] === '{') braceCount++; + if (row[i] === '}') { braceCount--; if (braceCount === 0) { jsonEnd = i + 1; break; } } + } + msgJson = row.slice(0, jsonEnd); + partsRaw = row.slice(jsonEnd + 1); // skip | + } catch { continue; } + + let msgData; + try { msgData = JSON.parse(msgJson); } catch { continue; } + + const role = msgData.role; + if (role !== 'user' && role !== 'assistant') continue; + + // Extract text + tools from parts + let content = ''; + const tools = []; + const toolSeen = new Set(); + if (partsRaw) { + for (const partStr of partsRaw.split('|||')) { + try { + const part = JSON.parse(partStr); + if (part.type === 'text' && part.text) { + content += part.text + '\n'; + } else if (part.type === 'tool' && part.tool) { + const toolName = part.tool; + if (toolName === 'skill' || toolName === 'skill_mcp') { + const skillRaw = part.state && part.state.input && part.state.input.name; + if (skillRaw) { + const sk = skillRaw.includes(':') ? skillRaw.split(':')[0] : skillRaw; + const key = 'skill:' + sk; + if (!toolSeen.has(key)) { + toolSeen.add(key); + tools.push({ type: 'skill', skill: sk }); + } + } + } else { + const server = parseOpenCodeMcpServer(toolName); + if (server) { + const tool = toolName.slice(server.length + 1); + const key = 'mcp:' + server + ':' + tool; + if (!toolSeen.has(key)) { + toolSeen.add(key); + tools.push({ type: 'mcp', server: server, tool: tool }); + } + } + } + } + } catch {} + } + } + + content = content.trim(); + if (!content) continue; + + const tokens = msgData.tokens || {}; + + const msg = { + role: role, + content: content.slice(0, 2000), + uuid: '', + model: msgData.modelID || msgData.model?.modelID || '', + tokens: tokens, + }; + if (tools.length > 0) msg.tools = tools; + messages.push(msg); + } + + return { messages: messages.slice(0, 200) }; + } catch { + return { messages: [] }; + } +} + +function scanKiroSessions() { + const sessions = []; + if (!fs.existsSync(KIRO_DB)) return sessions; + + try { + const rows = execFileSync('sqlite3', [ + '-separator', '\t', + KIRO_DB, + 'SELECT key, conversation_id, created_at, updated_at, substr(value, 1, 500), length(value) FROM conversations_v2 ORDER BY updated_at DESC' + ], { encoding: 'utf8', timeout: 5000, windowsHide: true }).trim(); + + if (!rows) return sessions; + + for (const row of rows.split('\n')) { + const parts = row.split('\t'); + if (parts.length < 5) continue; + const [directory, convId, createdAt, updatedAt, valuePeek, valueLen] = parts; + + // Extract first user prompt and estimate message count from JSON peek + let firstMsg = ''; + let msgCount = 0; + try { + const promptMatch = valuePeek.match(/"prompt":"([^"]{1,100})"/); + if (promptMatch) firstMsg = promptMatch[1]; + // Count "prompt" occurrences as rough message estimate (each turn has user+assistant) + const promptCount = (valuePeek.match(/"prompt"/g) || []).length; + msgCount = promptCount * 2; // user + assistant per turn + if (msgCount === 0 && parseInt(valueLen) > 100) msgCount = Math.max(2, Math.floor(parseInt(valueLen) / 2000)); + } catch {} + + sessions.push({ + id: convId, + tool: 'kiro', + project: directory || '', + project_short: (directory || '').replace(os.homedir(), '~'), + first_ts: parseInt(createdAt) || Date.now(), + last_ts: parseInt(updatedAt) || Date.now(), + messages: msgCount, + first_message: firstMsg, + has_detail: true, + file_size: parseInt(valueLen) || 0, + detail_messages: msgCount, + }); + } + } catch {} + + return sessions; +} + +function loadKiroDetail(conversationId) { + if (!fs.existsSync(KIRO_DB)) return { messages: [] }; + + try { + const raw = execFileSync('sqlite3', [ + KIRO_DB, + `SELECT value FROM conversations_v2 WHERE conversation_id = '${conversationId.replace(/'/g, "''")}';` + ], { encoding: 'utf8', timeout: 10000, windowsHide: true }).trim(); + + if (!raw) return { messages: [] }; + + const data = JSON.parse(raw); + const messages = []; + + for (const entry of (data.history || [])) { + if (entry.user) { + const prompt = (entry.user.content || {}).Prompt || {}; + const text = prompt.prompt || ''; + if (text) messages.push({ role: 'user', content: text.slice(0, 2000), uuid: '' }); + } + if (entry.assistant) { + const resp = entry.assistant.Response || entry.assistant.response || {}; + const text = resp.content || ''; + if (text) messages.push({ role: 'assistant', content: text.slice(0, 2000), uuid: resp.message_id || '' }); + } + } + + return { messages: messages.slice(0, 200) }; + } catch { + return { messages: [] }; + } +} + +// Cursor stores each workspace under ~/.cursor/projects// where is the +// absolute path with / and . replaced by -. Hyphens inside a directory name are +// preserved, so splitting on "-" cannot recover the path. Decode by +// greedily matching the longest real child directory name at each level. +function decodeCursorProjectFolderKey(proj) { + if (!proj) return ''; + let enc = proj; + let cwd = ''; + while (enc.length > 0) { + const parent = cwd || '/'; + let dirs; + try { + dirs = fs.readdirSync(parent, { withFileTypes: true }) + .filter(function (e) { return e.isDirectory(); }) + .map(function (e) { return e.name; }); + } catch { + return cwd || ('/' + proj.replace(/-/g, '/')); + } + dirs.sort(function (a, b) { return b.length - a.length; }); + var matched = null; + for (var j = 0; j < dirs.length; j++) { + var d = dirs[j]; + // Cursor encodes both / and . as -, so compare against encoded dir name + var encoded = d.replace(/[^a-zA-Z0-9-]/g, '-'); + if (enc === encoded || (enc.startsWith(encoded) && (enc.length === encoded.length || enc[encoded.length] === '-'))) { + matched = d; + break; + } + } + if (!matched) { + var idx = enc.indexOf('-'); + var part = idx === -1 ? enc : enc.slice(0, idx); + var next = cwd ? path.join(cwd, part) : path.join('/', part); + if (fs.existsSync(next)) { + cwd = next; + enc = idx === -1 ? '' : enc.slice(idx + 1); + } else { + return cwd || ('/' + proj.replace(/-/g, '/')); + } + continue; + } + cwd = cwd ? path.join(cwd, matched) : path.join('/', matched); + enc = enc.length === matched.length ? '' : enc.slice(matched.length + 1); + } + return cwd; +} + +// Build composerId -> project path mapping from Cursor workspace storage +// Uses disk cache to avoid querying 190+ SQLite files on every startup +let _cursorWsMapCache = null; +const CURSOR_WS_MAP_CACHE_FILE = path.join(os.tmpdir(), 'codedash-cursor-ws-map.json'); +const CURSOR_WS_MAP_TTL = 600000; // 10 minutes + +function buildCursorWorkspaceMap() { + if (_cursorWsMapCache) return _cursorWsMapCache; + + // Try loading from disk cache first (~1ms vs ~1500ms full rebuild) + try { + if (fs.existsSync(CURSOR_WS_MAP_CACHE_FILE)) { + const cached = JSON.parse(fs.readFileSync(CURSOR_WS_MAP_CACHE_FILE, 'utf8')); + if (cached._ts && (Date.now() - cached._ts) < CURSOR_WS_MAP_TTL) { + delete cached._ts; + _cursorWsMapCache = cached; + return cached; + } + } + } catch {} + + const map = {}; // composerId -> projectPath + if (!fs.existsSync(CURSOR_WORKSPACE_STORAGE)) return map; + + try { + // Step 1: Read all workspace.json files (fast fs reads, ~10ms) + const hashToFolder = {}; + for (const hash of fs.readdirSync(CURSOR_WORKSPACE_STORAGE)) { + const wsJson = path.join(CURSOR_WORKSPACE_STORAGE, hash, 'workspace.json'); + try { + const wsData = JSON.parse(fs.readFileSync(wsJson, 'utf8')); + let folder = wsData.folder || ''; + if (folder.startsWith('file://')) { + folder = decodeURIComponent(folder.replace('file://', '')); + } else if (folder.startsWith('vscode-remote://')) { + const m = folder.match(/vscode-remote:\/\/[^/]+(\/.*)/); + folder = m ? decodeURIComponent(m[1]) : ''; + } + if (folder) hashToFolder[hash] = folder; + } catch {} + } + + // Step 2: Query workspace state.vscdb files for composer IDs + for (const hash of Object.keys(hashToFolder)) { + const wsDb = path.join(CURSOR_WORKSPACE_STORAGE, hash, 'state.vscdb'); + if (!fs.existsSync(wsDb)) continue; + try { + const raw = execFileSync('sqlite3', [ + wsDb, + "SELECT value FROM ItemTable WHERE key = 'composer.composerData'" + ], { encoding: 'utf8', timeout: 2000, windowsHide: true }).trim(); + if (!raw) continue; + const data = JSON.parse(raw); + for (const c of (data.allComposers || [])) { + if (c.composerId) map[c.composerId] = hashToFolder[hash]; + } + } catch {} + } + } catch {} + + _cursorWsMapCache = map; + + // Save to disk cache for fast startup next time + try { + fs.writeFileSync(CURSOR_WS_MAP_CACHE_FILE, JSON.stringify(Object.assign({ _ts: Date.now() }, map))); + } catch {} + + return map; +} + +// ── GitHub Copilot Chat ────────────────────────────────── + +// Extract text from Copilot response parts array +// Parts without a `kind` field carry the actual markdown text in `part.value` +function extractCopilotResponseText(responseParts) { + if (!Array.isArray(responseParts)) return ''; + const parts = []; + for (const part of responseParts) { + if (!part) continue; + // Current format: markdown text and thinking chunks are stored in value. + if (typeof part.value === 'string' && part.value.trim() && (part.kind === undefined || part.kind === '' || part.kind === 'thinking')) { + parts.push(part.value.trim()); + } else if ((part.kind === 'agentTurnSummary' || part.kind === 'markdownContent') && part.content) { + // Older format fallback + parts.push(typeof part.content === 'string' ? part.content : (part.content.value || '')); + } + } + return parts.join('\n').trim(); +} + +function setByPath(root, pathArr, value) { + if (!root || !Array.isArray(pathArr) || pathArr.length === 0) return; + let cur = root; + for (let i = 0; i < pathArr.length - 1; i++) { + const key = pathArr[i]; + const nextKey = pathArr[i + 1]; + if (cur[key] === undefined || cur[key] === null) { + cur[key] = typeof nextKey === 'number' ? [] : {}; + } + cur = cur[key]; + } + cur[pathArr[pathArr.length - 1]] = value; +} + +function appendByPath(root, pathArr, items) { + if (!root || !Array.isArray(pathArr) || pathArr.length === 0 || !Array.isArray(items)) return; + let cur = root; + for (let i = 0; i < pathArr.length; i++) { + const key = pathArr[i]; + const isLeaf = i === pathArr.length - 1; + if (isLeaf) { + if (!Array.isArray(cur[key])) cur[key] = []; + cur[key].push(...items); + return; + } + const nextKey = pathArr[i + 1]; + if (cur[key] === undefined || cur[key] === null) { + cur[key] = typeof nextKey === 'number' ? [] : {}; + } + cur = cur[key]; + } +} + +// Read Copilot session requests from either .json or .jsonl file +function readCopilotRequests(filePath) { + const raw = fs.readFileSync(filePath, 'utf8'); + // .json files are plain JSON objects + if (filePath.endsWith('.json')) { + try { + const d = JSON.parse(raw); + return { requests: d.requests || [], creationDate: d.creationDate || 0, lastMessageDate: d.lastMessageDate || 0 }; + } catch { return { requests: [], creationDate: 0, lastMessageDate: 0 }; } + } + // .jsonl files use kind:0/1/2 delta format + let state = { requests: [] }; + for (const line of raw.split('\n').map(l => l.replace(/\r$/, '')).filter(Boolean)) { + try { + const entry = JSON.parse(line); + if (entry.kind === 0 && entry.v) { + state = entry.v; + if (!Array.isArray(state.requests)) state.requests = []; + } else if (entry.kind === 1 && Array.isArray(entry.k)) { + setByPath(state, entry.k, entry.v); + } else if (entry.kind === 2 && Array.isArray(entry.k) && Array.isArray(entry.v)) { + appendByPath(state, entry.k, entry.v); + } + } catch {} + } + return { + requests: Array.isArray(state.requests) ? state.requests : [], + creationDate: state.creationDate || 0, + lastMessageDate: state.lastMessageDate || 0, + }; +} + +// Parse session file to extract metadata (fast path — reads file once) +function parseCopilotSessionMeta(filePath) { + let parsed; + try { parsed = readCopilotRequests(filePath); } catch { return { creationDate: 0, lastTs: 0, requestCount: 0, firstMessage: '' }; } + const { requests, creationDate, lastMessageDate } = parsed; + const firstMessage = requests.length > 0 && requests[0].message + ? (requests[0].message.text || '').slice(0, 200) : ''; + const lastTs = lastMessageDate || (requests.length > 0 ? (requests[requests.length - 1].timestamp || 0) : 0); + return { creationDate, lastTs: lastTs || creationDate, requestCount: requests.length, firstMessage }; +} + +// Load detail messages from a Copilot session file +function loadCopilotDetailFromFile(filePath) { + let parsed; + try { parsed = readCopilotRequests(filePath); } catch { return { messages: [] }; } + const messages = []; + for (const req of parsed.requests) { + const userText = req.message && req.message.text ? req.message.text.trim() : ''; + if (userText) { + messages.push({ role: 'user', content: userText.slice(0, 2000), uuid: req.requestId || '' }); + } + const assistantText = extractCopilotResponseText(req.response); + if (assistantText) { + messages.push({ role: 'assistant', content: assistantText.slice(0, 2000), uuid: req.responseId || '' }); + } + } + return { messages: messages.slice(0, 200) }; +} + +// Scan all Copilot chat sessions across all VS Code workspace storages +function scanCopilotSessions() { + const sessions = []; + const allWsDirs = [COPILOT_WORKSPACE_STORAGE].concat(EXTRA_COPILOT_WS_STORAGE); + + for (const wsDir of allWsDirs) { + if (!fs.existsSync(wsDir)) continue; + let hashes; + try { hashes = fs.readdirSync(wsDir); } catch { continue; } + + for (const hash of hashes) { + const chatSessionsDir = path.join(wsDir, hash, 'chatSessions'); + if (!fs.existsSync(chatSessionsDir)) continue; + + // Resolve workspace hash → project path + let projectPath = ''; + try { + const wsData = JSON.parse(fs.readFileSync(path.join(wsDir, hash, 'workspace.json'), 'utf8')); + let folder = wsData.folder || ''; + if (folder.startsWith('file:///')) { + folder = decodeURIComponent(folder.slice(8)); + // Windows: /d:/path → d:/path + if (process.platform === 'win32' && /^\/[a-zA-Z]:\//.test(folder)) { + folder = folder.slice(1); + } + } + projectPath = folder; + } catch {} + + let files; + try { files = fs.readdirSync(chatSessionsDir); } catch { continue; } + + for (const file of files) { + if (!file.endsWith('.jsonl') && !file.endsWith('.json')) continue; + const sessionId = file.endsWith('.jsonl') ? file.replace('.jsonl', '') : file.replace('.json', ''); + const filePath = path.join(chatSessionsDir, file); + try { + const stat = fs.statSync(filePath); + const meta = parseCopilotSessionMeta(filePath); + sessions.push({ + id: sessionId, + tool: 'copilot', + project: projectPath, + project_short: projectPath ? projectPath.replace(os.homedir(), '~') : '', + first_ts: meta.creationDate || stat.mtimeMs, + last_ts: meta.lastTs || stat.mtimeMs, + messages: meta.requestCount, + first_message: meta.firstMessage, + has_detail: true, + file_size: stat.size, + detail_messages: meta.requestCount * 2, + mcp_servers: [], + skills: [], + _file: filePath, + }); + } catch {} + } + } + } + return sessions; +} + +function scanCursorSessions() { + const sessions = []; + const seenIds = new Set(); + + // Scan ~/.cursor/projects/*/agent-transcripts/*/*.jsonl + if (fs.existsSync(CURSOR_PROJECTS)) { + try { + for (const proj of fs.readdirSync(CURSOR_PROJECTS)) { + const transcriptsDir = path.join(CURSOR_PROJECTS, proj, 'agent-transcripts'); + if (!fs.existsSync(transcriptsDir)) continue; + + const projectPath = decodeCursorProjectFolderKey(proj) || ('/' + proj.replace(/-/g, '/')); + + for (const sessDir of fs.readdirSync(transcriptsDir)) { + const sessFile = path.join(transcriptsDir, sessDir, sessDir + '.jsonl'); + if (!fs.existsSync(sessFile)) continue; + + const stat = fs.statSync(sessFile); + let firstMsg = ''; + let msgCount = 0; + try { + const firstLine = fs.readFileSync(sessFile, 'utf8').split('\n')[0].replace(/\r$/, ''); + const d = JSON.parse(firstLine); + const content = (d.message || {}).content; + if (Array.isArray(content)) { + for (const part of content) { + if (part.type === 'text' && part.text) { + // Strip wrapper + firstMsg = part.text.replace(/<\/?user_query>/g, '').trim().slice(0, 200); + break; + } + } + } + // Count lines + msgCount = readLines(sessFile).length; + } catch {} + + seenIds.add(sessDir); + sessions.push({ + id: sessDir, + tool: 'cursor', + project: projectPath, + project_short: projectPath.replace(os.homedir(), '~'), + first_ts: stat.mtimeMs - (msgCount * 60000), // rough estimate + last_ts: stat.mtimeMs, + messages: msgCount, + first_message: firstMsg, + has_detail: true, + file_size: stat.size, + detail_messages: msgCount, + _file: sessFile, + }); + } + } + } catch {} + } + + // Also scan ~/.cursor/chats/*/ (Linux format) + if (fs.existsSync(CURSOR_CHATS)) { + try { + for (const chatDir of fs.readdirSync(CURSOR_CHATS)) { + const fullDir = path.join(CURSOR_CHATS, chatDir); + if (!fs.statSync(fullDir).isDirectory()) continue; + + // Look for .jsonl or .json inside + for (const f of fs.readdirSync(fullDir)) { + if (!f.endsWith('.jsonl') && !f.endsWith('.json')) continue; + const filePath = path.join(fullDir, f); + const stat = fs.statSync(filePath); + + let firstMsg = ''; + let msgCount = 0; + try { + const firstLine = fs.readFileSync(filePath, 'utf8').split('\n')[0].replace(/\r$/, ''); + const d = JSON.parse(firstLine); + if (d.role === 'user') { + const content = (d.message || {}).content || d.content; + if (typeof content === 'string') firstMsg = content.slice(0, 200); + else if (Array.isArray(content)) { + for (const p of content) { + if (p.text) { firstMsg = p.text.replace(/<\/?user_query>/g, '').trim().slice(0, 200); break; } + } + } + } + msgCount = readLines(filePath).length; + } catch {} + + seenIds.add(chatDir); + sessions.push({ + id: chatDir, + tool: 'cursor', + project: '', + project_short: '', + first_ts: stat.mtimeMs - (msgCount * 60000), + last_ts: stat.mtimeMs, + messages: msgCount, + first_message: firstMsg, + has_detail: true, + file_size: stat.size, + detail_messages: msgCount, + _file: filePath, + }); + break; // one file per chat dir + } + } + } catch {} + } + + // Cursor vscdb sessions are loaded via background task (see _loadCursorVscdbInBackground) + // and merged into loadSessions() result when ready + + return sessions; +} + +function loadCursorDetail(sessionId) { + // Find the file + let filePath = null; + + // Search in projects + if (fs.existsSync(CURSOR_PROJECTS)) { + for (const proj of fs.readdirSync(CURSOR_PROJECTS)) { + const f = path.join(CURSOR_PROJECTS, proj, 'agent-transcripts', sessionId, sessionId + '.jsonl'); + if (fs.existsSync(f)) { filePath = f; break; } + } + } + + // Search in chats + if (!filePath && fs.existsSync(CURSOR_CHATS)) { + const chatDir = path.join(CURSOR_CHATS, sessionId); + if (fs.existsSync(chatDir)) { + for (const f of fs.readdirSync(chatDir)) { + if (f.endsWith('.jsonl') || f.endsWith('.json')) { + filePath = path.join(chatDir, f); + break; + } + } + } + } + + // Try loading from global vscdb (Cursor stores most sessions here) + if (!filePath && fs.existsSync(CURSOR_GLOBAL_DB)) { + return loadCursorVscdbDetail(sessionId); + } + + if (!filePath) return { messages: [] }; + + const messages = []; + const lines = readLines(filePath); + + for (const line of lines) { + try { + const d = JSON.parse(line); + const role = d.role; + if (role !== 'user' && role !== 'assistant') continue; + + const content = (d.message || {}).content || d.content || ''; + let text = ''; + if (typeof content === 'string') { + text = content; + } else if (Array.isArray(content)) { + text = content + .filter(function(p) { return p.type === 'text' && p.text; }) + .map(function(p) { return p.text; }) + .join('\n'); + } + + // Strip Cursor wrappers + text = text.replace(/<\/?user_query>/g, '').replace(/<\/?tool_call>/g, '').trim(); + if (!text) continue; + + messages.push({ role: role, content: text.slice(0, 2000), uuid: '' }); + } catch {} + } + + return { messages: messages.slice(0, 200) }; +} + +// Load Cursor session detail from global state.vscdb (composerData + bubbleId entries) +function loadCursorVscdbDetail(sessionId) { + const messages = []; + + try { + // Get bubble order from composerData + const cleanId = sessionId.replace(/'/g, "''"); + const composerRaw = execFileSync('sqlite3', [ + CURSOR_GLOBAL_DB, + "SELECT value FROM cursorDiskKV WHERE key = 'composerData:" + cleanId + "'" + ], { encoding: 'utf8', timeout: 5000, windowsHide: true }).trim(); + + if (!composerRaw) return { messages: [] }; + + const composer = JSON.parse(composerRaw); + const bubbleHeaders = composer.fullConversationHeadersOnly || []; + if (bubbleHeaders.length === 0) return { messages: [] }; + + // Query all bubbles for this composer in one go + const bubbleRows = execFileSync('sqlite3', [ + '-separator', '\t', + CURSOR_GLOBAL_DB, + "SELECT key, value FROM cursorDiskKV WHERE key LIKE 'bubbleId:" + cleanId + ":%'" + ], { encoding: 'utf8', timeout: 10000, maxBuffer: 50 * 1024 * 1024, windowsHide: true }).trim(); + + if (!bubbleRows) return { messages: [] }; + + // Build bubbleId -> data map + const bubbleMap = {}; + for (const row of bubbleRows.split('\n')) { + const tabIdx = row.indexOf('\t'); + if (tabIdx < 0) continue; + const key = row.slice(0, tabIdx); + const value = row.slice(tabIdx + 1); + // key format: bubbleId:: + const parts = key.split(':'); + const bubbleId = parts[2]; + if (!bubbleId) continue; + try { + bubbleMap[bubbleId] = JSON.parse(value); + } catch {} + } + + // Iterate in conversation order + for (const header of bubbleHeaders) { + const bubble = bubbleMap[header.bubbleId]; + if (!bubble) continue; + + // type 1 = user, type 2 = assistant + const bType = bubble.type; + if (bType !== 1 && bType !== 2) continue; + + const role = bType === 1 ? 'user' : 'assistant'; + let text = bubble.text || ''; + text = text.replace(/<\/?user_query>/g, '').replace(/<\/?tool_call>/g, '').trim(); + if (!text) continue; + + messages.push({ role: role, content: text.slice(0, 2000), uuid: '' }); + } + } catch {} + + return { messages: messages.slice(0, 200) }; +} + +function parseCodexSessionFile(sessionFile) { + if (!fs.existsSync(sessionFile)) return null; + + let stat; + let lines; + try { + stat = fs.statSync(sessionFile); + lines = readLines(sessionFile); + } catch { + return null; + } + + const parseTimestamp = (value) => { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) return NaN; + if (/^\d+$/.test(trimmed)) return Number(trimmed); + return Date.parse(trimmed); + } + return NaN; + }; + + let projectPath = ''; + let msgCount = 0; + let userMsgCount = 0; + let firstMsg = ''; + let firstTs = stat.mtimeMs; + let lastTs = stat.mtimeMs; + const mcpSet = new Set(); + + for (const line of lines) { + try { + const entry = JSON.parse(line); + const ts = parseTimestamp(entry.timestamp || entry.ts); + if (Number.isFinite(ts)) { + if (ts < firstTs) firstTs = ts; + if (ts > lastTs) lastTs = ts; + } + + if (entry.type === 'session_meta' && entry.payload && entry.payload.cwd && !projectPath) { + projectPath = entry.payload.cwd; + continue; + } + + if (entry.type !== 'response_item' || !entry.payload) continue; + + // MCP function_call extraction + if (entry.payload.type === 'function_call') { + const name = entry.payload.name || ''; + if (name.startsWith('mcp__')) { + const parts = name.split('__'); + if (parts.length >= 3) mcpSet.add(parts[1]); + } + continue; + } + + const role = entry.payload.role; + if (role !== 'user' && role !== 'assistant') continue; + + const content = extractContent(entry.payload.content); + if (!content || isSystemMessage(content)) continue; + + msgCount++; + if (role === 'user') userMsgCount++; + if (!firstMsg) firstMsg = content.slice(0, 200); + } catch {} + } + + return { + projectPath, + msgCount, + userMsgCount, + firstMsg, + firstTs, + lastTs, + fileSize: stat.size, + mcpServers: Array.from(mcpSet), + }; +} + +function scanCodexSessions() { + const sessions = []; + const codexTitles = parseCodexSessionIndex(CODEX_DIR); + const codexHistory = path.join(CODEX_DIR, 'history.jsonl'); + if (fs.existsSync(codexHistory)) { + const lines = readLines(codexHistory); + for (const line of lines) { + try { + const d = JSON.parse(line); + // Codex uses session_id, ts (seconds), text + const sid = d.session_id || d.sessionId || d.id; + if (!sid) continue; + const ts = d.ts ? d.ts * 1000 : (d.timestamp || Date.now()); + if (!sessions.find(s => s.id === sid)) { + sessions.push({ + id: sid, + tool: 'codex', + project: d.project || d.cwd || '', + project_short: (d.project || d.cwd || '').replace(os.homedir(), '~'), + first_ts: ts, + last_ts: ts, + messages: 1, + first_message: codexTitles[sid] || d.text || d.display || d.prompt || '', + has_detail: false, + file_size: 0, + detail_messages: 0, + }); + } + } catch {} + } + } + + // Enrich with session files from ~/.codex/sessions/ + const codexSessionsDir = path.join(CODEX_DIR, 'sessions'); + if (fs.existsSync(codexSessionsDir)) { + try { + // Walk year/month/day directories + const files = []; + const walkDir = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walkDir(full); + else if (entry.name.endsWith('.jsonl')) files.push(full); + } + }; + walkDir(codexSessionsDir); + + for (const f of files) { + // Extract session ID from filename (rollout-DATE-UUID.jsonl) + const basename = path.basename(f, '.jsonl'); + const uuidMatch = basename.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/); + if (!uuidMatch) continue; + const sid = uuidMatch[1]; + const summary = parseCodexSessionFile(f); + if (!summary) continue; + + const existing = sessions.find(s => s.id === sid); + if (existing) { + existing.has_detail = true; + existing.file_size = summary.fileSize; + existing.messages = summary.msgCount; + existing.detail_messages = summary.msgCount; + existing.user_messages = summary.userMsgCount || 0; + if (codexTitles[sid]) { + existing.first_message = codexTitles[sid]; + } else if (summary.firstMsg && !existing.first_message) { + existing.first_message = summary.firstMsg; + } + if (summary.projectPath && !existing.project) { + existing.project = summary.projectPath; + existing.project_short = summary.projectPath.replace(os.homedir(), '~'); + } + existing.first_ts = Math.min(existing.first_ts, summary.firstTs); + existing.last_ts = Math.max(existing.last_ts, summary.lastTs); + if (summary.mcpServers && summary.mcpServers.length > 0) { + existing.mcp_servers = summary.mcpServers; + } + } else { + sessions.push({ + id: sid, + tool: 'codex', + project: summary.projectPath, + project_short: summary.projectPath ? summary.projectPath.replace(os.homedir(), '~') : '', + first_ts: summary.firstTs, + last_ts: summary.lastTs, + messages: summary.msgCount, + first_message: codexTitles[sid] || summary.firstMsg || '', + has_detail: true, + file_size: summary.fileSize, + detail_messages: summary.msgCount, + user_messages: summary.userMsgCount || 0, + mcp_servers: summary.mcpServers || [], + skills: [], + }); + } + } + } catch {} + } + + return sessions; +} + +// ── Git root resolver ─────────────────────────────────────── +// +// Priority order for determining the git root of a session: +// 1. worktree-state.originalCwd — written by Claude Code into the JSONL when +// the session runs inside a git worktree. Container-safe: no git required. +// 2. git rev-parse --show-toplevel — resolves the root at runtime. Fails +// gracefully (returns '') in containerized setups where git repos are not +// mounted; the try/catch ensures it never crashes the server. +// 3. Path heuristic in the frontend (getGitProjectName) — parses /.claude/worktrees/ +// from the session cwd string. Works without git for standard worktree layouts. + +const _gitRootCache = {}; +const GIT_ROOT_CACHE_FILE = path.join(os.tmpdir(), 'codedash-gitroot-cache.json'); +let _gitRootDiskCache = null; + +function _loadGitRootDiskCache() { + if (_gitRootDiskCache) return; + try { + if (fs.existsSync(GIT_ROOT_CACHE_FILE)) { + _gitRootDiskCache = JSON.parse(fs.readFileSync(GIT_ROOT_CACHE_FILE, 'utf8')); + // Pre-fill memory cache from disk + Object.assign(_gitRootCache, _gitRootDiskCache); + } + } catch {} + if (!_gitRootDiskCache) _gitRootDiskCache = {}; +} + +function _saveGitRootDiskCache() { + try { + fs.writeFileSync(GIT_ROOT_CACHE_FILE, JSON.stringify(_gitRootCache)); + } catch {} +} + +function resolveGitRoot(projectPath) { + if (!projectPath) return ''; + _loadGitRootDiskCache(); + if (_gitRootCache[projectPath] !== undefined) return _gitRootCache[projectPath]; + // Skip remote/non-existent paths + if (!fs.existsSync(projectPath)) { + _gitRootCache[projectPath] = ''; + return ''; + } + try { + const root = execFileSync('git', ['-C', projectPath, 'rev-parse', '--show-toplevel'], { + encoding: 'utf8', timeout: 2000, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] + }).trim(); + _gitRootCache[projectPath] = root; + return root; + } catch { + _gitRootCache[projectPath] = ''; + return ''; + } +} + +const _gitInfoCache = {}; +const GIT_INFO_CACHE_TTL = 30000; // 30 seconds + +function getProjectGitInfo(projectPath) { + if (!projectPath || !fs.existsSync(projectPath)) return null; + if (process.platform === 'win32') return null; + + const now = Date.now(); + const cached = _gitInfoCache[projectPath]; + if (cached && (now - cached._ts) < GIT_INFO_CACHE_TTL) return cached; + + const gitRoot = resolveGitRoot(projectPath); + if (!gitRoot) return null; + + const cwd = gitRoot; + const opts = { encoding: 'utf8', timeout: 3000, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }; + const info = { gitRoot, branch: '', remoteUrl: '', lastCommit: '', lastCommitDate: '', isDirty: false, _ts: now }; + + try { info.branch = execFileSync('git', ['-C', cwd, 'rev-parse', '--abbrev-ref', 'HEAD'], opts).trim(); } catch {} + try { info.remoteUrl = execFileSync('git', ['-C', cwd, 'config', '--get', 'remote.origin.url'], opts).trim(); } catch {} + try { + const log = execFileSync('git', ['-C', cwd, 'log', '-1', '--format=%h %s'], opts).trim(); + if (log) { + const sp = log.indexOf(' '); + info.lastCommit = sp > 0 ? log.slice(sp + 1).slice(0, 80) : log; + info.lastCommitHash = sp > 0 ? log.slice(0, sp) : ''; + } + } catch {} + try { info.lastCommitDate = execFileSync('git', ['-C', cwd, 'log', '-1', '--format=%ci'], opts).trim(); } catch {} + try { + const status = execFileSync('git', ['-C', cwd, 'status', '--porcelain'], opts).trim(); + info.isDirty = status.length > 0; + } catch {} + + _gitInfoCache[projectPath] = info; + return info; +} + +// ── Public API ───────────────────────────────────────────── + +let _sessionsCache = null; +let _sessionsCacheTs = 0; +const SESSIONS_CACHE_TTL = 60000; // 60 seconds — hot cache, invalidated by file changes + +// Track file mtimes for smart invalidation +let _historyMtime = 0; +let _historySize = 0; +let _projectsDirMtime = 0; + +function _sessionsNeedRescan() { + // Check if history.jsonl or projects dir changed since last scan + try { + if (fs.existsSync(HISTORY_FILE)) { + const st = fs.statSync(HISTORY_FILE); + if (st.mtimeMs !== _historyMtime || st.size !== _historySize) return true; + } + if (fs.existsSync(PROJECTS_DIR)) { + const st = fs.statSync(PROJECTS_DIR); + if (st.mtimeMs !== _projectsDirMtime) return true; + } + } catch {} + return false; +} + +function _updateScanMarkers() { + try { + if (fs.existsSync(HISTORY_FILE)) { + const st = fs.statSync(HISTORY_FILE); + _historyMtime = st.mtimeMs; + _historySize = st.size; + } + if (fs.existsSync(PROJECTS_DIR)) { + _projectsDirMtime = fs.statSync(PROJECTS_DIR).mtimeMs; + } + } catch {} +} + +function _invalidateRuntimeCaches() { + _sessionsCache = null; + _sessionsCacheTs = 0; + _sessionFileIndex = null; + _sessionFileIndexTs = 0; + searchIndex = null; + searchIndexBuiltAt = 0; + for (const k in _costMemCache) delete _costMemCache[k]; + _analyticsCacheResult = null; + _analyticsCacheKey = null; +} + +// Progressive loading: cursor vscdb sessions load in background +let _cursorVscdbSessions = null; +let _cursorVscdbLoading = false; + +function _loadCursorVscdbInBackground() { + if (_cursorVscdbLoading || _cursorVscdbSessions) return; + if (!fs.existsSync(CURSOR_GLOBAL_DB)) { _cursorVscdbSessions = []; return; } + _cursorVscdbLoading = true; + + // Workspace map from disk cache is instant (~1ms), only global DB query is slow + const wsMap = buildCursorWorkspaceMap(); + const homedir = os.homedir(); + + // Async sqlite3 queries — do NOT block the event loop + // Query 1: session metadata, Query 2: exact user bubble count per composer + const query = "SELECT json_extract(value, '$.composerId'), json_extract(value, '$.name'), json_extract(value, '$.createdAt'), json_extract(value, '$.lastUpdatedAt'), json_array_length(json_extract(value, '$.fullConversationHeadersOnly')) FROM cursorDiskKV WHERE key LIKE 'composerData:%'"; + + const cp = require('child_process'); + cp.execFile('sqlite3', [ + '-separator', '\t', CURSOR_GLOBAL_DB, query + ], { encoding: 'utf8', timeout: 15000, maxBuffer: 10 * 1024 * 1024, windowsHide: true }, + function(err, stdout) { + // Query 2: user bubble counts + token totals per composer (combined for efficiency) + const statsQuery = "SELECT substr(key, 10, 36) as cid, " + + "sum(CASE WHEN json_extract(value, '$.type') = 1 THEN 1 ELSE 0 END), " + + "sum(CASE WHEN json_extract(value, '$.tokenCount.inputTokens') > 0 THEN json_extract(value, '$.tokenCount.inputTokens') ELSE 0 END), " + + "sum(CASE WHEN json_extract(value, '$.tokenCount.outputTokens') > 0 THEN json_extract(value, '$.tokenCount.outputTokens') ELSE 0 END) " + + "FROM cursorDiskKV WHERE key LIKE 'bubbleId:%' GROUP BY cid"; + + cp.execFile('sqlite3', [ + '-separator', '\t', CURSOR_GLOBAL_DB, statsQuery + ], { encoding: 'utf8', timeout: 30000, maxBuffer: 10 * 1024 * 1024, windowsHide: true }, + function(err2, stdout2) { + // Build per-composer stats from query 2 + const composerStats = {}; // { userCount, inputTokens, outputTokens } + if (stdout2) { + for (const row of stdout2.trim().split('\n')) { + const cols = row.split('\t'); + if (cols.length < 4) continue; + composerStats[cols[0]] = { + userCount: parseInt(cols[1]) || 0, + inputTokens: parseInt(cols[2]) || 0, + outputTokens: parseInt(cols[3]) || 0, + }; + } + } + + // Build model map from composerData (query 1 already has this via the main query) + // We need to add model to the main query — for now extract from sessions metadata + // Query 3: models per composer (lightweight) + const modelQuery = "SELECT json_extract(value, '$.composerId'), json_extract(value, '$.modelConfig.modelName') FROM cursorDiskKV WHERE key LIKE 'composerData:%'"; + + cp.execFile('sqlite3', [ + '-separator', '\t', CURSOR_GLOBAL_DB, modelQuery + ], { encoding: 'utf8', timeout: 10000, maxBuffer: 5 * 1024 * 1024, windowsHide: true }, + function(err3, stdout3) { + const composerModels = {}; + if (stdout3) { + for (const row of stdout3.trim().split('\n')) { + const tabIdx = row.indexOf('\t'); + if (tabIdx > 0) composerModels[row.slice(0, tabIdx)] = row.slice(tabIdx + 1) || ''; + } + } + + try { + const results = []; + const rows = (stdout || '').trim(); + if (rows) { + for (const row of rows.split('\n')) { + const cols = row.split('\t'); + if (cols.length < 5) continue; + const composerId = cols[0]; + if (!composerId) continue; + const msgCount = parseInt(cols[4]) || 0; + if (msgCount === 0) continue; + const projectPath = wsMap[composerId] || ''; + const stats = composerStats[composerId] || {}; + results.push({ + id: composerId, + tool: 'cursor', + project: projectPath, + project_short: projectPath ? projectPath.replace(homedir, '~') : '', + first_ts: parseInt(cols[2]) || 0, + last_ts: parseInt(cols[3]) || parseInt(cols[2]) || 0, + messages: msgCount, + first_message: (cols[1] || '').slice(0, 200), + has_detail: true, + file_size: 0, + detail_messages: msgCount, + user_messages: stats.userCount || 0, + _cursor_vscdb: true, + _cursor_input_tokens: stats.inputTokens || 0, + _cursor_output_tokens: stats.outputTokens || 0, + _cursor_model: composerModels[composerId] || '', + }); + } + } + _cursorVscdbSessions = results; + } catch { + _cursorVscdbSessions = []; + } + _cursorVscdbLoading = false; + // Merge into existing cache instead of full invalidation + if (_sessionsCache && _cursorVscdbSessions && _cursorVscdbSessions.length > 0) { + const existingIds = new Set(_sessionsCache.map(function(s) { return s.id; })); + const newSessions = []; + for (var i = 0; i < _cursorVscdbSessions.length; i++) { + var cs = _cursorVscdbSessions[i]; + if (existingIds.has(cs.id)) continue; + cs.first_time = new Date(cs.first_ts).toLocaleString('sv-SE').slice(0, 16); + cs.last_time = new Date(cs.last_ts).toLocaleString('sv-SE').slice(0, 16); + var dt = new Date(cs.last_ts); + cs.date = dt.getFullYear() + '-' + String(dt.getMonth()+1).padStart(2,'0') + '-' + String(dt.getDate()).padStart(2,'0'); + cs.git_root = ''; + if (!cs.mcp_servers) cs.mcp_servers = []; + if (!cs.skills) cs.skills = []; + newSessions.push(cs); + } + if (newSessions.length > 0) { + _sessionsCache = _sessionsCache.concat(newSessions).sort(function(a, b) { return b.last_ts - a.last_ts; }); + // Keep the same cache timestamp — no full rebuild needed + } + } else { + _sessionsCache = null; + _sessionsCacheTs = 0; + } + }); // end execFile query 3 (models) + }); // end execFile query 2 (stats) + }); // end execFile query 1 (sessions) +} + +function loadSessions() { + const now = Date.now(); + if (_sessionsCache) { + // Hot cache: return immediately if within TTL and no file changes + if ((now - _sessionsCacheTs) < SESSIONS_CACHE_TTL) return _sessionsCache; + // Extended cache: even after TTL, only rescan if files actually changed + if (!_sessionsNeedRescan()) { + _sessionsCacheTs = now; // extend TTL + return _sessionsCache; + } + } + const sessions = {}; + + // Load Claude Code sessions + if (fs.existsSync(HISTORY_FILE)) { + const lines = readLines(HISTORY_FILE); + for (const line of lines) { + try { + const d = JSON.parse(line); + const sid = d.sessionId; + if (!sid) continue; + + if (!sessions[sid]) { + sessions[sid] = { + id: sid, + tool: 'claude', + project: d.project || '', + project_short: (d.project || '').replace(os.homedir(), '~'), + first_ts: d.timestamp, + last_ts: d.timestamp, + messages: 0, + first_message: '', + _claude_dir: CLAUDE_DIR, + }; + } + + const s = sessions[sid]; + s.last_ts = Math.max(s.last_ts, d.timestamp); + s.first_ts = Math.min(s.first_ts, d.timestamp); + s.messages++; + + if (d.display && d.display !== 'exit' && !s.first_message) { + s.first_message = d.display.slice(0, 200); + } + } catch {} + } + } + + // Load Codex sessions + if (fs.existsSync(CODEX_DIR)) { + try { + const codexSessions = scanCodexSessions(); + for (const cs of codexSessions) { + sessions[cs.id] = cs; + } + } catch {} + } + + // Load OpenCode sessions + try { + const opencodeSessions = scanOpenCodeSessions(); + for (const ocs of opencodeSessions) { + sessions[ocs.id] = ocs; + } + } catch {} + + // Load Cursor sessions + try { + const cursorSessions = scanCursorSessions(); + for (const cs of cursorSessions) { + sessions[cs.id] = cs; + } + } catch {} + + // Load Kiro sessions + try { + const kiroSessions = scanKiroSessions(); + for (const ks of kiroSessions) { + sessions[ks.id] = ks; + } + } catch {} + + // Load Copilot sessions + try { + const copilotSessions = scanCopilotSessions(); + for (const cs of copilotSessions) { + sessions[cs.id] = cs; + } + } catch {} + + // WSL: also load from Windows-side dirs + for (const extraClaudeDir of EXTRA_CLAUDE_DIRS) { + try { + const extraHistory = path.join(extraClaudeDir, 'history.jsonl'); + if (fs.existsSync(extraHistory)) { + const lines = readLines(extraHistory); + for (const line of lines) { + let d; + try { + d = JSON.parse(line); + const sid = d.sessionId; + if (!sid) continue; + if (!sessions[sid]) { + sessions[sid] = { + id: sid, tool: 'claude', + project: d.project || '', project_short: (d.project || '').replace(os.homedir(), '~'), + first_ts: d.timestamp, last_ts: d.timestamp, + messages: 0, first_message: '', + _claude_dir: extraClaudeDir, + }; + } + } catch {} + if (!d || !d.sessionId) continue; + const s = sessions[d.sessionId]; + if (s) { s.last_ts = Math.max(s.last_ts, d.timestamp); s.first_ts = Math.min(s.first_ts, d.timestamp); s.messages++; if (d.display && d.display !== 'exit' && !s.first_message) s.first_message = d.display.slice(0, 200); } + } + } + // Scan extra projects dirs + const extraProjects = path.join(extraClaudeDir, 'projects'); + if (fs.existsSync(extraProjects)) { + for (const proj of fs.readdirSync(extraProjects)) { + const projDir = path.join(extraProjects, proj); + if (!fs.statSync(projDir).isDirectory()) continue; + for (const file of fs.readdirSync(projDir)) { + if (!file.endsWith('.jsonl')) continue; + const sid = file.replace('.jsonl', ''); + const fp = path.join(projDir, file); + if (sessions[sid]) { + const summary = parseClaudeSessionFile(fp); + if (summary) mergeClaudeSessionDetail(sessions[sid], summary, fp); + else if (!sessions[sid].has_detail) { + sessions[sid].has_detail = true; + sessions[sid].file_size = fs.statSync(fp).size; + sessions[sid]._session_file = fp; + } + continue; + } + const summary = parseClaudeSessionFile(fp); + if (!summary) continue; + sessions[sid] = { + id: sid, + tool: summary.tool, + project: summary.projectPath, + project_short: summary.projectPath.replace(os.homedir(), '~'), + first_ts: summary.firstTs, + last_ts: summary.lastTs, + messages: summary.msgCount, + first_message: summary.customTitle || summary.firstMsg, + has_detail: true, + file_size: summary.fileSize, + detail_messages: summary.msgCount, + _claude_dir: extraClaudeDir, + _session_file: fp, + worktree_original_cwd: summary.worktreeOriginalCwd || '', + }; + } + } + } + } catch {} + } + + // Enrich Claude sessions with detail file info + // Build file index once to avoid O(sessions*projects) existsSync scans + _buildSessionFileIndex(); + for (const [sid, s] of Object.entries(sessions)) { + if (s.tool !== 'claude' && s.tool !== 'claude-ext') continue; + let sessionFile = ''; + if (s._session_file) { + sessionFile = s._session_file; + } else { + // Use pre-built index instead of scanning dirs + const indexed = _sessionFileIndex[sid]; + if (indexed && indexed.format === 'claude') sessionFile = indexed.file; + } + + if (sessionFile) { + const summary = parseClaudeSessionFile(sessionFile); + if (summary) mergeClaudeSessionDetail(s, summary, sessionFile); + else { + s.has_detail = true; + try { s.file_size = fs.statSync(sessionFile).size; } catch { s.file_size = 0; } + s._session_file = sessionFile; + } + } else if (!s.has_detail) { + s.has_detail = false; + s.file_size = 0; + s.detail_messages = 0; + s.mcp_servers = []; + s.skills = []; + } + } + + // Scan project dirs for orphan sessions (e.g. Claude Extension sessions not in history.jsonl) + if (fs.existsSync(PROJECTS_DIR)) { + try { + for (const proj of fs.readdirSync(PROJECTS_DIR)) { + const projDir = path.join(PROJECTS_DIR, proj); + if (!fs.statSync(projDir).isDirectory()) continue; + for (const file of fs.readdirSync(projDir)) { + if (!file.endsWith('.jsonl')) continue; + const sid = file.replace('.jsonl', ''); + const filePath = path.join(projDir, file); + if (sessions[sid]) { + const summary = parseClaudeSessionFile(filePath); + if (summary) mergeClaudeSessionDetail(sessions[sid], summary, filePath); + continue; + } + const summary = parseClaudeSessionFile(filePath); + if (!summary) continue; + sessions[sid] = { + id: sid, + tool: summary.tool, + project: summary.projectPath, + project_short: summary.projectPath.replace(os.homedir(), '~'), + first_ts: summary.firstTs, + last_ts: summary.lastTs, + messages: summary.msgCount, + first_message: summary.customTitle || summary.firstMsg, + has_detail: true, + file_size: summary.fileSize, + detail_messages: summary.msgCount, + mcp_servers: summary.mcpServers, + skills: summary.skills, + _claude_dir: CLAUDE_DIR, + _session_file: filePath, + worktree_original_cwd: summary.worktreeOriginalCwd || '', + }; + } + } + } catch {} + } + + // Ensure all sessions have mcp_servers/skills (defaults for non-Claude) + for (const s of Object.values(sessions)) { + if (!s.mcp_servers) s.mcp_servers = []; + if (!s.skills) s.skills = []; + } + + // Merge background-loaded Cursor vscdb sessions (progressive loading) + const existingIds = new Set(Object.keys(sessions)); + if (_cursorVscdbSessions) { + for (const cs of _cursorVscdbSessions) { + if (!existingIds.has(cs.id)) sessions[cs.id] = cs; + } + } else { + // Kick off background loading if not started yet + _loadCursorVscdbInBackground(); + } + + const result = Object.values(sessions).sort((a, b) => b.last_ts - a.last_ts); + + // Collect unique project paths and resolve git roots in one pass + const uniquePaths = [...new Set(result.map(s => s.project).filter(Boolean))]; + for (const p of uniquePaths) resolveGitRoot(p); + + for (const s of result) { + s.first_time = new Date(s.first_ts).toLocaleString('sv-SE').slice(0, 16); + s.last_time = new Date(s.last_ts).toLocaleString('sv-SE').slice(0, 16); + const dt = new Date(s.last_ts); + s.date = dt.getFullYear() + '-' + String(dt.getMonth()+1).padStart(2,'0') + '-' + String(dt.getDate()).padStart(2,'0'); + // Priority: worktree-state.originalCwd (container-safe) > git rev-parse > path heuristic (frontend) + s.git_root = s.worktree_original_cwd || (s.project ? (_gitRootCache[s.project] || '') : ''); + } + + // Flag for frontend: true = cursor vscdb still loading, will have more data soon + result._loading = !_cursorVscdbSessions && _cursorVscdbLoading; + + // Flush disk caches + _saveParsedDiskCache(); + _saveGitRootDiskCache(); + _updateScanMarkers(); + + _sessionsCache = result; + _sessionsCacheTs = Date.now(); + return result; +} + +function loadSessionDetail(sessionId, project) { + const found = findSessionFile(sessionId, project); + if (!found) return { error: 'Session file not found', messages: [] }; + + // OpenCode uses SQLite + if (found.format === 'opencode') { + return loadOpenCodeDetail(sessionId); + } + + // Cursor + if (found.format === 'cursor') { + return loadCursorDetail(sessionId); + } + + // Kiro uses SQLite + if (found.format === 'kiro') { + return loadKiroDetail(sessionId); + } + + // Copilot + if (found.format === 'copilot') { + return loadCopilotDetailFromFile(found.file); + } + + const messages = []; + const lines = readLines(found.file); + + for (const line of lines) { + try { + const entry = JSON.parse(line); + + if (found.format === 'claude') { + if (entry.type === 'user' || entry.type === 'assistant') { + const content = extractContent((entry.message || {}).content); + if (content) { + const msg = { role: entry.type, content: content.slice(0, 2000), uuid: entry.uuid || '' }; + if (entry.type === 'assistant') { + const rawContent = (entry.message || {}).content; + if (Array.isArray(rawContent)) { + const tools = extractTools(rawContent); + if (tools.length > 0) msg.tools = tools; + } + } + messages.push(msg); + } + } + } else { + // Codex format: response_item with payload + if (entry.type === 'response_item' && entry.payload) { + const pType = entry.payload.type; + const role = entry.payload.role; + if (role === 'user' || role === 'assistant') { + const content = extractContent(entry.payload.content); + if (content && !isSystemMessage(content)) { + messages.push({ role: role, content: content.slice(0, 2000), uuid: '' }); + } + } + // Codex function_call → attach as tool to last assistant message + if (pType === 'function_call') { + const name = entry.payload.name || ''; + if (name.startsWith('mcp__')) { + const parts = name.split('__'); + if (parts.length >= 3) { + const lastMsg = messages.length > 0 ? messages[messages.length - 1] : null; + if (lastMsg && lastMsg.role === 'assistant') { + if (!lastMsg.tools) lastMsg.tools = []; + if (!lastMsg._toolSeen) lastMsg._toolSeen = new Set(); + const tool = parts.slice(2).join('__'); + const key = 'mcp:' + parts[1] + ':' + tool; + if (!lastMsg._toolSeen.has(key)) { + lastMsg._toolSeen.add(key); + lastMsg.tools.push({ type: 'mcp', server: parts[1], tool: tool }); + } + } + } + } + } + } + } + } catch {} + } + + // Clean up internal markers from Codex + for (const m of messages) { + if (m._toolSeen) delete m._toolSeen; + } + + return { messages: messages.slice(0, 200) }; +} + +function deleteSession(sessionId, project) { + project = project || ''; + const deleted = []; + + // For copilot: remove matching session file(s) from all workspaceStorage roots. + _buildSessionFileIndex(); + const foundForDelete = _sessionFileIndex[sessionId]; + if (foundForDelete && foundForDelete.format === 'copilot') { + const allCopilotWsDirs = [COPILOT_WORKSPACE_STORAGE].concat(EXTRA_COPILOT_WS_STORAGE); + const seenFiles = new Set(); + + // Delete from indexed file first + if (foundForDelete.file && fs.existsSync(foundForDelete.file)) { + fs.unlinkSync(foundForDelete.file); + seenFiles.add(foundForDelete.file); + deleted.push('copilot session file'); + } + + // Also delete same session id across all known workspace hashes (defensive) + for (const wsDir of allCopilotWsDirs) { + if (!fs.existsSync(wsDir)) continue; + let hashes; + try { hashes = fs.readdirSync(wsDir); } catch { continue; } + + for (const hash of hashes) { + const chatDir = path.join(wsDir, hash, 'chatSessions'); + if (fs.existsSync(chatDir)) { + for (const ext of ['.jsonl', '.json']) { + const fp = path.join(chatDir, sessionId + ext); + if (!seenFiles.has(fp) && fs.existsSync(fp)) { + fs.unlinkSync(fp); + seenFiles.add(fp); + deleted.push('copilot session file'); + } + } + } + + // Remove chatEditingSessions sibling if present + const editDir = path.join(wsDir, hash, 'chatEditingSessions', sessionId); + if (fs.existsSync(editDir)) { + fs.rmSync(editDir, { recursive: true, force: true }); + deleted.push('copilot editing session'); + } + } + } + + if (!deleted.length) throw new Error('Session file not found'); + _invalidateRuntimeCaches(); + return deleted; + } + + // 1. Remove session JSONL file from project dir + const projectKey = project.replace(/[^a-zA-Z0-9-]/g, '-'); + const sessionFile = path.join(PROJECTS_DIR, projectKey, `${sessionId}.jsonl`); + if (fs.existsSync(sessionFile)) { + fs.unlinkSync(sessionFile); + deleted.push('session file'); + } + + // Also remove companion directory if exists (some sessions have one) + const sessionDir = path.join(PROJECTS_DIR, projectKey, sessionId); + if (fs.existsSync(sessionDir) && fs.statSync(sessionDir).isDirectory()) { + fs.rmSync(sessionDir, { recursive: true }); + deleted.push('session dir'); + } + + // 2. Remove entries from history.jsonl + if (fs.existsSync(HISTORY_FILE)) { + const lines = readLines(HISTORY_FILE); + const filtered = lines.filter(line => { + try { + const d = JSON.parse(line); + return d.sessionId !== sessionId; + } catch { return true; } + }); + if (filtered.length < lines.length) { + fs.writeFileSync(HISTORY_FILE, filtered.join('\n') + '\n'); + deleted.push(`${lines.length - filtered.length} history entries`); + } + } + + // 3. Remove session-env file if exists + const envFile = path.join(CLAUDE_DIR, 'session-env', `${sessionId}.json`); + if (fs.existsSync(envFile)) { + fs.unlinkSync(envFile); + deleted.push('env file'); + } + + if (!deleted.length) throw new Error('Session file not found'); + _invalidateRuntimeCaches(); + + return deleted; +} + +function getGitCommits(projectDir, fromTs, toTs) { + try { + if (!projectDir || !fs.existsSync(projectDir)) { + return []; + } + + const afterDate = new Date(fromTs).toISOString(); + const beforeDate = new Date(toTs).toISOString(); + + const output = execFileSync('git', [ + 'log', '--oneline', `--after=${afterDate}`, `--before=${beforeDate}` + ], { cwd: projectDir, encoding: 'utf8', timeout: 5000, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }).trim(); + + if (!output) return []; + + return output.split('\n').map(line => { + const spaceIdx = line.indexOf(' '); + if (spaceIdx === -1) return { hash: line, message: '' }; + return { + hash: line.slice(0, spaceIdx), + message: line.slice(spaceIdx + 1), + }; + }); + } catch { + return []; + } +} + +function exportSessionMarkdown(sessionId, project) { + const found = findSessionFile(sessionId, project); + + // For non-Claude formats, use the detail loader for markdown export + if (found && found.format !== 'claude') { + const detail = + found.format === 'cursor' ? loadCursorDetail(sessionId) : + found.format === 'opencode' ? loadOpenCodeDetail(sessionId) : + found.format === 'kiro' ? loadKiroDetail(sessionId) : + null; + if (detail && detail.messages && detail.messages.length > 0) { + const parts = [`# Session ${sessionId}\n\n**Project:** ${project || '(none)'}\n`]; + for (const msg of detail.messages) { + const header = msg.role === 'user' ? '## User' : '## Assistant'; + parts.push(`\n${header}\n\n${msg.content}\n`); + } + return parts.join(''); + } + } + + if (!found || found.format !== 'claude' || !fs.existsSync(found.file)) { + return `# Session ${sessionId}\n\nSession file not found.\n`; + } + + const sessionFile = found.file; + const summary = parseClaudeSessionFile(sessionFile); + const lines = readLines(sessionFile); + const projectLabel = project || (summary && summary.projectPath) || '(none)'; + const parts = [`# Session ${sessionId}\n\n**Project:** ${projectLabel}\n`]; + + for (const line of lines) { + try { + const entry = JSON.parse(line); + if (entry.type === 'user' || entry.type === 'assistant') { + const msg = entry.message || {}; + let content = msg.content || ''; + if (Array.isArray(content)) { + content = content + .map(b => (typeof b === 'string' ? b : (b.type === 'text' ? b.text : ''))) + .filter(Boolean) + .join('\n'); + } + const header = entry.type === 'user' ? '## User' : '## Assistant'; + parts.push(`\n${header}\n\n${content}\n`); + } + } catch {} + } + + return parts.join(''); +} + +// ── Session Preview (first N messages, lightweight) ──────── + +// Session file index: sessionId -> file path (built once, avoids O(sessions*projects) scans) +let _sessionFileIndex = null; +let _sessionFileIndexTs = 0; +const SESSION_FILE_INDEX_TTL = 120000; // 2 minutes — dirs rarely change + +function _buildSessionFileIndex() { + const now = Date.now(); + if (_sessionFileIndex && (now - _sessionFileIndexTs) < SESSION_FILE_INDEX_TTL) return; + + _sessionFileIndex = {}; + // Index Claude project files + const allProjectDirs = [PROJECTS_DIR]; + for (const extraDir of EXTRA_CLAUDE_DIRS) { + allProjectDirs.push(path.join(extraDir, 'projects')); + } + for (const projDir of allProjectDirs) { + if (!fs.existsSync(projDir)) continue; + try { + for (const proj of fs.readdirSync(projDir)) { + const dir = path.join(projDir, proj); + try { + if (!fs.statSync(dir).isDirectory()) continue; + for (const file of fs.readdirSync(dir)) { + if (!file.endsWith('.jsonl')) continue; + const sid = file.replace('.jsonl', ''); + if (!_sessionFileIndex[sid]) { + _sessionFileIndex[sid] = { file: path.join(dir, file), format: 'claude' }; + } + } + } catch {} + } + } catch {} + } + + // Index Cursor transcript files + if (fs.existsSync(CURSOR_PROJECTS)) { + try { + for (const proj of fs.readdirSync(CURSOR_PROJECTS)) { + const transcriptsDir = path.join(CURSOR_PROJECTS, proj, 'agent-transcripts'); + if (!fs.existsSync(transcriptsDir)) continue; + try { + for (const sessDir of fs.readdirSync(transcriptsDir)) { + const f = path.join(transcriptsDir, sessDir, sessDir + '.jsonl'); + if (fs.existsSync(f)) _sessionFileIndex[sessDir] = { file: f, format: 'cursor' }; + } + } catch {} + } + } catch {} + } + + // Index Cursor chat files + if (fs.existsSync(CURSOR_CHATS)) { + try { + for (const chatDir of fs.readdirSync(CURSOR_CHATS)) { + const fullDir = path.join(CURSOR_CHATS, chatDir); + try { + if (!fs.statSync(fullDir).isDirectory()) continue; + for (const f of fs.readdirSync(fullDir)) { + if (f.endsWith('.jsonl') || f.endsWith('.json')) { + _sessionFileIndex[chatDir] = { file: path.join(fullDir, f), format: 'cursor' }; + break; + } + } + } catch {} + } + } catch {} + } + + // Index Copilot chatSessions files + const allCopilotWsDirs = [COPILOT_WORKSPACE_STORAGE].concat(EXTRA_COPILOT_WS_STORAGE); + for (const wsDir of allCopilotWsDirs) { + if (!fs.existsSync(wsDir)) continue; + try { + for (const hash of fs.readdirSync(wsDir)) { + const chatDir = path.join(wsDir, hash, 'chatSessions'); + if (!fs.existsSync(chatDir)) continue; + try { + for (const file of fs.readdirSync(chatDir)) { + if (!file.endsWith('.jsonl') && !file.endsWith('.json')) continue; + const sid = file.endsWith('.jsonl') ? file.replace('.jsonl', '') : file.replace('.json', ''); + if (!_sessionFileIndex[sid]) { + _sessionFileIndex[sid] = { file: path.join(chatDir, file), format: 'copilot' }; + } + } + } catch {} + } + } catch {} + } + + _sessionFileIndexTs = now; +} + +function findSessionFile(sessionId, project) { + _buildSessionFileIndex(); + + // Fast index lookup + if (_sessionFileIndex[sessionId]) return _sessionFileIndex[sessionId]; + + // Try Claude projects dir (direct path if project known) + if (project) { + const projectKey = project.replace(/[^a-zA-Z0-9-]/g, '-'); + const claudeFile = path.join(PROJECTS_DIR, projectKey, `${sessionId}.jsonl`); + if (fs.existsSync(claudeFile)) return { file: claudeFile, format: 'claude' }; + } + + // Extra Claude dirs and Cursor files are already in the index. + // Only Codex (date tree) and SQLite agents need fallback lookup. + + // Try Codex sessions dir (walk year/month/day) + const codexSessionsDir = path.join(CODEX_DIR, 'sessions'); + if (fs.existsSync(codexSessionsDir)) { + const walkDir = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + const result = walkDir(full); + if (result) return result; + } else if (entry.name.includes(sessionId) && entry.name.endsWith('.jsonl')) { + return full; + } + } + return null; + }; + const codexFile = walkDir(codexSessionsDir); + if (codexFile) return { file: codexFile, format: 'codex' }; + } + + // Try OpenCode (SQLite — return special marker) + if (fs.existsSync(OPENCODE_DB) && sessionId.startsWith('ses_')) { + return { file: OPENCODE_DB, format: 'opencode', sessionId: sessionId }; + } + + // Cursor JSONL files are already in the index. Only check vscdb fallback. + + // Try Cursor global vscdb + if (fs.existsSync(CURSOR_GLOBAL_DB)) { + try { + const cleanId = sessionId.replace(/'/g, "''"); + const check = execFileSync('sqlite3', [ + CURSOR_GLOBAL_DB, + "SELECT COUNT(*) FROM cursorDiskKV WHERE key = 'composerData:" + cleanId + "'" + ], { encoding: 'utf8', timeout: 3000, windowsHide: true }).trim(); + if (parseInt(check) > 0) { + return { file: CURSOR_GLOBAL_DB, format: 'cursor', sessionId: sessionId }; + } + } catch {} + } + + // Try Kiro (SQLite) + if (fs.existsSync(KIRO_DB)) { + try { + const check = execFileSync('sqlite3', [ + KIRO_DB, + `SELECT COUNT(*) FROM conversations_v2 WHERE conversation_id = '${sessionId.replace(/'/g, "''")}';` + ], { encoding: 'utf8', timeout: 3000, windowsHide: true }).trim(); + if (parseInt(check) > 0) { + return { file: KIRO_DB, format: 'kiro', sessionId: sessionId }; + } + } catch {} + } + + return null; +} + +function isSystemMessage(text) { + if (!text) return true; + var t = text.trim(); + if (t === 'exit' || t === 'quit' || t === '/exit') return true; + if (t.startsWith('')) return true; + // Codex developer role system prompts + if (t.startsWith('You are Codex')) return true; + if (t.startsWith('Filesystem sandboxing')) return true; + return false; +} + +function extractContent(raw) { + if (!raw) return ''; + if (typeof raw === 'string') return raw; + if (Array.isArray(raw)) { + return raw + .map(b => (typeof b === 'string' ? b : (b.text || b.input_text || ''))) + .filter(Boolean) + .join('\n'); + } + return String(raw); +} + +// Extract MCP/Skill tool_use blocks from a Claude assistant message content array. +// Returns deduplicated array of { type, server, tool } or { type, skill }. +function extractTools(contentBlocks) { + if (!Array.isArray(contentBlocks)) return []; + const tools = []; + const seen = new Set(); + for (const block of contentBlocks) { + if (!block || block.type !== 'tool_use') continue; + const name = block.name || ''; + if (name.startsWith('mcp__')) { + const parts = name.split('__'); + if (parts.length >= 3) { + const tool = parts.slice(2).join('__'); + const key = 'mcp:' + parts[1] + ':' + tool; + if (!seen.has(key)) { + seen.add(key); + tools.push({ type: 'mcp', server: parts[1], tool: tool }); + } + } + } else if (name === 'Skill') { + const skillRaw = (block.input || {}).skill; + if (skillRaw) { + // Use plugin name only (e.g. "superpowers:writing-plans" -> "superpowers") + const skill = skillRaw.includes(':') ? skillRaw.split(':')[0] : skillRaw; + const key = 'skill:' + skill; + if (!seen.has(key)) { + seen.add(key); + tools.push({ type: 'skill', skill: skill }); + } + } + } + } + return tools; +} + +function getSessionPreview(sessionId, project, limit) { + limit = limit || 10; + const found = findSessionFile(sessionId, project); + if (!found) return []; + + // Cursor + if (found.format === 'cursor') { + var detail = loadCursorDetail(sessionId); + return detail.messages.slice(0, limit).map(function(m) { + return { role: m.role, content: m.content.slice(0, 300) }; + }); + } + + // Kiro: use loadKiroDetail and slice + if (found.format === 'kiro') { + var detail = loadKiroDetail(sessionId); + return detail.messages.slice(0, limit).map(function(m) { + return { role: m.role, content: m.content.slice(0, 300) }; + }); + } + + // Copilot + if (found.format === 'copilot') { + var detail = loadCopilotDetailFromFile(found.file); + return detail.messages.slice(0, limit).map(function(m) { + return { role: m.role, content: m.content.slice(0, 300) }; + }); + } + + // OpenCode: use loadOpenCodeDetail and slice + if (found.format === 'opencode') { + const detail = loadOpenCodeDetail(sessionId); + return detail.messages.slice(0, limit).map(function(m) { + return { role: m.role, content: m.content.slice(0, 300) }; + }); + } + + const messages = []; + const lines = readLines(found.file); + + for (const line of lines) { + if (messages.length >= limit) break; + try { + const entry = JSON.parse(line); + + if (found.format === 'claude') { + // Claude: {type: "user"|"assistant", message: {content: ...}} + if (entry.type === 'user' || entry.type === 'assistant') { + const content = extractContent((entry.message || {}).content); + if (content) { + messages.push({ role: entry.type, content: content.slice(0, 300) }); + } + } + } else { + // Codex: {type: "response_item", payload: {role: "user"|"assistant", content: [...]}} + if (entry.type === 'response_item' && entry.payload) { + const role = entry.payload.role; + if (role === 'user' || role === 'assistant') { + const content = extractContent(entry.payload.content); + // Skip system-like messages + if (content && !isSystemMessage(content)) { + messages.push({ role: role, content: content.slice(0, 300) }); + } + } + } + } + } catch {} + } + + return messages; +} + +// ── Full-text search index ───────────────────────────────── +// +// Built once on first search, then cached in memory. +// Each entry: { sessionId, texts: [{role, content}] } +// Total text is kept lowercase for fast substring matching. + +let searchIndex = null; +let searchIndexBuiltAt = 0; +const INDEX_TTL = 60000; // rebuild every 60s + +function buildSearchIndex(sessions) { + const startMs = Date.now(); + const index = []; + + for (const s of sessions) { + if (!s.has_detail) continue; + + const found = findSessionFile(s.id, s.project); + if (!found) continue; + + try { + const lines = readLines(found.file); + const texts = []; + + for (const line of lines) { + try { + const entry = JSON.parse(line); + let role, content; + + if (found.format === 'claude') { + if (entry.type !== 'user' && entry.type !== 'assistant') continue; + role = entry.type; + content = extractContent((entry.message || {}).content); + } else { + if (entry.type !== 'response_item' || !entry.payload) continue; + role = entry.payload.role; + if (role !== 'user' && role !== 'assistant') continue; + content = extractContent(entry.payload.content); + } + + if (content && !isSystemMessage(content)) { + texts.push({ role, content: content.slice(0, 500) }); + } + } catch {} + } + + if (texts.length > 0) { + // Pre-compute lowercase full text for fast matching + const fullText = texts.map(t => t.content).join(' ').toLowerCase(); + index.push({ sessionId: s.id, texts, fullText }); + } + } catch {} + } + + const elapsed = Date.now() - startMs; + console.log(` \x1b[2mSearch index: ${index.length} sessions, ${elapsed}ms\x1b[0m`); + return index; +} + +function getSearchIndex(sessions) { + const now = Date.now(); + if (!searchIndex || (now - searchIndexBuiltAt) > INDEX_TTL) { + searchIndex = buildSearchIndex(sessions); + searchIndexBuiltAt = now; + } + return searchIndex; +} + +function searchFullText(query, sessions) { + if (!query || query.length < 2) return []; + const q = query.toLowerCase(); + const index = getSearchIndex(sessions); + const results = []; + + for (const entry of index) { + if (entry.fullText.indexOf(q) === -1) continue; + + // Find matching messages with snippets + const matches = []; + for (const t of entry.texts) { + if (matches.length >= 3) break; + const idx = t.content.toLowerCase().indexOf(q); + if (idx >= 0) { + const start = Math.max(0, idx - 50); + const end = Math.min(t.content.length, idx + q.length + 50); + matches.push({ + role: t.role, + snippet: (start > 0 ? '...' : '') + t.content.slice(start, end) + (end < t.content.length ? '...' : ''), + }); + } + } + + if (matches.length > 0) { + results.push({ sessionId: entry.sessionId, matches }); + } + } + + return results; +} + +// ── Exports ──────────────────────────────────────────────── + +// ── Session replay data (with timestamps) ───────────────── + +function getSessionReplay(sessionId, project) { + const found = findSessionFile(sessionId, project); + if (!found) return { messages: [], duration: 0 }; + + // Copilot: reconstruct from request objects (no per-message ISO timestamps) + if (found.format === 'copilot') { + const detail = loadCopilotDetailFromFile(found.file); + const msgs = detail.messages.map(function(m) { + return { role: m.role, content: m.content, timestamp: '', ms: 0 }; + }); + return { messages: msgs, startMs: 0, endMs: 0, duration: 0 }; + } + + const messages = []; + const lines = readLines(found.file); + + for (const line of lines) { + try { + const entry = JSON.parse(line); + let role, content, ts; + + if (found.format === 'claude') { + if (entry.type !== 'user' && entry.type !== 'assistant') continue; + role = entry.type; + content = extractContent((entry.message || {}).content); + ts = entry.timestamp || ''; + } else { + if (entry.type !== 'response_item' || !entry.payload) continue; + role = entry.payload.role; + if (role !== 'user' && role !== 'assistant') continue; + content = extractContent(entry.payload.content); + ts = entry.timestamp || ''; + } + + if (!content || isSystemMessage(content)) continue; + + messages.push({ + role, + content: content.slice(0, 3000), + timestamp: ts, + ms: ts ? new Date(ts).getTime() : 0, + }); + } catch {} + } + + // Calculate duration + const startMs = messages.length > 0 ? messages[0].ms : 0; + const endMs = messages.length > 0 ? messages[messages.length - 1].ms : 0; + + return { + messages, + startMs, + endMs, + duration: endMs - startMs, + }; +} + +const CONTEXT_WINDOW = 200_000; // Claude's max context window (tokens) + +// ── Pricing per model (per token, April 2026) ───────────── + +const MODEL_PRICING = { + 'claude-opus-4-6': { input: 5.00 / 1e6, output: 25.00 / 1e6, cache_read: 0.50 / 1e6, cache_create: 6.25 / 1e6 }, + 'claude-opus-4-5': { input: 5.00 / 1e6, output: 25.00 / 1e6, cache_read: 0.50 / 1e6, cache_create: 6.25 / 1e6 }, + 'claude-sonnet-4-6': { input: 3.00 / 1e6, output: 15.00 / 1e6, cache_read: 0.30 / 1e6, cache_create: 3.75 / 1e6 }, + 'claude-sonnet-4-5': { input: 3.00 / 1e6, output: 15.00 / 1e6, cache_read: 0.30 / 1e6, cache_create: 3.75 / 1e6 }, + 'claude-haiku-4-5': { input: 1.00 / 1e6, output: 5.00 / 1e6, cache_read: 0.10 / 1e6, cache_create: 1.25 / 1e6 }, + 'codex-mini-latest': { input: 1.50 / 1e6, output: 6.00 / 1e6, cache_read: 0.375 / 1e6, cache_create: 1.875 / 1e6 }, + 'gpt-5': { input: 1.25 / 1e6, output: 10.00 / 1e6, cache_read: 0.625 / 1e6, cache_create: 1.25 / 1e6 }, +}; + +function getModelPricing(model) { + if (!model) return MODEL_PRICING['claude-sonnet-4-6']; // default + for (const key in MODEL_PRICING) { + if (model.includes(key) || model.startsWith(key)) return MODEL_PRICING[key]; + } + // Fallback: try partial match + if (model.includes('opus')) return MODEL_PRICING['claude-opus-4-6']; + if (model.includes('haiku')) return MODEL_PRICING['claude-haiku-4-5']; + if (model.includes('sonnet')) return MODEL_PRICING['claude-sonnet-4-6']; + if (model.includes('codex')) return MODEL_PRICING['codex-mini-latest']; + return MODEL_PRICING['claude-sonnet-4-6']; +} + +// ── Compute real cost from session file token usage ──────── + +// Disk cache for computed session costs +const COST_CACHE_FILE = path.join(os.tmpdir(), 'codedash-cost-cache.json'); +let _costDiskCache = null; + +function _loadCostDiskCache() { + if (_costDiskCache) return; + try { + if (fs.existsSync(COST_CACHE_FILE)) { + _costDiskCache = JSON.parse(fs.readFileSync(COST_CACHE_FILE, 'utf8')); + } + } catch {} + if (!_costDiskCache) _costDiskCache = {}; +} + +function _saveCostDiskCache() { + if (!_costDiskCache) return; + try { + fs.writeFileSync(COST_CACHE_FILE, JSON.stringify(_costDiskCache)); + } catch {} +} + +const EMPTY_COST = { cost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, contextPctSum: 0, contextTurnCount: 0, model: '' }; + +// In-memory cost cache (reset when sessions cache resets) +const _costMemCache = {}; + +function computeSessionCost(sessionId, project) { + // Fast in-memory cache (same session never changes within request cycle) + if (_costMemCache[sessionId] !== undefined) return _costMemCache[sessionId]; + + const found = findSessionFile(sessionId, project); + if (!found) { _costMemCache[sessionId] = EMPTY_COST; return EMPTY_COST; } + + // Skip formats that never have cost data + if (found.format === 'cursor' || found.format === 'kiro' || found.format === 'copilot') { _costMemCache[sessionId] = EMPTY_COST; return EMPTY_COST; } + + // Check disk cache (keyed by file path + mtime + size for JSONL, sessionId for SQLite) + _loadCostDiskCache(); + let cacheKey = ''; + if (found.format === 'opencode') { + cacheKey = 'opencode:' + sessionId; + } else if (found.file) { + // Use file stat lookup (reuse from parsed cache index if available) + const cached = _fileCacheKeyIndex[found.file]; + if (cached) { + cacheKey = cached; + } else { + try { + const stat = fs.statSync(found.file); + cacheKey = found.file + '|' + stat.mtimeMs + '|' + stat.size; + _fileCacheKeyIndex[found.file] = cacheKey; + } catch {} + } + } + if (cacheKey && _costDiskCache[cacheKey]) return _costDiskCache[cacheKey]; + + let totalCost = 0; + let totalInput = 0; + let totalOutput = 0; + let totalCacheRead = 0; + let totalCacheCreate = 0; + let contextPctSum = 0; + let contextTurnCount = 0; + let model = ''; + + // OpenCode: query SQLite directly for token data + if (found.format === 'opencode') { + const safeId = /^[a-zA-Z0-9_-]+$/.test(found.sessionId) ? found.sessionId : ''; + if (!safeId) return { cost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, contextPctSum: 0, contextTurnCount: 0, model: '' }; + try { + const rows = execFileSync('sqlite3', [ + OPENCODE_DB, + `SELECT data FROM message WHERE session_id = '${safeId}' AND json_extract(data, '$.role') = 'assistant' ORDER BY time_created` + ], { encoding: 'utf8', timeout: 10000, windowsHide: true }).trim(); + if (rows) { + for (const row of rows.split('\n')) { + try { + const msgData = JSON.parse(row); + const t = msgData.tokens || {}; + if (!model && msgData.modelID) model = msgData.modelID; + const inp = t.input || 0; + const out = (t.output || 0) + (t.reasoning || 0); + const cacheRead = (t.cache && t.cache.read) || 0; + const cacheCreate = (t.cache && t.cache.write) || 0; + if (inp === 0 && out === 0) continue; + + const pricing = getModelPricing(msgData.modelID || model); + totalInput += inp; + totalOutput += out; + totalCacheRead += cacheRead; + totalCacheCreate += cacheCreate; + totalCost += inp * pricing.input + + cacheCreate * pricing.cache_create + + cacheRead * pricing.cache_read + + out * pricing.output; + + const contextThisTurn = inp + cacheCreate + cacheRead; + if (contextThisTurn > 0) { + contextPctSum += (contextThisTurn / CONTEXT_WINDOW) * 100; + contextTurnCount++; + } + } catch {} + } + } + } catch {} + return { cost: totalCost, inputTokens: totalInput, outputTokens: totalOutput, cacheReadTokens: totalCacheRead, cacheCreateTokens: totalCacheCreate, contextPctSum, contextTurnCount, model }; + } + + try { + const lines = readLines(found.file); + for (const line of lines) { + try { + const entry = JSON.parse(line); + if (found.format === 'claude' && entry.type === 'assistant') { + const msg = entry.message || {}; + if (!model && msg.model) model = msg.model; + const u = msg.usage; + if (!u) continue; + + const pricing = getModelPricing(msg.model || model); + const inp = u.input_tokens || 0; + const cacheCreate = u.cache_creation_input_tokens || 0; + const cacheRead = u.cache_read_input_tokens || 0; + const out = u.output_tokens || 0; + + totalInput += inp; + totalOutput += out; + totalCacheRead += cacheRead; + totalCacheCreate += cacheCreate; + totalCost += inp * pricing.input + + cacheCreate * pricing.cache_create + + cacheRead * pricing.cache_read + + out * pricing.output; + + // Track per-turn context window usage (average, not peak) + const contextThisTurn = inp + cacheCreate + cacheRead; + if (contextThisTurn > 0) { + contextPctSum += (contextThisTurn / CONTEXT_WINDOW) * 100; + contextTurnCount++; + } + } + // Codex: estimate from file size (no token usage in session files) + } catch {} + } + } catch {} + + // Fallback for Codex or sessions without usage data + if (totalCost === 0 && found.format === 'codex') { + try { + const size = fs.statSync(found.file).size; + const tokens = size / 4; + const pricing = MODEL_PRICING['codex-mini-latest']; + totalInput = Math.round(tokens * 0.3); + totalOutput = Math.round(tokens * 0.7); + totalCost = totalInput * pricing.input + totalOutput * pricing.output; + } catch {} + } + + const result = { cost: totalCost, inputTokens: totalInput, outputTokens: totalOutput, cacheReadTokens: totalCacheRead, cacheCreateTokens: totalCacheCreate, contextPctSum, contextTurnCount, model }; + if (cacheKey) _costDiskCache[cacheKey] = result; + _costMemCache[sessionId] = result; + return result; +} + +// ── Cost analytics ──────────────────────────────────────── + +// Analytics result cache — avoids recomputing 31k sessions every request +const ANALYTICS_CACHE_FILE = path.join(os.tmpdir(), 'codedash-analytics-cache.json'); +let _analyticsCacheResult = null; +let _analyticsCacheKey = null; + +function _analyticsKey(sessions) { + // Key: session count + newest session mtime + let newest = 0; + for (const s of sessions) { + if (s.last_ts > newest) newest = s.last_ts; + } + return sessions.length + ':' + newest; +} + +function getCostAnalytics(sessions) { + // Fast cache check — if sessions haven't changed, return cached result + const key = _analyticsKey(sessions); + if (_analyticsCacheResult && _analyticsCacheKey === key) return _analyticsCacheResult; + + // Try disk cache + if (!_analyticsCacheResult) { + try { + if (fs.existsSync(ANALYTICS_CACHE_FILE)) { + const cached = JSON.parse(fs.readFileSync(ANALYTICS_CACHE_FILE, 'utf8')); + if (cached._key === key) { + _analyticsCacheResult = cached.data; + _analyticsCacheKey = key; + return cached.data; + } + } + } catch {} + } + + const result = _computeCostAnalytics(sessions); + + // Save to cache + _analyticsCacheResult = result; + _analyticsCacheKey = key; + try { fs.writeFileSync(ANALYTICS_CACHE_FILE, JSON.stringify({ _key: key, data: result })); } catch {} + + return result; +} + +function _computeCostAnalytics(sessions) { + const byDay = {}; + const byProject = {}; + const byWeek = {}; + const byAgent = {}; + let totalCost = 0; + let totalTokens = 0; + let totalInputTokens = 0; + let totalOutputTokens = 0; + let totalCacheReadTokens = 0; + let totalCacheCreateTokens = 0; + let globalContextPctSum = 0; + let globalContextTurnCount = 0; + let firstDate = null; + let lastDate = null; + let sessionsWithData = 0; + const agentNoCostData = {}; + for (const s of sessions) { + if (!byAgent[s.tool]) byAgent[s.tool] = { cost: 0, sessions: 0, tokens: 0, estimated: false }; + } + const sessionCosts = []; + + // Pre-compute OpenCode costs in one batch query (avoids O(n) execSync calls) + const opencodeCostCache = {}; + const opencodeSessions = sessions.filter(s => s.tool === 'opencode'); + if (opencodeSessions.length > 0 && fs.existsSync(OPENCODE_DB)) { + try { + const batchRows = execFileSync('sqlite3', [ + OPENCODE_DB, + `SELECT session_id, data FROM message WHERE json_extract(data, '$.role') = 'assistant' ORDER BY time_created` + ], { encoding: 'utf8', timeout: 30000, windowsHide: true }).trim(); + if (batchRows) { + for (const row of batchRows.split('\n')) { + const sepIdx = row.indexOf('|'); + if (sepIdx < 0) continue; + const sessId = row.slice(0, sepIdx); + const jsonStr = row.slice(sepIdx + 1); + try { + const msgData = JSON.parse(jsonStr); + const t = msgData.tokens || {}; + const inp = t.input || 0; + const out = (t.output || 0) + (t.reasoning || 0); + const cacheRead = (t.cache && t.cache.read) || 0; + const cacheCreate = (t.cache && t.cache.write) || 0; + if (inp === 0 && out === 0) continue; + if (!opencodeCostCache[sessId]) opencodeCostCache[sessId] = { cost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, contextPctSum: 0, contextTurnCount: 0, model: '' }; + const c = opencodeCostCache[sessId]; + if (!c.model && msgData.modelID) c.model = msgData.modelID; + const pricing = getModelPricing(msgData.modelID || c.model); + c.inputTokens += inp; + c.outputTokens += out; + c.cacheReadTokens += cacheRead; + c.cacheCreateTokens += cacheCreate; + c.cost += inp * pricing.input + cacheCreate * pricing.cache_create + cacheRead * pricing.cache_read + out * pricing.output; + const ctx = inp + cacheCreate + cacheRead; + if (ctx > 0) { c.contextPctSum += (ctx / CONTEXT_WINDOW) * 100; c.contextTurnCount++; } + } catch {} + } + } + } catch {} + } + + for (const s of sessions) { + let costData; + if (s.tool === 'opencode' && opencodeCostCache[s.id]) { + costData = opencodeCostCache[s.id]; + } else if (s.tool === 'cursor') { + // Use real token data from Cursor vscdb if available + const inp = s._cursor_input_tokens || 0; + const out = s._cursor_output_tokens || 0; + if (inp > 0 || out > 0) { + const model = s._cursor_model || ''; + const pricing = getModelPricing(model); + costData = { cost: inp * pricing.input + out * pricing.output, inputTokens: inp, outputTokens: out, cacheReadTokens: 0, cacheCreateTokens: 0, contextPctSum: 0, contextTurnCount: 0, model: model }; + } else if (s.user_messages > 0 || s.messages > 0) { + // Fallback: estimate from user prompt count + const userMsgs = s.user_messages || Math.ceil((s.messages || 0) * 0.07); + const model = s._cursor_model || 'claude-sonnet'; + const pricing = getModelPricing(model); + const estInput = userMsgs * 2000; + const estOutput = userMsgs * 1000; + costData = { cost: estInput * pricing.input + estOutput * pricing.output, inputTokens: estInput, outputTokens: estOutput, cacheReadTokens: 0, cacheCreateTokens: 0, contextPctSum: 0, contextTurnCount: 0, model: model + '-estimated' }; + } else { + costData = EMPTY_COST; + } + } else { + costData = computeSessionCost(s.id, s.project); + } + const cost = costData.cost; + const tokens = costData.inputTokens + costData.outputTokens + costData.cacheReadTokens + costData.cacheCreateTokens; + if (cost === 0 && tokens === 0) { + if (!agentNoCostData[s.tool]) agentNoCostData[s.tool] = 0; + agentNoCostData[s.tool]++; + continue; + } + sessionsWithData++; + totalCost += cost; + totalTokens += tokens; + totalInputTokens += costData.inputTokens; + totalOutputTokens += costData.outputTokens; + totalCacheReadTokens += costData.cacheReadTokens; + totalCacheCreateTokens += costData.cacheCreateTokens; + + // Per-agent breakdown + const agent = s.tool || 'unknown'; + if (!byAgent[agent]) byAgent[agent] = { cost: 0, sessions: 0, tokens: 0, estimated: false }; + byAgent[agent].cost += cost; + byAgent[agent].sessions++; + byAgent[agent].tokens += tokens; + if (agent === 'codex') byAgent[agent].estimated = true; + if (agent === 'cursor' && costData.model && costData.model.includes('-estimated')) byAgent[agent].estimated = true; + if (agent === 'opencode' && !costData.model) byAgent[agent].estimated = true; + + // Context % across all turns + globalContextPctSum += costData.contextPctSum; + globalContextTurnCount += costData.contextTurnCount; + + // Date range + const day = s.date || 'unknown'; + if (s.date) { + if (!firstDate || s.date < firstDate) firstDate = s.date; + if (!lastDate || s.date > lastDate) lastDate = s.date; + } + if (!byDay[day]) byDay[day] = { cost: 0, sessions: 0, tokens: 0 }; + byDay[day].cost += cost; + byDay[day].sessions++; + byDay[day].tokens += tokens; + + // By week + if (s.date) { + const d = new Date(s.date); + const weekStart = new Date(d); + weekStart.setDate(d.getDate() - d.getDay()); + const weekKey = weekStart.toISOString().slice(0, 10); + if (!byWeek[weekKey]) byWeek[weekKey] = { cost: 0, sessions: 0 }; + byWeek[weekKey].cost += cost; + byWeek[weekKey].sessions++; + } + + // By project + const proj = s.project_short || s.project || 'unknown'; + if (!byProject[proj]) byProject[proj] = { cost: 0, sessions: 0, tokens: 0 }; + byProject[proj].cost += cost; + byProject[proj].sessions++; + byProject[proj].tokens += tokens; + + sessionCosts.push({ id: s.id, cost, project: proj, date: s.date, last_ts: s.last_ts || 0 }); + } + + // Sort top sessions by cost + sessionCosts.sort((a, b) => b.cost - a.cost); + + const days = firstDate && lastDate + ? Math.max(1, Math.round((new Date(lastDate) - new Date(firstDate)) / 86400000) + 1) + : 1; + + // Burn rate: derived from already-computed sessionCosts — no extra IO + const now = Date.now(); + const todayStr = new Date().toISOString().slice(0, 10); + const hoursElapsedToday = (now - new Date(todayStr).getTime()) / 3600000; + let last1hCost = 0; + let todayCost = 0; + for (const sc of sessionCosts) { + if (sc.last_ts >= now - 3600000) last1hCost += sc.cost; + if (sc.date === todayStr) todayCost += sc.cost; + } + + _saveCostDiskCache(); + + return { + totalCost, + totalTokens, + totalInputTokens, + totalOutputTokens, + totalCacheReadTokens, + totalCacheCreateTokens, + avgContextPct: globalContextTurnCount > 0 ? Math.round(globalContextPctSum / globalContextTurnCount) : 0, + dailyRate: totalCost / days, + firstDate, + lastDate, + days, + totalSessions: sessionsWithData, + totalSessionsAll: sessions.length, + byDay, + byWeek, + byProject, + topSessions: sessionCosts.slice(0, 10), + byAgent, + agentNoCostData, + last1hCost, + todayCost, + hoursElapsedToday: Math.max(1, hoursElapsedToday), + }; +} + +// ── Active sessions detection ───────────────────────────── + +function getActiveSessions() { + const active = []; + const seenPids = new Set(); + + // 1. Claude Code — read PID files for session ID mapping + const sessionsDir = path.join(CLAUDE_DIR, 'sessions'); + const claudePidMap = {}; // pid → {sessionId, cwd, startedAt} + if (fs.existsSync(sessionsDir)) { + for (const file of fs.readdirSync(sessionsDir)) { + if (!file.endsWith('.json')) continue; + try { + const data = JSON.parse(fs.readFileSync(path.join(sessionsDir, file), 'utf8')); + if (data.pid) claudePidMap[data.pid] = data; + } catch {} + } + } + + // 2. Scan ALL agent processes via ps + const agentPatterns = [ + { pattern: 'claude', tool: 'claude', match: /\/claude\s|^claude\s|\bclaude\b/ }, + { pattern: 'codex', tool: 'codex', match: /\/codex\s|^codex\s|codex app-server|\bcodex\b/ }, + { pattern: 'opencode', tool: 'opencode', match: /\/opencode\s|^opencode\s|\bopencode\b/ }, + { pattern: 'kiro', tool: 'kiro', match: /kiro-cli/ }, + { pattern: 'cursor-agent', tool: 'cursor', match: /cursor-agent/ }, + ]; + + // Skip process scanning on Windows (no ps/grep) + if (process.platform === 'win32') return active; + + try { + const psOut = execSync( + 'ps aux 2>/dev/null | grep -E "claude|codex|opencode|kiro-cli|cursor-agent" | grep -v grep || true', + { encoding: 'utf8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] } + ); + + for (const line of psOut.split('\n').filter(Boolean)) { + const parts = line.trim().split(/\s+/); + if (parts.length < 11) continue; + + const pid = parseInt(parts[1]); + if (seenPids.has(pid)) continue; + + const cpu = parseFloat(parts[2]) || 0; + const rss = parseInt(parts[5]) || 0; + const stat = parts[7] || ''; + const cmd = parts.slice(10).join(' '); + + // Determine tool + let tool = ''; + for (const ap of agentPatterns) { + if (ap.match.test(cmd)) { tool = ap.tool; break; } + } + if (!tool) continue; + + // Skip node/npm/shell wrappers, MCP servers, plugins — only main agent processes + if (cmd.includes('node bin/cli') || cmd.includes('npm') || cmd.includes('grep')) continue; + if (cmd.includes('mcp-server') || cmd.includes('mcp_server') || cmd.includes('/mcp/') || cmd.includes('/mcp-servers/')) continue; + if (cmd.includes('/plugins/') || cmd.includes('plugin-') || cmd.includes('app-server-broker')) continue; + if (cmd.includes('.claude/') && !cmd.includes('claude ') && tool === 'claude') continue; + if (cmd.includes('.codex/') && !cmd.includes('codex ') && tool === 'codex') continue; + + seenPids.add(pid); + + // Get session ID from Claude PID files + let sessionId = ''; + let cwd = ''; + let startedAt = 0; + let sessionSource = ''; + if (claudePidMap[pid]) { + sessionId = claudePidMap[pid].sessionId || ''; + cwd = claudePidMap[pid].cwd || ''; + startedAt = claudePidMap[pid].startedAt || 0; + if (sessionId) sessionSource = 'pid-file'; + } + + // Try to get cwd from lsof if not from PID file + if (!cwd) { + try { + const lsofOut = execSync(`lsof -d cwd -p ${pid} -Fn 2>/dev/null`, { encoding: 'utf8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'] }); + const match = lsofOut.match(/\nn(\/[^\n]+)/); + if (match) cwd = match[1]; + } catch {} + } + + // Try to find session ID by matching cwd + tool to loaded sessions + if (!sessionId) { + const allS = loadSessions(); + const match = allS.find(s => s.tool === tool && s.project === cwd); + if (match) { + sessionId = match.id; + sessionSource = 'cwd-match'; + } + // If still no match, find latest session of this tool + if (!sessionId) { + const latest = allS.filter(s => s.tool === tool).sort((a,b) => b.last_ts - a.last_ts)[0]; + if (latest) { + sessionId = latest.id; + sessionSource = 'fallback-latest'; + } + } + } + + const status = cpu < 1 && (stat.includes('S') || stat.includes('T')) ? 'waiting' : 'active'; + + active.push({ + pid: pid, + sessionId: sessionId, + cwd: cwd, + startedAt: startedAt, + kind: tool, + entrypoint: tool, + status: status, + cpu: cpu, + memoryMB: Math.round(rss / 1024), + _sessionSource: sessionSource, + }); + } + } catch {} + + return active; +} + +// ── Leaderboard stats ───────────────────────────────────── + +const ANON_NAMES_ADJ = ['brave','swift','calm','bold','keen','wise','cool','fast','wild','epic','rare','pure','warm','dark','deep','fair','free','glad','gold','iron']; +const ANON_NAMES_NOUN = ['fox','owl','cat','wolf','bear','hawk','lion','deer','hare','crow','lynx','moth','seal','wren','dove','frog','newt','crab','swan','kite']; + +function getOrCreateAnonId() { + const configDir = path.join(os.homedir(), '.codedash'); + const idFile = path.join(configDir, 'anon-id.json'); + try { + const data = JSON.parse(fs.readFileSync(idFile, 'utf8')); + if (data.id && data.name) return data; + } catch {} + // Generate new + const id = require('crypto').randomUUID(); + const adj = ANON_NAMES_ADJ[Math.floor(Math.random() * ANON_NAMES_ADJ.length)]; + const noun = ANON_NAMES_NOUN[Math.floor(Math.random() * ANON_NAMES_NOUN.length)]; + const num = Math.floor(Math.random() * 100); + const name = adj + '-' + noun + '-' + num; + const data = { id, name, createdAt: new Date().toISOString() }; + if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(idFile, JSON.stringify(data, null, 2)); + return data; +} + +const fmtLocalDay = (ts) => { + const d = new Date(ts); + return d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0') + '-' + String(d.getDate()).padStart(2,'0'); +}; + +// Disk cache for per-session daily message breakdown +const DAILY_STATS_CACHE_FILE = path.join(os.tmpdir(), 'codedash-daily-stats-cache.json'); +let _dailyStatsDiskCache = null; + +function _loadDailyStatsDiskCache() { + if (_dailyStatsDiskCache) return; + try { + if (fs.existsSync(DAILY_STATS_CACHE_FILE)) { + _dailyStatsDiskCache = JSON.parse(fs.readFileSync(DAILY_STATS_CACHE_FILE, 'utf8')); + } + } catch {} + if (!_dailyStatsDiskCache) _dailyStatsDiskCache = {}; +} + +function _saveDailyStatsDiskCache() { + if (!_dailyStatsDiskCache) return; + try { + fs.writeFileSync(DAILY_STATS_CACHE_FILE, JSON.stringify(_dailyStatsDiskCache)); + } catch {} +} + +function _computeSessionDailyBreakdown(s, found) { + const msgsByDay = {}; + const tsByDay = {}; + try { + const lines = readLines(found.file); + for (const line of lines) { + try { + const entry = JSON.parse(line); + let isUser = false; + let hasText = false; + let ts = 0; + + if (found.format === 'claude') { + if (entry.type !== 'user') continue; + isUser = true; + if (entry.timestamp) ts = typeof entry.timestamp === 'number' ? entry.timestamp : new Date(entry.timestamp).getTime(); + const c = entry.message && entry.message.content; + if (typeof c === 'string' && c.trim()) hasText = true; + else if (Array.isArray(c)) { for (const p of c) { if (p.type === 'text' && p.text && p.text.trim()) { hasText = true; break; } } } + } else if (found.format === 'cursor') { + if (entry.role !== 'user') continue; + isUser = true; + ts = s.first_ts; + const c = (entry.message || {}).content; + if (Array.isArray(c)) { for (const p of c) { if (p.type === 'text' && p.text && p.text.replace(/<\/?user_query>/g,'').trim()) { hasText = true; break; } } } + else if (typeof c === 'string' && c.trim()) hasText = true; + } else if (found.format === 'codex') { + if (entry.type === 'response_item' && entry.payload && entry.payload.role === 'user') { + isUser = true; + ts = s.first_ts; + const c = entry.payload.content; + if (Array.isArray(c)) { for (const p of c) { if ((p.text || '').trim()) { hasText = true; break; } } } + } else continue; + } + + if (!isUser || !hasText) continue; + if (!ts || ts < 1000000000000) ts = s.first_ts; + const day = (found.format === 'claude' && ts) ? fmtLocalDay(ts) : (s.date || fmtLocalDay(s.last_ts)); + msgsByDay[day] = (msgsByDay[day] || 0) + 1; + if (!tsByDay[day]) tsByDay[day] = { first: ts, last: ts }; + if (ts < tsByDay[day].first) tsByDay[day].first = ts; + if (ts > tsByDay[day].last) tsByDay[day].last = ts; + } catch {} + } + } catch {} + return { msgsByDay, tsByDay }; +} + +// Daily stats result cache +const DAILY_RESULT_CACHE_FILE = path.join(os.tmpdir(), 'codedash-daily-result-cache.json'); +let _dailyResultCache = null; +let _dailyResultCacheKey = null; + +function getDailyStats(sessions) { + const key = _analyticsKey(sessions); + if (_dailyResultCache && _dailyResultCacheKey === key) return _dailyResultCache; + + // Try disk cache + if (!_dailyResultCache) { + try { + if (fs.existsSync(DAILY_RESULT_CACHE_FILE)) { + const cached = JSON.parse(fs.readFileSync(DAILY_RESULT_CACHE_FILE, 'utf8')); + if (cached._key === key) { + _dailyResultCache = cached.data; + _dailyResultCacheKey = key; + return cached.data; + } + } + } catch {} + } + + const result = _computeDailyStats(sessions); + _dailyResultCache = result; + _dailyResultCacheKey = key; + try { fs.writeFileSync(DAILY_RESULT_CACHE_FILE, JSON.stringify({ _key: key, data: result })); } catch {} + return result; +} + +function _computeDailyStats(sessions) { + const byDay = {}; + const ensureDay = (date) => { + if (!byDay[date]) byDay[date] = { date, sessions: 0, messages: 0, hours: 0, cost: 0, agents: {} }; + return byDay[date]; + }; + + _loadDailyStatsDiskCache(); + + for (const s of sessions) { + if (!s.first_ts || !s.last_ts) continue; + const tool = s.tool || 'unknown'; + + // Cost per session + const costData = computeSessionCost(s.id, s.project); + const sessionCost = (costData && costData.cost) || 0; + + // For sessions with detail files — read actual message timestamps + const found = s.has_detail ? findSessionFile(s.id, s.project) : null; + if (found && found.format !== 'opencode' && found.format !== 'kiro' && found.format !== 'cursor' && fs.existsSync(found.file)) { + // Check disk cache for daily breakdown + let breakdown; + let dailyCacheKey = ''; + try { + const stat = fs.statSync(found.file); + dailyCacheKey = found.file + '|' + stat.mtimeMs + '|' + stat.size; + } catch {} + if (dailyCacheKey && _dailyStatsDiskCache[dailyCacheKey]) { + breakdown = _dailyStatsDiskCache[dailyCacheKey]; + } else { + breakdown = _computeSessionDailyBreakdown(s, found); + if (dailyCacheKey) _dailyStatsDiskCache[dailyCacheKey] = breakdown; + } + const { msgsByDay, tsByDay } = breakdown; + + const dayKeys = Object.keys(msgsByDay); + if (dayKeys.length > 0) { + const totalMsgs = dayKeys.reduce((a, k) => a + msgsByDay[k], 0) || 1; + for (const day of dayKeys) { + const d = ensureDay(day); + d.sessions++; + d.messages += msgsByDay[day]; + const dayHours = tsByDay[day] ? Math.min((tsByDay[day].last - tsByDay[day].first) / 3600000, 16) : 0; + d.hours += dayHours; + d.cost += sessionCost * (msgsByDay[day] / totalMsgs); // cost proportional to messages + d.agents[tool] = (d.agents[tool] || 0) + 1; + } + continue; // done with this session + } + } + + // Fallback for non-Claude or sessions without detail: single-day attribution + const day = s.date || fmtLocalDay(s.last_ts); + const d = ensureDay(day); + d.sessions++; + // Use exact user_messages count if available, otherwise estimate + if (s.user_messages > 0) { + d.messages += s.user_messages; + } else { + const totalMsgEst = s.detail_messages || s.messages || 0; + d.messages += Math.ceil(totalMsgEst * 0.5); + } + d.hours += Math.min((s.last_ts - s.first_ts) / 3600000, 16); + d.cost += sessionCost; + d.agents[tool] = (d.agents[tool] || 0) + 1; + } + + // Round + for (const d of Object.values(byDay)) { + d.hours = Math.round(d.hours * 10) / 10; + d.cost = Math.round(d.cost * 100) / 100; + } + _saveDailyStatsDiskCache(); + return Object.values(byDay).sort((a, b) => b.date.localeCompare(a.date)); +} + +let _lbCache = null; +let _lbCacheTs = 0; +const LB_CACHE_TTL = 60000; // 60 seconds + +function getLeaderboardStats() { + const now = Date.now(); + if (_lbCache && (now - _lbCacheTs) < LB_CACHE_TTL) return _lbCache; + + const sessions = loadSessions(); + const anon = getOrCreateAnonId(); + const daily = getDailyStats(sessions); + + // Totals + let totalMessages = 0, totalHours = 0, totalCost = 0, totalSessions = sessions.length; + const agentTotals = {}; + for (const d of daily) { + totalMessages += d.messages; + totalHours += d.hours; + totalCost += d.cost; + for (const [agent, count] of Object.entries(d.agents)) { + agentTotals[agent] = (agentTotals[agent] || 0) + count; + } + } + + // Today + const today = new Date().toISOString().slice(0, 10); + const todayStats = daily.find(d => d.date === today) || { sessions: 0, messages: 0, hours: 0, cost: 0, agents: {} }; + + // Streak (consecutive days with sessions) + let streak = 0; + const dt = new Date(); + for (let i = 0; i < 365; i++) { + const day = dt.toISOString().slice(0, 10); + if (daily.find(d => d.date === day)) { + streak++; + dt.setDate(dt.getDate() - 1); + } else { + break; + } + } + + const result = { + anon, + today: todayStats, + totals: { sessions: totalSessions, messages: totalMessages, hours: Math.round(totalHours * 10) / 10, cost: Math.round(totalCost * 100) / 100 }, + agents: agentTotals, + streak, + daily: daily.slice(0, 30), // last 30 days + activeDays: daily.length, + }; + _lbCache = result; + _lbCacheTs = Date.now(); + return result; +} + +module.exports = { + loadSessions, + loadSessionDetail, + getProjectGitInfo, + getLeaderboardStats, + getOrCreateAnonId, + deleteSession, + getGitCommits, + exportSessionMarkdown, + getSessionPreview, + searchFullText, + getActiveSessions, + getSessionReplay, + getCostAnalytics, + computeSessionCost, + MODEL_PRICING, + findSessionFile, + extractContent, + isSystemMessage, + loadOpenCodeDetail, + CLAUDE_DIR, + CODEX_DIR, + OPENCODE_DB, + KIRO_DB, + HISTORY_FILE, + PROJECTS_DIR, +}; From 61b67537ee870341bf783d07b4206ffb98bf5674 Mon Sep 17 00:00:00 2001 From: Anykeyev Date: Thu, 9 Apr 2026 23:02:26 +0300 Subject: [PATCH 3/4] frontend changes --- src/frontend/analytics.js | 498 ++-- src/frontend/app.js | 3704 +++++++++++------------ src/frontend/calendar.js | 431 +-- src/frontend/detail.js | 814 ++--- src/frontend/index.html | 360 +-- src/frontend/styles.css | 5971 +++++++++++++++++++------------------ 6 files changed, 5904 insertions(+), 5874 deletions(-) diff --git a/src/frontend/analytics.js b/src/frontend/analytics.js index cabf110..794f1ac 100644 --- a/src/frontend/analytics.js +++ b/src/frontend/analytics.js @@ -1,249 +1,249 @@ -// ── Cost Analytics ──────────────────────────────────────────── - -var _analyticsHtmlCache = null; -var _analyticsCacheUrl = null; - -async function renderAnalytics(container) { - // Check frontend cache first — show instantly if same filters - var url = '/api/analytics/cost'; - var params = []; - if (dateFrom) params.push('from=' + dateFrom); - if (dateTo) params.push('to=' + dateTo); - if (params.length) url += '?' + params.join('&'); - - if (_analyticsHtmlCache && _analyticsCacheUrl === url) { - container.innerHTML = _analyticsHtmlCache; - return; - } - - container.innerHTML = '
Loading analytics...
'; - - try { - var resp = await fetch(url); - var data = await resp.json(); - - // Guard: if user navigated away during fetch, don't overwrite - if (currentView !== 'analytics') return; - - var html = '
'; - html += '

Cost Analytics

'; - - // ── Summary cards ────────────────────────────────────────── - html += '
'; - html += '
$' + data.totalCost.toFixed(2) + 'Total cost (API-equivalent)
'; - html += '
' + formatTokens(data.totalTokens) + 'Total tokens
'; - html += '
$' + (data.dailyRate || 0).toFixed(2) + 'Avg per day (' + (data.days || 1) + ' days)
'; - html += '
' + data.totalSessions + 'Sessions with cost data' + (data.totalSessionsAll > data.totalSessions ? ' / ' + data.totalSessionsAll + ' total' : '') + '
'; - html += '
'; - - // ── Burn rate ────────────────────────────────────────────── - var todayCost = data.todayCost || 0; - var last1hCost = data.last1hCost || 0; - var dailyRate = data.dailyRate || 0; - var hoursElapsed = data.hoursElapsedToday || 1; - // Project today's pace to a full day for comparison - var projectedDaily = todayCost / (hoursElapsed / 24); - var paceRatio = dailyRate > 0 ? projectedDaily / dailyRate : 0; - var burnClass = paceRatio >= 2 ? 'burn-high' : paceRatio >= 1.3 ? 'burn-medium' : 'burn-low'; - var paceLabel = paceRatio >= 2 ? '🔥 ' + Math.round(paceRatio) + 'x avg' : paceRatio >= 1.3 ? '↑ ' + paceRatio.toFixed(1) + 'x avg' : dailyRate > 0 ? '✓ normal' : ''; - html += '
'; - html += '
Burn Rate
'; - html += '
'; - html += '
$' + todayCost.toFixed(3) + 'today' + (paceLabel ? '' + paceLabel + '' : '') + '
'; - html += '
$' + last1hCost.toFixed(3) + 'last hour
'; - if (dailyRate > 0) { - html += '
$' + projectedDaily.toFixed(2) + 'projected today
'; - } - html += '
'; - html += '
'; - - // ── Data coverage note ──────────────────────────────────── - if (data.byAgent || data.agentNoCostData) { - var coverageparts = []; - var byAgent = data.byAgent || {}; - var noCost = data.agentNoCostData || {}; - if (byAgent['claude'] && byAgent['claude'].sessions > 0) - coverageparts.push('Claude Code \u2713'); - if (byAgent['claude-ext'] && byAgent['claude-ext'].sessions > 0) - coverageparts.push('Claude Extension \u2713'); - if (byAgent['codex'] && byAgent['codex'].sessions > 0) - coverageparts.push('Codex ~est.'); - if (byAgent['opencode'] && byAgent['opencode'].sessions > 0) - coverageparts.push(byAgent['opencode'].estimated - ? 'OpenCode ~est.' - : 'OpenCode \u2713'); - ['cursor', 'kiro'].forEach(function(a) { - if (noCost[a] > 0) - coverageparts.push('' + a + ' \u2717 (no token data)'); - }); - if (noCost['opencode'] > 0 && !(byAgent['opencode'] && byAgent['opencode'].sessions > 0)) - coverageparts.push('opencode \u2717 (no token data)'); - if (coverageparts.length > 0) { - html += '
Cost data: ' + coverageparts.join(' \u00b7 ') + '
'; - } - } - - // ── Token breakdown ──────────────────────────────────────── - if (data.totalInputTokens !== undefined) { - var totalTok = data.totalInputTokens + data.totalOutputTokens + data.totalCacheReadTokens + data.totalCacheCreateTokens; - var pctOf = function(n) { return totalTok > 0 ? Math.round(n / totalTok * 100) : 0; }; - html += '
'; - html += '

Token Breakdown

'; - html += '
'; - html += '
' + formatTokens(data.totalInputTokens) + 'Input' + pctOf(data.totalInputTokens) + '%
'; - html += '
' + formatTokens(data.totalOutputTokens) + 'Output' + pctOf(data.totalOutputTokens) + '%
'; - html += '
' + formatTokens(data.totalCacheReadTokens) + 'Cache read' + pctOf(data.totalCacheReadTokens) + '%
'; - html += '
' + formatTokens(data.totalCacheCreateTokens) + 'Cache write' + pctOf(data.totalCacheCreateTokens) + '%
'; - if (data.avgContextPct > 0) { - html += '
' + data.avgContextPct + '%Avg context usedof 200K
'; - } - html += '
'; - html += '
'; - } - - // ── Subscription vs API ──────────────────────────────────── - var sub = getSubscriptionConfig(); - var subEntries = (sub && sub.entries) || []; - var totalPaid = subTotalPaid(subEntries); - html += '
'; - html += '

Subscription vs API

'; - - if (totalPaid > 0) { - var savings = data.totalCost - totalPaid; - var multiplier = data.totalCost / totalPaid; - var savingsPositive = savings > 0; - var breakdown = subEntries.map(function(e) { - return escHtml(e.plan || 'Sub') + ' $' + parseFloat(e.paid).toFixed(0); - }).join(' + '); - html += '
'; - html += '
$' + totalPaid.toFixed(2) + 'Paid (' + breakdown + ')
'; - html += '
$' + data.totalCost.toFixed(2) + 'Would cost at API rates
'; - html += '
' + (savingsPositive ? '+' : '') + '$' + Math.abs(savings).toFixed(2) + '' + (savingsPositive ? 'Saved (' + multiplier.toFixed(1) + '\u00d7 ROI)' : 'API would be cheaper') + '
'; - html += '
'; - var barPct = Math.min(100, data.totalCost > 0 ? (totalPaid / data.totalCost * 100) : 100); - html += '
'; - html += '
'; - html += '
'; - } else { - html += '

Add your subscription periods below to see how much you\'re saving vs API rates.

'; - } - - // Period list - html += '
'; - if (subEntries.length > 0) { - subEntries.forEach(function(e, i) { - var serviceLabel = e.service && SERVICE_PLANS[e.service] ? SERVICE_PLANS[e.service].label : e.service || ''; - html += '
'; - if (serviceLabel) html += '' + escHtml(serviceLabel) + ''; - html += '' + escHtml(e.plan || '\u2014') + ''; - html += '$' + parseFloat(e.paid || 0).toFixed(2) + '/mo'; - html += '' + (e.from ? 'from ' + e.from : 'no date') + ''; - html += ''; - html += '
'; - }); - } - html += '
'; - - // Add form - var serviceOptions = '' + - Object.keys(SERVICE_PLANS).map(function(k) { - return ''; - }).join(''); - html += '
'; - html += ''; - html += ''; - html += ''; - html += ''; - html += ''; - html += '
'; - html += '
'; - - // ── Daily cost chart ─────────────────────────────────────── - var dayKeys = Object.keys(data.byDay).sort(); - var last30 = dayKeys.slice(-30); - if (last30.length > 0) { - var maxCost = Math.max.apply(null, last30.map(function(d) { return data.byDay[d].cost; })); - html += '

Daily Cost (last 30 days)

'; - html += '
'; - last30.forEach(function(d) { - var c = data.byDay[d]; - var pct = maxCost > 0 ? (c.cost / maxCost * 100) : 0; - var label = d.slice(5); // MM-DD - html += '
'; - html += '
'; - html += '
' + label + '
'; - html += '
'; - }); - html += '
'; - } - - // ── Cost by project ──────────────────────────────────────── - var projects = Object.entries(data.byProject).sort(function(a, b) { return b[1].cost - a[1].cost; }); - var topProjects = projects.slice(0, 10); - if (topProjects.length > 0) { - var maxProjCost = topProjects[0][1].cost; - html += '

Cost by Project

'; - html += '
'; - topProjects.forEach(function(entry) { - var name = entry[0]; - var info = entry[1]; - var pct = maxProjCost > 0 ? (info.cost / maxProjCost * 100) : 0; - html += '
'; - html += '' + escHtml(name) + ''; - html += '
'; - html += '$' + info.cost.toFixed(2) + ''; - html += '
'; - }); - html += '
'; - } - - // ── Top expensive sessions ───────────────────────────────── - if (data.topSessions && data.topSessions.length > 0) { - html += '

Most Expensive Sessions

'; - html += '
'; - data.topSessions.forEach(function(s) { - html += '
'; - html += '$' + s.cost.toFixed(2) + ''; - html += '' + escHtml(s.project) + ''; - html += '' + (s.date || '') + ''; - html += '' + s.id.slice(0, 8) + ''; - html += '
'; - }); - html += '
'; - } - - // ── Cost by agent ────────────────────────────────────────── - var agentEntries = Object.entries(data.byAgent || {}).filter(function(e) { return e[1].sessions > 0; }); - if (agentEntries.length > 1) { - agentEntries.sort(function(a, b) { return b[1].cost - a[1].cost; }); - html += '

Cost by Agent

'; - html += '
'; - var maxAgentCost = agentEntries[0][1].cost || 1; - agentEntries.forEach(function(entry) { - var name = entry[0]; var info = entry[1]; - var pct = maxAgentCost > 0 ? (info.cost / maxAgentCost * 100) : 0; - var label = { 'claude': 'Claude Code', 'claude-ext': 'Claude Ext', 'codex': 'Codex', 'opencode': 'OpenCode', 'cursor': 'Cursor', 'kiro': 'Kiro' }[name] || name; - var estMark = info.estimated ? ' ~est.' : ''; - html += '
'; - html += '' + label + estMark + ''; - html += '
'; - html += '$' + info.cost.toFixed(2) + ' (' + info.sessions + ' sess.)'; - html += '
'; - }); - html += '
'; - } - - html += '
'; - container.innerHTML = html; - _analyticsHtmlCache = html; - _analyticsCacheUrl = url; - } catch (e) { - container.innerHTML = '
Failed to load analytics.
'; - } -} - -function formatTokens(n) { - if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'; - if (n >= 1000) return (n / 1000).toFixed(0) + 'K'; - return String(n); -} +// ── Cost Analytics ──────────────────────────────────────────── + +var _analyticsHtmlCache = null; +var _analyticsCacheUrl = null; + +async function renderAnalytics(container) { + // Check frontend cache first — show instantly if same filters + var url = '/api/analytics/cost'; + var params = []; + if (dateFrom) params.push('from=' + dateFrom); + if (dateTo) params.push('to=' + dateTo); + if (params.length) url += '?' + params.join('&'); + + if (_analyticsHtmlCache && _analyticsCacheUrl === url) { + container.innerHTML = _analyticsHtmlCache; + return; + } + + container.innerHTML = '
Loading analytics...
'; + + try { + var resp = await fetch(url); + var data = await resp.json(); + + // Guard: if user navigated away during fetch, don't overwrite + if (currentView !== 'analytics') return; + + var html = '
'; + html += '

Cost Analytics

'; + + // ── Summary cards ────────────────────────────────────────── + html += '
'; + html += '
$' + data.totalCost.toFixed(2) + 'Total cost (API-equivalent)
'; + html += '
' + formatTokens(data.totalTokens) + 'Total tokens
'; + html += '
$' + (data.dailyRate || 0).toFixed(2) + 'Avg per day (' + (data.days || 1) + ' days)
'; + html += '
' + data.totalSessions + 'Sessions with cost data' + (data.totalSessionsAll > data.totalSessions ? ' / ' + data.totalSessionsAll + ' total' : '') + '
'; + html += '
'; + + // ── Burn rate ────────────────────────────────────────────── + var todayCost = data.todayCost || 0; + var last1hCost = data.last1hCost || 0; + var dailyRate = data.dailyRate || 0; + var hoursElapsed = data.hoursElapsedToday || 1; + // Project today's pace to a full day for comparison + var projectedDaily = todayCost / (hoursElapsed / 24); + var paceRatio = dailyRate > 0 ? projectedDaily / dailyRate : 0; + var burnClass = paceRatio >= 2 ? 'burn-high' : paceRatio >= 1.3 ? 'burn-medium' : 'burn-low'; + var paceLabel = paceRatio >= 2 ? '🔥 ' + Math.round(paceRatio) + 'x avg' : paceRatio >= 1.3 ? '↑ ' + paceRatio.toFixed(1) + 'x avg' : dailyRate > 0 ? '✓ normal' : ''; + html += '
'; + html += '
Burn Rate
'; + html += '
'; + html += '
$' + todayCost.toFixed(3) + 'today' + (paceLabel ? '' + paceLabel + '' : '') + '
'; + html += '
$' + last1hCost.toFixed(3) + 'last hour
'; + if (dailyRate > 0) { + html += '
$' + projectedDaily.toFixed(2) + 'projected today
'; + } + html += '
'; + html += '
'; + + // ── Data coverage note ──────────────────────────────────── + if (data.byAgent || data.agentNoCostData) { + var coverageparts = []; + var byAgent = data.byAgent || {}; + var noCost = data.agentNoCostData || {}; + if (byAgent['claude'] && byAgent['claude'].sessions > 0) + coverageparts.push('Claude Code \u2713'); + if (byAgent['claude-ext'] && byAgent['claude-ext'].sessions > 0) + coverageparts.push('Claude Extension \u2713'); + if (byAgent['codex'] && byAgent['codex'].sessions > 0) + coverageparts.push('Codex ~est.'); + if (byAgent['opencode'] && byAgent['opencode'].sessions > 0) + coverageparts.push(byAgent['opencode'].estimated + ? 'OpenCode ~est.' + : 'OpenCode \u2713'); + ['cursor', 'kiro', 'copilot'].forEach(function(a) { + if (noCost[a] > 0) + coverageparts.push('' + a + ' \u2717 (no token data)'); + }); + if (noCost['opencode'] > 0 && !(byAgent['opencode'] && byAgent['opencode'].sessions > 0)) + coverageparts.push('opencode \u2717 (no token data)'); + if (coverageparts.length > 0) { + html += '
Cost data: ' + coverageparts.join(' \u00b7 ') + '
'; + } + } + + // ── Token breakdown ──────────────────────────────────────── + if (data.totalInputTokens !== undefined) { + var totalTok = data.totalInputTokens + data.totalOutputTokens + data.totalCacheReadTokens + data.totalCacheCreateTokens; + var pctOf = function(n) { return totalTok > 0 ? Math.round(n / totalTok * 100) : 0; }; + html += '
'; + html += '

Token Breakdown

'; + html += '
'; + html += '
' + formatTokens(data.totalInputTokens) + 'Input' + pctOf(data.totalInputTokens) + '%
'; + html += '
' + formatTokens(data.totalOutputTokens) + 'Output' + pctOf(data.totalOutputTokens) + '%
'; + html += '
' + formatTokens(data.totalCacheReadTokens) + 'Cache read' + pctOf(data.totalCacheReadTokens) + '%
'; + html += '
' + formatTokens(data.totalCacheCreateTokens) + 'Cache write' + pctOf(data.totalCacheCreateTokens) + '%
'; + if (data.avgContextPct > 0) { + html += '
' + data.avgContextPct + '%Avg context usedof 200K
'; + } + html += '
'; + html += '
'; + } + + // ── Subscription vs API ──────────────────────────────────── + var sub = getSubscriptionConfig(); + var subEntries = (sub && sub.entries) || []; + var totalPaid = subTotalPaid(subEntries); + html += '
'; + html += '

Subscription vs API

'; + + if (totalPaid > 0) { + var savings = data.totalCost - totalPaid; + var multiplier = data.totalCost / totalPaid; + var savingsPositive = savings > 0; + var breakdown = subEntries.map(function(e) { + return escHtml(e.plan || 'Sub') + ' $' + parseFloat(e.paid).toFixed(0); + }).join(' + '); + html += '
'; + html += '
$' + totalPaid.toFixed(2) + 'Paid (' + breakdown + ')
'; + html += '
$' + data.totalCost.toFixed(2) + 'Would cost at API rates
'; + html += '
' + (savingsPositive ? '+' : '') + '$' + Math.abs(savings).toFixed(2) + '' + (savingsPositive ? 'Saved (' + multiplier.toFixed(1) + '\u00d7 ROI)' : 'API would be cheaper') + '
'; + html += '
'; + var barPct = Math.min(100, data.totalCost > 0 ? (totalPaid / data.totalCost * 100) : 100); + html += '
'; + html += '
'; + html += '
'; + } else { + html += '

Add your subscription periods below to see how much you\'re saving vs API rates.

'; + } + + // Period list + html += '
'; + if (subEntries.length > 0) { + subEntries.forEach(function(e, i) { + var serviceLabel = e.service && SERVICE_PLANS[e.service] ? SERVICE_PLANS[e.service].label : e.service || ''; + html += '
'; + if (serviceLabel) html += '' + escHtml(serviceLabel) + ''; + html += '' + escHtml(e.plan || '\u2014') + ''; + html += '$' + parseFloat(e.paid || 0).toFixed(2) + '/mo'; + html += '' + (e.from ? 'from ' + e.from : 'no date') + ''; + html += ''; + html += '
'; + }); + } + html += '
'; + + // Add form + var serviceOptions = '' + + Object.keys(SERVICE_PLANS).map(function(k) { + return ''; + }).join(''); + html += '
'; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += '
'; + html += '
'; + + // ── Daily cost chart ─────────────────────────────────────── + var dayKeys = Object.keys(data.byDay).sort(); + var last30 = dayKeys.slice(-30); + if (last30.length > 0) { + var maxCost = Math.max.apply(null, last30.map(function(d) { return data.byDay[d].cost; })); + html += '

Daily Cost (last 30 days)

'; + html += '
'; + last30.forEach(function(d) { + var c = data.byDay[d]; + var pct = maxCost > 0 ? (c.cost / maxCost * 100) : 0; + var label = d.slice(5); // MM-DD + html += '
'; + html += '
'; + html += '
' + label + '
'; + html += '
'; + }); + html += '
'; + } + + // ── Cost by project ──────────────────────────────────────── + var projects = Object.entries(data.byProject).sort(function(a, b) { return b[1].cost - a[1].cost; }); + var topProjects = projects.slice(0, 10); + if (topProjects.length > 0) { + var maxProjCost = topProjects[0][1].cost; + html += '

Cost by Project

'; + html += '
'; + topProjects.forEach(function(entry) { + var name = entry[0]; + var info = entry[1]; + var pct = maxProjCost > 0 ? (info.cost / maxProjCost * 100) : 0; + html += '
'; + html += '' + escHtml(name) + ''; + html += '
'; + html += '$' + info.cost.toFixed(2) + ''; + html += '
'; + }); + html += '
'; + } + + // ── Top expensive sessions ───────────────────────────────── + if (data.topSessions && data.topSessions.length > 0) { + html += '

Most Expensive Sessions

'; + html += '
'; + data.topSessions.forEach(function(s) { + html += '
'; + html += '$' + s.cost.toFixed(2) + ''; + html += '' + escHtml(s.project) + ''; + html += '' + (s.date || '') + ''; + html += '' + s.id.slice(0, 8) + ''; + html += '
'; + }); + html += '
'; + } + + // ── Cost by agent ────────────────────────────────────────── + var agentEntries = Object.entries(data.byAgent || {}).filter(function(e) { return e[1].sessions > 0; }); + if (agentEntries.length > 1) { + agentEntries.sort(function(a, b) { return b[1].cost - a[1].cost; }); + html += '

Cost by Agent

'; + html += '
'; + var maxAgentCost = agentEntries[0][1].cost || 1; + agentEntries.forEach(function(entry) { + var name = entry[0]; var info = entry[1]; + var pct = maxAgentCost > 0 ? (info.cost / maxAgentCost * 100) : 0; + var label = { 'claude': 'Claude Code', 'claude-ext': 'Claude Ext', 'codex': 'Codex', 'opencode': 'OpenCode', 'cursor': 'Cursor', 'kiro': 'Kiro', 'copilot': 'GitHub Copilot' }[name] || name; + var estMark = info.estimated ? ' ~est.' : ''; + html += '
'; + html += '' + label + estMark + ''; + html += '
'; + html += '$' + info.cost.toFixed(2) + ' (' + info.sessions + ' sess.)'; + html += '
'; + }); + html += '
'; + } + + html += '
'; + container.innerHTML = html; + _analyticsHtmlCache = html; + _analyticsCacheUrl = url; + } catch (e) { + container.innerHTML = '
Failed to load analytics.
'; + } +} + +function formatTokens(n) { + if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'; + if (n >= 1000) return (n / 1000).toFixed(0) + 'K'; + return String(n); +} diff --git a/src/frontend/app.js b/src/frontend/app.js index 6fd757a..ce6cfcd 100644 --- a/src/frontend/app.js +++ b/src/frontend/app.js @@ -1,1846 +1,1858 @@ -// ── codedash frontend ────────────────────────────────────────── -// Plain browser JS, no modules, no build step. - -// ── State ────────────────────────────────────────────────────── - -let allSessions = []; -let filteredSessions = []; -let currentView = 'sessions'; // sessions, projects, timeline, activity, starred -let grouped = true; -let layout = localStorage.getItem('codedash-layout') || 'grid'; // 'grid' or 'list' -let groupingMode = normalizeGroupingMode(localStorage.getItem('codedash-grouping-mode')); -let searchQuery = ''; -let toolFilter = null; // null, 'claude', 'codex' -let tagFilter = ''; -let dateFrom = ''; -let dateTo = ''; -let selectMode = false; -let selectedIds = new Set(); -let focusedIndex = -1; -let availableTerminals = []; -let pendingDelete = null; -let activeSessions = {}; // sessionId -> {status, cpu, memoryMB, pid} -let renderLimit = 60; // pagination — render at most this many cards -const RENDER_PAGE_SIZE = 60; - -// Persisted in localStorage -let stars = JSON.parse(localStorage.getItem('codedash-stars') || '[]'); -let tags = JSON.parse(localStorage.getItem('codedash-tags') || '{}'); -let sessionTitles = JSON.parse(localStorage.getItem('codedash-titles') || '{}'); -let showAITitles = localStorage.getItem('codedash-ai-titles') !== 'false'; - -// ── Color palette for projects ───────────────────────────────── - -const PROJECT_COLORS = [ - '#6366f1', '#8b5cf6', '#a855f7', '#d946ef', '#ec4899', - '#f43f5e', '#ef4444', '#f97316', '#eab308', '#84cc16', - '#22c55e', '#14b8a6', '#06b6d4', '#3b82f6', '#2563eb', - '#7c3aed', '#c026d3', '#e11d48', '#ea580c', '#65a30d', -]; -const projectColorMap = {}; -let colorIdx = 0; - -function getProjectColor(project) { - if (!project) return '#6b7280'; - if (!projectColorMap[project]) { - projectColorMap[project] = PROJECT_COLORS[colorIdx % PROJECT_COLORS.length]; - colorIdx++; - } - return projectColorMap[project]; -} - -function getProjectName(fullPath) { - if (!fullPath) return 'unknown'; - const cleaned = fullPath.replace(/\/+$/, ''); - const parts = cleaned.split('/'); - return parts[parts.length - 1] || 'unknown'; -} - -function normalizeGroupingMode(mode) { - return mode === 'repo' ? 'repo' : 'folder'; -} - -function getRepoInfo(fullPath, gitRoot) { - var repoRoot = ''; - if (gitRoot) { - repoRoot = gitRoot.replace(/\/+$/, ''); - } else if (fullPath) { - var cleaned = fullPath.replace(/\/+$/, ''); - var wt = cleaned.match(/^(.*?)\/.claude\/worktrees\//); - var codex = cleaned.match(/^(.*?)\/.codex\//); - repoRoot = wt ? wt[1] : (codex ? codex[1] : cleaned); - } - - var name = repoRoot ? repoRoot.split('/').pop() : 'unknown'; - return { - key: repoRoot || 'unknown', - name: name || 'unknown' - }; -} - -function getGitProjectName(fullPath, gitRoot) { - return getRepoInfo(fullPath, gitRoot).name; -} - -function getSessionGroupInfo(session) { - if (groupingMode === 'repo') { - return getRepoInfo(session.project, session.git_root); - } - var name = getProjectName(session.project); - return { key: name, name: name }; -} - -// ── Utilities ────────────────────────────────────────────────── - -function timeAgo(dateStr) { - if (!dateStr) return ''; - const now = Date.now(); - const ts = typeof dateStr === 'number' ? dateStr : new Date(dateStr).getTime(); - const diff = now - ts; - const mins = Math.floor(diff / 60000); - if (mins < 1) return 'just now'; - if (mins < 60) return mins + 'm ago'; - const hrs = Math.floor(mins / 60); - if (hrs < 24) return hrs + 'h ago'; - const days = Math.floor(hrs / 24); - if (days < 30) return days + 'd ago'; - const months = Math.floor(days / 30); - if (months < 12) return months + 'mo ago'; - return Math.floor(months / 12) + 'y ago'; -} - -function escHtml(s) { - if (!s) return ''; - return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); -} - -function showToast(msg) { - const el = document.getElementById('toast'); - if (!el) return; - el.textContent = msg; - el.classList.add('show'); - setTimeout(() => el.classList.remove('show'), 2500); -} - -function formatBytes(bytes) { - if (!bytes || bytes < 1024) return (bytes || 0) + ' B'; - if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'; - return (bytes / 1048576).toFixed(1) + ' MB'; -} - -function estimateCost(fileSize) { - if (!fileSize) return 0; - var tokens = fileSize / 4; - // Quick card badge estimate (Sonnet 4.6: $3/M in, $15/M out) - return tokens * 0.3 * (3.0 / 1e6) + tokens * 0.7 * (15.0 / 1e6); -} - -// ── Subscription service plans (pricing as of 2025) ───────────── -var SERVICE_PLANS = { - 'Claude': { label: 'Claude (Anthropic)', plans: [ - { name: 'Pro', price: 20 }, - { name: 'Max 5×', price: 100 }, - { name: 'Max 20×', price: 200 } - ]}, - 'OpenAI': { label: 'OpenAI (ChatGPT)', plans: [ - { name: 'Plus', price: 20 }, - { name: 'Pro', price: 200 } - ]}, - 'Cursor': { label: 'Cursor', plans: [ - { name: 'Pro', price: 20 }, - { name: 'Pro+', price: 60 }, - { name: 'Ultra', price: 200 } - ]}, - 'Kiro': { label: 'Kiro', plans: [ - { name: 'Pro', price: 20 }, - { name: 'Pro+', price: 40 }, - { name: 'Power', price: 200 } - ]}, - 'OpenCode': { label: 'OpenCode', plans: [ - { name: 'Go', price: 10 } - ]} -}; - -function onSubServiceChange() { - var serviceEl = document.getElementById('sub-new-service'); - var planEl = document.getElementById('sub-new-plan'); - var paidEl = document.getElementById('sub-new-paid'); - var service = serviceEl ? serviceEl.value : ''; - if (!planEl) return; - planEl.innerHTML = ''; - paidEl.value = ''; - if (service && SERVICE_PLANS[service]) { - SERVICE_PLANS[service].plans.forEach(function(p) { - var opt = document.createElement('option'); - opt.value = p.name; - opt.textContent = p.name + ' ($' + p.price + '/mo)'; - planEl.appendChild(opt); - }); - } -} - -function onSubPlanChange() { - var serviceEl = document.getElementById('sub-new-service'); - var planEl = document.getElementById('sub-new-plan'); - var paidEl = document.getElementById('sub-new-paid'); - var service = serviceEl ? serviceEl.value : ''; - var planName = planEl ? planEl.value : ''; - if (service && planName && SERVICE_PLANS[service]) { - var found = SERVICE_PLANS[service].plans.find(function(p) { return p.name === planName; }); - if (found && paidEl) paidEl.value = found.price; - } -} - -// ── Subscription config helpers ────────────────────────────────── -function getSubscriptionConfig() { - var raw = JSON.parse(localStorage.getItem('codedash-subscription') || 'null'); - if (!raw) return { entries: [] }; - // Migrate old single-entry format {plan, paid} → new multi-period {entries: [...]} - if (!raw.entries) return { entries: [{ plan: raw.plan || 'Subscription', paid: raw.paid || 0, from: '' }] }; - return raw; -} -function saveSubscriptionConfig(cfg) { localStorage.setItem('codedash-subscription', JSON.stringify(cfg)); } -function subTotalPaid(entries) { return entries.reduce(function(s,e){return s+(parseFloat(e.paid)||0);},0); } -function addSubEntry() { - var service = (document.getElementById('sub-new-service').value || '').trim(); - var planEl = document.getElementById('sub-new-plan'); - var plan = planEl ? planEl.value.trim() : ''; - var paid = parseFloat(document.getElementById('sub-new-paid').value) || 0; - var from = (document.getElementById('sub-new-from').value || '').trim(); - if (!paid) return; - var cfg = getSubscriptionConfig(); - cfg.entries.push({ service: service || '', plan: plan || 'Subscription', paid: paid, from: from }); - cfg.entries.sort(function(a,b){return (a.from||'').localeCompare(b.from||'');}); - saveSubscriptionConfig(cfg); - render(); -} -function removeSubEntry(idx) { - var cfg = getSubscriptionConfig(); - cfg.entries.splice(idx, 1); - saveSubscriptionConfig(cfg); - render(); -} - -async function loadRealCost(sessionId, project) { - try { - var resp = await fetch('/api/cost/' + sessionId + '?project=' + encodeURIComponent(project)); - return await resp.json(); - } catch (e) { return null; } -} - -// ── Tag system ───────────────────────────────────────────────── - -const TAG_OPTIONS = ['bug', 'feature', 'research', 'infra', 'deploy', 'review']; - -function showTagDropdown(event, sessionId) { - event.stopPropagation(); - document.querySelectorAll('.tag-dropdown').forEach(function(el) { el.remove(); }); - var dd = document.createElement('div'); - dd.className = 'tag-dropdown'; - var existingTags = tags[sessionId] || []; - dd.innerHTML = TAG_OPTIONS.map(function(t) { - var has = existingTags.indexOf(t) >= 0; - return '
' + - (has ? '✓ ' : '') + t + '
'; - }).join(''); - - // Position near the button - var rect = event.target.getBoundingClientRect(); - dd.style.top = (rect.bottom + 4) + 'px'; - dd.style.left = rect.left + 'px'; - - document.body.appendChild(dd); - setTimeout(function() { - document.addEventListener('click', function() { dd.remove(); }, { once: true }); - }, 0); -} - -function addTag(sessionId, tag) { - if (!tags[sessionId]) tags[sessionId] = []; - if (!tags[sessionId].includes(tag)) tags[sessionId].push(tag); - localStorage.setItem('codedash-tags', JSON.stringify(tags)); - document.querySelectorAll('.tag-dropdown').forEach(function(el) { el.remove(); }); - render(); -} - -function removeTag(sessionId, tag) { - if (tags[sessionId]) { - tags[sessionId] = tags[sessionId].filter(function(t) { return t !== tag; }); - if (!tags[sessionId].length) delete tags[sessionId]; - localStorage.setItem('codedash-tags', JSON.stringify(tags)); - render(); - } -} - -// ── Stars ────────────────────────────────────────────────────── - -function toggleStar(id) { - var idx = stars.indexOf(id); - if (idx >= 0) stars.splice(idx, 1); - else stars.push(id); - localStorage.setItem('codedash-stars', JSON.stringify(stars)); - render(); -} - -// ── AI Titles ───────────────────────────────────────────────── - -function toggleAITitles(checked) { - showAITitles = checked; - localStorage.setItem('codedash-ai-titles', checked ? 'true' : 'false'); - render(); -} - -function saveGroupingMode(mode) { - groupingMode = normalizeGroupingMode(mode); - localStorage.setItem('codedash-grouping-mode', groupingMode); - render(); -} - -function loadLLMSettings() { - fetch('/api/llm-config').then(function(r) { return r.json(); }).then(function(c) { - var u = document.getElementById('llmUrl'); - var k = document.getElementById('llmApiKey'); - var m = document.getElementById('llmModel'); - if (u) u.value = c.url || ''; - if (k) k.value = c.apiKey || ''; - if (m) m.value = c.model || ''; - }); -} - -function saveLLMSettings() { - var config = { - url: document.getElementById('llmUrl').value.trim(), - apiKey: document.getElementById('llmApiKey').value.trim(), - model: document.getElementById('llmModel').value.trim(), - }; - fetch('/api/llm-config', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(config), - }).then(function() { - showToast('LLM settings saved'); - }); -} - -function testLLMConnection() { - // Generate title for the first available session as a test - var testSession = allSessions.find(function(s) { return s.has_detail && s.messages > 2; }); - if (!testSession) { showToast('No sessions to test with'); return; } - showToast('Testing LLM connection...'); - fetch('/api/generate-title', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId: testSession.id, project: testSession.project }), - }).then(function(r) { return r.json(); }).then(function(d) { - if (d.ok) { - showToast('OK: "' + d.title + '"'); - } else { - showToast('Error: ' + d.error); - } - }).catch(function(e) { showToast('Connection failed: ' + e.message); }); -} - -function generateTitle(sessionId, project) { - fetch('/api/generate-title', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId: sessionId, project: project }), - }).then(function(r) { return r.json(); }).then(function(d) { - if (d.ok && d.title) { - sessionTitles[sessionId] = d.title; - localStorage.setItem('codedash-titles', JSON.stringify(sessionTitles)); - render(); - } else { - showToast('Title generation failed: ' + (d.error || 'unknown')); - } - }).catch(function(e) { showToast('Error: ' + e.message); }); -} - -function generateAllTitles() { - var sessions = filteredSessions.filter(function(s) { - return s.has_detail && s.messages > 2 && !sessionTitles[s.id]; - }).slice(0, 20); // batch of 20 - if (!sessions.length) { showToast('All sessions already have titles'); return; } - showToast('Generating titles for ' + sessions.length + ' sessions...'); - var done = 0; - sessions.forEach(function(s) { - fetch('/api/generate-title', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId: s.id, project: s.project }), - }).then(function(r) { return r.json(); }).then(function(d) { - done++; - if (d.ok && d.title) { - sessionTitles[s.id] = d.title; - localStorage.setItem('codedash-titles', JSON.stringify(sessionTitles)); - } - if (done === sessions.length) { - render(); - showToast('Generated ' + done + ' titles'); - } - }).catch(function() { done++; }); - }); -} - -// ── Data loading ─────────────────────────────────────────────── - -async function loadSessions() { - try { - var resp = await fetch('/api/sessions'); - allSessions = await resp.json(); - applyFilters(); - // Progressive loading: if server is still loading cursor vscdb sessions, auto-refresh - if (resp.headers.get('X-Loading') === '1') { - setTimeout(loadSessions, 2000); - } - } catch (e) { - document.getElementById('content').innerHTML = '
Failed to load sessions. Is the server running?
'; - } -} - -function refreshData() { - loadSessions(); - showToast('Refreshed'); -} - -async function loadTerminals() { - try { - var resp = await fetch('/api/terminals'); - availableTerminals = await resp.json(); - var sel = document.getElementById('terminalSelect'); - if (!sel) return; - sel.innerHTML = ''; - var saved = localStorage.getItem('codedash-terminal') || ''; - availableTerminals.forEach(function(t) { - if (!t.available) return; - var opt = document.createElement('option'); - opt.value = t.id; - opt.textContent = t.name; - if (t.id === saved) opt.selected = true; - sel.appendChild(opt); - }); - if (!saved && availableTerminals.length > 0) { - var first = availableTerminals.find(function(t) { return t.available; }); - if (first) sel.value = first.id; - } - } catch (e) { - // terminals not available - } -} - -function saveTerminalPref(val) { - localStorage.setItem('codedash-terminal', val); -} - -// ── Active sessions polling ─────────────────────────────────── - -var _prevActiveKey = ''; - -async function pollActiveSessions() { - try { - var resp = await fetch('/api/active'); - var data = await resp.json(); - - // Build new state - var newActive = {}; - data.forEach(function(a) { - if (a.sessionId) newActive[a.sessionId] = a; - }); - - // Check if anything changed — skip DOM work if not - var newKey = data.map(function(a) { return (a.sessionId || a.pid) + ':' + a.status; }).sort().join(','); - if (newKey === _prevActiveKey) return; - _prevActiveKey = newKey; - - activeSessions = newActive; - - // Only touch cards that changed - document.querySelectorAll('.card').forEach(function(card) { - var id = card.getAttribute('data-id'); - var existing = card.querySelector('.live-badge'); - var parent = card.parentElement; - var wasActive = parent && parent.classList.contains('card-live-wrap'); - var isActive = !!activeSessions[id]; - - // No change — skip - if (!wasActive && !isActive && !existing) return; - - // Remove old badge - if (existing) existing.remove(); - - // Remove wrapper if no longer active - if (wasActive && !isActive) { - parent.replaceWith(card); - card.style.border = ''; - return; - } - - if (isActive) { - var a = activeSessions[id]; - - // Add badge - var badge = document.createElement('span'); - badge.className = 'live-badge live-' + a.status; - badge.textContent = a.status === 'waiting' ? 'WAITING' : 'LIVE'; - badge.title = 'PID ' + a.pid + ' | CPU ' + a.cpu.toFixed(1) + '% | ' + a.memoryMB + 'MB'; - var top = card.querySelector('.card-top'); - if (top) top.insertBefore(badge, top.firstChild); - - // Wrapper - if (wasActive) { - parent.className = 'card-live-wrap' + (a.status === 'waiting' ? ' live-waiting' : ''); - parent.style.setProperty('--live-color', a.status === 'waiting' - ? 'rgba(251, 191, 36, 0.5)' : 'rgba(74, 222, 128, 0.7)'); - } else { - var wrap = document.createElement('div'); - wrap.className = 'card-live-wrap' + (a.status === 'waiting' ? ' live-waiting' : ''); - wrap.style.setProperty('--live-color', a.status === 'waiting' - ? 'rgba(251, 191, 36, 0.5)' : 'rgba(74, 222, 128, 0.7)'); - var borderDiv = document.createElement('div'); - borderDiv.className = 'live-border'; - card.parentNode.insertBefore(wrap, card); - wrap.appendChild(borderDiv); - wrap.appendChild(card); - } - } - }); - } catch {} -} - -var activeInterval = null; -function startActivePolling() { - pollActiveSessions(); - activeInterval = setInterval(pollActiveSessions, 5000); -} -function stopActivePolling() { - if (activeInterval) clearInterval(activeInterval); -} - -// ── Trigram search ───────────────────────────────────────────── - -function trigrams(str) { - var s = ' ' + str.toLowerCase() + ' '; - var t = {}; - for (var i = 0; i < s.length - 2; i++) { - var tri = s.substring(i, i + 3); - t[tri] = (t[tri] || 0) + 1; - } - return t; -} - -function trigramScore(query, text) { - if (!query || !text) return 0; - var qt = trigrams(query); - var tt = trigrams(text); - var matches = 0; - var total = 0; - for (var k in qt) { - total += qt[k]; - if (tt[k]) matches += Math.min(qt[k], tt[k]); - } - return total > 0 ? matches / total : 0; -} - -function searchScore(query, session) { - var q = query.toLowerCase(); - var fields = [ - session.first_message || '', - session.project_short || '', - session.project || '', - session.id || '', - session.tool || '' - ]; - var haystack = fields.join(' ').toLowerCase(); - - // Exact substring match = highest score - if (haystack.indexOf(q) >= 0) return 1; - - // Trigram fuzzy match - var best = 0; - for (var i = 0; i < fields.length; i++) { - var score = trigramScore(q, fields[i]); - if (score > best) best = score; - } - // Also score against full haystack - var fullScore = trigramScore(q, haystack); - if (fullScore > best) best = fullScore; - - return best; -} - -// ── Filtering ────────────────────────────────────────────────── - -var SEARCH_THRESHOLD = 0.3; - -function applyFilters() { - renderLimit = RENDER_PAGE_SIZE; // reset pagination on filter change - var scored = []; - for (var i = 0; i < allSessions.length; i++) { - var s = allSessions[i]; - - // Tool filter - if (toolFilter) { - var toolMatch = s.tool === toolFilter || (s.tool === 'claude-ext' && toolFilter === 'claude'); - if (!toolMatch) continue; - } - - // Tag filter - if (tagFilter) { - var sessionTags = tags[s.id] || []; - if (sessionTags.indexOf(tagFilter) === -1) continue; - } - - // Date range - if (dateFrom && s.date < dateFrom) continue; - if (dateTo && s.date > dateTo) continue; - - // Search with trigram scoring - var score = 1; - if (searchQuery) { - score = searchScore(searchQuery, s); - if (score < SEARCH_THRESHOLD) continue; - } - - scored.push({ session: s, score: score }); - } - - // Sort: starred first, then by search score (if searching), then by time - scored.sort(function(a, b) { - var aStarred = stars.indexOf(a.session.id) >= 0 ? 1 : 0; - var bStarred = stars.indexOf(b.session.id) >= 0 ? 1 : 0; - if (aStarred !== bStarred) return bStarred - aStarred; - if (searchQuery && a.score !== b.score) return b.score - a.score; - return b.session.last_ts - a.session.last_ts; - }); - - filteredSessions = scored.map(function(x) { return x.session; }); - - render(); - -} - -function onSearch(val) { - searchQuery = val; - applyFilters(); - - // Trigger deep search after debounce - clearTimeout(deepSearchTimeout); - if (val && val.length >= 3) { - deepSearchTimeout = setTimeout(function() { deepSearch(val); }, 600); - } -} - -function onTagFilter(val) { - tagFilter = val; - applyFilters(); -} - -function onDateFilter() { - applyFilters(); - updateDateBtn(); -} - -// → moved to calendar.js - -// ── Rendering: Card ──────────────────────────────────────────── - -function renderCard(s, idx) { - var isStarred = stars.indexOf(s.id) >= 0; - var isSelected = selectedIds.has(s.id); - var isFocused = focusedIndex === idx; - var sessionTags = tags[s.id] || []; - var cost = estimateCost(s.file_size); - var costStr = cost > 0 ? '~$' + cost.toFixed(2) : ''; - var projName = getProjectName(s.project); - var projColor = getProjectColor(projName); - var toolClass = 'tool-' + s.tool; - var toolLabel = s.tool === 'claude-ext' ? 'claude ext' : s.tool; - - var classes = 'card'; - if (isSelected) classes += ' selected'; - if (isFocused) classes += ' focused'; - - var checkboxStyle = selectMode ? 'display:inline-block' : ''; - - var tagHtml = sessionTags.map(function(t) { - return '' + escHtml(t) + ' ×'; - }).join(''); - - var html = '
'; - html += '
'; - html += ''; - html += '' + escHtml(toolLabel) + ''; - html += '' + escHtml(projName) + ''; - html += '' + timeAgo(s.last_ts) + ''; - if (costStr) { - html += '' + costStr + ''; - } - html += ''; - if (cloudUnlocked) { - var inCloud = cloudSessionIds.has(s.id); - html += ''; - } - html += '
'; - var aiTitle = showAITitles && sessionTitles[s.id]; - if (aiTitle) { - html += '
' + escHtml(aiTitle) + '
'; - html += '
' + escHtml((s.first_message || '').slice(0, 80)) + '
'; - } else { - html += '
' + escHtml((s.first_message || '').slice(0, 120)) + '
'; - } - html += ''; - // MCP/Skills footer - if ((s.mcp_servers && s.mcp_servers.length > 0) || (s.skills && s.skills.length > 0)) { - html += '
'; - if (s.mcp_servers) { - s.mcp_servers.forEach(function(m) { - html += '' + escHtml(m) + ''; - }); - } - if (s.skills) { - s.skills.forEach(function(sk) { - html += '' + escHtml(sk) + ''; - }); - } - html += '
'; - } - // Expandable preview area (hidden by default) - html += '
'; - html += '
'; - return html; -} - -function toggleLayout() { - layout = layout === 'grid' ? 'list' : 'grid'; - localStorage.setItem('codedash-layout', layout); - var btn = document.getElementById('layoutBtn'); - if (btn) btn.classList.toggle('active', layout === 'list'); - var icon = document.getElementById('layoutIcon'); - if (icon) { - icon.innerHTML = layout === 'list' - ? '' - : ''; - } - render(); -} - -function renderListCard(s, idx) { - var isStarred = stars.indexOf(s.id) >= 0; - var isSelected = selectedIds.has(s.id); - var isFocused = focusedIndex === idx; - var projName = getProjectName(s.project); - var projColor = getProjectColor(projName); - - var classes = 'list-row'; - if (isSelected) classes += ' selected'; - if (isFocused) classes += ' focused'; - - var html = '
'; - var listToolLabel = s.tool === 'claude-ext' ? 'claude ext' : s.tool; - html += '' + escHtml(listToolLabel) + ''; - if (s.mcp_servers && s.mcp_servers.length > 0) { - s.mcp_servers.forEach(function(m) { - html += '' + escHtml(m) + ''; - }); - } - if (s.skills && s.skills.length > 0) { - s.skills.forEach(function(sk) { - html += '' + escHtml(sk) + ''; - }); - } - html += '' + escHtml(projName) + ''; - html += '' + escHtml((s.first_message || '').slice(0, 80)) + ''; - html += '' + s.messages + ' msgs'; - html += '' + timeAgo(s.last_ts) + ''; - html += ''; - html += '
'; - return html; -} - -// ── Card expand (inline preview) ────────────────────────────── - -async function toggleExpand(sessionId, project, btn) { - var area = document.getElementById('preview-' + sessionId); - if (!area) return; - - if (area.classList.contains('open')) { - area.classList.remove('open'); - area.innerHTML = ''; - btn.innerHTML = '▾'; - return; - } - - btn.innerHTML = '⌛'; - area.innerHTML = '
Loading...
'; - area.classList.add('open'); - - try { - var resp = await fetch('/api/preview/' + sessionId + '?project=' + encodeURIComponent(project) + '&limit=10'); - var messages = await resp.json(); - - if (messages.length === 0) { - area.innerHTML = '
No messages
'; - } else { - var html = ''; - messages.forEach(function(m) { - var cls = m.role === 'user' ? 'preview-user' : 'preview-assistant'; - var label = m.role === 'user' ? 'You' : 'AI'; - html += '
'; - html += '' + label + ' '; - var text = m.content.length > 500 ? m.content.slice(0, 500) + '...' : m.content; - html += escHtml(text); - html += '
'; - }); - area.innerHTML = html; - } - btn.innerHTML = '▴'; - } catch (e) { - area.innerHTML = '
Failed to load
'; - btn.innerHTML = '▾'; - } -} - - -// ── Deep search (full-text across session content) ──────────── - -var deepSearchCache = {}; -var deepSearchTimeout = null; - -async function deepSearch(query) { - if (!query || query.length < 3) return; - if (deepSearchCache[query]) { - applyDeepSearchResults(deepSearchCache[query]); - return; - } - - try { - var resp = await fetch('/api/search?q=' + encodeURIComponent(query)); - var results = await resp.json(); - deepSearchCache[query] = results; - applyDeepSearchResults(results); - } catch {} -} - -function applyDeepSearchResults(results) { - if (!results || results.length === 0) return; - - // Highlight matching session IDs in filtered list - var matchIds = results.map(function(r) { return r.sessionId; }); - - // Boost matching sessions to top if not already visible - var boosted = []; - var rest = []; - filteredSessions.forEach(function(s) { - if (matchIds.indexOf(s.id) >= 0) { - s._deepMatch = results.find(function(r) { return r.sessionId === s.id; }); - boosted.push(s); - } else { - rest.push(s); - } - }); - - // Also add sessions that weren't in filteredSessions but match - matchIds.forEach(function(id) { - if (!boosted.find(function(s) { return s.id === id; }) && !rest.find(function(s) { return s.id === id; })) { - var s = allSessions.find(function(x) { return x.id === id; }); - if (s) { - s._deepMatch = results.find(function(r) { return r.sessionId === id; }); - boosted.push(s); - } - } - }); - - filteredSessions = boosted.concat(rest); - render(); - - // Show deep search indicator - var stats = document.getElementById('stats'); - if (stats && boosted.length > 0) { - stats.textContent += ' | ' + boosted.length + ' deep matches'; - } -} - -function onCardClick(id, event) { - if (selectMode) { - toggleSelect(id, event); - } else { - var s = allSessions.find(function(x) { return x.id === id; }); - if (s) openDetail(s); - } -} - -// ── Rendering: Main ──────────────────────────────────────────── - -function render() { - var content = document.getElementById('content'); - var stats = document.getElementById('stats'); - if (!content) return; - - var sessions = filteredSessions; - - // Stats - if (stats) { - stats.textContent = sessions.length + ' sessions' + - (toolFilter ? ' (' + toolFilter + ')' : '') + - (tagFilter ? ' [' + tagFilter + ']' : ''); - } - - // Route to view - if (currentView === 'activity') { - renderHeatmap(content); - return; - } - - if (currentView === 'analytics') { - renderAnalytics(content); - return; - } - - if (currentView === 'changelog') { - renderChangelog(content); - return; - } - - if (currentView === 'leaderboard') { - renderLeaderboard(content); - return; - } - - if (currentView === 'cloud') { - renderCloud(content); - return; - } - - if (currentView === 'settings') { - renderSettings(content); - return; - } - - if (currentView === 'running') { - renderRunning(content, sessions); - return; - } - - if (currentView === 'starred') { - var starredSessions = sessions.filter(function(s) { return stars.indexOf(s.id) >= 0; }); - if (starredSessions.length === 0) { - content.innerHTML = '
No starred sessions. Click the star on any session to bookmark it.
'; - return; - } - var idx = 0; - content.innerHTML = starredSessions.map(function(s) { return renderCard(s, idx++); }).join(''); - return; - } - - if (currentView === 'timeline') { - renderTimeline(content, sessions); - return; - } - - if (currentView === 'projects') { - renderProjects(content, sessions); - return; - } - - // Default: sessions view - if (sessions.length === 0) { - content.innerHTML = '
No sessions found.' + - (searchQuery ? ' Try a different search.' : '') + '
'; - return; - } - - var renderFn = layout === 'list' ? renderListCard : renderCard; - var visible = sessions.slice(0, renderLimit); - var hasMore = sessions.length > renderLimit; - - if (grouped) { - renderGrouped(content, visible, renderFn); - } else { - var idx2 = 0; - var wrapClass = layout === 'list' ? 'list-view' : 'grid-view'; - content.innerHTML = '
' + visible.map(function(s) { return renderFn(s, idx2++); }).join('') + '
'; - } - - if (hasMore) { - content.innerHTML += '
'; - } -} - -function loadMoreCards() { - renderLimit += RENDER_PAGE_SIZE; - render(); -} - -function renderGrouped(container, sessions, renderFn) { - renderFn = renderFn || renderCard; - var groups = {}; - sessions.forEach(function(s) { - var group = getSessionGroupInfo(s); - if (!groups[group.key]) groups[group.key] = { name: group.name, sessions: [] }; - groups[group.key].sessions.push(s); - }); - - var sortedKeys = Object.keys(groups).sort(function(a, b) { - return groups[b].sessions[0].last_ts - groups[a].sessions[0].last_ts; - }); - - var globalIdx = 0; - var html = ''; - sortedKeys.forEach(function(key) { - var group = groups[key]; - var color = getProjectColor(key); - html += '
'; - html += '
'; - html += ''; - html += '' + escHtml(group.name) + ''; - html += '' + group.sessions.length + ''; - html += ''; - html += '
'; - var bodyClass = layout === 'list' ? 'group-body group-body-list' : 'group-body'; - html += '
'; - group.sessions.forEach(function(s) { - html += renderFn(s, globalIdx++); - }); - html += '
'; - }); - container.innerHTML = html; -} - -function renderTimeline(container, sessions) { - // Group by date - var byDate = {}; - sessions.forEach(function(s) { - var d = s.date || 'unknown'; - if (!byDate[d]) byDate[d] = []; - byDate[d].push(s); - }); - - var dates = Object.keys(byDate).sort().reverse(); - if (dates.length === 0) { - container.innerHTML = '
No sessions to display in timeline.
'; - return; - } - - var renderFn = layout === 'list' ? renderListCard : renderCard; - var globalIdx = 0; - var html = '
'; - dates.forEach(function(d) { - html += '
'; - html += '
' + escHtml(d) + ' ' + byDate[d].length + ' sessions
'; - var wrapClass = layout === 'list' ? 'list-view' : 'grid-view'; - html += '
'; - byDate[d].forEach(function(s) { - html += renderFn(s, globalIdx++); - }); - html += '
'; - }); - html += '
'; - container.innerHTML = html; -} - -function renderQACard(s, idx) { - var isStarred = stars.indexOf(s.id) >= 0; - var toolLabel = s.tool === 'claude-ext' ? 'claude ext' : s.tool; - var toolClass = 'tool-' + s.tool; - var cost = estimateCost(s.file_size); - var costStr = cost > 0 ? '~$' + cost.toFixed(2) : ''; - var classes = 'qa-item' + (selectedIds.has(s.id) ? ' selected' : ''); - - var html = '
'; - html += '' + escHtml(toolLabel) + ''; - html += '' + escHtml((s.first_message || '').slice(0, 160)) + ''; - html += ''; - html += '' + s.messages + ' msgs'; - if (costStr) html += '' + costStr + ''; - html += '' + timeAgo(s.last_ts) + ''; - html += ''; - html += ''; - html += '
'; - return html; -} - -function renderProjects(container, sessions) { - var byGit = {}; - sessions.forEach(function(s) { - var name = getGitProjectName(s.project, s.git_root); - if (!byGit[name]) byGit[name] = []; - byGit[name].push(s); - }); - - var sorted = Object.entries(byGit).sort(function(a, b) { - return b[1][0].last_ts - a[1][0].last_ts; - }); - - if (sorted.length === 0) { - container.innerHTML = '
No projects found.
'; - return; - } - - var globalIdx = 0; - var html = '
'; - sorted.forEach(function(entry) { - var name = entry[0]; - var list = entry[1].slice().sort(function(a, b) { return b.last_ts - a.last_ts; }); - var color = getProjectColor(name); - var totalMsgs = list.reduce(function(s, e) { return s + (e.messages || 0); }, 0); - var totalCost = list.reduce(function(s, e) { return s + estimateCost(e.file_size); }, 0); - var costLabel = totalCost > 0 ? ' · ~$' + totalCost.toFixed(2) : ''; - - html += '
'; - html += '
'; - html += ''; - html += '' + escHtml(name) + ''; - html += '' + list.length + ' sessions · ' + totalMsgs + ' msgs' + escHtml(costLabel) + ''; - html += ''; - html += '
'; - html += '
'; - list.forEach(function(s) { html += renderQACard(s, globalIdx++); }); - html += '
'; - html += '
'; - }); - html += '
'; - container.innerHTML = html; -} - -// → moved to heatmap.js - -// → moved to detail.js - -// ── Delete ───────────────────────────────────────────────────── - -function showDeleteConfirm(sessionId, project) { - pendingDelete = { id: sessionId, project: project }; - var overlay = document.getElementById('confirmOverlay'); - if (overlay) overlay.style.display = 'flex'; - document.getElementById('confirmTitle').textContent = 'Delete Session?'; - document.getElementById('confirmText').textContent = 'This will permanently delete the session file, history entries, and env data.'; - document.getElementById('confirmId').textContent = sessionId; - var btn = document.getElementById('confirmAction'); - btn.textContent = 'Delete'; - btn.className = 'btn-delete'; - btn.onclick = function() { confirmDelete(); }; -} - -function closeConfirm() { - pendingDelete = null; - var overlay = document.getElementById('confirmOverlay'); - if (overlay) overlay.style.display = 'none'; -} - -async function confirmDelete() { - if (!pendingDelete) return; - try { - var resp = await fetch('/api/session/' + pendingDelete.id, { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ project: pendingDelete.project }) - }); - var data = await resp.json(); - if (data.ok) { - showToast('Session deleted'); - allSessions = allSessions.filter(function(s) { return s.id !== pendingDelete.id; }); - // Clear search if no more results - if (searchQuery) { - var remaining = allSessions.filter(function(s) { - return (s.project || '').toLowerCase().indexOf(searchQuery.toLowerCase()) >= 0 || - (s.first_message || '').toLowerCase().indexOf(searchQuery.toLowerCase()) >= 0; - }); - if (remaining.length === 0) { - searchQuery = ''; - document.querySelector('.search-box').value = ''; - } - } - closeConfirm(); - closeDetail(); - applyFilters(); - } else { - showToast('Delete failed: ' + (data.error || 'unknown')); - } - } catch (e) { - showToast('Delete failed'); - } - closeConfirm(); -} - -// ── Bulk actions ─────────────────────────────────────────────── - -function toggleSelectMode() { - selectMode = !selectMode; - if (!selectMode) selectedIds.clear(); - var btn = document.getElementById('selectBtn'); - if (btn) btn.classList.toggle('active', selectMode); - var content = document.getElementById('content'); - if (content) content.classList.toggle('select-mode', selectMode); - updateBulkBar(); - render(); -} - -function toggleSelect(id, event) { - if (event) event.stopPropagation(); - if (selectedIds.has(id)) selectedIds.delete(id); - else selectedIds.add(id); - updateBulkBar(); - render(); -} - -function updateBulkBar() { - var bar = document.getElementById('bulkBar'); - if (!bar) return; - if (selectedIds.size > 0) { - bar.style.display = 'flex'; - document.getElementById('bulkCount').textContent = selectedIds.size + ' selected'; - } else { - bar.style.display = 'none'; - } -} - -function clearSelection() { - selectedIds.clear(); - selectMode = false; - var btn = document.getElementById('selectBtn'); - if (btn) btn.classList.remove('active'); - updateBulkBar(); - render(); -} - -async function bulkDelete() { - if (!confirm('Delete ' + selectedIds.size + ' sessions? This cannot be undone.')) return; - var sessions = []; - selectedIds.forEach(function(id) { - var s = allSessions.find(function(x) { return x.id === id; }); - sessions.push({ id: id, project: s ? s.project : '' }); - }); - try { - var resp = await fetch('/api/bulk-delete', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessions: sessions }) - }); - var data = await resp.json(); - if (data.ok) { - showToast('Deleted ' + sessions.length + ' sessions'); - allSessions = allSessions.filter(function(s) { return !selectedIds.has(s.id); }); - clearSelection(); - applyFilters(); - } - } catch (e) { - showToast('Bulk delete failed'); - } -} - -// ── Project actions ──────────────────────────────────────────── - -function openProject(name) { - currentView = 'sessions'; - searchQuery = name; - document.querySelector('.search-box').value = name; - document.querySelectorAll('.sidebar-item').forEach(function(el) { - el.classList.toggle('active', el.getAttribute('data-view') === 'sessions'); - }); - applyFilters(); -} - -// ── Themes ───────────────────────────────────────────────────── - -function setTheme(theme) { - if (theme === 'dark') { - document.body.removeAttribute('data-theme'); - } else if (theme === 'system') { - var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; - if (prefersDark) { - document.body.removeAttribute('data-theme'); - } else { - document.body.setAttribute('data-theme', 'light'); - } - } else { - document.body.setAttribute('data-theme', theme); - } - localStorage.setItem('codedash-theme', theme); -} - -function saveThemePref(val) { - setTheme(val); -} - -// ── Keyboard navigation ──────────────────────────────────────── - -function isInput(e) { - var tag = document.activeElement ? document.activeElement.tagName : ''; - return ['INPUT', 'SELECT', 'TEXTAREA'].indexOf(tag) >= 0; -} - -function moveFocus(delta) { - var cards = document.querySelectorAll('.card'); - if (cards.length === 0) return; - focusedIndex = Math.max(0, Math.min(cards.length - 1, focusedIndex + delta)); - cards.forEach(function(c, i) { - c.classList.toggle('focused', i === focusedIndex); - }); - if (cards[focusedIndex]) { - cards[focusedIndex].scrollIntoView({ block: 'nearest' }); - } -} - -function openFocusedCard() { - var cards = document.querySelectorAll('.card'); - if (focusedIndex < 0 || focusedIndex >= cards.length) return; - var id = cards[focusedIndex].getAttribute('data-id'); - if (!id) return; - var s = allSessions.find(function(x) { return x.id === id; }); - if (s) { - if (selectMode) { - toggleSelect(id); - } else { - openDetail(s); - } - } -} - -function toggleStarFocused() { - var cards = document.querySelectorAll('.card'); - if (focusedIndex < 0 || focusedIndex >= cards.length) return; - var id = cards[focusedIndex].getAttribute('data-id'); - if (id) toggleStar(id); -} - -function deleteFocused() { - var cards = document.querySelectorAll('.card'); - if (focusedIndex < 0 || focusedIndex >= cards.length) return; - var id = cards[focusedIndex].getAttribute('data-id'); - if (!id) return; - var s = allSessions.find(function(x) { return x.id === id; }); - if (s) showDeleteConfirm(s.id, s.project || ''); -} - -document.addEventListener('keydown', function(e) { - if (e.key === 'Escape') { - if (pendingDelete) { - closeConfirm(); - } else { - closeDetail(); - } - return; - } - if (e.key === '/' && !isInput(e)) { - e.preventDefault(); - var searchBox = document.querySelector('.search-box'); - if (searchBox) searchBox.focus(); - return; - } - if (e.key === 'j' && !isInput(e)) { - e.preventDefault(); - moveFocus(1); - return; - } - if (e.key === 'k' && !isInput(e)) { - e.preventDefault(); - moveFocus(-1); - return; - } - if (e.key === 'Enter' && !isInput(e) && focusedIndex >= 0) { - e.preventDefault(); - openFocusedCard(); - return; - } - if (e.key === 'x' && !isInput(e) && focusedIndex >= 0) { - e.preventDefault(); - toggleStarFocused(); - return; - } - if (e.key === 'd' && !isInput(e) && focusedIndex >= 0) { - e.preventDefault(); - deleteFocused(); - return; - } - if (e.key === 'r' && !isInput(e)) { - e.preventDefault(); - refreshData(); - return; - } - if (e.key === 'g' && !isInput(e)) { - e.preventDefault(); - toggleGroup(); - return; - } - if (e.key === 's' && !isInput(e)) { - e.preventDefault(); - toggleSelectMode(); - return; - } -}); - -// ── Running Sessions View (Kanban) ───────────────────────────── - -function renderRunningCard(a, s) { - var projName = s ? getProjectName(s.project) : (a.cwd ? a.cwd.split('/').pop() : 'unknown'); - var projColor = getProjectColor(projName); - var statusClass = a.status === 'waiting' ? 'running-waiting' : 'running-active'; - var uptime = a.startedAt ? formatDuration(Date.now() - a.startedAt) : ''; - var sid = a.sessionId; - - var html = '
'; - html += '
'; - html += '' + (a.status === 'waiting' ? 'WAITING' : 'LIVE') + ''; - html += '' + escHtml(projName) + ''; - html += '' + escHtml(a.entrypoint || a.kind || 'claude') + ''; - html += '
'; - html += '
'; - html += '
' + a.cpu.toFixed(1) + '%CPU
'; - html += '
' + a.memoryMB + 'MBMEM
'; - if (uptime) html += '
' + uptime + 'Uptime
'; - html += '
'; - if (s && s.first_message) html += '
' + escHtml(s.first_message.slice(0, 120)) + '
'; - html += '
'; - html += ''; - if (s) { - html += ''; - html += ''; - } - html += '
'; - html += '
'; - return html; -} - -function renderDoneCard(s) { - var projName = getProjectName(s.project); - var projColor = getProjectColor(projName); - var html = '
'; - html += '
'; - html += 'DONE'; - html += '' + escHtml(projName) + ''; - html += '' + escHtml(s.tool || 'claude') + ''; - html += '
'; - if (s.first_message) html += '
' + escHtml(s.first_message.slice(0, 120)) + '
'; - html += '
'; - html += '
' + (s.messages || 0) + 'msgs
'; - if (s.last_time) html += '
' + s.last_time.slice(11) + 'ended
'; - html += '
'; - html += '
'; - html += ''; - html += '
'; - html += '
'; - return html; -} - -function renderRunning(container, sessions) { - var allActiveIds = Object.keys(activeSessions); - var running = allActiveIds.filter(function(sid) { return activeSessions[sid].status !== 'waiting'; }); - var waiting = allActiveIds.filter(function(sid) { return activeSessions[sid].status === 'waiting'; }); - var cutoff = Date.now() - 4 * 3600 * 1000; - var done = sessions.filter(function(s) { - return !activeSessions[s.id] && s.last_ts >= cutoff; - }).slice(0, 8); - - if (allActiveIds.length === 0 && done.length === 0) { - container.innerHTML = '
No running sessions detected.
Start a Claude Code or Codex session and it will appear here.
'; - return; - } - - var html = '
'; - html += '

Agent Board

'; - html += '
'; - - // ── Running column ────────────────────────────────────────── - html += '
'; - html += '
Running' + running.length + '
'; - if (running.length === 0) { - html += '
No active sessions
'; - } else { - running.forEach(function(sid) { - var a = activeSessions[sid]; - var s = allSessions.find(function(x) { return x.id === sid; }); - html += renderRunningCard(a, s); - }); - } - html += '
'; - - // ── Waiting column ────────────────────────────────────────── - html += '
'; - html += '
Waiting for input' + waiting.length + '
'; - if (waiting.length === 0) { - html += '
No sessions waiting
'; - } else { - waiting.forEach(function(sid) { - var a = activeSessions[sid]; - var s = allSessions.find(function(x) { return x.id === sid; }); - html += renderRunningCard(a, s); - }); - } - html += '
'; - - // ── Done column ───────────────────────────────────────────── - html += '
'; - html += '
Done (last 4h)' + done.length + '
'; - if (done.length === 0) { - html += '
No recent sessions
'; - } else { - done.forEach(function(s) { html += renderDoneCard(s); }); - } - html += '
'; - - html += '
'; // kanban-board - html += '
'; // running-container - container.innerHTML = html; -} - -// → moved to detail.js (Session Replay) - -// → moved to analytics.js - -// ── Focus active session (switch to terminal) ───────────────── - -function focusSession(sessionId) { - var a = activeSessions[sessionId]; - if (!a) { showToast('Session not active'); return; } - - fetch('/api/focus', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ pid: a.pid }) - }).then(function(r) { return r.json(); }).then(function(data) { - if (data.ok) { - var hint = data.terminal || 'terminal'; - var cwd = a.cwd ? a.cwd.split('/').pop() : ''; - showToast('Switched to ' + hint + (cwd ? ' — look for: ' + cwd : '') + ' (PID ' + a.pid + ')'); - } else { - showToast('Could not focus — try clicking the terminal manually'); - } - }).catch(function() { - showToast('Focus failed'); - }); -} - -// ── Changelog view ──────────────────────────────────────────── - -function renderSettings(container) { - var savedTheme = localStorage.getItem('codedash-theme') || 'dark'; - var savedTerminal = localStorage.getItem('codedash-terminal') || ''; - var aiTitlesOn = localStorage.getItem('codedash-ai-titles') === 'true'; - var savedGroupingMode = normalizeGroupingMode(localStorage.getItem('codedash-grouping-mode')); - - var html = '
'; - html += '

Settings

'; - - // Theme - html += '
'; - html += ''; - html += '
'; - ['dark', 'light', 'system'].forEach(function(t) { - var active = savedTheme === t ? ' active' : ''; - html += ''; - }); - html += '
'; - html += '
'; - - // Terminal - html += '
'; - html += ''; - html += ''; - html += '
'; - - // AI Titles - html += '
'; - html += ''; - html += '
'; - html += ''; - html += 'Show generated titles'; - html += '
'; - html += '
'; - - // Grouping - html += '
'; - html += ''; - html += '
'; - ['folder', 'repo'].forEach(function(mode) { - var active = savedGroupingMode === mode ? ' active' : ''; - var label = mode === 'repo' ? 'Repository' : 'Folder'; - html += ''; - }); - html += '
'; - html += '

Applies to grouped session views like All Sessions and Claude Code. Projects always stay repository-based.

'; - html += '
'; - - // LLM Configuration - html += '
'; - html += ''; - html += '

OpenAI-compatible API for session title generation

'; - html += '
'; - html += ''; - html += ''; - html += ''; - html += '
'; - html += '
'; - html += ''; - html += ''; - html += '
'; - html += '
'; - - html += '
'; - container.innerHTML = html; - - // Load LLM config into the inputs - loadLLMSettings(); -} - -// → moved to leaderboard.js - -async function renderChangelog(container) { - container.innerHTML = '
Loading changelog...
'; - try { - var resp = await fetch('/api/changelog'); - var log = await resp.json(); - - var html = '
'; - html += '

Changelog

'; - - log.forEach(function(entry, i) { - var isNew = i === 0; - html += '
'; - html += '
'; - html += 'v' + escHtml(entry.version) + ''; - if (isNew) html += 'NEW'; - html += '' + escHtml(entry.date) + ''; - html += '
'; - html += '
' + escHtml(entry.title) + '
'; - html += '
    '; - entry.changes.forEach(function(c) { - html += '
  • ' + escHtml(c) + '
  • '; - }); - html += '
'; - }); - - html += '
'; - container.innerHTML = html; - } catch (e) { - container.innerHTML = '
Failed to load changelog.
'; - } -} - -// ── Convert session ─────────────────────────────────────────── - -async function convertTo(sessionId, project, targetFormat) { - if (!confirm('Convert this session to ' + targetFormat + '? A new session will be created.')) return; - showToast('Converting...'); - try { - var resp = await fetch('/api/convert', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId: sessionId, project: project, targetFormat: targetFormat }), - }); - var data = await resp.json(); - if (data.ok) { - showToast('Converted! New session: ' + data.target.sessionId.slice(0, 12)); - // Refresh to show new session - await loadSessions(); - closeDetail(); - } else { - showToast('Error: ' + (data.error || 'unknown')); - } - } catch (e) { - showToast('Convert failed: ' + e.message); - } -} - -// ── Open in IDE ─────────────────────────────────────────────── - -function openInCursor(project) { - if (!project) { showToast('No project path'); return; } - fetch('/api/open-ide', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ide: 'cursor', project: project }) - }).then(function(r) { return r.json(); }).then(function(data) { - if (data.ok) showToast('Opening project in Cursor...'); - else showToast('Failed: ' + (data.error || 'unknown')); - }).catch(function() { showToast('Failed to open Cursor'); }); -} - -// ── Handoff ─────────────────────────────────────────────────── - -function downloadHandoff(sessionId, project) { - window.open('/api/handoff/' + sessionId + '?project=' + encodeURIComponent(project) + '&verbosity=standard'); -} - -// ── Install agents ──────────────────────────────────────────── - -var AGENT_INSTALL = { - claude: { - name: 'Claude Code', - cmd: 'curl -fsSL https://claude.ai/install.sh | bash', - alt: 'npm i -g @anthropic-ai/claude-code', - url: 'https://code.claude.com', - }, - codex: { - name: 'Codex CLI', - cmd: 'npm i -g @openai/codex', - alt: 'brew install --cask codex', - url: 'https://github.com/openai/codex', - }, - kiro: { - name: 'Kiro CLI', - cmd: 'curl -fsSL https://cli.kiro.dev/install | bash', - alt: null, - url: 'https://kiro.dev/docs/cli/installation/', - }, - opencode: { - name: 'OpenCode', - cmd: 'curl -fsSL https://opencode.ai/install | bash', - alt: 'npm i -g opencode-ai@latest', - url: 'https://opencode.ai', - }, -}; - -function installAgent(agent) { - var info = AGENT_INSTALL[agent]; - if (!info) return; - - var overlay = document.getElementById('confirmOverlay'); - document.getElementById('confirmTitle').textContent = 'Install ' + info.name; - var html = '' + escHtml(info.cmd) + ''; - if (info.alt) { - html += 'or: ' + escHtml(info.alt) + '
'; - } - html += '
' + info.url + ''; - document.getElementById('confirmText').innerHTML = html; - document.getElementById('confirmId').textContent = ''; - document.getElementById('confirmAction').textContent = 'Copy Install Command'; - document.getElementById('confirmAction').className = 'launch-btn btn-primary'; - document.getElementById('confirmAction').onclick = function() { - navigator.clipboard.writeText(info.cmd).then(function() { - showToast('Copied: ' + info.cmd); - }); - closeConfirm(); - }; - if (overlay) overlay.style.display = 'flex'; -} - -// ── Export/Import dialog ────────────────────────────────────── - -function showExportDialog() { - var overlay = document.getElementById('confirmOverlay'); - document.getElementById('confirmTitle').textContent = 'Export / Import Sessions'; - document.getElementById('confirmText').innerHTML = - 'Export all sessions to migrate to another PC:
' + - 'codedash export' + - 'Creates a tar.gz with all Claude & Codex session data.

' + - 'Import on the new machine:
' + - 'codedash import <file.tar.gz>' + - '
Don\'t forget to clone your git repos separately.'; - document.getElementById('confirmId').textContent = ''; - document.getElementById('confirmAction').textContent = 'Copy Export Command'; - document.getElementById('confirmAction').className = 'launch-btn btn-primary'; - document.getElementById('confirmAction').onclick = function() { - navigator.clipboard.writeText('codedash export').then(function() { - showToast('Copied: codedash export'); - }); - closeConfirm(); - }; - if (overlay) overlay.style.display = 'flex'; -} - -// ── Update check ────────────────────────────────────────────── - -async function checkForUpdates() { - try { - var resp = await fetch('/api/version'); - var data = await resp.json(); - var badge = document.getElementById('versionBadge'); - - if (badge) { - badge.textContent = 'v' + data.current; - } - - // Show "what's new" if version changed since last visit - var lastSeenVersion = localStorage.getItem('codedash-last-version'); - if (lastSeenVersion && lastSeenVersion !== data.current) { - showToast('Updated to v' + data.current + ' — check Changelog!'); - } - localStorage.setItem('codedash-last-version', data.current); - - if (data.updateAvailable) { - if (badge) { - badge.textContent = 'v' + data.current + ' → v' + data.latest; - badge.classList.add('update-available'); - badge.title = 'Click to copy update command'; - badge.onclick = function() { - navigator.clipboard.writeText('npm i -g codedash-app@latest').then(function() { - showToast('Copied: npm i -g codedash-app@latest'); - }); - }; - } - var banner = document.getElementById('updateBanner'); - var text = document.getElementById('updateText'); - if (banner && text) { - text.textContent = 'v' + data.latest + ' available — run: npm i -g codedash-app@latest'; - banner.style.display = 'flex'; - banner.dataset.cmd = 'npm i -g codedash-app@latest'; - } - } - } catch {} -} - -function copyUpdate() { - var cmd = 'codedash update && codedash restart'; - navigator.clipboard.writeText(cmd).then(function() { - showToast('Copied: ' + cmd + ' (run in terminal)'); - }); -} - -function dismissUpdate() { - var banner = document.getElementById('updateBanner'); - if (banner) banner.style.display = 'none'; -} - -// ── Initialization ───────────────────────────────────────────── - -(function init() { - // Load data - loadSessions(); - loadTerminals(); - checkForUpdates(); - startActivePolling(); - - // Apply saved theme - var savedTheme = localStorage.getItem('codedash-theme') || 'dark'; - setTheme(savedTheme); - - // Set saved theme in selector - var themeSel = document.getElementById('themeSelect'); - if (themeSel) themeSel.value = savedTheme; - - // Set group button state - var groupBtn = document.getElementById('groupBtn'); - if (groupBtn) groupBtn.classList.toggle('active', grouped); - - // Set AI titles toggle - var aiToggle = document.getElementById('aiTitlesToggle'); - if (aiToggle) aiToggle.checked = showAITitles; -})(); - -// → moved to cloud.js +// ── codedash frontend ────────────────────────────────────────── +// Plain browser JS, no modules, no build step. + +// ── State ────────────────────────────────────────────────────── + +let allSessions = []; +let filteredSessions = []; +let currentView = 'sessions'; // sessions, projects, timeline, activity, starred +let grouped = true; +let layout = localStorage.getItem('codedash-layout') || 'grid'; // 'grid' or 'list' +let groupingMode = normalizeGroupingMode(localStorage.getItem('codedash-grouping-mode')); +let searchQuery = ''; +let toolFilter = null; // null, 'claude', 'codex' +let tagFilter = ''; +let dateFrom = ''; +let dateTo = ''; +let selectMode = false; +let selectedIds = new Set(); +let focusedIndex = -1; +let availableTerminals = []; +let pendingDelete = null; +let activeSessions = {}; // sessionId -> {status, cpu, memoryMB, pid} +let renderLimit = 60; // pagination — render at most this many cards +const RENDER_PAGE_SIZE = 60; + +// Persisted in localStorage +let stars = JSON.parse(localStorage.getItem('codedash-stars') || '[]'); +let tags = JSON.parse(localStorage.getItem('codedash-tags') || '{}'); +let sessionTitles = JSON.parse(localStorage.getItem('codedash-titles') || '{}'); +let showAITitles = localStorage.getItem('codedash-ai-titles') !== 'false'; + +// ── Color palette for projects ───────────────────────────────── + +const PROJECT_COLORS = [ + '#6366f1', '#8b5cf6', '#a855f7', '#d946ef', '#ec4899', + '#f43f5e', '#ef4444', '#f97316', '#eab308', '#84cc16', + '#22c55e', '#14b8a6', '#06b6d4', '#3b82f6', '#2563eb', + '#7c3aed', '#c026d3', '#e11d48', '#ea580c', '#65a30d', +]; +const projectColorMap = {}; +let colorIdx = 0; + +function getProjectColor(project) { + if (!project) return '#6b7280'; + if (!projectColorMap[project]) { + projectColorMap[project] = PROJECT_COLORS[colorIdx % PROJECT_COLORS.length]; + colorIdx++; + } + return projectColorMap[project]; +} + +function getProjectName(fullPath) { + if (!fullPath) return 'unknown'; + const cleaned = fullPath.replace(/\/+$/, ''); + const parts = cleaned.split('/'); + return parts[parts.length - 1] || 'unknown'; +} + +function normalizeGroupingMode(mode) { + return mode === 'repo' ? 'repo' : 'folder'; +} + +function getRepoInfo(fullPath, gitRoot) { + var repoRoot = ''; + if (gitRoot) { + repoRoot = gitRoot.replace(/\/+$/, ''); + } else if (fullPath) { + var cleaned = fullPath.replace(/\/+$/, ''); + var wt = cleaned.match(/^(.*?)\/.claude\/worktrees\//); + var codex = cleaned.match(/^(.*?)\/.codex\//); + repoRoot = wt ? wt[1] : (codex ? codex[1] : cleaned); + } + + var name = repoRoot ? repoRoot.split('/').pop() : 'unknown'; + return { + key: repoRoot || 'unknown', + name: name || 'unknown' + }; +} + +function getGitProjectName(fullPath, gitRoot) { + return getRepoInfo(fullPath, gitRoot).name; +} + +function getSessionGroupInfo(session) { + if (groupingMode === 'repo') { + return getRepoInfo(session.project, session.git_root); + } + var name = getProjectName(session.project); + return { key: name, name: name }; +} + +// ── Utilities ────────────────────────────────────────────────── + +function timeAgo(dateStr) { + if (!dateStr) return ''; + const now = Date.now(); + const ts = typeof dateStr === 'number' ? dateStr : new Date(dateStr).getTime(); + const diff = now - ts; + const mins = Math.floor(diff / 60000); + if (mins < 1) return 'just now'; + if (mins < 60) return mins + 'm ago'; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return hrs + 'h ago'; + const days = Math.floor(hrs / 24); + if (days < 30) return days + 'd ago'; + const months = Math.floor(days / 30); + if (months < 12) return months + 'mo ago'; + return Math.floor(months / 12) + 'y ago'; +} + +function escHtml(s) { + if (!s) return ''; + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function showToast(msg) { + const el = document.getElementById('toast'); + if (!el) return; + el.textContent = msg; + el.classList.add('show'); + setTimeout(() => el.classList.remove('show'), 2500); +} + +function formatBytes(bytes) { + if (!bytes || bytes < 1024) return (bytes || 0) + ' B'; + if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / 1048576).toFixed(1) + ' MB'; +} + +function estimateCost(fileSize) { + if (!fileSize) return 0; + var tokens = fileSize / 4; + // Quick card badge estimate (Sonnet 4.6: $3/M in, $15/M out) + return tokens * 0.3 * (3.0 / 1e6) + tokens * 0.7 * (15.0 / 1e6); +} + +// ── Subscription service plans (pricing as of 2025) ───────────── +var SERVICE_PLANS = { + 'Claude': { label: 'Claude (Anthropic)', plans: [ + { name: 'Pro', price: 20 }, + { name: 'Max 5×', price: 100 }, + { name: 'Max 20×', price: 200 } + ]}, + 'OpenAI': { label: 'OpenAI (ChatGPT)', plans: [ + { name: 'Plus', price: 20 }, + { name: 'Pro', price: 200 } + ]}, + 'Cursor': { label: 'Cursor', plans: [ + { name: 'Pro', price: 20 }, + { name: 'Pro+', price: 60 }, + { name: 'Ultra', price: 200 } + ]}, + 'Kiro': { label: 'Kiro', plans: [ + { name: 'Pro', price: 20 }, + { name: 'Pro+', price: 40 }, + { name: 'Power', price: 200 } + ]}, + 'OpenCode': { label: 'OpenCode', plans: [ + { name: 'Go', price: 10 } + ]} +}; + +function onSubServiceChange() { + var serviceEl = document.getElementById('sub-new-service'); + var planEl = document.getElementById('sub-new-plan'); + var paidEl = document.getElementById('sub-new-paid'); + var service = serviceEl ? serviceEl.value : ''; + if (!planEl) return; + planEl.innerHTML = ''; + paidEl.value = ''; + if (service && SERVICE_PLANS[service]) { + SERVICE_PLANS[service].plans.forEach(function(p) { + var opt = document.createElement('option'); + opt.value = p.name; + opt.textContent = p.name + ' ($' + p.price + '/mo)'; + planEl.appendChild(opt); + }); + } +} + +function onSubPlanChange() { + var serviceEl = document.getElementById('sub-new-service'); + var planEl = document.getElementById('sub-new-plan'); + var paidEl = document.getElementById('sub-new-paid'); + var service = serviceEl ? serviceEl.value : ''; + var planName = planEl ? planEl.value : ''; + if (service && planName && SERVICE_PLANS[service]) { + var found = SERVICE_PLANS[service].plans.find(function(p) { return p.name === planName; }); + if (found && paidEl) paidEl.value = found.price; + } +} + +// ── Subscription config helpers ────────────────────────────────── +function getSubscriptionConfig() { + var raw = JSON.parse(localStorage.getItem('codedash-subscription') || 'null'); + if (!raw) return { entries: [] }; + // Migrate old single-entry format {plan, paid} → new multi-period {entries: [...]} + if (!raw.entries) return { entries: [{ plan: raw.plan || 'Subscription', paid: raw.paid || 0, from: '' }] }; + return raw; +} +function saveSubscriptionConfig(cfg) { localStorage.setItem('codedash-subscription', JSON.stringify(cfg)); } +function subTotalPaid(entries) { return entries.reduce(function(s,e){return s+(parseFloat(e.paid)||0);},0); } +function addSubEntry() { + var service = (document.getElementById('sub-new-service').value || '').trim(); + var planEl = document.getElementById('sub-new-plan'); + var plan = planEl ? planEl.value.trim() : ''; + var paid = parseFloat(document.getElementById('sub-new-paid').value) || 0; + var from = (document.getElementById('sub-new-from').value || '').trim(); + if (!paid) return; + var cfg = getSubscriptionConfig(); + cfg.entries.push({ service: service || '', plan: plan || 'Subscription', paid: paid, from: from }); + cfg.entries.sort(function(a,b){return (a.from||'').localeCompare(b.from||'');}); + saveSubscriptionConfig(cfg); + render(); +} +function removeSubEntry(idx) { + var cfg = getSubscriptionConfig(); + cfg.entries.splice(idx, 1); + saveSubscriptionConfig(cfg); + render(); +} + +async function loadRealCost(sessionId, project) { + try { + var resp = await fetch('/api/cost/' + sessionId + '?project=' + encodeURIComponent(project)); + return await resp.json(); + } catch (e) { return null; } +} + +// ── Tag system ───────────────────────────────────────────────── + +const TAG_OPTIONS = ['bug', 'feature', 'research', 'infra', 'deploy', 'review']; + +function showTagDropdown(event, sessionId) { + event.stopPropagation(); + document.querySelectorAll('.tag-dropdown').forEach(function(el) { el.remove(); }); + var dd = document.createElement('div'); + dd.className = 'tag-dropdown'; + var existingTags = tags[sessionId] || []; + dd.innerHTML = TAG_OPTIONS.map(function(t) { + var has = existingTags.indexOf(t) >= 0; + return '
' + + (has ? '✓ ' : '') + t + '
'; + }).join(''); + + // Position near the button + var rect = event.target.getBoundingClientRect(); + dd.style.top = (rect.bottom + 4) + 'px'; + dd.style.left = rect.left + 'px'; + + document.body.appendChild(dd); + setTimeout(function() { + document.addEventListener('click', function() { dd.remove(); }, { once: true }); + }, 0); +} + +function addTag(sessionId, tag) { + if (!tags[sessionId]) tags[sessionId] = []; + if (!tags[sessionId].includes(tag)) tags[sessionId].push(tag); + localStorage.setItem('codedash-tags', JSON.stringify(tags)); + document.querySelectorAll('.tag-dropdown').forEach(function(el) { el.remove(); }); + render(); +} + +function removeTag(sessionId, tag) { + if (tags[sessionId]) { + tags[sessionId] = tags[sessionId].filter(function(t) { return t !== tag; }); + if (!tags[sessionId].length) delete tags[sessionId]; + localStorage.setItem('codedash-tags', JSON.stringify(tags)); + render(); + } +} + +// ── Stars ────────────────────────────────────────────────────── + +function toggleStar(id) { + var idx = stars.indexOf(id); + if (idx >= 0) stars.splice(idx, 1); + else stars.push(id); + localStorage.setItem('codedash-stars', JSON.stringify(stars)); + render(); +} + +// ── AI Titles ───────────────────────────────────────────────── + +function toggleAITitles(checked) { + showAITitles = checked; + localStorage.setItem('codedash-ai-titles', checked ? 'true' : 'false'); + render(); +} + +function saveGroupingMode(mode) { + groupingMode = normalizeGroupingMode(mode); + localStorage.setItem('codedash-grouping-mode', groupingMode); + render(); +} + +function loadLLMSettings() { + fetch('/api/llm-config').then(function(r) { return r.json(); }).then(function(c) { + var u = document.getElementById('llmUrl'); + var k = document.getElementById('llmApiKey'); + var m = document.getElementById('llmModel'); + if (u) u.value = c.url || ''; + if (k) k.value = c.apiKey || ''; + if (m) m.value = c.model || ''; + }); +} + +function saveLLMSettings() { + var config = { + url: document.getElementById('llmUrl').value.trim(), + apiKey: document.getElementById('llmApiKey').value.trim(), + model: document.getElementById('llmModel').value.trim(), + }; + fetch('/api/llm-config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config), + }).then(function() { + showToast('LLM settings saved'); + }); +} + +function testLLMConnection() { + // Generate title for the first available session as a test + var testSession = allSessions.find(function(s) { return s.has_detail && s.messages > 2; }); + if (!testSession) { showToast('No sessions to test with'); return; } + showToast('Testing LLM connection...'); + fetch('/api/generate-title', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId: testSession.id, project: testSession.project }), + }).then(function(r) { return r.json(); }).then(function(d) { + if (d.ok) { + showToast('OK: "' + d.title + '"'); + } else { + showToast('Error: ' + d.error); + } + }).catch(function(e) { showToast('Connection failed: ' + e.message); }); +} + +function generateTitle(sessionId, project) { + fetch('/api/generate-title', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId: sessionId, project: project }), + }).then(function(r) { return r.json(); }).then(function(d) { + if (d.ok && d.title) { + sessionTitles[sessionId] = d.title; + localStorage.setItem('codedash-titles', JSON.stringify(sessionTitles)); + render(); + } else { + showToast('Title generation failed: ' + (d.error || 'unknown')); + } + }).catch(function(e) { showToast('Error: ' + e.message); }); +} + +function generateAllTitles() { + var sessions = filteredSessions.filter(function(s) { + return s.has_detail && s.messages > 2 && !sessionTitles[s.id]; + }).slice(0, 20); // batch of 20 + if (!sessions.length) { showToast('All sessions already have titles'); return; } + showToast('Generating titles for ' + sessions.length + ' sessions...'); + var done = 0; + sessions.forEach(function(s) { + fetch('/api/generate-title', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId: s.id, project: s.project }), + }).then(function(r) { return r.json(); }).then(function(d) { + done++; + if (d.ok && d.title) { + sessionTitles[s.id] = d.title; + localStorage.setItem('codedash-titles', JSON.stringify(sessionTitles)); + } + if (done === sessions.length) { + render(); + showToast('Generated ' + done + ' titles'); + } + }).catch(function() { done++; }); + }); +} + +// ── Data loading ─────────────────────────────────────────────── + +async function loadSessions() { + try { + var resp = await fetch('/api/sessions'); + allSessions = await resp.json(); + applyFilters(); + // Progressive loading: if server is still loading cursor vscdb sessions, auto-refresh + if (resp.headers.get('X-Loading') === '1') { + setTimeout(loadSessions, 2000); + } + } catch (e) { + document.getElementById('content').innerHTML = '
Failed to load sessions. Is the server running?
'; + } +} + +function refreshData() { + loadSessions(); + showToast('Refreshed'); +} + +async function loadTerminals() { + try { + var resp = await fetch('/api/terminals'); + availableTerminals = await resp.json(); + var sel = document.getElementById('terminalSelect'); + if (!sel) return; + sel.innerHTML = ''; + var saved = localStorage.getItem('codedash-terminal') || ''; + availableTerminals.forEach(function(t) { + if (!t.available) return; + var opt = document.createElement('option'); + opt.value = t.id; + opt.textContent = t.name; + if (t.id === saved) opt.selected = true; + sel.appendChild(opt); + }); + if (!saved && availableTerminals.length > 0) { + var first = availableTerminals.find(function(t) { return t.available; }); + if (first) sel.value = first.id; + } + } catch (e) { + // terminals not available + } +} + +function saveTerminalPref(val) { + localStorage.setItem('codedash-terminal', val); +} + +// ── Active sessions polling ─────────────────────────────────── + +var _prevActiveKey = ''; + +async function pollActiveSessions() { + try { + var resp = await fetch('/api/active'); + var data = await resp.json(); + + // Build new state + var newActive = {}; + data.forEach(function(a) { + if (a.sessionId) newActive[a.sessionId] = a; + }); + + // Check if anything changed — skip DOM work if not + var newKey = data.map(function(a) { return (a.sessionId || a.pid) + ':' + a.status; }).sort().join(','); + if (newKey === _prevActiveKey) return; + _prevActiveKey = newKey; + + activeSessions = newActive; + + // Only touch cards that changed + document.querySelectorAll('.card').forEach(function(card) { + var id = card.getAttribute('data-id'); + var existing = card.querySelector('.live-badge'); + var parent = card.parentElement; + var wasActive = parent && parent.classList.contains('card-live-wrap'); + var isActive = !!activeSessions[id]; + + // No change — skip + if (!wasActive && !isActive && !existing) return; + + // Remove old badge + if (existing) existing.remove(); + + // Remove wrapper if no longer active + if (wasActive && !isActive) { + parent.replaceWith(card); + card.style.border = ''; + return; + } + + if (isActive) { + var a = activeSessions[id]; + + // Add badge + var badge = document.createElement('span'); + badge.className = 'live-badge live-' + a.status; + badge.textContent = a.status === 'waiting' ? 'WAITING' : 'LIVE'; + badge.title = 'PID ' + a.pid + ' | CPU ' + a.cpu.toFixed(1) + '% | ' + a.memoryMB + 'MB'; + var top = card.querySelector('.card-top'); + if (top) top.insertBefore(badge, top.firstChild); + + // Wrapper + if (wasActive) { + parent.className = 'card-live-wrap' + (a.status === 'waiting' ? ' live-waiting' : ''); + parent.style.setProperty('--live-color', a.status === 'waiting' + ? 'rgba(251, 191, 36, 0.5)' : 'rgba(74, 222, 128, 0.7)'); + } else { + var wrap = document.createElement('div'); + wrap.className = 'card-live-wrap' + (a.status === 'waiting' ? ' live-waiting' : ''); + wrap.style.setProperty('--live-color', a.status === 'waiting' + ? 'rgba(251, 191, 36, 0.5)' : 'rgba(74, 222, 128, 0.7)'); + var borderDiv = document.createElement('div'); + borderDiv.className = 'live-border'; + card.parentNode.insertBefore(wrap, card); + wrap.appendChild(borderDiv); + wrap.appendChild(card); + } + } + }); + } catch {} +} + +var activeInterval = null; +function startActivePolling() { + pollActiveSessions(); + activeInterval = setInterval(pollActiveSessions, 5000); +} +function stopActivePolling() { + if (activeInterval) clearInterval(activeInterval); +} + +// ── Trigram search ───────────────────────────────────────────── + +function trigrams(str) { + var s = ' ' + str.toLowerCase() + ' '; + var t = {}; + for (var i = 0; i < s.length - 2; i++) { + var tri = s.substring(i, i + 3); + t[tri] = (t[tri] || 0) + 1; + } + return t; +} + +function trigramScore(query, text) { + if (!query || !text) return 0; + var qt = trigrams(query); + var tt = trigrams(text); + var matches = 0; + var total = 0; + for (var k in qt) { + total += qt[k]; + if (tt[k]) matches += Math.min(qt[k], tt[k]); + } + return total > 0 ? matches / total : 0; +} + +function searchScore(query, session) { + var q = query.toLowerCase(); + var fields = [ + session.first_message || '', + session.project_short || '', + session.project || '', + session.id || '', + session.tool || '' + ]; + var haystack = fields.join(' ').toLowerCase(); + + // Exact substring match = highest score + if (haystack.indexOf(q) >= 0) return 1; + + // Trigram fuzzy match + var best = 0; + for (var i = 0; i < fields.length; i++) { + var score = trigramScore(q, fields[i]); + if (score > best) best = score; + } + // Also score against full haystack + var fullScore = trigramScore(q, haystack); + if (fullScore > best) best = fullScore; + + return best; +} + +// ── Filtering ────────────────────────────────────────────────── + +var SEARCH_THRESHOLD = 0.3; + +function applyFilters() { + renderLimit = RENDER_PAGE_SIZE; // reset pagination on filter change + var scored = []; + for (var i = 0; i < allSessions.length; i++) { + var s = allSessions[i]; + + // Tool filter + if (toolFilter) { + var toolMatch = s.tool === toolFilter || (s.tool === 'claude-ext' && toolFilter === 'claude'); + if (!toolMatch) continue; + } + + // Tag filter + if (tagFilter) { + var sessionTags = tags[s.id] || []; + if (sessionTags.indexOf(tagFilter) === -1) continue; + } + + // Date range + if (dateFrom && s.date < dateFrom) continue; + if (dateTo && s.date > dateTo) continue; + + // Search with trigram scoring + var score = 1; + if (searchQuery) { + score = searchScore(searchQuery, s); + if (score < SEARCH_THRESHOLD) continue; + } + + scored.push({ session: s, score: score }); + } + + // Sort: starred first, then by search score (if searching), then by time + scored.sort(function(a, b) { + var aStarred = stars.indexOf(a.session.id) >= 0 ? 1 : 0; + var bStarred = stars.indexOf(b.session.id) >= 0 ? 1 : 0; + if (aStarred !== bStarred) return bStarred - aStarred; + if (searchQuery && a.score !== b.score) return b.score - a.score; + return b.session.last_ts - a.session.last_ts; + }); + + filteredSessions = scored.map(function(x) { return x.session; }); + + render(); + +} + +function onSearch(val) { + searchQuery = val; + applyFilters(); + + // Trigger deep search after debounce + clearTimeout(deepSearchTimeout); + if (val && val.length >= 3) { + deepSearchTimeout = setTimeout(function() { deepSearch(val); }, 600); + } +} + +function onTagFilter(val) { + tagFilter = val; + applyFilters(); +} + +function onDateFilter() { + applyFilters(); + updateDateBtn(); +} + +// → moved to calendar.js + +// ── Rendering: Card ──────────────────────────────────────────── + +function renderCard(s, idx) { + var isStarred = stars.indexOf(s.id) >= 0; + var isSelected = selectedIds.has(s.id); + var isFocused = focusedIndex === idx; + var sessionTags = tags[s.id] || []; + var cost = estimateCost(s.file_size); + var costStr = cost > 0 ? '~$' + cost.toFixed(2) : ''; + var projName = getProjectName(s.project); + var projColor = getProjectColor(projName); + var toolClass = 'tool-' + s.tool; + var toolLabel = s.tool === 'claude-ext' ? 'claude ext' : s.tool; + + var classes = 'card'; + if (isSelected) classes += ' selected'; + if (isFocused) classes += ' focused'; + + var checkboxStyle = selectMode ? 'display:inline-block' : ''; + + var tagHtml = sessionTags.map(function(t) { + return '' + escHtml(t) + ' ×'; + }).join(''); + + var html = '
'; + html += '
'; + html += ''; + html += '' + escHtml(toolLabel) + ''; + html += '' + escHtml(projName) + ''; + html += '' + timeAgo(s.last_ts) + ''; + if (costStr) { + html += '' + costStr + ''; + } + html += ''; + if (cloudUnlocked) { + var inCloud = cloudSessionIds.has(s.id); + html += ''; + } + html += '
'; + var aiTitle = showAITitles && sessionTitles[s.id]; + if (aiTitle) { + html += '
' + escHtml(aiTitle) + '
'; + html += '
' + escHtml((s.first_message || '').slice(0, 80)) + '
'; + } else { + html += '
' + escHtml((s.first_message || '').slice(0, 120)) + '
'; + } + html += ''; + // MCP/Skills footer + if ((s.mcp_servers && s.mcp_servers.length > 0) || (s.skills && s.skills.length > 0)) { + html += '
'; + if (s.mcp_servers) { + s.mcp_servers.forEach(function(m) { + html += '' + escHtml(m) + ''; + }); + } + if (s.skills) { + s.skills.forEach(function(sk) { + html += '' + escHtml(sk) + ''; + }); + } + html += '
'; + } + // Expandable preview area (hidden by default) + html += '
'; + html += '
'; + return html; +} + +function toggleLayout() { + layout = layout === 'grid' ? 'list' : 'grid'; + localStorage.setItem('codedash-layout', layout); + var btn = document.getElementById('layoutBtn'); + if (btn) btn.classList.toggle('active', layout === 'list'); + var icon = document.getElementById('layoutIcon'); + if (icon) { + icon.innerHTML = layout === 'list' + ? '' + : ''; + } + render(); +} + +function renderListCard(s, idx) { + var isStarred = stars.indexOf(s.id) >= 0; + var isSelected = selectedIds.has(s.id); + var isFocused = focusedIndex === idx; + var projName = getProjectName(s.project); + var projColor = getProjectColor(projName); + + var classes = 'list-row'; + if (isSelected) classes += ' selected'; + if (isFocused) classes += ' focused'; + + var html = '
'; + var listToolLabel = s.tool === 'claude-ext' ? 'claude ext' : s.tool; + html += '' + escHtml(listToolLabel) + ''; + if (s.mcp_servers && s.mcp_servers.length > 0) { + s.mcp_servers.forEach(function(m) { + html += '' + escHtml(m) + ''; + }); + } + if (s.skills && s.skills.length > 0) { + s.skills.forEach(function(sk) { + html += '' + escHtml(sk) + ''; + }); + } + html += '' + escHtml(projName) + ''; + html += '' + escHtml((s.first_message || '').slice(0, 80)) + ''; + html += '' + s.messages + ' msgs'; + html += '' + timeAgo(s.last_ts) + ''; + html += ''; + html += '
'; + return html; +} + +// ── Card expand (inline preview) ────────────────────────────── + +async function toggleExpand(sessionId, project, btn) { + var area = document.getElementById('preview-' + sessionId); + if (!area) return; + + if (area.classList.contains('open')) { + area.classList.remove('open'); + area.innerHTML = ''; + btn.innerHTML = '▾'; + return; + } + + btn.innerHTML = '⌛'; + area.innerHTML = '
Loading...
'; + area.classList.add('open'); + + try { + var resp = await fetch('/api/preview/' + sessionId + '?project=' + encodeURIComponent(project) + '&limit=10'); + var messages = await resp.json(); + + if (messages.length === 0) { + area.innerHTML = '
No messages
'; + } else { + var html = ''; + messages.forEach(function(m) { + var cls = m.role === 'user' ? 'preview-user' : 'preview-assistant'; + var label = m.role === 'user' ? 'You' : 'AI'; + html += '
'; + html += '' + label + ' '; + var text = m.content.length > 500 ? m.content.slice(0, 500) + '...' : m.content; + html += escHtml(text); + html += '
'; + }); + area.innerHTML = html; + } + btn.innerHTML = '▴'; + } catch (e) { + area.innerHTML = '
Failed to load
'; + btn.innerHTML = '▾'; + } +} + + +// ── Deep search (full-text across session content) ──────────── + +var deepSearchCache = {}; +var deepSearchTimeout = null; + +async function deepSearch(query) { + if (!query || query.length < 3) return; + if (deepSearchCache[query]) { + applyDeepSearchResults(deepSearchCache[query]); + return; + } + + try { + var resp = await fetch('/api/search?q=' + encodeURIComponent(query)); + var results = await resp.json(); + deepSearchCache[query] = results; + applyDeepSearchResults(results); + } catch {} +} + +function applyDeepSearchResults(results) { + if (!results || results.length === 0) return; + + // Highlight matching session IDs in filtered list + var matchIds = results.map(function(r) { return r.sessionId; }); + + // Boost matching sessions to top if not already visible + var boosted = []; + var rest = []; + filteredSessions.forEach(function(s) { + if (matchIds.indexOf(s.id) >= 0) { + s._deepMatch = results.find(function(r) { return r.sessionId === s.id; }); + boosted.push(s); + } else { + rest.push(s); + } + }); + + // Also add sessions that weren't in filteredSessions but match + matchIds.forEach(function(id) { + if (!boosted.find(function(s) { return s.id === id; }) && !rest.find(function(s) { return s.id === id; })) { + var s = allSessions.find(function(x) { return x.id === id; }); + if (s) { + s._deepMatch = results.find(function(r) { return r.sessionId === id; }); + boosted.push(s); + } + } + }); + + filteredSessions = boosted.concat(rest); + render(); + + // Show deep search indicator + var stats = document.getElementById('stats'); + if (stats && boosted.length > 0) { + stats.textContent += ' | ' + boosted.length + ' deep matches'; + } +} + +function onCardClick(id, event) { + if (selectMode) { + toggleSelect(id, event); + } else { + var s = allSessions.find(function(x) { return x.id === id; }); + if (s) openDetail(s); + } +} + +// ── Rendering: Main ──────────────────────────────────────────── + +function render() { + var content = document.getElementById('content'); + var stats = document.getElementById('stats'); + if (!content) return; + + var sessions = filteredSessions; + + // Stats + if (stats) { + stats.textContent = sessions.length + ' sessions' + + (toolFilter ? ' (' + toolFilter + ')' : '') + + (tagFilter ? ' [' + tagFilter + ']' : ''); + } + + // Route to view + if (currentView === 'activity') { + renderHeatmap(content); + return; + } + + if (currentView === 'analytics') { + renderAnalytics(content); + return; + } + + if (currentView === 'changelog') { + renderChangelog(content); + return; + } + + if (currentView === 'leaderboard') { + renderLeaderboard(content); + return; + } + + if (currentView === 'cloud') { + renderCloud(content); + return; + } + + if (currentView === 'settings') { + renderSettings(content); + return; + } + + if (currentView === 'running') { + renderRunning(content, sessions); + return; + } + + if (currentView === 'starred') { + var starredSessions = sessions.filter(function(s) { return stars.indexOf(s.id) >= 0; }); + if (starredSessions.length === 0) { + content.innerHTML = '
No starred sessions. Click the star on any session to bookmark it.
'; + return; + } + var idx = 0; + content.innerHTML = starredSessions.map(function(s) { return renderCard(s, idx++); }).join(''); + return; + } + + if (currentView === 'timeline') { + renderTimeline(content, sessions); + return; + } + + if (currentView === 'projects') { + renderProjects(content, sessions); + return; + } + + // Default: sessions view + if (sessions.length === 0) { + content.innerHTML = '
No sessions found.' + + (searchQuery ? ' Try a different search.' : '') + '
'; + return; + } + + var renderFn = layout === 'list' ? renderListCard : renderCard; + var visible = sessions.slice(0, renderLimit); + var hasMore = sessions.length > renderLimit; + + if (grouped) { + renderGrouped(content, visible, renderFn); + } else { + var idx2 = 0; + var wrapClass = layout === 'list' ? 'list-view' : 'grid-view'; + content.innerHTML = '
' + visible.map(function(s) { return renderFn(s, idx2++); }).join('') + '
'; + } + + if (hasMore) { + content.innerHTML += '
'; + } +} + +function loadMoreCards() { + renderLimit += RENDER_PAGE_SIZE; + render(); +} + +function renderGrouped(container, sessions, renderFn) { + renderFn = renderFn || renderCard; + var groups = {}; + sessions.forEach(function(s) { + var group = getSessionGroupInfo(s); + if (!groups[group.key]) groups[group.key] = { name: group.name, sessions: [] }; + groups[group.key].sessions.push(s); + }); + + var sortedKeys = Object.keys(groups).sort(function(a, b) { + return groups[b].sessions[0].last_ts - groups[a].sessions[0].last_ts; + }); + + var globalIdx = 0; + var html = ''; + sortedKeys.forEach(function(key) { + var group = groups[key]; + var color = getProjectColor(key); + html += '
'; + html += '
'; + html += ''; + html += '' + escHtml(group.name) + ''; + html += '' + group.sessions.length + ''; + html += ''; + html += '
'; + var bodyClass = layout === 'list' ? 'group-body group-body-list' : 'group-body'; + html += '
'; + group.sessions.forEach(function(s) { + html += renderFn(s, globalIdx++); + }); + html += '
'; + }); + container.innerHTML = html; +} + +function renderTimeline(container, sessions) { + // Group by date + var byDate = {}; + sessions.forEach(function(s) { + var d = s.date || 'unknown'; + if (!byDate[d]) byDate[d] = []; + byDate[d].push(s); + }); + + var dates = Object.keys(byDate).sort().reverse(); + if (dates.length === 0) { + container.innerHTML = '
No sessions to display in timeline.
'; + return; + } + + var renderFn = layout === 'list' ? renderListCard : renderCard; + var globalIdx = 0; + var html = '
'; + dates.forEach(function(d) { + html += '
'; + html += '
' + escHtml(d) + ' ' + byDate[d].length + ' sessions
'; + var wrapClass = layout === 'list' ? 'list-view' : 'grid-view'; + html += '
'; + byDate[d].forEach(function(s) { + html += renderFn(s, globalIdx++); + }); + html += '
'; + }); + html += '
'; + container.innerHTML = html; +} + +function renderQACard(s, idx) { + var isStarred = stars.indexOf(s.id) >= 0; + var toolLabel = s.tool === 'claude-ext' ? 'claude ext' : s.tool; + var toolClass = 'tool-' + s.tool; + var cost = estimateCost(s.file_size); + var costStr = cost > 0 ? '~$' + cost.toFixed(2) : ''; + var classes = 'qa-item' + (selectedIds.has(s.id) ? ' selected' : ''); + + var html = '
'; + html += '' + escHtml(toolLabel) + ''; + html += '' + escHtml((s.first_message || '').slice(0, 160)) + ''; + html += ''; + html += '' + s.messages + ' msgs'; + if (costStr) html += '' + costStr + ''; + html += '' + timeAgo(s.last_ts) + ''; + html += ''; + html += ''; + html += '
'; + return html; +} + +function renderProjects(container, sessions) { + var byGit = {}; + sessions.forEach(function(s) { + var name = getGitProjectName(s.project, s.git_root); + if (!byGit[name]) byGit[name] = []; + byGit[name].push(s); + }); + + var sorted = Object.entries(byGit).sort(function(a, b) { + return b[1][0].last_ts - a[1][0].last_ts; + }); + + if (sorted.length === 0) { + container.innerHTML = '
No projects found.
'; + return; + } + + var globalIdx = 0; + var html = '
'; + sorted.forEach(function(entry) { + var name = entry[0]; + var list = entry[1].slice().sort(function(a, b) { return b.last_ts - a.last_ts; }); + var color = getProjectColor(name); + var totalMsgs = list.reduce(function(s, e) { return s + (e.messages || 0); }, 0); + var totalCost = list.reduce(function(s, e) { return s + estimateCost(e.file_size); }, 0); + var costLabel = totalCost > 0 ? ' · ~$' + totalCost.toFixed(2) : ''; + + html += '
'; + html += '
'; + html += ''; + html += '' + escHtml(name) + ''; + html += '' + list.length + ' sessions · ' + totalMsgs + ' msgs' + escHtml(costLabel) + ''; + html += ''; + html += '
'; + html += '
'; + list.forEach(function(s) { html += renderQACard(s, globalIdx++); }); + html += '
'; + html += '
'; + }); + html += '
'; + container.innerHTML = html; +} + +// → moved to heatmap.js + +// → moved to detail.js + +// ── Delete ───────────────────────────────────────────────────── + +function showDeleteConfirm(sessionId, project) { + pendingDelete = { id: sessionId, project: project }; + var overlay = document.getElementById('confirmOverlay'); + if (overlay) overlay.style.display = 'flex'; + document.getElementById('confirmTitle').textContent = 'Delete Session?'; + document.getElementById('confirmText').textContent = 'This will permanently delete the session file, history entries, and env data.'; + document.getElementById('confirmId').textContent = sessionId; + var btn = document.getElementById('confirmAction'); + btn.textContent = 'Delete'; + btn.className = 'btn-delete'; + btn.onclick = function() { confirmDelete(); }; +} + +function closeConfirm() { + pendingDelete = null; + var overlay = document.getElementById('confirmOverlay'); + if (overlay) overlay.style.display = 'none'; +} + +async function confirmDelete() { + if (!pendingDelete) return; + try { + var resp = await fetch('/api/session/' + pendingDelete.id, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ project: pendingDelete.project }) + }); + var data = await resp.json(); + if (data.ok) { + showToast('Session deleted'); + allSessions = allSessions.filter(function(s) { return s.id !== pendingDelete.id; }); + // Clear search if no more results + if (searchQuery) { + var remaining = allSessions.filter(function(s) { + return (s.project || '').toLowerCase().indexOf(searchQuery.toLowerCase()) >= 0 || + (s.first_message || '').toLowerCase().indexOf(searchQuery.toLowerCase()) >= 0; + }); + if (remaining.length === 0) { + searchQuery = ''; + document.querySelector('.search-box').value = ''; + } + } + closeConfirm(); + closeDetail(); + applyFilters(); + } else { + showToast('Delete failed: ' + (data.error || 'unknown')); + } + } catch (e) { + showToast('Delete failed'); + } + closeConfirm(); +} + +// ── Bulk actions ─────────────────────────────────────────────── + +function toggleSelectMode() { + selectMode = !selectMode; + if (!selectMode) selectedIds.clear(); + var btn = document.getElementById('selectBtn'); + if (btn) btn.classList.toggle('active', selectMode); + var content = document.getElementById('content'); + if (content) content.classList.toggle('select-mode', selectMode); + updateBulkBar(); + render(); +} + +function toggleSelect(id, event) { + if (event) event.stopPropagation(); + if (selectedIds.has(id)) selectedIds.delete(id); + else selectedIds.add(id); + updateBulkBar(); + render(); +} + +function updateBulkBar() { + var bar = document.getElementById('bulkBar'); + if (!bar) return; + if (selectedIds.size > 0) { + bar.style.display = 'flex'; + document.getElementById('bulkCount').textContent = selectedIds.size + ' selected'; + } else { + bar.style.display = 'none'; + } +} + +function clearSelection() { + selectedIds.clear(); + selectMode = false; + var btn = document.getElementById('selectBtn'); + if (btn) btn.classList.remove('active'); + updateBulkBar(); + render(); +} + +async function bulkDelete() { + if (!confirm('Delete ' + selectedIds.size + ' sessions? This cannot be undone.')) return; + var sessions = []; + selectedIds.forEach(function(id) { + var s = allSessions.find(function(x) { return x.id === id; }); + sessions.push({ id: id, project: s ? s.project : '' }); + }); + try { + var resp = await fetch('/api/bulk-delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessions: sessions }) + }); + var data = await resp.json(); + if (data.ok) { + showToast('Deleted ' + sessions.length + ' sessions'); + allSessions = allSessions.filter(function(s) { return !selectedIds.has(s.id); }); + clearSelection(); + applyFilters(); + } + } catch (e) { + showToast('Bulk delete failed'); + } +} + +// ── Project actions ──────────────────────────────────────────── + +function openProject(name) { + currentView = 'sessions'; + searchQuery = name; + document.querySelector('.search-box').value = name; + document.querySelectorAll('.sidebar-item').forEach(function(el) { + el.classList.toggle('active', el.getAttribute('data-view') === 'sessions'); + }); + applyFilters(); +} + +// ── Themes ───────────────────────────────────────────────────── + +function setTheme(theme) { + if (theme === 'dark') { + document.body.removeAttribute('data-theme'); + } else if (theme === 'system') { + var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + if (prefersDark) { + document.body.removeAttribute('data-theme'); + } else { + document.body.setAttribute('data-theme', 'light'); + } + } else { + document.body.setAttribute('data-theme', theme); + } + localStorage.setItem('codedash-theme', theme); +} + +function saveThemePref(val) { + setTheme(val); +} + +// ── Keyboard navigation ──────────────────────────────────────── + +function isInput(e) { + var tag = document.activeElement ? document.activeElement.tagName : ''; + return ['INPUT', 'SELECT', 'TEXTAREA'].indexOf(tag) >= 0; +} + +function moveFocus(delta) { + var cards = document.querySelectorAll('.card'); + if (cards.length === 0) return; + focusedIndex = Math.max(0, Math.min(cards.length - 1, focusedIndex + delta)); + cards.forEach(function(c, i) { + c.classList.toggle('focused', i === focusedIndex); + }); + if (cards[focusedIndex]) { + cards[focusedIndex].scrollIntoView({ block: 'nearest' }); + } +} + +function openFocusedCard() { + var cards = document.querySelectorAll('.card'); + if (focusedIndex < 0 || focusedIndex >= cards.length) return; + var id = cards[focusedIndex].getAttribute('data-id'); + if (!id) return; + var s = allSessions.find(function(x) { return x.id === id; }); + if (s) { + if (selectMode) { + toggleSelect(id); + } else { + openDetail(s); + } + } +} + +function toggleStarFocused() { + var cards = document.querySelectorAll('.card'); + if (focusedIndex < 0 || focusedIndex >= cards.length) return; + var id = cards[focusedIndex].getAttribute('data-id'); + if (id) toggleStar(id); +} + +function deleteFocused() { + var cards = document.querySelectorAll('.card'); + if (focusedIndex < 0 || focusedIndex >= cards.length) return; + var id = cards[focusedIndex].getAttribute('data-id'); + if (!id) return; + var s = allSessions.find(function(x) { return x.id === id; }); + if (s) showDeleteConfirm(s.id, s.project || ''); +} + +document.addEventListener('keydown', function(e) { + if (e.key === 'Escape') { + if (pendingDelete) { + closeConfirm(); + } else { + closeDetail(); + } + return; + } + if (e.key === '/' && !isInput(e)) { + e.preventDefault(); + var searchBox = document.querySelector('.search-box'); + if (searchBox) searchBox.focus(); + return; + } + if (e.key === 'j' && !isInput(e)) { + e.preventDefault(); + moveFocus(1); + return; + } + if (e.key === 'k' && !isInput(e)) { + e.preventDefault(); + moveFocus(-1); + return; + } + if (e.key === 'Enter' && !isInput(e) && focusedIndex >= 0) { + e.preventDefault(); + openFocusedCard(); + return; + } + if (e.key === 'x' && !isInput(e) && focusedIndex >= 0) { + e.preventDefault(); + toggleStarFocused(); + return; + } + if (e.key === 'd' && !isInput(e) && focusedIndex >= 0) { + e.preventDefault(); + deleteFocused(); + return; + } + if (e.key === 'r' && !isInput(e)) { + e.preventDefault(); + refreshData(); + return; + } + if (e.key === 'g' && !isInput(e)) { + e.preventDefault(); + toggleGroup(); + return; + } + if (e.key === 's' && !isInput(e)) { + e.preventDefault(); + toggleSelectMode(); + return; + } +}); + +// ── Running Sessions View (Kanban) ───────────────────────────── + +function renderRunningCard(a, s) { + var projName = s ? getProjectName(s.project) : (a.cwd ? a.cwd.split('/').pop() : 'unknown'); + var projColor = getProjectColor(projName); + var statusClass = a.status === 'waiting' ? 'running-waiting' : 'running-active'; + var uptime = a.startedAt ? formatDuration(Date.now() - a.startedAt) : ''; + var sid = a.sessionId; + + var html = '
'; + html += '
'; + html += '' + (a.status === 'waiting' ? 'WAITING' : 'LIVE') + ''; + html += '' + escHtml(projName) + ''; + html += '' + escHtml(a.entrypoint || a.kind || 'claude') + ''; + html += '
'; + html += '
'; + html += '
' + a.cpu.toFixed(1) + '%CPU
'; + html += '
' + a.memoryMB + 'MBMEM
'; + if (uptime) html += '
' + uptime + 'Uptime
'; + html += '
'; + if (s && s.first_message) html += '
' + escHtml(s.first_message.slice(0, 120)) + '
'; + html += '
'; + html += ''; + if (s) { + html += ''; + html += ''; + } + html += '
'; + html += '
'; + return html; +} + +function renderDoneCard(s) { + var projName = getProjectName(s.project); + var projColor = getProjectColor(projName); + var html = '
'; + html += '
'; + html += 'DONE'; + html += '' + escHtml(projName) + ''; + html += '' + escHtml(s.tool || 'claude') + ''; + html += '
'; + if (s.first_message) html += '
' + escHtml(s.first_message.slice(0, 120)) + '
'; + html += '
'; + html += '
' + (s.messages || 0) + 'msgs
'; + if (s.last_time) html += '
' + s.last_time.slice(11) + 'ended
'; + html += '
'; + html += '
'; + html += ''; + html += '
'; + html += '
'; + return html; +} + +function renderRunning(container, sessions) { + var allActiveIds = Object.keys(activeSessions); + var running = allActiveIds.filter(function(sid) { return activeSessions[sid].status !== 'waiting'; }); + var waiting = allActiveIds.filter(function(sid) { return activeSessions[sid].status === 'waiting'; }); + var cutoff = Date.now() - 4 * 3600 * 1000; + var done = sessions.filter(function(s) { + return !activeSessions[s.id] && s.last_ts >= cutoff; + }).slice(0, 8); + + if (allActiveIds.length === 0 && done.length === 0) { + container.innerHTML = '
No running sessions detected.
Start a Claude Code or Codex session and it will appear here.
'; + return; + } + + var html = '
'; + html += '

Agent Board

'; + html += '
'; + + // ── Running column ────────────────────────────────────────── + html += '
'; + html += '
Running' + running.length + '
'; + if (running.length === 0) { + html += '
No active sessions
'; + } else { + running.forEach(function(sid) { + var a = activeSessions[sid]; + var s = allSessions.find(function(x) { return x.id === sid; }); + html += renderRunningCard(a, s); + }); + } + html += '
'; + + // ── Waiting column ────────────────────────────────────────── + html += '
'; + html += '
Waiting for input' + waiting.length + '
'; + if (waiting.length === 0) { + html += '
No sessions waiting
'; + } else { + waiting.forEach(function(sid) { + var a = activeSessions[sid]; + var s = allSessions.find(function(x) { return x.id === sid; }); + html += renderRunningCard(a, s); + }); + } + html += '
'; + + // ── Done column ───────────────────────────────────────────── + html += '
'; + html += '
Done (last 4h)' + done.length + '
'; + if (done.length === 0) { + html += '
No recent sessions
'; + } else { + done.forEach(function(s) { html += renderDoneCard(s); }); + } + html += '
'; + + html += '
'; // kanban-board + html += '
'; // running-container + container.innerHTML = html; +} + +// → moved to detail.js (Session Replay) + +// → moved to analytics.js + +// ── Focus active session (switch to terminal) ───────────────── + +function focusSession(sessionId) { + var a = activeSessions[sessionId]; + if (!a) { showToast('Session not active'); return; } + + fetch('/api/focus', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pid: a.pid }) + }).then(function(r) { return r.json(); }).then(function(data) { + if (data.ok) { + var hint = data.terminal || 'terminal'; + var cwd = a.cwd ? a.cwd.split('/').pop() : ''; + showToast('Switched to ' + hint + (cwd ? ' — look for: ' + cwd : '') + ' (PID ' + a.pid + ')'); + } else { + showToast('Could not focus — try clicking the terminal manually'); + } + }).catch(function() { + showToast('Focus failed'); + }); +} + +// ── Changelog view ──────────────────────────────────────────── + +function renderSettings(container) { + var savedTheme = localStorage.getItem('codedash-theme') || 'dark'; + var savedTerminal = localStorage.getItem('codedash-terminal') || ''; + var aiTitlesOn = localStorage.getItem('codedash-ai-titles') === 'true'; + var savedGroupingMode = normalizeGroupingMode(localStorage.getItem('codedash-grouping-mode')); + + var html = '
'; + html += '

Settings

'; + + // Theme + html += '
'; + html += ''; + html += '
'; + ['dark', 'light', 'system'].forEach(function(t) { + var active = savedTheme === t ? ' active' : ''; + html += ''; + }); + html += '
'; + html += '
'; + + // Terminal + html += '
'; + html += ''; + html += ''; + html += '
'; + + // AI Titles + html += '
'; + html += ''; + html += '
'; + html += ''; + html += 'Show generated titles'; + html += '
'; + html += '
'; + + // Grouping + html += '
'; + html += ''; + html += '
'; + ['folder', 'repo'].forEach(function(mode) { + var active = savedGroupingMode === mode ? ' active' : ''; + var label = mode === 'repo' ? 'Repository' : 'Folder'; + html += ''; + }); + html += '
'; + html += '

Applies to grouped session views like All Sessions and Claude Code. Projects always stay repository-based.

'; + html += '
'; + + // LLM Configuration + html += '
'; + html += ''; + html += '

OpenAI-compatible API for session title generation

'; + html += '
'; + html += ''; + html += ''; + html += ''; + html += '
'; + html += '
'; + html += ''; + html += ''; + html += '
'; + html += '
'; + + html += '
'; + container.innerHTML = html; + + // Load LLM config into the inputs + loadLLMSettings(); +} + +// → moved to leaderboard.js + +async function renderChangelog(container) { + container.innerHTML = '
Loading changelog...
'; + try { + var resp = await fetch('/api/changelog'); + var log = await resp.json(); + + var html = '
'; + html += '

Changelog

'; + + log.forEach(function(entry, i) { + var isNew = i === 0; + html += '
'; + html += '
'; + html += 'v' + escHtml(entry.version) + ''; + if (isNew) html += 'NEW'; + html += '' + escHtml(entry.date) + ''; + html += '
'; + html += '
' + escHtml(entry.title) + '
'; + html += '
    '; + entry.changes.forEach(function(c) { + html += '
  • ' + escHtml(c) + '
  • '; + }); + html += '
'; + }); + + html += '
'; + container.innerHTML = html; + } catch (e) { + container.innerHTML = '
Failed to load changelog.
'; + } +} + +// ── Convert session ─────────────────────────────────────────── + +async function convertTo(sessionId, project, targetFormat) { + if (!confirm('Convert this session to ' + targetFormat + '? A new session will be created.')) return; + showToast('Converting...'); + try { + var resp = await fetch('/api/convert', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId: sessionId, project: project, targetFormat: targetFormat }), + }); + var data = await resp.json(); + if (data.ok) { + showToast('Converted! New session: ' + data.target.sessionId.slice(0, 12)); + // Refresh to show new session + await loadSessions(); + closeDetail(); + } else { + showToast('Error: ' + (data.error || 'unknown')); + } + } catch (e) { + showToast('Convert failed: ' + e.message); + } +} + +// ── Open in IDE ─────────────────────────────────────────────── + +function openInCursor(project) { + if (!project) { showToast('No project path'); return; } + fetch('/api/open-ide', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ide: 'cursor', project: project }) + }).then(function(r) { return r.json(); }).then(function(data) { + if (data.ok) showToast('Opening project in Cursor...'); + else showToast('Failed: ' + (data.error || 'unknown')); + }).catch(function() { showToast('Failed to open Cursor'); }); +} + +function openInVSCode(project) { + if (!project) { showToast('No project path'); return; } + fetch('/api/open-ide', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ide: 'code', project: project }) + }).then(function(r) { return r.json(); }).then(function(data) { + if (data.ok) showToast('Opening project in VS Code...'); + else showToast('Failed: ' + (data.error || 'unknown')); + }).catch(function() { showToast('Failed to open VS Code'); }); +} + +// ── Handoff ─────────────────────────────────────────────────── + +function downloadHandoff(sessionId, project) { + window.open('/api/handoff/' + sessionId + '?project=' + encodeURIComponent(project) + '&verbosity=standard'); +} + +// ── Install agents ──────────────────────────────────────────── + +var AGENT_INSTALL = { + claude: { + name: 'Claude Code', + cmd: 'curl -fsSL https://claude.ai/install.sh | bash', + alt: 'npm i -g @anthropic-ai/claude-code', + url: 'https://code.claude.com', + }, + codex: { + name: 'Codex CLI', + cmd: 'npm i -g @openai/codex', + alt: 'brew install --cask codex', + url: 'https://github.com/openai/codex', + }, + kiro: { + name: 'Kiro CLI', + cmd: 'curl -fsSL https://cli.kiro.dev/install | bash', + alt: null, + url: 'https://kiro.dev/docs/cli/installation/', + }, + opencode: { + name: 'OpenCode', + cmd: 'curl -fsSL https://opencode.ai/install | bash', + alt: 'npm i -g opencode-ai@latest', + url: 'https://opencode.ai', + }, +}; + +function installAgent(agent) { + var info = AGENT_INSTALL[agent]; + if (!info) return; + + var overlay = document.getElementById('confirmOverlay'); + document.getElementById('confirmTitle').textContent = 'Install ' + info.name; + var html = '' + escHtml(info.cmd) + ''; + if (info.alt) { + html += 'or: ' + escHtml(info.alt) + '
'; + } + html += '
' + info.url + ''; + document.getElementById('confirmText').innerHTML = html; + document.getElementById('confirmId').textContent = ''; + document.getElementById('confirmAction').textContent = 'Copy Install Command'; + document.getElementById('confirmAction').className = 'launch-btn btn-primary'; + document.getElementById('confirmAction').onclick = function() { + navigator.clipboard.writeText(info.cmd).then(function() { + showToast('Copied: ' + info.cmd); + }); + closeConfirm(); + }; + if (overlay) overlay.style.display = 'flex'; +} + +// ── Export/Import dialog ────────────────────────────────────── + +function showExportDialog() { + var overlay = document.getElementById('confirmOverlay'); + document.getElementById('confirmTitle').textContent = 'Export / Import Sessions'; + document.getElementById('confirmText').innerHTML = + 'Export all sessions to migrate to another PC:
' + + 'codedash export' + + 'Creates a tar.gz with all Claude & Codex session data.

' + + 'Import on the new machine:
' + + 'codedash import <file.tar.gz>' + + '
Don\'t forget to clone your git repos separately.'; + document.getElementById('confirmId').textContent = ''; + document.getElementById('confirmAction').textContent = 'Copy Export Command'; + document.getElementById('confirmAction').className = 'launch-btn btn-primary'; + document.getElementById('confirmAction').onclick = function() { + navigator.clipboard.writeText('codedash export').then(function() { + showToast('Copied: codedash export'); + }); + closeConfirm(); + }; + if (overlay) overlay.style.display = 'flex'; +} + +// ── Update check ────────────────────────────────────────────── + +async function checkForUpdates() { + try { + var resp = await fetch('/api/version'); + var data = await resp.json(); + var badge = document.getElementById('versionBadge'); + + if (badge) { + badge.textContent = 'v' + data.current; + } + + // Show "what's new" if version changed since last visit + var lastSeenVersion = localStorage.getItem('codedash-last-version'); + if (lastSeenVersion && lastSeenVersion !== data.current) { + showToast('Updated to v' + data.current + ' — check Changelog!'); + } + localStorage.setItem('codedash-last-version', data.current); + + if (data.updateAvailable) { + if (badge) { + badge.textContent = 'v' + data.current + ' → v' + data.latest; + badge.classList.add('update-available'); + badge.title = 'Click to copy update command'; + badge.onclick = function() { + navigator.clipboard.writeText('npm i -g codedash-app@latest').then(function() { + showToast('Copied: npm i -g codedash-app@latest'); + }); + }; + } + var banner = document.getElementById('updateBanner'); + var text = document.getElementById('updateText'); + if (banner && text) { + text.textContent = 'v' + data.latest + ' available — run: npm i -g codedash-app@latest'; + banner.style.display = 'flex'; + banner.dataset.cmd = 'npm i -g codedash-app@latest'; + } + } + } catch {} +} + +function copyUpdate() { + var cmd = 'codedash update && codedash restart'; + navigator.clipboard.writeText(cmd).then(function() { + showToast('Copied: ' + cmd + ' (run in terminal)'); + }); +} + +function dismissUpdate() { + var banner = document.getElementById('updateBanner'); + if (banner) banner.style.display = 'none'; +} + +// ── Initialization ───────────────────────────────────────────── + +(function init() { + // Load data + loadSessions(); + loadTerminals(); + checkForUpdates(); + startActivePolling(); + + // Apply saved theme + var savedTheme = localStorage.getItem('codedash-theme') || 'dark'; + setTheme(savedTheme); + + // Set saved theme in selector + var themeSel = document.getElementById('themeSelect'); + if (themeSel) themeSel.value = savedTheme; + + // Set group button state + var groupBtn = document.getElementById('groupBtn'); + if (groupBtn) groupBtn.classList.toggle('active', grouped); + + // Set AI titles toggle + var aiToggle = document.getElementById('aiTitlesToggle'); + if (aiToggle) aiToggle.checked = showAITitles; +})(); + +// → moved to cloud.js diff --git a/src/frontend/calendar.js b/src/frontend/calendar.js index 2e46d57..72ec7c1 100644 --- a/src/frontend/calendar.js +++ b/src/frontend/calendar.js @@ -1,214 +1,217 @@ -// ── Calendar ───────────────────────────────────────────────── - -var calYear = new Date().getFullYear(); -var calMonth = new Date().getMonth(); -var calStart = null; -var calEnd = null; -var calSelecting = false; - -function toggleCalendar() { - var popup = document.getElementById('calendarPopup'); - var btn = document.getElementById('dateBtn'); - if (!popup || !btn) return; - if (popup.classList.contains('open')) { - popup.classList.remove('open'); - return; - } - renderCalendar(); - // Position popup below the button - var rect = btn.getBoundingClientRect(); - var popupWidth = 280; - var left = rect.left; - // Keep within viewport - if (left + popupWidth > window.innerWidth - 8) { - left = window.innerWidth - popupWidth - 8; - } - popup.style.left = left + 'px'; - popup.style.top = (rect.bottom + 4) + 'px'; - popup.classList.add('open'); - setTimeout(function() { - document.addEventListener('click', closeCalendarOutside, { once: true }); - }, 0); -} - -function closeCalendarOutside(e) { - var popup = document.getElementById('calendarPopup'); - var btn = document.getElementById('dateBtn'); - if (popup && !popup.contains(e.target) && btn && !btn.contains(e.target)) { - popup.classList.remove('open'); - } else if (popup && popup.classList.contains('open')) { - document.addEventListener('click', closeCalendarOutside, { once: true }); - } -} - -function renderCalendar() { - var popup = document.getElementById('calendarPopup'); - if (!popup) return; - - var monthNames = ['January','February','March','April','May','June','July','August','September','October','November','December']; - var firstDay = new Date(calYear, calMonth, 1); - var lastDay = new Date(calYear, calMonth + 1, 0); - var startWeekday = (firstDay.getDay() + 6) % 7; // Monday = 0 - var daysInMonth = lastDay.getDate(); - var today = new Date(); - var todayStr = today.getFullYear() + '-' + String(today.getMonth()+1).padStart(2,'0') + '-' + String(today.getDate()).padStart(2,'0'); - - var html = '
'; - html += ''; - html += '' + monthNames[calMonth] + ' ' + calYear + ''; - html += ''; - html += '
'; - - html += '
MoTuWeThFrSaSu
'; - html += '
'; - - var prevLastDay = new Date(calYear, calMonth, 0).getDate(); - for (var i = startWeekday - 1; i >= 0; i--) { - html += '
' + (prevLastDay - i) + '
'; - } - - for (var d = 1; d <= daysInMonth; d++) { - var dateStr = calYear + '-' + String(calMonth+1).padStart(2,'0') + '-' + String(d).padStart(2,'0'); - var cls = 'cal-day'; - if (dateStr === todayStr) cls += ' today'; - if (calStart && calEnd) { - if (dateStr === calStart) cls += ' range-start'; - if (dateStr === calEnd) cls += ' range-end'; - if (dateStr > calStart && dateStr < calEnd) cls += ' in-range'; - if (calStart === calEnd && dateStr === calStart) cls += ' range-start range-end'; - } else if (calStart && dateStr === calStart) { - cls += ' range-start range-end'; - } - html += '
' + d + '
'; - } - - var totalCells = startWeekday + daysInMonth; - var remaining = (7 - (totalCells % 7)) % 7; - for (var n = 1; n <= remaining; n++) { - html += '
' + n + '
'; - } - html += '
'; - - html += '
'; - var presets = [['All',''],['Today','0'],['7d','7'],['30d','30'],['90d','90']]; - presets.forEach(function(p) { - html += ''; - }); - html += '
'; - - popup.innerHTML = html; -} - -function calNav(dir) { - calMonth += dir; - if (calMonth < 0) { calMonth = 11; calYear--; } - if (calMonth > 11) { calMonth = 0; calYear++; } - renderCalendar(); -} - -function calPickDay(dateStr) { - if (!calSelecting) { - calStart = dateStr; - calEnd = null; - calSelecting = true; - } else { - if (dateStr < calStart) { - calEnd = calStart; - calStart = dateStr; - } else { - calEnd = dateStr; - } - calSelecting = false; - } - renderCalendar(); - dateFrom = calStart || ''; - dateTo = calEnd || calStart || ''; - onDateFilter(); -} - -function calPreset(days) { - calSelecting = false; - if (!days) { - calStart = null; - calEnd = null; - dateFrom = ''; - dateTo = ''; - } else { - var now = new Date(); - calEnd = now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0') + '-' + String(now.getDate()).padStart(2,'0'); - if (days === '0') { - calStart = calEnd; - } else { - var from = new Date(now.getTime() - parseInt(days) * 86400000); - calStart = from.getFullYear() + '-' + String(from.getMonth()+1).padStart(2,'0') + '-' + String(from.getDate()).padStart(2,'0'); - } - dateFrom = calStart; - dateTo = calEnd; - } - renderCalendar(); - onDateFilter(); - var popup = document.getElementById('calendarPopup'); - if (popup) popup.classList.remove('open'); -} - -function updateDateBtn() { - var btn = document.getElementById('dateBtn'); - var label = document.getElementById('dateBtnLabel'); - if (!btn || !label) return; - if (!dateFrom && !dateTo) { - label.textContent = 'All time'; - btn.classList.remove('has-filter'); - } else if (dateFrom === dateTo) { - label.textContent = dateFrom; - btn.classList.add('has-filter'); - } else { - var f = dateFrom.slice(5) || ''; - var t = dateTo.slice(5) || ''; - label.textContent = f + ' \u2014 ' + t; - btn.classList.add('has-filter'); - } -} - -function toggleGroup() { - grouped = !grouped; - var btn = document.getElementById('groupBtn'); - if (btn) btn.classList.toggle('active', grouped); - render(); -} - -function setView(view) { - // Handle tool filter views - if (view === 'claude-only') { - toolFilter = toolFilter === 'claude' ? null : 'claude'; - currentView = 'sessions'; - } else if (view === 'codex-only') { - toolFilter = toolFilter === 'codex' ? null : 'codex'; - currentView = 'sessions'; - } else if (view === 'cursor-only') { - toolFilter = toolFilter === 'cursor' ? null : 'cursor'; - currentView = 'sessions'; - } else if (view === 'kiro-only') { - toolFilter = toolFilter === 'kiro' ? null : 'kiro'; - currentView = 'sessions'; - } else if (view === 'opencode-only') { - toolFilter = toolFilter === 'opencode' ? null : 'opencode'; - currentView = 'sessions'; - } else { - toolFilter = null; - currentView = view; - } - - // Update sidebar active state - document.querySelectorAll('.sidebar-item').forEach(function(el) { - el.classList.toggle('active', el.getAttribute('data-view') === view); - }); - - applyFilters(); -} - -// Wire up sidebar clicks -document.querySelectorAll('.sidebar-item').forEach(function(el) { - el.addEventListener('click', function() { - setView(el.getAttribute('data-view')); - }); -}); +// ── Calendar ───────────────────────────────────────────────── + +var calYear = new Date().getFullYear(); +var calMonth = new Date().getMonth(); +var calStart = null; +var calEnd = null; +var calSelecting = false; + +function toggleCalendar() { + var popup = document.getElementById('calendarPopup'); + var btn = document.getElementById('dateBtn'); + if (!popup || !btn) return; + if (popup.classList.contains('open')) { + popup.classList.remove('open'); + return; + } + renderCalendar(); + // Position popup below the button + var rect = btn.getBoundingClientRect(); + var popupWidth = 280; + var left = rect.left; + // Keep within viewport + if (left + popupWidth > window.innerWidth - 8) { + left = window.innerWidth - popupWidth - 8; + } + popup.style.left = left + 'px'; + popup.style.top = (rect.bottom + 4) + 'px'; + popup.classList.add('open'); + setTimeout(function() { + document.addEventListener('click', closeCalendarOutside, { once: true }); + }, 0); +} + +function closeCalendarOutside(e) { + var popup = document.getElementById('calendarPopup'); + var btn = document.getElementById('dateBtn'); + if (popup && !popup.contains(e.target) && btn && !btn.contains(e.target)) { + popup.classList.remove('open'); + } else if (popup && popup.classList.contains('open')) { + document.addEventListener('click', closeCalendarOutside, { once: true }); + } +} + +function renderCalendar() { + var popup = document.getElementById('calendarPopup'); + if (!popup) return; + + var monthNames = ['January','February','March','April','May','June','July','August','September','October','November','December']; + var firstDay = new Date(calYear, calMonth, 1); + var lastDay = new Date(calYear, calMonth + 1, 0); + var startWeekday = (firstDay.getDay() + 6) % 7; // Monday = 0 + var daysInMonth = lastDay.getDate(); + var today = new Date(); + var todayStr = today.getFullYear() + '-' + String(today.getMonth()+1).padStart(2,'0') + '-' + String(today.getDate()).padStart(2,'0'); + + var html = '
'; + html += ''; + html += '' + monthNames[calMonth] + ' ' + calYear + ''; + html += ''; + html += '
'; + + html += '
MoTuWeThFrSaSu
'; + html += '
'; + + var prevLastDay = new Date(calYear, calMonth, 0).getDate(); + for (var i = startWeekday - 1; i >= 0; i--) { + html += '
' + (prevLastDay - i) + '
'; + } + + for (var d = 1; d <= daysInMonth; d++) { + var dateStr = calYear + '-' + String(calMonth+1).padStart(2,'0') + '-' + String(d).padStart(2,'0'); + var cls = 'cal-day'; + if (dateStr === todayStr) cls += ' today'; + if (calStart && calEnd) { + if (dateStr === calStart) cls += ' range-start'; + if (dateStr === calEnd) cls += ' range-end'; + if (dateStr > calStart && dateStr < calEnd) cls += ' in-range'; + if (calStart === calEnd && dateStr === calStart) cls += ' range-start range-end'; + } else if (calStart && dateStr === calStart) { + cls += ' range-start range-end'; + } + html += '
' + d + '
'; + } + + var totalCells = startWeekday + daysInMonth; + var remaining = (7 - (totalCells % 7)) % 7; + for (var n = 1; n <= remaining; n++) { + html += '
' + n + '
'; + } + html += '
'; + + html += '
'; + var presets = [['All',''],['Today','0'],['7d','7'],['30d','30'],['90d','90']]; + presets.forEach(function(p) { + html += ''; + }); + html += '
'; + + popup.innerHTML = html; +} + +function calNav(dir) { + calMonth += dir; + if (calMonth < 0) { calMonth = 11; calYear--; } + if (calMonth > 11) { calMonth = 0; calYear++; } + renderCalendar(); +} + +function calPickDay(dateStr) { + if (!calSelecting) { + calStart = dateStr; + calEnd = null; + calSelecting = true; + } else { + if (dateStr < calStart) { + calEnd = calStart; + calStart = dateStr; + } else { + calEnd = dateStr; + } + calSelecting = false; + } + renderCalendar(); + dateFrom = calStart || ''; + dateTo = calEnd || calStart || ''; + onDateFilter(); +} + +function calPreset(days) { + calSelecting = false; + if (!days) { + calStart = null; + calEnd = null; + dateFrom = ''; + dateTo = ''; + } else { + var now = new Date(); + calEnd = now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0') + '-' + String(now.getDate()).padStart(2,'0'); + if (days === '0') { + calStart = calEnd; + } else { + var from = new Date(now.getTime() - parseInt(days) * 86400000); + calStart = from.getFullYear() + '-' + String(from.getMonth()+1).padStart(2,'0') + '-' + String(from.getDate()).padStart(2,'0'); + } + dateFrom = calStart; + dateTo = calEnd; + } + renderCalendar(); + onDateFilter(); + var popup = document.getElementById('calendarPopup'); + if (popup) popup.classList.remove('open'); +} + +function updateDateBtn() { + var btn = document.getElementById('dateBtn'); + var label = document.getElementById('dateBtnLabel'); + if (!btn || !label) return; + if (!dateFrom && !dateTo) { + label.textContent = 'All time'; + btn.classList.remove('has-filter'); + } else if (dateFrom === dateTo) { + label.textContent = dateFrom; + btn.classList.add('has-filter'); + } else { + var f = dateFrom.slice(5) || ''; + var t = dateTo.slice(5) || ''; + label.textContent = f + ' \u2014 ' + t; + btn.classList.add('has-filter'); + } +} + +function toggleGroup() { + grouped = !grouped; + var btn = document.getElementById('groupBtn'); + if (btn) btn.classList.toggle('active', grouped); + render(); +} + +function setView(view) { + // Handle tool filter views + if (view === 'claude-only') { + toolFilter = toolFilter === 'claude' ? null : 'claude'; + currentView = 'sessions'; + } else if (view === 'codex-only') { + toolFilter = toolFilter === 'codex' ? null : 'codex'; + currentView = 'sessions'; + } else if (view === 'cursor-only') { + toolFilter = toolFilter === 'cursor' ? null : 'cursor'; + currentView = 'sessions'; + } else if (view === 'kiro-only') { + toolFilter = toolFilter === 'kiro' ? null : 'kiro'; + currentView = 'sessions'; + } else if (view === 'opencode-only') { + toolFilter = toolFilter === 'opencode' ? null : 'opencode'; + currentView = 'sessions'; + } else if (view === 'copilot-only') { + toolFilter = toolFilter === 'copilot' ? null : 'copilot'; + currentView = 'sessions'; + } else { + toolFilter = null; + currentView = view; + } + + // Update sidebar active state + document.querySelectorAll('.sidebar-item').forEach(function(el) { + el.classList.toggle('active', el.getAttribute('data-view') === view); + }); + + applyFilters(); +} + +// Wire up sidebar clicks +document.querySelectorAll('.sidebar-item').forEach(function(el) { + el.addEventListener('click', function() { + setView(el.getAttribute('data-view')); + }); +}); diff --git a/src/frontend/detail.js b/src/frontend/detail.js index 1f71257..e3fa297 100644 --- a/src/frontend/detail.js +++ b/src/frontend/detail.js @@ -1,404 +1,410 @@ -// ── Detail panel ─────────────────────────────────────────────── - -async function openDetail(s) { - var panel = document.getElementById('detailPanel'); - var overlay = document.getElementById('overlay'); - var title = document.getElementById('detailTitle'); - var body = document.getElementById('detailBody'); - if (!panel || !body) return; - - title.textContent = escHtml(getProjectName(s.project)) + ' / ' + s.id.slice(0, 12); - - var cost = estimateCost(s.file_size); - var costStr = cost > 0 ? '~$' + cost.toFixed(2) : ''; - var isStarred = stars.indexOf(s.id) >= 0; - var sessionTags = tags[s.id] || []; - var terminal = localStorage.getItem('codedash-terminal') || ''; - - var infoHtml = '
'; - // AI Title row - var aiTitle = sessionTitles[s.id]; - var escProject = escHtml(s.project || '').replace(/'/g, "\\'"); - if (aiTitle) { - infoHtml += '
AI Title' + escHtml(aiTitle) + '
'; - } else if (s.has_detail) { - infoHtml += '
AI Title
'; - } - var detailToolLabel = s.tool === 'claude-ext' ? 'claude ext' : s.tool; - infoHtml += '
Tool' + escHtml(detailToolLabel) + '
'; - infoHtml += '
Project' + escHtml(s.project_short || s.project || '') + '
'; - infoHtml += '
'; - infoHtml += '
Session ID' + escHtml(s.id) + '
'; - infoHtml += '
First seen' + escHtml(s.first_time || '') + '
'; - infoHtml += '
Last seen' + escHtml(s.last_time || '') + ' (' + timeAgo(s.last_ts) + ')
'; - infoHtml += '
Messages' + (s.detail_messages || s.messages || 0) + '
'; - infoHtml += '
File size' + formatBytes(s.file_size) + '
'; - if (costStr) { - infoHtml += '
Est. cost' + costStr + '
'; - } - infoHtml += ''; - // Tags - infoHtml += '
Tags'; - sessionTags.forEach(function(t) { - infoHtml += '' + escHtml(t) + ' ×'; - }); - infoHtml += ''; - infoHtml += '
'; - // MCP servers row - if (s.mcp_servers && s.mcp_servers.length > 0) { - infoHtml += '
MCP'; - s.mcp_servers.forEach(function(m) { - infoHtml += '' + escHtml(m) + ''; - }); - infoHtml += '
'; - } - // Skills row - if (s.skills && s.skills.length > 0) { - infoHtml += '
Skills'; - s.skills.forEach(function(sk) { - infoHtml += '' + escHtml(sk) + ''; - }); - infoHtml += '
'; - } - infoHtml += '
'; - - // Action buttons - infoHtml += '
'; - // Tool-specific launch buttons - if (s.tool === 'cursor') { - infoHtml += ''; - } else if (activeSessions[s.id]) { - infoHtml += ''; - } else { - infoHtml += ''; - if (s.tool === 'claude') { - infoHtml += ''; - } - } - infoHtml += ''; - if (s.has_detail) { - infoHtml += ''; - infoHtml += ''; - var convertTarget = s.tool === 'codex' ? 'claude' : 'codex'; - infoHtml += ''; - infoHtml += ''; - } - infoHtml += ''; - infoHtml += ''; - infoHtml += '
'; - - body.innerHTML = infoHtml + '
Loading messages...
'; - - panel.classList.add('open'); - overlay.classList.add('open'); - - // Load messages - if (s.has_detail) { - try { - var resp = await fetch('/api/session/' + s.id + '?project=' + encodeURIComponent(s.project || '')); - var data = await resp.json(); - var msgContainer = body.querySelector('.detail-messages'); - if (data.messages && data.messages.length > 0) { - var msgsHtml = '

Conversation

'; - data.messages.forEach(function(m) { - var roleClass = m.role === 'user' ? 'msg-user' : 'msg-assistant'; - var roleLabel = m.role === 'user' ? 'You' : 'Assistant'; - var hasTools = m.tools && m.tools.length > 0; - msgsHtml += '
'; - msgsHtml += '
'; - msgsHtml += '
' + roleLabel + '
'; - msgsHtml += '
' + escHtml(m.content) + '
'; - msgsHtml += '
'; - if (hasTools) { - msgsHtml += '
'; - m.tools.forEach(function(t) { - if (t.type === 'mcp') { - msgsHtml += '' + escHtml(t.tool) + ''; - } else if (t.type === 'skill') { - msgsHtml += '' + escHtml(t.skill) + ''; - } - }); - msgsHtml += '
'; - } - msgsHtml += '
'; - }); - msgContainer.innerHTML = msgsHtml; - } else { - msgContainer.innerHTML = '
No messages found in detail file.
'; - } - } catch (e) { - body.querySelector('.detail-messages').innerHTML = '
Failed to load messages.
'; - } - } else { - body.querySelector('.detail-messages').innerHTML = '
No detail file available for this session.
'; - } - - // Load real cost - loadRealCost(s.id, s.project || '').then(function(costData) { - if (!costData || !costData.cost) return; - var row = document.getElementById('detail-real-cost'); - if (row) { - row.style.display = ''; - var cacheStr = ''; - if ((costData.cacheReadTokens || 0) + (costData.cacheCreateTokens || 0) > 0) - cacheStr = ' / ' + formatTokens((costData.cacheReadTokens||0) + (costData.cacheCreateTokens||0)) + ' cache'; - row.querySelector('span:last-child').innerHTML = - '$' + costData.cost.toFixed(2) + '' + - ' ' + - formatTokens(costData.inputTokens) + ' in / ' + formatTokens(costData.outputTokens) + ' out' + cacheStr + - (costData.model ? ' (' + costData.model + ')' : '') + ''; - } - // Update estimated badge to show it was estimated - var estBadge = document.getElementById('detail-cost'); - if (estBadge) estBadge.style.opacity = '0.5'; - }); - - // Load git info - if (s.project) { - fetch('/api/git-info?project=' + encodeURIComponent(s.project)) - .then(function(r) { return r.json(); }) - .then(function(git) { - var el = document.getElementById('detail-git-info'); - if (!el || git.error) return; - var html = ''; - if (git.branch) { - html += '
Branch' + escHtml(git.branch); - if (git.isDirty) html += ' *'; - html += '
'; - } - if (git.lastCommit) { - html += '
Last commit'; - if (git.lastCommitHash) html += '' + escHtml(git.lastCommitHash) + ' '; - html += escHtml(git.lastCommit) + '
'; - } - if (git.remoteUrl) { - var displayUrl = git.remoteUrl.replace(/\.git$/, '').replace(/^https?:\/\//, '').replace(/^git@([^:]+):/, '$1/'); - html += '
Remote' + escHtml(displayUrl) + '
'; - } - el.innerHTML = html; - }).catch(function() {}); - } - - // Load git commits - if (s.project) { - var commits = await loadGitCommits(s.project, s.first_ts, s.last_ts); - var commitsContainer = body.querySelector('.detail-commits'); - if (commits && commits.length > 0) { - var cHtml = '

Related Commits

'; - commits.forEach(function(c) { - cHtml += '
'; - cHtml += '' + escHtml(c.hash) + ''; - cHtml += '' + escHtml(c.message) + ''; - cHtml += '
'; - }); - cHtml += '
'; - commitsContainer.innerHTML = cHtml; - } - } -} - -function closeDetail() { - var panel = document.getElementById('detailPanel'); - var overlay = document.getElementById('overlay'); - if (panel) panel.classList.remove('open'); - if (overlay) overlay.classList.remove('open'); -} - -// ── Detail panel resize ─────────────────────────────────────── -(function initDetailResize() { - var handle = document.getElementById('detailResizeHandle'); - var panel = document.getElementById('detailPanel'); - if (!handle || !panel) return; - - var startX, startW; - - handle.addEventListener('mousedown', function(e) { - e.preventDefault(); - startX = e.clientX; - startW = panel.offsetWidth; - document.addEventListener('mousemove', onDrag); - document.addEventListener('mouseup', onStop); - panel.style.transition = 'none'; - }); - - function onDrag(e) { - var diff = startX - e.clientX; - var newW = Math.max(320, Math.min(window.innerWidth * 0.85, startW + diff)); - panel.style.width = newW + 'px'; - } - - function onStop() { - document.removeEventListener('mousemove', onDrag); - document.removeEventListener('mouseup', onStop); - panel.style.transition = ''; - localStorage.setItem('codedash-detail-width', panel.style.width); - } - - // Restore saved width - var saved = localStorage.getItem('codedash-detail-width'); - if (saved) panel.style.width = saved; -})(); - -async function loadGitCommits(project, fromTs, toTs) { - try { - var resp = await fetch('/api/git-commits?project=' + encodeURIComponent(project) + '&from=' + fromTs + '&to=' + toTs); - return await resp.json(); - } catch (e) { - return []; - } -} - -function launchDangerous(sessionId, project) { - launchSession(sessionId, 'claude', project, ['skip-permissions']); -} - -function launchSession(sessionId, tool, project, flags) { - var terminal = localStorage.getItem('codedash-terminal') || ''; - fetch('/api/launch', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sessionId: sessionId, - tool: tool, - flags: flags || [], - project: project, - terminal: terminal - }) - }).then(function(resp) { - return resp.json(); - }).then(function(data) { - if (data.ok) showToast('Launched in terminal'); - else showToast('Launch failed: ' + (data.error || 'unknown')); - }).catch(function() { - showToast('Launch failed'); - }); -} - -function copyResume(sessionId, tool) { - var s = allSessions.find(function(x) { return x.id === sessionId; }); - var cmd; - if (tool === 'codex') { - cmd = 'codex resume ' + sessionId; - } else if (tool === 'cursor') { - cmd = 'cursor ' + (s && s.project ? '"' + s.project + '"' : '.'); - } else { - cmd = 'claude --resume ' + sessionId; - } - navigator.clipboard.writeText(cmd).then(function() { - showToast('Copied: ' + cmd); - }).catch(function() { - // Fallback - prompt('Copy this command:', cmd); - }); -} - -function exportMd(sessionId, project) { - window.open('/api/session/' + sessionId + '/export?project=' + encodeURIComponent(project)); -} - -// ── Session Replay ──────────────────────────────────────────── - -async function openReplay(sessionId, project) { - var content = document.getElementById('content'); - content.innerHTML = '
Loading replay...
'; - - try { - var resp = await fetch('/api/replay/' + sessionId + '?project=' + encodeURIComponent(project)); - var data = await resp.json(); - - if (!data.messages || data.messages.length === 0) { - content.innerHTML = '
No messages to replay.
'; - return; - } - - var msgs = data.messages; - var html = '
'; - html += '
'; - html += ''; - html += 'Session Replay — ' + sessionId.slice(0, 12) + ''; - html += '' + formatDuration(data.duration) + ''; - html += '
'; - - // Timeline slider - html += '
'; - html += ''; - html += ''; - html += '1 / ' + msgs.length + ''; - html += '
'; - - // Messages area - html += '
'; - html += '
'; - - content.innerHTML = html; - - // Store messages for replay - window._replayMsgs = msgs; - window._replayPos = 0; - window._replayPlaying = false; - window._replayTimer = null; - seekReplay(0); - } catch (e) { - content.innerHTML = '
Failed to load replay.
'; - } -} - -function seekReplay(pos) { - pos = parseInt(pos); - var msgs = window._replayMsgs; - if (!msgs) return; - window._replayPos = pos; - - var container = document.getElementById('replayMessages'); - var slider = document.getElementById('replaySlider'); - var counter = document.getElementById('replayCounter'); - if (!container) return; - - var html = ''; - for (var i = 0; i <= pos && i < msgs.length; i++) { - var m = msgs[i]; - var cls = m.role === 'user' ? 'preview-user' : 'preview-assistant'; - var label = m.role === 'user' ? 'You' : 'AI'; - var time = m.timestamp ? new Date(m.timestamp).toLocaleTimeString() : ''; - var isLatest = i === pos; - html += '
'; - html += '
' + label + '' + time + '
'; - html += '
' + escHtml(m.content) + '
'; - html += '
'; - } - container.innerHTML = html; - container.scrollTop = container.scrollHeight; - - if (slider) slider.value = pos; - if (counter) counter.textContent = (pos + 1) + ' / ' + msgs.length; -} - -function toggleReplayPlay() { - var btn = document.getElementById('replayPlayBtn'); - if (window._replayPlaying) { - window._replayPlaying = false; - clearInterval(window._replayTimer); - if (btn) btn.innerHTML = '▶'; - } else { - window._replayPlaying = true; - if (btn) btn.innerHTML = '▮▮'; - window._replayTimer = setInterval(function() { - var next = window._replayPos + 1; - if (next >= window._replayMsgs.length) { - toggleReplayPlay(); - return; - } - seekReplay(next); - }, 1500); - } -} - -function formatDuration(ms) { - if (!ms) return ''; - var s = Math.floor(ms / 1000); - var m = Math.floor(s / 60); - var h = Math.floor(m / 60); - if (h > 0) return h + 'h ' + (m % 60) + 'm'; - if (m > 0) return m + 'm ' + (s % 60) + 's'; - return s + 's'; -} +// ── Detail panel ─────────────────────────────────────────────── + +async function openDetail(s) { + var panel = document.getElementById('detailPanel'); + var overlay = document.getElementById('overlay'); + var title = document.getElementById('detailTitle'); + var body = document.getElementById('detailBody'); + if (!panel || !body) return; + + title.textContent = escHtml(getProjectName(s.project)) + ' / ' + s.id.slice(0, 12); + + var cost = estimateCost(s.file_size); + var costStr = cost > 0 ? '~$' + cost.toFixed(2) : ''; + var isStarred = stars.indexOf(s.id) >= 0; + var sessionTags = tags[s.id] || []; + var terminal = localStorage.getItem('codedash-terminal') || ''; + + var infoHtml = '
'; + // AI Title row + var aiTitle = sessionTitles[s.id]; + var escProject = escHtml(s.project || '').replace(/'/g, "\\'"); + if (aiTitle) { + infoHtml += '
AI Title' + escHtml(aiTitle) + '
'; + } else if (s.has_detail) { + infoHtml += '
AI Title
'; + } + var detailToolLabel = s.tool === 'claude-ext' ? 'claude ext' : s.tool; + infoHtml += '
Tool' + escHtml(detailToolLabel) + '
'; + infoHtml += '
Project' + escHtml(s.project_short || s.project || '') + '
'; + infoHtml += '
'; + infoHtml += '
Session ID' + escHtml(s.id) + '
'; + infoHtml += '
First seen' + escHtml(s.first_time || '') + '
'; + infoHtml += '
Last seen' + escHtml(s.last_time || '') + ' (' + timeAgo(s.last_ts) + ')
'; + infoHtml += '
Messages' + (s.detail_messages || s.messages || 0) + '
'; + infoHtml += '
File size' + formatBytes(s.file_size) + '
'; + if (costStr) { + infoHtml += '
Est. cost' + costStr + '
'; + } + infoHtml += ''; + // Tags + infoHtml += '
Tags'; + sessionTags.forEach(function(t) { + infoHtml += '' + escHtml(t) + ' ×'; + }); + infoHtml += ''; + infoHtml += '
'; + // MCP servers row + if (s.mcp_servers && s.mcp_servers.length > 0) { + infoHtml += '
MCP'; + s.mcp_servers.forEach(function(m) { + infoHtml += '' + escHtml(m) + ''; + }); + infoHtml += '
'; + } + // Skills row + if (s.skills && s.skills.length > 0) { + infoHtml += '
Skills'; + s.skills.forEach(function(sk) { + infoHtml += '' + escHtml(sk) + ''; + }); + infoHtml += '
'; + } + infoHtml += '
'; + + // Action buttons + infoHtml += '
'; + // Tool-specific launch buttons + if (s.tool === 'cursor') { + infoHtml += ''; + } else if (s.tool === 'copilot') { + infoHtml += ''; + } else if (activeSessions[s.id]) { + infoHtml += ''; + } else { + infoHtml += ''; + if (s.tool === 'claude') { + infoHtml += ''; + } + } + infoHtml += ''; + if (s.has_detail) { + infoHtml += ''; + infoHtml += ''; + if (s.tool === 'claude' || s.tool === 'claude-ext' || s.tool === 'codex') { + var convertTarget = s.tool === 'codex' ? 'claude' : 'codex'; + infoHtml += ''; + } + infoHtml += ''; + } + infoHtml += ''; + infoHtml += ''; + infoHtml += '
'; + + body.innerHTML = infoHtml + '
Loading messages...
'; + + panel.classList.add('open'); + overlay.classList.add('open'); + + // Load messages + if (s.has_detail) { + try { + var resp = await fetch('/api/session/' + s.id + '?project=' + encodeURIComponent(s.project || '')); + var data = await resp.json(); + var msgContainer = body.querySelector('.detail-messages'); + if (data.messages && data.messages.length > 0) { + var msgsHtml = '

Conversation

'; + data.messages.forEach(function(m) { + var roleClass = m.role === 'user' ? 'msg-user' : 'msg-assistant'; + var roleLabel = m.role === 'user' ? 'You' : 'Assistant'; + var hasTools = m.tools && m.tools.length > 0; + msgsHtml += '
'; + msgsHtml += '
'; + msgsHtml += '
' + roleLabel + '
'; + msgsHtml += '
' + escHtml(m.content) + '
'; + msgsHtml += '
'; + if (hasTools) { + msgsHtml += '
'; + m.tools.forEach(function(t) { + if (t.type === 'mcp') { + msgsHtml += '' + escHtml(t.tool) + ''; + } else if (t.type === 'skill') { + msgsHtml += '' + escHtml(t.skill) + ''; + } + }); + msgsHtml += '
'; + } + msgsHtml += '
'; + }); + msgContainer.innerHTML = msgsHtml; + } else { + msgContainer.innerHTML = '
No messages found in detail file.
'; + } + } catch (e) { + body.querySelector('.detail-messages').innerHTML = '
Failed to load messages.
'; + } + } else { + body.querySelector('.detail-messages').innerHTML = '
No detail file available for this session.
'; + } + + // Load real cost + loadRealCost(s.id, s.project || '').then(function(costData) { + if (!costData || !costData.cost) return; + var row = document.getElementById('detail-real-cost'); + if (row) { + row.style.display = ''; + var cacheStr = ''; + if ((costData.cacheReadTokens || 0) + (costData.cacheCreateTokens || 0) > 0) + cacheStr = ' / ' + formatTokens((costData.cacheReadTokens||0) + (costData.cacheCreateTokens||0)) + ' cache'; + row.querySelector('span:last-child').innerHTML = + '$' + costData.cost.toFixed(2) + '' + + ' ' + + formatTokens(costData.inputTokens) + ' in / ' + formatTokens(costData.outputTokens) + ' out' + cacheStr + + (costData.model ? ' (' + costData.model + ')' : '') + ''; + } + // Update estimated badge to show it was estimated + var estBadge = document.getElementById('detail-cost'); + if (estBadge) estBadge.style.opacity = '0.5'; + }); + + // Load git info + if (s.project) { + fetch('/api/git-info?project=' + encodeURIComponent(s.project)) + .then(function(r) { return r.json(); }) + .then(function(git) { + var el = document.getElementById('detail-git-info'); + if (!el || git.error) return; + var html = ''; + if (git.branch) { + html += '
Branch' + escHtml(git.branch); + if (git.isDirty) html += ' *'; + html += '
'; + } + if (git.lastCommit) { + html += '
Last commit'; + if (git.lastCommitHash) html += '' + escHtml(git.lastCommitHash) + ' '; + html += escHtml(git.lastCommit) + '
'; + } + if (git.remoteUrl) { + var displayUrl = git.remoteUrl.replace(/\.git$/, '').replace(/^https?:\/\//, '').replace(/^git@([^:]+):/, '$1/'); + html += '
Remote' + escHtml(displayUrl) + '
'; + } + el.innerHTML = html; + }).catch(function() {}); + } + + // Load git commits + if (s.project) { + var commits = await loadGitCommits(s.project, s.first_ts, s.last_ts); + var commitsContainer = body.querySelector('.detail-commits'); + if (commits && commits.length > 0) { + var cHtml = '

Related Commits

'; + commits.forEach(function(c) { + cHtml += '
'; + cHtml += '' + escHtml(c.hash) + ''; + cHtml += '' + escHtml(c.message) + ''; + cHtml += '
'; + }); + cHtml += '
'; + commitsContainer.innerHTML = cHtml; + } + } +} + +function closeDetail() { + var panel = document.getElementById('detailPanel'); + var overlay = document.getElementById('overlay'); + if (panel) panel.classList.remove('open'); + if (overlay) overlay.classList.remove('open'); +} + +// ── Detail panel resize ─────────────────────────────────────── +(function initDetailResize() { + var handle = document.getElementById('detailResizeHandle'); + var panel = document.getElementById('detailPanel'); + if (!handle || !panel) return; + + var startX, startW; + + handle.addEventListener('mousedown', function(e) { + e.preventDefault(); + startX = e.clientX; + startW = panel.offsetWidth; + document.addEventListener('mousemove', onDrag); + document.addEventListener('mouseup', onStop); + panel.style.transition = 'none'; + }); + + function onDrag(e) { + var diff = startX - e.clientX; + var newW = Math.max(320, Math.min(window.innerWidth * 0.85, startW + diff)); + panel.style.width = newW + 'px'; + } + + function onStop() { + document.removeEventListener('mousemove', onDrag); + document.removeEventListener('mouseup', onStop); + panel.style.transition = ''; + localStorage.setItem('codedash-detail-width', panel.style.width); + } + + // Restore saved width + var saved = localStorage.getItem('codedash-detail-width'); + if (saved) panel.style.width = saved; +})(); + +async function loadGitCommits(project, fromTs, toTs) { + try { + var resp = await fetch('/api/git-commits?project=' + encodeURIComponent(project) + '&from=' + fromTs + '&to=' + toTs); + return await resp.json(); + } catch (e) { + return []; + } +} + +function launchDangerous(sessionId, project) { + launchSession(sessionId, 'claude', project, ['skip-permissions']); +} + +function launchSession(sessionId, tool, project, flags) { + var terminal = localStorage.getItem('codedash-terminal') || ''; + fetch('/api/launch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sessionId: sessionId, + tool: tool, + flags: flags || [], + project: project, + terminal: terminal + }) + }).then(function(resp) { + return resp.json(); + }).then(function(data) { + if (data.ok) showToast('Launched in terminal'); + else showToast('Launch failed: ' + (data.error || 'unknown')); + }).catch(function() { + showToast('Launch failed'); + }); +} + +function copyResume(sessionId, tool) { + var s = allSessions.find(function(x) { return x.id === sessionId; }); + var cmd; + if (tool === 'codex') { + cmd = 'codex resume ' + sessionId; + } else if (tool === 'cursor') { + cmd = 'cursor ' + (s && s.project ? '"' + s.project + '"' : '.'); + } else if (tool === 'copilot') { + cmd = 'code ' + (s && s.project ? '"' + s.project + '"' : '.'); + } else { + cmd = 'claude --resume ' + sessionId; + } + navigator.clipboard.writeText(cmd).then(function() { + showToast('Copied: ' + cmd); + }).catch(function() { + // Fallback + prompt('Copy this command:', cmd); + }); +} + +function exportMd(sessionId, project) { + window.open('/api/session/' + sessionId + '/export?project=' + encodeURIComponent(project)); +} + +// ── Session Replay ──────────────────────────────────────────── + +async function openReplay(sessionId, project) { + var content = document.getElementById('content'); + content.innerHTML = '
Loading replay...
'; + + try { + var resp = await fetch('/api/replay/' + sessionId + '?project=' + encodeURIComponent(project)); + var data = await resp.json(); + + if (!data.messages || data.messages.length === 0) { + content.innerHTML = '
No messages to replay.
'; + return; + } + + var msgs = data.messages; + var html = '
'; + html += '
'; + html += ''; + html += 'Session Replay — ' + sessionId.slice(0, 12) + ''; + html += '' + formatDuration(data.duration) + ''; + html += '
'; + + // Timeline slider + html += '
'; + html += ''; + html += ''; + html += '1 / ' + msgs.length + ''; + html += '
'; + + // Messages area + html += '
'; + html += '
'; + + content.innerHTML = html; + + // Store messages for replay + window._replayMsgs = msgs; + window._replayPos = 0; + window._replayPlaying = false; + window._replayTimer = null; + seekReplay(0); + } catch (e) { + content.innerHTML = '
Failed to load replay.
'; + } +} + +function seekReplay(pos) { + pos = parseInt(pos); + var msgs = window._replayMsgs; + if (!msgs) return; + window._replayPos = pos; + + var container = document.getElementById('replayMessages'); + var slider = document.getElementById('replaySlider'); + var counter = document.getElementById('replayCounter'); + if (!container) return; + + var html = ''; + for (var i = 0; i <= pos && i < msgs.length; i++) { + var m = msgs[i]; + var cls = m.role === 'user' ? 'preview-user' : 'preview-assistant'; + var label = m.role === 'user' ? 'You' : 'AI'; + var time = m.timestamp ? new Date(m.timestamp).toLocaleTimeString() : ''; + var isLatest = i === pos; + html += '
'; + html += '
' + label + '' + time + '
'; + html += '
' + escHtml(m.content) + '
'; + html += '
'; + } + container.innerHTML = html; + container.scrollTop = container.scrollHeight; + + if (slider) slider.value = pos; + if (counter) counter.textContent = (pos + 1) + ' / ' + msgs.length; +} + +function toggleReplayPlay() { + var btn = document.getElementById('replayPlayBtn'); + if (window._replayPlaying) { + window._replayPlaying = false; + clearInterval(window._replayTimer); + if (btn) btn.innerHTML = '▶'; + } else { + window._replayPlaying = true; + if (btn) btn.innerHTML = '▮▮'; + window._replayTimer = setInterval(function() { + var next = window._replayPos + 1; + if (next >= window._replayMsgs.length) { + toggleReplayPlay(); + return; + } + seekReplay(next); + }, 1500); + } +} + +function formatDuration(ms) { + if (!ms) return ''; + var s = Math.floor(ms / 1000); + var m = Math.floor(s / 60); + var h = Math.floor(m / 60); + if (h > 0) return h + 'h ' + (m % 60) + 'm'; + if (m > 0) return m + 'm ' + (s % 60) + 's'; + return s + 's'; +} diff --git a/src/frontend/index.html b/src/frontend/index.html index c8b3fd9..d721db7 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -1,178 +1,182 @@ - - - - - -codedash - - - - - - -
-
- - - -
- - - - - - -
-
-
- -
-
-
-
- Session Detail - -
-
-
- -
-
-

Delete Session?

-

This will permanently delete the session file and remove it from history.

-
-
- - -
-
-
- - - -
- - - - - - + + + + + +codedash + + + + + + +
+
+ + + +
+ + + + + + +
+
+
+ +
+
+
+
+ Session Detail + +
+
+
+ +
+
+

Delete Session?

+

This will permanently delete the session file and remove it from history.

+
+
+ + +
+
+
+ + + +
+ + + + + + diff --git a/src/frontend/styles.css b/src/frontend/styles.css index be798f4..d0aafbc 100644 --- a/src/frontend/styles.css +++ b/src/frontend/styles.css @@ -1,2983 +1,2988 @@ -/* ================================================================ - Claude Sessions Dashboard — Stylesheet - ================================================================ */ - -/* ── Reset ─────────────────────────────────────────────────────── */ - -* { margin: 0; padding: 0; box-sizing: border-box; } - -/* ── CSS Custom Properties (Dark Theme — Default) ──────────────── */ - -:root { - --bg-primary: #1a1d23; - --bg-secondary: #22262e; - --bg-card: #2a2e37; - --bg-card-hover: #333842; - --bg-input: #2a2e37; - --text-primary: #e4e7eb; - --text-secondary: #8b919a; - --text-muted: #5f6571; - --accent-green: #4ade80; - --accent-blue: #60a5fa; - --accent-orange: #fb923c; - --accent-purple: #c084fc; - --accent-red: #f87171; - --accent-cyan: #22d3ee; - --border: #363b44; - --sidebar-bg: #1e2128; -} - -/* ── Light Theme ───────────────────────────────────────────────── */ - -[data-theme="light"] { - --bg-primary: #f5f5f7; - --bg-secondary: #ffffff; - --bg-card: #ffffff; - --bg-card-hover: #f0f0f2; - --bg-input: #ffffff; - --text-primary: #1d1d1f; - --text-secondary: #6e6e73; - --text-muted: #a1a1a6; - --accent-green: #34c759; - --accent-blue: #007aff; - --accent-orange: #ff9500; - --accent-purple: #af52de; - --accent-red: #ff3b30; - --accent-cyan: #00b4d8; - --border: #d2d2d7; - --sidebar-bg: #f5f5f7; -} - -/* ── Monokai Theme ─────────────────────────────────────────────── */ - -[data-theme="monokai"] { - --bg-primary: #272822; - --bg-secondary: #1e1f1c; - --bg-card: #3e3d32; - --bg-card-hover: #4a493d; - --bg-input: #3e3d32; - --text-primary: #f8f8f2; - --text-secondary: #a6a690; - --text-muted: #75715e; - --accent-green: #a6e22e; - --accent-blue: #66d9ef; - --accent-orange: #fd971f; - --accent-purple: #ae81ff; - --accent-red: #f92672; - --accent-cyan: #66d9ef; - --border: #49483e; - --sidebar-bg: #1e1f1c; -} - -/* ── Body & Layout ─────────────────────────────────────────────── */ - -body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - background: var(--bg-primary); - color: var(--text-primary); - height: 100vh; - display: flex; - overflow: hidden; -} - -/* ── Sidebar ───────────────────────────────────────────────────── */ - -.sidebar { - width: 200px; - background: var(--sidebar-bg); - border-right: 1px solid var(--border); - padding: 16px 0; - display: flex; - flex-direction: column; - flex-shrink: 0; - overflow-y: auto; - overflow-x: hidden; -} - -.sidebar-brand { - padding: 8px 20px 20px; - font-size: 15px; - font-weight: 700; - color: var(--accent-blue); - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; -} - -.version-badge { - font-size: 10px; - font-weight: 500; - color: var(--text-muted); - background: rgba(255,255,255,0.06); - padding: 1px 6px; - border-radius: 4px; -} - -.version-badge.update-available { - color: var(--accent-green); - background: rgba(74, 222, 128, 0.15); - cursor: pointer; -} - -.version-badge.update-available:hover { - background: rgba(74, 222, 128, 0.25); -} - -.sidebar-item { - display: flex; - align-items: center; - gap: 10px; - padding: 10px 20px; - color: var(--text-secondary); - cursor: pointer; - transition: all 0.15s; - font-size: 13px; - user-select: none; -} -.sidebar-item:hover { background: rgba(255,255,255,0.05); color: var(--text-primary); } -.sidebar-item.active { color: var(--text-primary); background: rgba(255,255,255,0.08); } -.sidebar-item svg { width: 18px; height: 18px; opacity: 0.7; } - -.sidebar-divider { height: 1px; background: var(--border); margin: 12px 20px; } - -.sidebar-section { - padding: 8px 20px 6px; - font-size: 10px; - text-transform: uppercase; - letter-spacing: 1.2px; - color: var(--text-muted); -} - -.sidebar-author { - margin-top: auto; - padding: 8px 20px 0; - font-size: 11px; - text-align: center; -} -.sidebar-author a { - color: var(--text-muted); - text-decoration: none; - transition: color 0.15s; -} -.sidebar-author a:hover { color: var(--accent-blue); } - -/* ── Settings page ──────────────────────────────────────────── */ - -.settings-page { - max-width: 480px; - padding: 24px; -} -.settings-group { - margin-bottom: 20px; -} -.settings-label { - font-size: 12px; - color: var(--text-muted); - display: block; - margin-bottom: 8px; - text-transform: uppercase; - letter-spacing: 0.5px; -} -.settings-theme-btns { - display: flex; - gap: 8px; -} -.theme-btn { - padding: 8px 20px; - border-radius: 6px; - background: rgba(255,255,255,0.04); - color: var(--text-secondary); - border: 1px solid var(--border); - font-size: 13px; - cursor: pointer; - transition: all 0.15s; -} -.theme-btn:hover { background: rgba(255,255,255,0.08); color: var(--text-primary); } -.theme-btn.active { - background: rgba(96, 165, 250, 0.15); - color: var(--accent-blue); - border-color: rgba(96, 165, 250, 0.3); -} -.settings-select { - width: 100%; - padding: 8px 12px; - border-radius: 6px; - background: var(--bg-card, rgba(255,255,255,0.04)); - color: var(--text-primary); - border: 1px solid var(--border); - font-size: 13px; - -webkit-appearance: none; - appearance: none; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%236e7681' d='M2 4l4 4 4-4'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 10px center; - padding-right: 28px; -} -.settings-select option { - background: var(--bg-card, #161b22); - color: var(--text-primary, #e6edf3); -} -.settings-checkbox { - display: flex; - align-items: center; - gap: 8px; -} -.settings-checkbox input { margin: 0; } - -/* Light theme sidebar hover adjustments */ -[data-theme="light"] .sidebar-item:hover { background: rgba(0,0,0,0.04); } -[data-theme="light"] .sidebar-item.active { background: rgba(0,0,0,0.06); } - -/* ── Main Area ─────────────────────────────────────────────────── */ - -.main { - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; -} - -/* ── Toolbar ───────────────────────────────────────────────────── */ - -.toolbar { - display: flex; - align-items: center; - gap: 12px; - padding: 12px 20px; - border-bottom: 1px solid var(--border); - background: var(--bg-secondary); - flex-wrap: wrap; - position: relative; -} - -.search-box { - flex: 1; - min-width: 200px; - background: var(--bg-input); - border: 1px solid var(--border); - border-radius: 8px; - padding: 10px 14px; - color: var(--text-primary); - font-size: 14px; - outline: none; - transition: border-color 0.2s; -} -.search-box:focus { border-color: var(--accent-blue); } -.search-box::placeholder { color: var(--text-muted); } - -.toolbar-btn { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: 8px; - padding: 8px 14px; - color: var(--text-secondary); - cursor: pointer; - font-size: 13px; - white-space: nowrap; - transition: all 0.15s; - user-select: none; -} -.toolbar-btn:hover { background: var(--bg-card-hover); color: var(--text-primary); } -.toolbar-btn.active { background: var(--accent-blue); color: #fff; border-color: var(--accent-blue); } - -.tool-filter { - display: flex; - gap: 4px; -} -.tool-chip { - padding: 6px 12px; - border-radius: 6px; - font-size: 12px; - cursor: pointer; - border: 1px solid var(--border); - background: var(--bg-card); - color: var(--text-secondary); - transition: all 0.15s; - user-select: none; -} -.tool-chip:hover { color: var(--text-primary); } -.tool-chip.active-claude { background: rgba(96, 165, 250, 0.2); border-color: var(--accent-blue); color: var(--accent-blue); } -.tool-chip.active-codex { background: rgba(34, 211, 238, 0.2); border-color: var(--accent-cyan); color: var(--accent-cyan); } - -.stats { color: var(--text-muted); font-size: 13px; white-space: nowrap; } - -/* ── Content Area ──────────────────────────────────────────────── */ - -.content { - flex: 1; - overflow-y: auto; - padding: 20px; -} -.content::-webkit-scrollbar { width: 8px; } -.content::-webkit-scrollbar-track { background: transparent; } -.content::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } - -.group-header { - font-size: 13px; - font-weight: 600; - color: var(--text-secondary); - margin: 20px 0 10px; - text-transform: uppercase; - letter-spacing: 0.5px; -} -.group-header:first-child { margin-top: 0; } - -/* ── Cards Grid ────────────────────────────────────────────────── */ - -.cards { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); - gap: 10px; - margin-bottom: 10px; -} - -.card { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: 10px; - padding: 14px 16px; - cursor: pointer; - transition: all 0.15s; - display: flex; - align-items: flex-start; - gap: 12px; - position: relative; -} -.card:hover { - background: var(--bg-card-hover); - border-color: rgba(255,255,255,0.1); - transform: translateY(-1px); -} - -.card.selected { - border-color: var(--accent-blue); - background: var(--bg-card-hover); - box-shadow: 0 0 0 1px var(--accent-blue); -} - -.card.focused { - outline: 2px solid var(--accent-blue); - outline-offset: 2px; -} - -[data-theme="light"] .card:hover { - border-color: rgba(0,0,0,0.12); -} -[data-theme="light"] .card.selected { - box-shadow: 0 0 0 1px var(--accent-blue), 0 2px 8px rgba(0,122,255,0.1); -} - -.card-icon { - width: 40px; - height: 40px; - border-radius: 10px; - display: flex; - align-items: center; - justify-content: center; - font-size: 18px; - flex-shrink: 0; -} - -.card-body { flex: 1; min-width: 0; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } - -.card-title { - font-size: 14px; - font-weight: 600; - margin-bottom: 4px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.card-meta { - font-size: 12px; - color: var(--text-muted); - display: flex; - gap: 8px; - flex-wrap: wrap; - align-items: center; -} - -.card-preview { - font-size: 12px; - color: var(--text-secondary); - margin-top: 6px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -/* ── Card Checkbox (bulk select) ───────────────────────────────── */ - -.card-checkbox { - width: 16px; - height: 16px; - cursor: pointer; - accent-color: var(--accent-blue); - flex-shrink: 0; - display: none; -} -.select-mode .card:hover .card-checkbox, -.select-mode .card.selected .card-checkbox, -.card.selected .card-checkbox { - display: inline-block; -} - -/* ── Card Actions ──────────────────────────────────────────────── */ - -.card-actions { - position: absolute; - top: 8px; - right: 8px; - display: none; - gap: 4px; -} -.card:hover .card-actions { display: flex; } - -.card-action-btn { - width: 28px; - height: 28px; - border-radius: 6px; - border: 1px solid var(--border); - background: var(--bg-secondary); - color: var(--text-secondary); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - font-size: 14px; - transition: all 0.15s; -} -.card-action-btn:hover { background: var(--accent-blue); color: #fff; border-color: var(--accent-blue); } -.card-action-btn.danger:hover { background: var(--accent-orange); border-color: var(--accent-orange); } -.card-action-btn.delete:hover { background: var(--accent-red); border-color: var(--accent-red); } - -/* ── Badges ────────────────────────────────────────────────────── */ - -.badge { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 2px 8px; - border-radius: 4px; - font-size: 11px; - background: rgba(255,255,255,0.06); -} -.badge-claude { background: rgba(96, 165, 250, 0.15); color: var(--accent-blue); } -.badge-codex { background: rgba(34, 211, 238, 0.15); color: var(--accent-cyan); } - -[data-theme="light"] .badge { background: rgba(0,0,0,0.05); } - -/* ── Star Button ───────────────────────────────────────────────── */ - -.star-btn { - background: none; - border: none; - cursor: pointer; - font-size: 16px; - color: var(--text-muted); - transition: color 0.15s, transform 0.15s; - padding: 2px 4px; - line-height: 1; -} -.star-btn:hover { - color: #fbbf24; - transform: scale(1.15); -} -.star-btn.active { - color: #fbbf24; -} - -/* ── Tag Pills ─────────────────────────────────────────────────── */ - -.tag-pill { - display: inline-flex; - align-items: center; - padding: 2px 8px; - border-radius: 10px; - font-size: 10px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.3px; - line-height: 1.6; - white-space: nowrap; -} -.tag-pill.tag-bug { background: rgba(248,113,113,0.18); color: var(--accent-red); } -.tag-pill.tag-feature { background: rgba(96,165,250,0.18); color: var(--accent-blue); } -.tag-pill.tag-research { background: rgba(192,132,252,0.18); color: var(--accent-purple); } -.tag-pill.tag-infra { background: rgba(251,146,60,0.18); color: var(--accent-orange); } -.tag-pill.tag-deploy { background: rgba(74,222,128,0.18); color: var(--accent-green); } -.tag-pill.tag-review { background: rgba(34,211,238,0.18); color: var(--accent-cyan); } - -[data-theme="light"] .tag-pill.tag-bug { background: rgba(255,59,48,0.12); } -[data-theme="light"] .tag-pill.tag-feature { background: rgba(0,122,255,0.12); } -[data-theme="light"] .tag-pill.tag-research { background: rgba(175,82,222,0.12); } -[data-theme="light"] .tag-pill.tag-infra { background: rgba(255,149,0,0.12); } -[data-theme="light"] .tag-pill.tag-deploy { background: rgba(52,199,89,0.12); } -[data-theme="light"] .tag-pill.tag-review { background: rgba(0,180,216,0.12); } - -/* ── Tag Add Button & Dropdown ─────────────────────────────────── */ - -.tag-add-btn { - display: inline-flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - border-radius: 10px; - border: 1px dashed var(--text-muted); - background: none; - color: var(--text-muted); - font-size: 12px; - cursor: pointer; - transition: all 0.15s; - line-height: 1; -} -.tag-add-btn:hover { - border-color: var(--accent-blue); - color: var(--accent-blue); - background: rgba(96,165,250,0.1); -} - -.tag-dropdown { - position: fixed; - background: var(--bg-secondary); - border: 1px solid var(--border); - border-radius: 8px; - padding: 6px; - min-width: 140px; - box-shadow: 0 8px 24px rgba(0,0,0,0.3); - z-index: 150; -} - -.tag-dropdown-item { - display: flex; - align-items: center; - gap: 8px; - padding: 6px 10px; - border-radius: 6px; - font-size: 12px; - cursor: pointer; - color: var(--text-secondary); - transition: background 0.1s; -} -.tag-dropdown-item:hover { - background: var(--bg-card-hover); - color: var(--text-primary); -} - -[data-theme="light"] .tag-dropdown { - box-shadow: 0 8px 24px rgba(0,0,0,0.12); -} - -/* ── Cost Badge ────────────────────────────────────────────────── */ - -.cost-badge { - display: inline-flex; - align-items: center; - gap: 3px; - padding: 2px 7px; - border-radius: 4px; - font-size: 11px; - font-weight: 600; - background: rgba(74,222,128,0.15); - color: var(--accent-green); - white-space: nowrap; -} -[data-theme="light"] .cost-badge { - background: rgba(52,199,89,0.12); -} - -/* ── Date Range Inputs ─────────────────────────────────────────── */ - -/* ── Calendar popup ─────────────────────────────────────────── */ - -#dateBtn { display: flex; align-items: center; gap: 5px; } -#dateBtn.has-filter { background: rgba(96,165,250,0.15); color: var(--accent-blue); border-color: rgba(96,165,250,0.3); } - -.calendar-popup { - display: none; - position: fixed; - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: 10px; - padding: 16px; - width: 280px; - box-shadow: 0 8px 32px rgba(0,0,0,0.4); - z-index: 50; -} -.calendar-popup.open { display: block; } - -.cal-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 12px; -} -.cal-header span { color: var(--text-primary); font-size: 13px; font-weight: 600; } -.cal-nav { - background: none; border: none; color: var(--text-muted); font-size: 18px; - cursor: pointer; padding: 0 4px; line-height: 1; -} -.cal-nav:hover { color: var(--text-primary); } - -.cal-weekdays { - display: grid; - grid-template-columns: repeat(7, 1fr); - gap: 2px; - text-align: center; - font-size: 11px; - color: var(--text-muted); - margin-bottom: 4px; -} - -.cal-days { - display: grid; - grid-template-columns: repeat(7, 1fr); - gap: 2px; - text-align: center; -} - -.cal-day { - padding: 6px 0; - font-size: 12px; - border-radius: 4px; - cursor: pointer; - color: var(--text-secondary); - transition: all 0.1s; -} -.cal-day:hover { background: rgba(255,255,255,0.08); color: var(--text-primary); } -.cal-day.other-month { color: var(--text-muted); opacity: 0.3; } -.cal-day.today { font-weight: 700; color: var(--accent-blue); } -.cal-day.in-range { background: rgba(96,165,250,0.1); } -.cal-day.range-start, .cal-day.range-end { - background: rgba(96,165,250,0.3); color: #fff; font-weight: 600; -} -.cal-day.range-start { border-radius: 4px 0 0 4px; } -.cal-day.range-end { border-radius: 0 4px 4px 0; } -.cal-day.range-start.range-end { border-radius: 4px; } - -.cal-presets { - margin-top: 12px; - padding-top: 12px; - border-top: 1px solid var(--border); - display: flex; - gap: 6px; - flex-wrap: wrap; -} -.cal-preset { - font-size: 11px; - padding: 4px 10px; - border-radius: 4px; - background: rgba(255,255,255,0.04); - color: var(--text-muted); - border: none; - cursor: pointer; - transition: all 0.15s; -} -.cal-preset:hover { background: rgba(255,255,255,0.08); color: var(--text-primary); } - -/* ── Terminal Select ───────────────────────────────────────────── */ - -.terminal-select { - width: 100%; - background: var(--bg-input); - border: 1px solid var(--border); - border-radius: 6px; - padding: 8px 10px; - color: var(--text-primary); - font-size: 13px; - outline: none; - margin-bottom: 10px; - cursor: pointer; -} -.terminal-select:focus { border-color: var(--accent-blue); } -.terminal-select option { background: var(--bg-card); color: var(--text-primary); } - -/* ── Confirm Dialog ────────────────────────────────────────────── */ - -.confirm-overlay { - display: none; - position: fixed; - inset: 0; - background: rgba(0,0,0,0.6); - z-index: 300; - align-items: center; - justify-content: center; -} -.confirm-overlay.open { display: flex; } - -.confirm-box { - background: var(--bg-secondary); - border: 1px solid var(--border); - border-radius: 12px; - padding: 24px; - max-width: 400px; - width: 90%; -} -.confirm-box h3 { font-size: 16px; margin-bottom: 8px; } -.confirm-box p { font-size: 13px; color: var(--text-secondary); margin-bottom: 16px; line-height: 1.5; } -.confirm-box .confirm-id { font-family: monospace; font-size: 11px; color: var(--text-muted); margin-bottom: 16px; word-break: break-all; } - -.confirm-btns { display: flex; gap: 8px; justify-content: flex-end; } -.confirm-btns button { - padding: 8px 18px; - border-radius: 8px; - border: 1px solid var(--border); - font-size: 13px; - cursor: pointer; - font-weight: 500; -} -.btn-cancel { background: var(--bg-card); color: var(--text-primary); } -.btn-cancel:hover { background: var(--bg-card-hover); } -.btn-delete { background: var(--accent-red); color: #fff; border-color: var(--accent-red); } -.btn-delete:hover { opacity: 0.85; } - -/* ── Detail Panel ──────────────────────────────────────────────── */ - -.detail-panel { - position: fixed; - top: 0; - right: 0; - width: 840px; - min-width: 400px; - max-width: 90vw; - height: 100vh; - background: var(--bg-secondary); - border-left: 1px solid var(--border); - transform: translateX(100%); - transition: transform 0.25s ease; - display: flex; - flex-direction: column; - z-index: 100; -} -.detail-panel.open { transform: translateX(0); } -.detail-panel.resizing { transition: none; } - -.detail-resize-handle { - position: absolute; - left: -3px; - top: 0; - width: 6px; - height: 100%; - cursor: col-resize; - z-index: 101; -} -.detail-resize-handle:hover, -.detail-resize-handle.active { - background: var(--accent-blue); - opacity: 0.5; -} - -.detail-header { - padding: 16px 20px; - border-bottom: 1px solid var(--border); - display: flex; - align-items: center; - justify-content: space-between; -} - -.detail-close { - background: none; - border: none; - color: var(--text-secondary); - cursor: pointer; - font-size: 20px; - padding: 4px 8px; - border-radius: 4px; -} -.detail-close:hover { background: rgba(255,255,255,0.1); } - -.detail-body { - flex: 1; - overflow-y: auto; - padding: 16px 20px; -} - -.detail-info { - display: grid; - grid-template-columns: auto 1fr; - gap: 8px 16px; - font-size: 13px; - margin-bottom: 16px; -} -.detail-info dt { color: var(--text-muted); } -.detail-info dd { color: var(--text-primary); word-break: break-all; } - -/* ── Launch Section ────────────────────────────────────────────── */ - -.launch-section { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: 10px; - padding: 14px; - margin-bottom: 16px; -} - -.launch-title { - font-size: 12px; - font-weight: 600; - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.5px; - margin-bottom: 10px; -} - -.launch-options { - display: flex; - flex-direction: column; - gap: 8px; - margin-bottom: 12px; -} - -.launch-option { - display: flex; - align-items: center; - gap: 8px; - font-size: 13px; - cursor: pointer; - user-select: none; -} - -.launch-option input[type="checkbox"] { - accent-color: var(--accent-blue); - width: 16px; - height: 16px; -} - -.launch-btns { - display: flex; - gap: 8px; - flex-wrap: wrap; -} - -.launch-btn { - display: inline-flex; - align-items: center; - gap: 6px; - border: none; - border-radius: 8px; - padding: 9px 16px; - font-size: 13px; - cursor: pointer; - font-weight: 500; - transition: opacity 0.15s; -} -.launch-btn:hover { opacity: 0.85; } - -.btn-primary { background: var(--accent-blue); color: #fff; } -.btn-secondary { background: var(--bg-card-hover); color: var(--text-primary); border: 1px solid var(--border); } -.btn-codex { background: var(--accent-cyan); color: #000; } - -/* ── Commits List (Detail Panel) ───────────────────────────────── */ - -.commits-list { - list-style: none; - margin: 0; - padding: 0; -} - -.commits-list li { - display: flex; - align-items: flex-start; - gap: 10px; - padding: 8px 0; - border-bottom: 1px solid var(--border); - font-size: 13px; - line-height: 1.4; -} -.commits-list li:last-child { border-bottom: none; } - -.commits-list .commit-hash { - font-family: 'SF Mono', 'Fira Code', monospace; - font-size: 11px; - color: var(--accent-purple); - background: rgba(192,132,252,0.1); - padding: 2px 6px; - border-radius: 4px; - white-space: nowrap; - flex-shrink: 0; -} - -.commits-list .commit-msg { - color: var(--text-secondary); - flex: 1; - min-width: 0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -/* ── Message Bubbles ───────────────────────────────────────────── */ - -.msg-bubble { - margin-bottom: 12px; - padding: 10px 14px; - border-radius: 10px; - font-size: 13px; - line-height: 1.5; - white-space: pre-wrap; - word-break: break-word; - max-height: 300px; - overflow-y: auto; -} -.msg-bubble.user { background: rgba(96,165,250,0.15); border: 1px solid rgba(96,165,250,0.2); } -.msg-bubble.assistant { background: rgba(74,222,128,0.1); border: 1px solid rgba(74,222,128,0.15); } - -.msg-role { - font-size: 11px; - font-weight: 600; - margin-bottom: 4px; - text-transform: uppercase; - letter-spacing: 0.5px; -} -.msg-role.user { color: var(--accent-blue); } -.msg-role.assistant { color: var(--accent-green); } - -/* ── Overlay ───────────────────────────────────────────────────── */ - -.overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.4); z-index: 99; } -.overlay.open { display: block; } - -/* ── Icon Colors ───────────────────────────────────────────────── */ - -.icon-green { background: rgba(74,222,128,0.2); color: var(--accent-green); } -.icon-blue { background: rgba(96,165,250,0.2); color: var(--accent-blue); } -.icon-orange { background: rgba(251,146,60,0.2); color: var(--accent-orange); } -.icon-purple { background: rgba(192,132,252,0.2); color: var(--accent-purple); } -.icon-red { background: rgba(248,113,113,0.2); color: var(--accent-red); } -.icon-cyan { background: rgba(34,211,238,0.2); color: var(--accent-cyan); } - -/* ── Empty State ───────────────────────────────────────────────── */ - -.empty-state { - text-align: center; - padding: 60px 20px; - color: var(--text-muted); -} -.empty-state svg { width: 48px; height: 48px; margin-bottom: 16px; opacity: 0.3; } -.empty-state p { font-size: 14px; } - -/* ── Toast Notifications ───────────────────────────────────────── */ - -.toast { - position: fixed; - bottom: 20px; - right: 20px; - background: var(--accent-green); - color: #000; - padding: 10px 18px; - border-radius: 8px; - font-size: 13px; - font-weight: 500; - transform: translateY(80px); - transition: transform 0.3s; - z-index: 200; -} -.toast.show { transform: translateY(0); } - -/* ── Heatmap ───────────────────────────────────────────────────── */ - -.heatmap-grid { - display: grid; - grid-template-columns: repeat(53, 12px); - grid-template-rows: repeat(7, 12px); - gap: 2px; - grid-auto-flow: column; -} - -.heatmap-cell { - width: 12px; - height: 12px; - border-radius: 2px; - background: var(--bg-card); - transition: background 0.15s; -} - -.heatmap-cell.level-0 { background: var(--bg-card); } -.heatmap-cell.level-1 { background: rgba(74,222,128,0.25); } -.heatmap-cell.level-2 { background: rgba(74,222,128,0.45); } -.heatmap-cell.level-3 { background: rgba(74,222,128,0.70); } -.heatmap-cell.level-4 { background: #4ade80; } - -[data-theme="light"] .heatmap-cell.level-0 { background: #ebedf0; } -[data-theme="light"] .heatmap-cell.level-1 { background: #9be9a8; } -[data-theme="light"] .heatmap-cell.level-2 { background: #40c463; } -[data-theme="light"] .heatmap-cell.level-3 { background: #30a14e; } -[data-theme="light"] .heatmap-cell.level-4 { background: #216e39; } - -[data-theme="monokai"] .heatmap-cell.level-1 { background: rgba(166,226,46,0.25); } -[data-theme="monokai"] .heatmap-cell.level-2 { background: rgba(166,226,46,0.45); } -[data-theme="monokai"] .heatmap-cell.level-3 { background: rgba(166,226,46,0.70); } -[data-theme="monokai"] .heatmap-cell.level-4 { background: #a6e22e; } - -/* ── Bulk Action Bar ───────────────────────────────────────────── */ - -.bulk-bar { - position: fixed; - bottom: 0; - left: 0; - right: 0; - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 24px; - background: rgba(26,29,35,0.85); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - border-top: 1px solid var(--border); - z-index: 150; - transform: translateY(100%); - transition: transform 0.25s ease; -} -.bulk-bar.visible { transform: translateY(0); } - -.bulk-bar .bulk-count { - font-size: 14px; - font-weight: 600; - color: var(--text-primary); -} - -.bulk-bar .bulk-actions { - display: flex; - gap: 8px; -} - -.bulk-bar button { - padding: 8px 16px; - border-radius: 8px; - border: 1px solid var(--border); - font-size: 13px; - font-weight: 500; - cursor: pointer; - transition: all 0.15s; - background: var(--bg-card); - color: var(--text-primary); -} -.bulk-bar button:hover { background: var(--bg-card-hover); } -.bulk-bar button.bulk-delete { - background: var(--accent-red); - color: #fff; - border-color: var(--accent-red); -} -.bulk-bar button.bulk-delete:hover { opacity: 0.85; } - -[data-theme="light"] .bulk-bar { - background: rgba(245,245,247,0.88); -} -[data-theme="monokai"] .bulk-bar { - background: rgba(39,40,34,0.88); -} - -/* ── Export Button ─────────────────────────────────────────────── */ - -.export-btn { - display: inline-flex; - align-items: center; - gap: 6px; - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: 8px; - padding: 8px 14px; - color: var(--text-secondary); - font-size: 13px; - font-weight: 500; - cursor: pointer; - transition: all 0.15s; - white-space: nowrap; -} -.export-btn:hover { - background: var(--bg-card-hover); - color: var(--text-primary); -} -.export-btn svg { width: 16px; height: 16px; } - -/* ── Card structure (app.js layout) ─────────────────────────── */ - -.card-top { - display: flex; - align-items: center; - gap: 8px; - width: 100%; - margin-bottom: 6px; - flex-wrap: wrap; -} - -.card-title { - font-size: 14px; - font-weight: 600; - color: var(--text-primary); - line-height: 1.4; - margin-bottom: 2px; - word-break: break-word; -} - -.card-body { - font-size: 13px; - color: var(--text-primary); - line-height: 1.5; - word-break: break-word; - margin-bottom: 8px; - flex: unset; - min-width: unset; -} - -.card-body-sub { - font-size: 12px; - color: var(--text-muted); - margin-bottom: 6px; -} - -.card-footer { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; - font-size: 12px; - color: var(--text-muted); -} - -.card-id { - font-family: monospace; - font-size: 11px; - color: var(--text-muted); - opacity: 0.6; -} - -.card-project { - font-size: 12px; - font-weight: 600; -} - -.card-tags { - display: inline-flex; - align-items: center; - gap: 4px; - flex-wrap: wrap; -} - -.card-time { - font-size: 12px; - color: var(--text-muted); - margin-left: auto; -} - -.card { display: flex; flex-direction: column; } - -/* ── Tool badges ────────────────────────────────────────────── */ - -.tool-badge { - font-size: 10px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - padding: 2px 8px; - border-radius: 4px; -} - -.tool-claude { - background: rgba(96, 165, 250, 0.15); - color: var(--accent-blue); -} - -.tool-codex { - background: rgba(34, 211, 238, 0.15); - color: var(--accent-cyan); -} - -.tool-opencode { - background: rgba(192, 132, 252, 0.15); - color: var(--accent-purple); -} - -.tool-kiro { - background: rgba(251, 146, 60, 0.15); - color: var(--accent-orange); -} - -.tool-cursor { - background: rgba(96, 165, 250, 0.15); - color: #4a9eff; -} - -/* ── MCP / Skill badges ──────────────────────────────────────── */ - -.badge-mcp { - background: rgba(251, 146, 60, 0.15); - color: var(--accent-orange); -} - -.badge-skill { - background: rgba(139, 92, 246, 0.15); - color: var(--accent-purple); -} - -.card-tools { - padding: 8px 16px; - border-top: 1px solid var(--border); - display: flex; - gap: 6px; - flex-wrap: wrap; -} - -.message.has-tools { - padding: 0; - overflow: hidden; -} -.message.has-tools .msg-inner { - padding: 10px 14px; -} -.msg-tools { - display: flex; - flex-wrap: wrap; - gap: 6px; - padding: 6px 14px; - border-top: 1px solid var(--border); -} - -/* ── Groups ─────────────────────────────────────────────────── */ - -.group { - margin-bottom: 16px; -} - -.group.collapsed .group-body { display: none; } -.group.collapsed .group-chevron { transform: rotate(-90deg); } - -.group-body { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); - gap: 10px; - padding-top: 8px; -} - -.group-header { - display: flex; - align-items: center; - gap: 8px; - padding: 6px 4px; - cursor: pointer; - user-select: none; - font-size: 13px; - font-weight: 600; - color: var(--text-secondary); -} - -.group-dot { - width: 10px; - height: 10px; - border-radius: 50%; - flex-shrink: 0; -} - -.group-name { flex: 1; } - -.group-count { - font-size: 11px; - color: var(--text-muted); - background: rgba(255,255,255,0.06); - padding: 1px 8px; - border-radius: 10px; -} - -.group-chevron { - font-size: 10px; - transition: transform 0.2s; - color: var(--text-muted); -} - -/* ── GitHub-style Activity ──────────────────────────────────── */ - -.gh-activity { - padding: 20px; - max-width: 900px; -} - -.gh-header { - margin-bottom: 8px; -} - -.gh-total { - font-size: 14px; - color: var(--text-secondary); -} - -.gh-graph { - overflow-x: auto; - padding: 8px 0; - border: 1px solid var(--border); - border-radius: 6px; - background: var(--bg-card); - padding: 16px; -} - -.gh-graph svg rect { - outline: 1px solid rgba(27,31,35,0.04); - outline-offset: -1px; -} - -.gh-footer { - display: flex; - align-items: center; - justify-content: space-between; - margin-top: 8px; - font-size: 12px; -} - -.gh-link { - color: var(--text-muted); - text-decoration: none; -} - -.gh-legend { - display: flex; - align-items: center; - gap: 3px; - font-size: 11px; - color: var(--text-muted); -} - -.gh-legend-cell { - width: 11px; - height: 11px; - border-radius: 2px; - display: inline-block; -} - -/* Stats grid */ -.gh-stats { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); - gap: 12px; - margin-top: 20px; -} - -.gh-stat-card { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: 8px; - padding: 14px; - text-align: center; -} - -.gh-stat-num { - font-size: 24px; - font-weight: 700; - color: var(--text-primary); -} - -.gh-stat-label { - font-size: 11px; - color: var(--text-muted); - margin-top: 2px; -} - -/* Tool breakdown */ -.gh-tools { - margin-top: 20px; -} - -.gh-tool-row { - display: flex; - align-items: center; - gap: 12px; - margin-bottom: 8px; -} - -.gh-tool-name { - width: 80px; - font-size: 13px; - font-weight: 600; - flex-shrink: 0; -} - -.gh-tool-bar { - flex: 1; - height: 8px; - background: var(--bg-card); - border-radius: 4px; - overflow: hidden; -} - -.gh-tool-fill { - height: 100%; - border-radius: 4px; - transition: width 0.5s ease; -} - -.gh-tool-val { - font-size: 12px; - color: var(--text-muted); - min-width: 80px; - text-align: right; -} - -/* Light theme overrides for SVG */ -[data-theme="light"] .gh-graph svg rect { outline-color: rgba(27,31,35,0.06); } - -/* ── Detail panel additions ─────────────────────────────────── */ - -.detail-actions { - display: flex; - gap: 8px; - flex-wrap: wrap; - margin: 16px 0; -} - -.detail-row { - display: flex; - align-items: baseline; - gap: 12px; - padding: 6px 0; - border-bottom: 1px solid var(--border); - font-size: 13px; - word-break: break-word; - min-width: 0; -} - -.detail-label { - width: 100px; - flex-shrink: 0; - color: var(--text-muted); - font-size: 12px; -} - -.git-branch { - font-weight: 600; - color: var(--accent-purple); -} -.git-dirty { - color: var(--accent-orange); - font-weight: 700; -} - -.detail-messages { - margin-top: 16px; -} - -.detail-commits { - margin-top: 16px; -} - -.detail-commits h3, -.detail-messages h3 { - font-size: 14px; - font-weight: 600; - margin-bottom: 12px; - color: var(--text-secondary); -} - -.detail-star { - font-size: 14px; - padding: 8px 14px; -} - -.mono { - font-family: monospace; - font-size: 12px; - word-break: break-all; -} - -.loading { - color: var(--text-muted); - font-size: 13px; - padding: 20px 0; -} - -/* ── Messages ───────────────────────────────────────────────── */ - -.message { - margin-bottom: 12px; - padding: 10px 14px; - border-radius: 10px; - font-size: 13px; - line-height: 1.5; - word-break: break-word; - max-height: 300px; - overflow-y: auto; -} - -.msg-user { - background: rgba(96, 165, 250, 0.12); - border: 1px solid rgba(96, 165, 250, 0.2); -} - -.msg-assistant { - background: rgba(74, 222, 128, 0.08); - border: 1px solid rgba(74, 222, 128, 0.15); -} - -.msg-role { - font-size: 11px; - font-weight: 600; - margin-bottom: 4px; - text-transform: uppercase; - letter-spacing: 0.5px; - color: var(--text-muted); -} - -.msg-content { - white-space: pre-wrap; -} - -/* ── Commits ────────────────────────────────────────────────── */ - -.commit-item { - display: flex; - gap: 10px; - padding: 6px 0; - border-bottom: 1px solid var(--border); - font-size: 13px; -} - -.commit-hash { - font-family: monospace; - font-size: 12px; - color: var(--accent-blue); - flex-shrink: 0; -} - -.commit-msg { - color: var(--text-primary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -/* ── Git Projects accordion ─────────────────────────────────── */ - -.git-projects { - display: flex; - flex-direction: column; - gap: 8px; -} - -.git-project-group { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: 10px; - overflow: hidden; -} - -.git-project-header { - display: flex; - align-items: center; - gap: 10px; - padding: 12px 16px; - cursor: pointer; - transition: background 0.12s; - user-select: none; -} -.git-project-header:hover { background: var(--bg-card-hover); } - -.git-project-name { - font-size: 14px; - font-weight: 700; - flex: 0 0 auto; -} - -.git-project-stats { - font-size: 12px; - color: var(--text-muted); - flex: 1; -} - -.git-project-group .group-chevron { - font-size: 10px; - color: var(--text-muted); - transition: transform 0.2s; -} -.git-project-group.collapsed .group-chevron { transform: rotate(-90deg); } - -/* ── QA session list ────────────────────────────────────────── */ - -.qa-list { - border-top: 1px solid var(--border); - display: flex; - flex-direction: column; -} -.git-project-group.collapsed .qa-list { display: none; } - -.qa-item { - display: flex; - align-items: center; - gap: 10px; - padding: 9px 16px; - cursor: pointer; - border-bottom: 1px solid var(--border); - transition: background 0.12s; - min-width: 0; -} -.qa-item:last-child { border-bottom: none; } -.qa-item:hover { background: var(--bg-card-hover); } -.qa-item.selected { background: rgba(96, 165, 250, 0.08); } - -.qa-question { - flex: 1; - font-size: 13px; - color: var(--text); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - min-width: 0; -} - -.qa-meta { - display: flex; - align-items: center; - gap: 8px; - flex-shrink: 0; -} - -.qa-msgs, .qa-time { - font-size: 12px; - color: var(--text-muted); - white-space: nowrap; -} - -/* ── Projects grid (kept for reference) ─────────────────────── */ - -.projects-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); - gap: 12px; -} - -.project-card { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: 10px; - padding: 16px; - cursor: pointer; - transition: all 0.15s; -} - -.project-card:hover { - background: var(--bg-card-hover); - transform: translateY(-1px); -} - -.project-card-header { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 10px; -} - -.project-card-name { - font-size: 15px; - font-weight: 600; -} - -.project-card-stats { - display: flex; - gap: 12px; - font-size: 12px; - color: var(--text-muted); - margin-bottom: 6px; -} - -.project-card-time { - font-size: 12px; - color: var(--text-muted); -} - -/* ── Timeline ───────────────────────────────────────────────── */ - -.timeline { - display: flex; - flex-direction: column; - gap: 20px; -} - -.timeline-date { - display: flex; - flex-direction: column; - gap: 8px; -} - -.timeline-date-label { - font-size: 14px; - font-weight: 600; - color: var(--text-secondary); - padding-bottom: 8px; - border-bottom: 1px solid var(--border); -} - -.timeline-count { - font-size: 12px; - font-weight: 400; - color: var(--text-muted); - margin-left: 8px; -} - -/* ── Launch buttons ─────────────────────────────────────────── */ - -.launch-btn { - display: inline-flex; - align-items: center; - gap: 6px; - border: none; - border-radius: 8px; - padding: 8px 14px; - font-size: 13px; - cursor: pointer; - font-weight: 500; - background: var(--accent-blue); - color: #fff; - transition: opacity 0.15s; -} - -.launch-btn:hover { opacity: 0.85; } - -.launch-btn.btn-secondary { - background: var(--bg-card); - color: var(--text-primary); - border: 1px solid var(--border); -} - -.launch-btn.btn-delete { - background: var(--accent-red); - color: #fff; -} - -/* ── Live session badges ────────────────────────────────────── */ - -.live-badge { - font-size: 10px; - font-weight: 800; - letter-spacing: 0.8px; - padding: 3px 10px; - border-radius: 6px; - text-transform: uppercase; - display: inline-flex; - align-items: center; - gap: 5px; -} - -.live-badge::before { - content: ''; - width: 8px; - height: 8px; - border-radius: 50%; - display: inline-block; - animation: dot-pulse 1.5s infinite; -} - -.live-active { - background: rgba(74, 222, 128, 0.25); - color: #22c55e; - border: 1px solid rgba(74, 222, 128, 0.5); - box-shadow: 0 0 12px rgba(74, 222, 128, 0.3); -} - -.live-active::before { - background: #22c55e; - box-shadow: 0 0 6px #22c55e; -} - -.live-waiting { - background: rgba(251, 191, 36, 0.2); - color: #f59e0b; - border: 1px solid rgba(251, 191, 36, 0.4); - box-shadow: 0 0 12px rgba(251, 191, 36, 0.25); -} - -.live-waiting::before { - background: #f59e0b; - box-shadow: 0 0 6px #f59e0b; -} - -@keyframes dot-pulse { - 0%, 100% { opacity: 1; transform: scale(1); } - 50% { opacity: 0.3; transform: scale(0.6); } -} - -/* Animated border wrapper for live sessions */ -.card-live-wrap { - position: relative; - border-radius: 12px; - padding: 2px; - display: flex; - flex-direction: column; -} - -.card-live-wrap > .card { - border: none; - position: relative; - z-index: 1; - flex: 1; -} - -.card-live-wrap .live-border { - position: absolute; - inset: 0; - border-radius: 12px; - z-index: 0; - background: conic-gradient( - from var(--border-angle, 0deg), - transparent 35%, - var(--live-color, rgba(74, 222, 128, 0.6)) 50%, - transparent 65% - ); - animation: border-spin 3s linear infinite; -} - -.card-live-wrap.live-waiting .live-border { - animation: none; - background: conic-gradient( - from 90deg, - transparent 35%, - var(--live-color, rgba(251, 191, 36, 0.4)) 50%, - transparent 65% - ); -} - -@keyframes border-spin { - to { --border-angle: 360deg; } -} - -@property --border-angle { - syntax: ''; - initial-value: 0deg; - inherits: false; -} - -/* ── Card expand preview ────────────────────────────────────── */ - -.card-gen-btn { - background: none; - border: 1px solid var(--border); - border-radius: 4px; - color: var(--text-muted); - font-size: 11px; - cursor: pointer; - padding: 1px 5px; - transition: all 0.15s; -} -.card-gen-btn:hover { - color: var(--accent-purple); - border-color: var(--accent-purple); -} - -.card-expand-btn { - background: none; - border: 1px solid var(--border); - border-radius: 4px; - color: var(--text-muted); - font-size: 12px; - cursor: pointer; - padding: 1px 6px; - margin-left: auto; - transition: all 0.15s; -} -.card-expand-btn:hover { - color: var(--text-primary); - border-color: var(--accent-blue); -} - -.card-preview-area { - display: none; - border-top: 1px solid var(--border); - margin-top: 10px; - padding-top: 10px; - max-height: 200px; - overflow-y: auto; - animation: fadeIn 0.2s ease; -} - -/* Ensure card clips expanded content */ -.card { overflow: hidden; } - -.card-preview-area.open { - display: block; -} - -@keyframes fadeIn { - from { opacity: 0; transform: translateY(-4px); } - to { opacity: 1; transform: translateY(0); } -} - -.preview-msg { - font-size: 12px; - line-height: 1.5; - padding: 4px 8px; - margin-bottom: 4px; - border-radius: 6px; - word-break: break-word; - white-space: pre-wrap; -} - -.preview-user { - background: rgba(96, 165, 250, 0.08); -} - -.preview-assistant { - background: rgba(74, 222, 128, 0.06); -} - -.preview-role { - font-weight: 600; - font-size: 10px; - text-transform: uppercase; - letter-spacing: 0.3px; -} - -.preview-user .preview-role { color: var(--accent-blue); } -.preview-assistant .preview-role { color: var(--accent-green); } - -.preview-empty { - color: var(--text-muted); - font-size: 12px; - padding: 8px 0; -} - -/* ── Leaderboard ───────────────────────────────────────────── */ - -.leaderboard-container { padding: 24px; max-width: 700px; } - -.lb-hero { - display: flex; - align-items: center; - gap: 16px; - padding: 24px; - background: linear-gradient(135deg, rgba(99,102,241,0.15), rgba(168,85,247,0.15)); - border-radius: 16px; - margin-bottom: 24px; - border: 1px solid rgba(139,92,246,0.3); -} - -.lb-avatar { - width: 56px; - height: 56px; - border-radius: 50%; - background: linear-gradient(135deg, var(--accent-blue), var(--accent-purple)); - display: flex; - align-items: center; - justify-content: center; - font-size: 18px; - font-weight: 700; - color: #fff; - flex-shrink: 0; -} - -.lb-name { font-size: 20px; font-weight: 700; color: var(--text-primary); } -.lb-streak { font-size: 13px; color: var(--accent-orange); margin-top: 2px; } - -.lb-section-title { - font-size: 12px; - font-weight: 600; - color: var(--text-muted); - text-transform: uppercase; - letter-spacing: 1px; - margin: 20px 0 10px; -} - -.lb-stats-grid { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 12px; -} - -.lb-stat { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: 12px; - padding: 16px; - text-align: center; -} - -.lb-stat-value { font-size: 24px; font-weight: 700; color: var(--text-primary); } -.lb-stat-label { font-size: 11px; color: var(--text-muted); margin-top: 4px; } - -.lb-agents { display: flex; flex-direction: column; gap: 8px; } - -.lb-agent-row { - display: flex; - align-items: center; - gap: 12px; -} -.lb-agent-row .tool-badge { min-width: 80px; text-align: center; } - -.lb-agent-bar { - flex: 1; - height: 8px; - background: var(--bg-card); - border-radius: 4px; - overflow: hidden; -} -.lb-agent-bar-fill { - height: 100%; - background: linear-gradient(90deg, var(--accent-blue), var(--accent-purple)); - border-radius: 4px; - transition: width 0.5s; -} -.lb-agent-count { font-size: 12px; color: var(--text-muted); min-width: 60px; } - -.lb-daily-chart { - display: flex; - align-items: flex-end; - gap: 6px; - height: 150px; - padding: 8px 0; -} - -.lb-bar-col { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - justify-content: flex-end; - height: 100%; -} - -.lb-bar { - width: 100%; - max-width: 40px; - background: linear-gradient(180deg, var(--accent-blue), var(--accent-purple)); - border-radius: 4px 4px 0 0; - min-height: 4px; - cursor: default; -} -.lb-bar:hover { opacity: 0.8; } - -.lb-bar-label { font-size: 10px; color: var(--text-muted); margin-top: 4px; } - -.lb-footer { - margin-top: 24px; - padding-top: 16px; - border-top: 1px solid var(--border); - font-size: 11px; - color: var(--text-muted); - text-align: center; -} - -.lb-avatar-img { - width: 56px; - height: 56px; - border-radius: 50%; - border: 2px solid var(--accent-purple); - flex-shrink: 0; -} - -.lb-username { font-size: 13px; color: var(--text-muted); } - -.lb-github-btn { - display: inline-flex; - align-items: center; - gap: 8px; - padding: 8px 16px; - background: #24292e; - color: #fff; - border: none; - border-radius: 8px; - font-size: 13px; - font-weight: 600; - cursor: pointer; - text-decoration: none; - transition: background 0.15s; - margin-left: auto; -} -.lb-github-btn:hover { background: #2f363d; } -[data-theme="light"] .lb-github-btn { background: #24292e; color: #fff; } - -.lb-auth-code { - font-size: 32px; - font-weight: 700; - font-family: monospace; - letter-spacing: 4px; - padding: 16px; - background: var(--bg-card); - border: 2px dashed var(--accent-blue); - border-radius: 12px; - color: var(--accent-blue); - user-select: all; -} - -.lb-network { - display: flex; - justify-content: center; - gap: 24px; - padding: 12px 16px; - background: linear-gradient(135deg, rgba(99,102,241,0.08), rgba(168,85,247,0.08)); - border: 1px solid rgba(139,92,246,0.2); - border-radius: 10px; - margin: 16px 0; - font-size: 13px; - color: var(--text-muted); -} -.lb-network span { font-weight: 600; } - -.lb-sync-bar { - text-align: center; - margin: 20px 0; -} -.lb-sync-btn { - padding: 12px 32px; - background: linear-gradient(135deg, var(--accent-blue), var(--accent-purple)); - color: #fff; - border: none; - border-radius: 10px; - font-size: 15px; - font-weight: 700; - cursor: pointer; - transition: opacity 0.15s, transform 0.15s; -} -.lb-sync-btn:hover { opacity: 0.9; transform: translateY(-1px); } -.lb-sync-btn:active { transform: translateY(0); } - -.lb-global-row { - display: flex; - align-items: center; - gap: 12px; - padding: 12px 16px; - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: 10px; - margin-bottom: 6px; - transition: border-color 0.2s; -} -.lb-global-row:hover { border-color: var(--accent-blue); } - -.lb-rank { font-size: 16px; font-weight: 700; width: 32px; text-align: center; color: var(--text-muted); } -.lb-rank-1 { color: #ffd700; } -.lb-rank-2 { color: #c0c0c0; } -.lb-rank-3 { color: #cd7f32; } - -.lb-global-avatar { width: 40px; height: 40px; border-radius: 50%; border: 2px solid var(--border); } -.lb-global-info { flex: 1; min-width: 0; } -.lb-global-name { font-weight: 600; font-size: 14px; } -.lb-global-handle { font-size: 12px; color: var(--text-muted); } -.lb-global-stats { display: flex; gap: 12px; font-size: 12px; color: var(--text-muted); flex-shrink: 0; } -.lb-global-stats strong { color: var(--text-primary); } -.lb-streak-badge { background: rgba(251,146,60,0.15); color: var(--accent-orange); padding: 2px 8px; border-radius: 10px; font-weight: 600; } -.lb-verified { color: var(--accent-green); font-size: 12px; } -.lb-devices { font-size: 10px; color: var(--text-muted); background: var(--bg-card); padding: 1px 6px; border-radius: 8px; margin-left: 4px; } - -.lb-tabs { display: flex; gap: 4px; margin-bottom: 12px; } -.lb-tab { - padding: 6px 16px; border: 1px solid var(--border); border-radius: 8px; - background: none; color: var(--text-muted); cursor: pointer; font-size: 13px; font-weight: 600; - transition: all 0.15s; -} -.lb-tab.active { background: var(--accent-blue); color: #fff; border-color: var(--accent-blue); } -.lb-tab:hover:not(.active) { border-color: var(--accent-blue); color: var(--text-primary); } - -.lb-global-name a { color: var(--text-primary); text-decoration: none; } -.lb-global-name a:hover { color: var(--accent-blue); text-decoration: underline; } - -.lb-global-agents { display: flex; gap: 4px; margin-top: 2px; } -.lb-agent-mini { font-size: 10px; padding: 1px 5px; border-radius: 4px; background: rgba(99,102,241,0.1); color: var(--text-muted); } - -/* ── Changelog ──────────────────────────────────────────────── */ - -.changelog-container { padding: 20px; max-width: 700px; } - -.changelog-entry { - border-left: 2px solid var(--border); - padding: 0 0 24px 20px; - margin-left: 8px; - position: relative; -} - -.changelog-entry::before { - content: ''; - position: absolute; - left: -5px; - top: 4px; - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--text-muted); -} - -.changelog-latest { - border-left-color: var(--accent-green); -} - -.changelog-latest::before { - background: var(--accent-green); - box-shadow: 0 0 6px var(--accent-green); -} - -.changelog-header { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 4px; -} - -.changelog-version { - font-size: 15px; - font-weight: 700; - color: var(--text-primary); -} - -.changelog-new { - font-size: 9px; - font-weight: 700; - letter-spacing: 0.5px; - padding: 2px 6px; - border-radius: 4px; - background: rgba(74, 222, 128, 0.2); - color: var(--accent-green); -} - -.changelog-date { - font-size: 12px; - color: var(--text-muted); - margin-left: auto; -} - -.changelog-title { - font-size: 14px; - font-weight: 600; - color: var(--text-secondary); - margin-bottom: 6px; -} - -.changelog-list { - list-style: none; - padding: 0; - margin: 0; -} - -.changelog-list li { - font-size: 13px; - color: var(--text-secondary); - padding: 2px 0; - line-height: 1.5; -} - -.changelog-list li::before { - content: '+'; - color: var(--accent-green); - font-weight: 700; - margin-right: 8px; -} - -/* ── Running Sessions View ──────────────────────────────────── */ - -.running-container { padding: 20px; } - -/* ── Kanban board ── */ -.kanban-board { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 16px; - align-items: start; -} - -@media (max-width: 900px) { - .kanban-board { grid-template-columns: 1fr; } -} - -.kanban-col { - background: var(--bg-secondary); - border: 1px solid var(--border); - border-radius: 12px; - padding: 12px; - min-height: 120px; -} - -.kanban-col-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 8px 4px 12px; - margin-bottom: 4px; - border-bottom: 2px solid transparent; -} - -.kanban-running { border-bottom-color: var(--accent-green); } -.kanban-waiting { border-bottom-color: #f59e0b; } -.kanban-done { border-bottom-color: var(--text-muted); } - -.kanban-col-title { - font-size: 12px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--text-secondary); -} - -.kanban-col-count { - font-size: 12px; - font-weight: 700; - color: var(--text-muted); - background: var(--bg-card); - border-radius: 10px; - padding: 1px 8px; -} - -.kanban-empty { - font-size: 12px; - color: var(--text-muted); - text-align: center; - padding: 20px 0; -} - -.running-card { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: 12px; - padding: 16px; - margin-bottom: 10px; -} - -.running-active { border-left: 3px solid var(--accent-green); } -.running-waiting { border-left: 3px solid #f59e0b; } -.running-done { border-left: 3px solid var(--text-muted); opacity: 0.85; } - -.live-done { - background: rgba(148,163,184,0.15); - color: var(--text-muted); - font-size: 10px; - font-weight: 700; - padding: 2px 7px; - border-radius: 4px; - letter-spacing: 0.05em; -} - -.running-card-header { - display: flex; - align-items: center; - gap: 12px; - margin-bottom: 14px; -} - -.running-project { font-size: 16px; font-weight: 600; } -.running-tool { font-size: 12px; color: var(--text-muted); margin-left: auto; } - -.running-stats { - display: flex; - gap: 24px; - margin-bottom: 12px; -} - -.running-stat { display: flex; flex-direction: column; gap: 2px; } -.running-stat-val { font-size: 18px; font-weight: 700; color: var(--text-primary); } -.running-stat-label { font-size: 11px; color: var(--text-muted); } - -.running-msg { - font-size: 13px; - color: var(--text-secondary); - padding: 10px; - background: rgba(255,255,255,0.03); - border-radius: 8px; - margin-bottom: 12px; - line-height: 1.5; -} - -.running-actions { display: flex; gap: 8px; } - -/* ── Session Replay ─────────────────────────────────────────── */ - -.replay-container { padding: 20px; } - -.replay-header { - display: flex; - align-items: center; - gap: 16px; - margin-bottom: 16px; -} - -.replay-title { - font-size: 16px; - font-weight: 600; - flex: 1; -} - -.replay-duration { - color: var(--text-muted); - font-size: 13px; -} - -.replay-controls { - display: flex; - align-items: center; - gap: 12px; - margin-bottom: 20px; - padding: 12px 16px; - background: var(--bg-card); - border-radius: 10px; - border: 1px solid var(--border); -} - -.replay-play-btn { - width: 36px; - height: 36px; - border-radius: 50%; - border: none; - background: var(--accent-blue); - color: #fff; - font-size: 14px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; -} -.replay-play-btn:hover { opacity: 0.85; } - -.replay-slider { - flex: 1; - height: 6px; - -webkit-appearance: none; - appearance: none; - background: var(--border); - border-radius: 3px; - outline: none; - cursor: pointer; -} -.replay-slider::-webkit-slider-thumb { - -webkit-appearance: none; - width: 16px; - height: 16px; - border-radius: 50%; - background: var(--accent-blue); - cursor: pointer; -} - -.replay-counter { - font-size: 12px; - color: var(--text-muted); - white-space: nowrap; - min-width: 60px; - text-align: right; -} - -.replay-messages { - max-height: calc(100vh - 200px); - overflow-y: auto; -} - -.replay-msg { - padding: 12px 16px; - margin-bottom: 8px; - border-radius: 10px; - animation: fadeIn 0.3s ease; -} - -.replay-latest { - box-shadow: 0 0 0 2px var(--accent-blue); -} - -.replay-msg-header { - display: flex; - justify-content: space-between; - margin-bottom: 4px; -} - -.replay-time { - font-size: 11px; - color: var(--text-muted); -} - -.replay-msg-content { - font-size: 13px; - line-height: 1.6; - white-space: pre-wrap; - word-break: break-word; -} - -/* ── Cost Analytics ─────────────────────────────────────────── */ - -.analytics-container { padding: 20px; } - -.analytics-summary { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 12px; - margin-bottom: 24px; -} - -.analytics-card { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: 10px; - padding: 16px; - display: flex; - flex-direction: column; - gap: 4px; -} - -.analytics-val { - font-size: 24px; - font-weight: 700; - color: var(--accent-green); -} - -.analytics-label { - font-size: 12px; - color: var(--text-muted); -} - -.burn-rate-bar { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: 10px; - padding: 14px 18px; - margin-bottom: 24px; - display: flex; - align-items: center; - gap: 24px; -} - -.burn-rate-title { - font-size: 12px; - font-weight: 600; - color: var(--text-muted); - text-transform: uppercase; - letter-spacing: 0.05em; - white-space: nowrap; -} - -.burn-rate-stats { - display: flex; - gap: 28px; - flex-wrap: wrap; -} - -.burn-stat { - display: flex; - align-items: baseline; - gap: 6px; -} - -.burn-val { - font-size: 18px; - font-weight: 700; - color: var(--text-primary); -} - -.burn-val.burn-low { color: var(--accent-green); } -.burn-val.burn-medium { color: #f59e0b; } -.burn-val.burn-high { color: #ef4444; } - -.burn-label { - font-size: 12px; - color: var(--text-muted); -} - -.burn-pace { - font-size: 11px; - font-weight: 600; - padding: 2px 6px; - border-radius: 4px; - margin-left: 2px; -} - -.burn-pace.burn-low { background: rgba(74,222,128,0.12); color: var(--accent-green); } -.burn-pace.burn-medium { background: rgba(245,158,11,0.12); color: #f59e0b; } -.burn-pace.burn-high { background: rgba(239,68,68,0.12); color: #ef4444; } - -.chart-section { - margin-bottom: 28px; -} - -.chart-section h3 { - font-size: 14px; - font-weight: 600; - color: var(--text-secondary); - margin-bottom: 12px; -} - -/* Bar chart (vertical) */ -.bar-chart { - display: flex; - align-items: flex-end; - gap: 3px; - height: 160px; - padding: 0 4px; - border-bottom: 1px solid var(--border); -} - -.bar-col { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - height: 100%; - justify-content: flex-end; - min-width: 0; -} - -.bar-fill { - width: 100%; - background: linear-gradient(to top, var(--accent-blue), var(--accent-purple)); - border-radius: 3px 3px 0 0; - min-height: 2px; - transition: height 0.3s ease; -} - -.bar-label { - font-size: 9px; - color: var(--text-muted); - margin-top: 6px; - transform: rotate(-45deg); - white-space: nowrap; -} - -/* Horizontal bar chart */ -.hbar-chart { - display: flex; - flex-direction: column; - gap: 8px; -} - -.hbar-row { - display: flex; - align-items: center; - gap: 12px; -} - -.hbar-name { - width: 140px; - font-size: 13px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - flex-shrink: 0; -} - -.hbar-track { - flex: 1; - height: 24px; - background: var(--bg-card); - border-radius: 6px; - overflow: hidden; -} - -.hbar-fill { - height: 100%; - background: linear-gradient(to right, var(--accent-blue), var(--accent-green)); - border-radius: 6px; - transition: width 0.5s ease; -} - -.hbar-val { - font-size: 13px; - font-weight: 600; - color: var(--accent-green); - min-width: 70px; - text-align: right; -} - -/* Top sessions list */ -.top-sessions { display: flex; flex-direction: column; gap: 4px; } - -.top-session-row { - display: flex; - align-items: center; - gap: 12px; - padding: 8px 12px; - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: 8px; - cursor: pointer; - font-size: 13px; - transition: background 0.15s; -} -.top-session-row:hover { background: var(--bg-card-hover); } - -.top-session-cost { - font-weight: 700; - color: var(--accent-green); - min-width: 70px; -} - -.top-session-project { - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.top-session-date { color: var(--text-muted); font-size: 12px; } -.top-session-id { font-family: monospace; font-size: 11px; color: var(--text-muted); } - -/* ── Data coverage indicators ──────────────────────────────── */ - -.analytics-coverage { - font-size: 12px; - color: var(--text-muted); - margin: -8px 0 16px; - display: flex; - gap: 8px; - flex-wrap: wrap; - align-items: center; -} - -.coverage-ok { color: var(--accent-green); } -.coverage-est { color: var(--accent-orange, #f59e0b); } -.coverage-none { color: var(--text-muted); opacity: 0.6; } - -/* ── Token breakdown grid ─────────────────────────────────── */ - -.token-breakdown-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); - gap: 10px; -} - -.token-type-card { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: 8px; - padding: 12px; - text-align: center; - display: flex; - flex-direction: column; - gap: 4px; -} - -.token-type-val { font-size: 18px; font-weight: 700; color: var(--text); } -.token-type-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; } -.token-type-pct { font-size: 12px; color: var(--text-muted); } - -.token-cache-read { border-color: rgba(96, 165, 250, 0.3); } -.token-cache-create { border-color: rgba(251, 191, 36, 0.3); } -.token-context { border-color: rgba(168, 85, 247, 0.3); } - -/* ── Subscription vs API ──────────────────────────────────── */ - -.subscription-section { margin-top: 8px; } - -.sub-comparison { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); - gap: 10px; - margin-bottom: 12px; -} - -.sub-card { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: 8px; - padding: 14px; - text-align: center; -} - -.sub-val { font-size: 20px; font-weight: 700; display: block; } -.sub-label { font-size: 11px; color: var(--text-muted); display: block; margin-top: 4px; } - -.sub-paid .sub-val { color: var(--text); } -.sub-api .sub-val { color: var(--accent-blue, #60a5fa); } -.sub-savings .sub-val { color: var(--accent-green); } -.sub-loss .sub-val { color: var(--accent-red, #f87171); } - -.sub-bar-track { - height: 8px; - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: 4px; - margin-bottom: 14px; - overflow: hidden; -} - -.sub-bar-fill { - height: 100%; - background: var(--accent-green); - border-radius: 4px; - transition: width 0.3s; -} - -.sub-hint { - color: var(--text-muted); - font-size: 13px; - margin: 8px 0; -} - -.sub-entries { margin-bottom: 10px; } - -.sub-entry-row { - display: flex; - align-items: center; - gap: 12px; - padding: 6px 0; - border-bottom: 1px solid var(--border); - font-size: 13px; -} - -.sub-entry-service { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; color: var(--accent-blue); background: rgba(96,165,250,0.12); border-radius: 4px; padding: 2px 6px; } -.sub-entry-plan { font-weight: 600; min-width: 60px; } -.sub-entry-paid { color: var(--accent-green); min-width: 80px; } -.sub-entry-from { color: var(--text-muted); flex: 1; } -.sub-entry-remove { - background: none; - border: none; - color: var(--text-muted); - cursor: pointer; - font-size: 16px; - padding: 2px 6px; - border-radius: 4px; -} -.sub-entry-remove:hover { background: rgba(248, 113, 113, 0.15); color: var(--accent-red, #f87171); } - -.sub-add-form { - display: flex; - gap: 8px; - align-items: center; - flex-wrap: wrap; -} - -.sub-add-form input { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: 6px; - padding: 6px 10px; - color: var(--text); - font-size: 13px; -} - -.sub-add-form select { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: 6px; - padding: 6px 10px; - color: var(--text); - font-size: 13px; - cursor: pointer; -} -.sub-add-form select:first-child { width: 170px; } -.sub-add-form select:nth-child(2) { width: 150px; } -.sub-add-form input[type="number"] { width: 80px; } -.sub-add-form input[type="date"] { width: 140px; } - -.sub-add-form button { - background: var(--accent-blue, #60a5fa); - color: #fff; - border: none; - border-radius: 6px; - padding: 6px 14px; - cursor: pointer; - font-size: 13px; - font-weight: 600; -} -.sub-add-form button:hover { opacity: 0.85; } - -/* ── Update banner ──────────────────────────────────────────── */ - -.update-banner { - position: fixed; - top: 0; - left: 200px; - right: 0; - background: linear-gradient(135deg, var(--accent-blue), var(--accent-purple)); - color: #fff; - padding: 10px 20px; - display: flex; - align-items: center; - gap: 12px; - font-size: 13px; - z-index: 200; - animation: slideDown 0.3s ease; -} - -@keyframes slideDown { - from { transform: translateY(-100%); } - to { transform: translateY(0); } -} - -.update-btn { - background: rgba(255,255,255,0.2); - border: 1px solid rgba(255,255,255,0.3); - color: #fff; - padding: 4px 12px; - border-radius: 6px; - font-size: 12px; - cursor: pointer; - white-space: nowrap; -} -.update-btn:hover { background: rgba(255,255,255,0.3); } - -.update-dismiss { - background: none; - border: none; - color: rgba(255,255,255,0.7); - font-size: 18px; - cursor: pointer; - margin-left: auto; - padding: 0 4px; -} -.update-dismiss:hover { color: #fff; } - -/* ── List view ──────────────────────────────────────────────── */ - -.list-view { - display: flex; - flex-direction: column; - gap: 2px; -} - -.list-row { - display: flex; - align-items: center; - gap: 12px; - padding: 10px 14px; - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: 8px; - cursor: pointer; - transition: background 0.15s; - font-size: 13px; -} - -.list-row:hover { - background: var(--bg-card-hover); -} - -.list-row.selected { - border-color: var(--accent-blue); -} - -.list-row.focused { - outline: 2px solid var(--accent-blue); - outline-offset: -2px; -} - -.list-project { - font-weight: 600; - font-size: 12px; - width: 100px; - flex-shrink: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.list-msg { - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: var(--text-primary); -} - -.list-meta { - color: var(--text-muted); - font-size: 12px; - flex-shrink: 0; -} - -.list-time { - color: var(--text-muted); - font-size: 12px; - flex-shrink: 0; - width: 60px; - text-align: right; -} - -.grid-view { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); - gap: 10px; -} - -.group-body-list { - display: flex; - flex-direction: column; - gap: 2px; -} +/* ================================================================ + Claude Sessions Dashboard — Stylesheet + ================================================================ */ + +/* ── Reset ─────────────────────────────────────────────────────── */ + +* { margin: 0; padding: 0; box-sizing: border-box; } + +/* ── CSS Custom Properties (Dark Theme — Default) ──────────────── */ + +:root { + --bg-primary: #1a1d23; + --bg-secondary: #22262e; + --bg-card: #2a2e37; + --bg-card-hover: #333842; + --bg-input: #2a2e37; + --text-primary: #e4e7eb; + --text-secondary: #8b919a; + --text-muted: #5f6571; + --accent-green: #4ade80; + --accent-blue: #60a5fa; + --accent-orange: #fb923c; + --accent-purple: #c084fc; + --accent-red: #f87171; + --accent-cyan: #22d3ee; + --border: #363b44; + --sidebar-bg: #1e2128; +} + +/* ── Light Theme ───────────────────────────────────────────────── */ + +[data-theme="light"] { + --bg-primary: #f5f5f7; + --bg-secondary: #ffffff; + --bg-card: #ffffff; + --bg-card-hover: #f0f0f2; + --bg-input: #ffffff; + --text-primary: #1d1d1f; + --text-secondary: #6e6e73; + --text-muted: #a1a1a6; + --accent-green: #34c759; + --accent-blue: #007aff; + --accent-orange: #ff9500; + --accent-purple: #af52de; + --accent-red: #ff3b30; + --accent-cyan: #00b4d8; + --border: #d2d2d7; + --sidebar-bg: #f5f5f7; +} + +/* ── Monokai Theme ─────────────────────────────────────────────── */ + +[data-theme="monokai"] { + --bg-primary: #272822; + --bg-secondary: #1e1f1c; + --bg-card: #3e3d32; + --bg-card-hover: #4a493d; + --bg-input: #3e3d32; + --text-primary: #f8f8f2; + --text-secondary: #a6a690; + --text-muted: #75715e; + --accent-green: #a6e22e; + --accent-blue: #66d9ef; + --accent-orange: #fd971f; + --accent-purple: #ae81ff; + --accent-red: #f92672; + --accent-cyan: #66d9ef; + --border: #49483e; + --sidebar-bg: #1e1f1c; +} + +/* ── Body & Layout ─────────────────────────────────────────────── */ + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + height: 100vh; + display: flex; + overflow: hidden; +} + +/* ── Sidebar ───────────────────────────────────────────────────── */ + +.sidebar { + width: 200px; + background: var(--sidebar-bg); + border-right: 1px solid var(--border); + padding: 16px 0; + display: flex; + flex-direction: column; + flex-shrink: 0; + overflow-y: auto; + overflow-x: hidden; +} + +.sidebar-brand { + padding: 8px 20px 20px; + font-size: 15px; + font-weight: 700; + color: var(--accent-blue); + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.version-badge { + font-size: 10px; + font-weight: 500; + color: var(--text-muted); + background: rgba(255,255,255,0.06); + padding: 1px 6px; + border-radius: 4px; +} + +.version-badge.update-available { + color: var(--accent-green); + background: rgba(74, 222, 128, 0.15); + cursor: pointer; +} + +.version-badge.update-available:hover { + background: rgba(74, 222, 128, 0.25); +} + +.sidebar-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 20px; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.15s; + font-size: 13px; + user-select: none; +} +.sidebar-item:hover { background: rgba(255,255,255,0.05); color: var(--text-primary); } +.sidebar-item.active { color: var(--text-primary); background: rgba(255,255,255,0.08); } +.sidebar-item svg { width: 18px; height: 18px; opacity: 0.7; } + +.sidebar-divider { height: 1px; background: var(--border); margin: 12px 20px; } + +.sidebar-section { + padding: 8px 20px 6px; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 1.2px; + color: var(--text-muted); +} + +.sidebar-author { + margin-top: auto; + padding: 8px 20px 0; + font-size: 11px; + text-align: center; +} +.sidebar-author a { + color: var(--text-muted); + text-decoration: none; + transition: color 0.15s; +} +.sidebar-author a:hover { color: var(--accent-blue); } + +/* ── Settings page ──────────────────────────────────────────── */ + +.settings-page { + max-width: 480px; + padding: 24px; +} +.settings-group { + margin-bottom: 20px; +} +.settings-label { + font-size: 12px; + color: var(--text-muted); + display: block; + margin-bottom: 8px; + text-transform: uppercase; + letter-spacing: 0.5px; +} +.settings-theme-btns { + display: flex; + gap: 8px; +} +.theme-btn { + padding: 8px 20px; + border-radius: 6px; + background: rgba(255,255,255,0.04); + color: var(--text-secondary); + border: 1px solid var(--border); + font-size: 13px; + cursor: pointer; + transition: all 0.15s; +} +.theme-btn:hover { background: rgba(255,255,255,0.08); color: var(--text-primary); } +.theme-btn.active { + background: rgba(96, 165, 250, 0.15); + color: var(--accent-blue); + border-color: rgba(96, 165, 250, 0.3); +} +.settings-select { + width: 100%; + padding: 8px 12px; + border-radius: 6px; + background: var(--bg-card, rgba(255,255,255,0.04)); + color: var(--text-primary); + border: 1px solid var(--border); + font-size: 13px; + -webkit-appearance: none; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%236e7681' d='M2 4l4 4 4-4'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; + padding-right: 28px; +} +.settings-select option { + background: var(--bg-card, #161b22); + color: var(--text-primary, #e6edf3); +} +.settings-checkbox { + display: flex; + align-items: center; + gap: 8px; +} +.settings-checkbox input { margin: 0; } + +/* Light theme sidebar hover adjustments */ +[data-theme="light"] .sidebar-item:hover { background: rgba(0,0,0,0.04); } +[data-theme="light"] .sidebar-item.active { background: rgba(0,0,0,0.06); } + +/* ── Main Area ─────────────────────────────────────────────────── */ + +.main { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* ── Toolbar ───────────────────────────────────────────────────── */ + +.toolbar { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 20px; + border-bottom: 1px solid var(--border); + background: var(--bg-secondary); + flex-wrap: wrap; + position: relative; +} + +.search-box { + flex: 1; + min-width: 200px; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px 14px; + color: var(--text-primary); + font-size: 14px; + outline: none; + transition: border-color 0.2s; +} +.search-box:focus { border-color: var(--accent-blue); } +.search-box::placeholder { color: var(--text-muted); } + +.toolbar-btn { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + padding: 8px 14px; + color: var(--text-secondary); + cursor: pointer; + font-size: 13px; + white-space: nowrap; + transition: all 0.15s; + user-select: none; +} +.toolbar-btn:hover { background: var(--bg-card-hover); color: var(--text-primary); } +.toolbar-btn.active { background: var(--accent-blue); color: #fff; border-color: var(--accent-blue); } + +.tool-filter { + display: flex; + gap: 4px; +} +.tool-chip { + padding: 6px 12px; + border-radius: 6px; + font-size: 12px; + cursor: pointer; + border: 1px solid var(--border); + background: var(--bg-card); + color: var(--text-secondary); + transition: all 0.15s; + user-select: none; +} +.tool-chip:hover { color: var(--text-primary); } +.tool-chip.active-claude { background: rgba(96, 165, 250, 0.2); border-color: var(--accent-blue); color: var(--accent-blue); } +.tool-chip.active-codex { background: rgba(34, 211, 238, 0.2); border-color: var(--accent-cyan); color: var(--accent-cyan); } + +.stats { color: var(--text-muted); font-size: 13px; white-space: nowrap; } + +/* ── Content Area ──────────────────────────────────────────────── */ + +.content { + flex: 1; + overflow-y: auto; + padding: 20px; +} +.content::-webkit-scrollbar { width: 8px; } +.content::-webkit-scrollbar-track { background: transparent; } +.content::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } + +.group-header { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + margin: 20px 0 10px; + text-transform: uppercase; + letter-spacing: 0.5px; +} +.group-header:first-child { margin-top: 0; } + +/* ── Cards Grid ────────────────────────────────────────────────── */ + +.cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + gap: 10px; + margin-bottom: 10px; +} + +.card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 10px; + padding: 14px 16px; + cursor: pointer; + transition: all 0.15s; + display: flex; + align-items: flex-start; + gap: 12px; + position: relative; +} +.card:hover { + background: var(--bg-card-hover); + border-color: rgba(255,255,255,0.1); + transform: translateY(-1px); +} + +.card.selected { + border-color: var(--accent-blue); + background: var(--bg-card-hover); + box-shadow: 0 0 0 1px var(--accent-blue); +} + +.card.focused { + outline: 2px solid var(--accent-blue); + outline-offset: 2px; +} + +[data-theme="light"] .card:hover { + border-color: rgba(0,0,0,0.12); +} +[data-theme="light"] .card.selected { + box-shadow: 0 0 0 1px var(--accent-blue), 0 2px 8px rgba(0,122,255,0.1); +} + +.card-icon { + width: 40px; + height: 40px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + flex-shrink: 0; +} + +.card-body { flex: 1; min-width: 0; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } + +.card-title { + font-size: 14px; + font-weight: 600; + margin-bottom: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.card-meta { + font-size: 12px; + color: var(--text-muted); + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; +} + +.card-preview { + font-size: 12px; + color: var(--text-secondary); + margin-top: 6px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── Card Checkbox (bulk select) ───────────────────────────────── */ + +.card-checkbox { + width: 16px; + height: 16px; + cursor: pointer; + accent-color: var(--accent-blue); + flex-shrink: 0; + display: none; +} +.select-mode .card:hover .card-checkbox, +.select-mode .card.selected .card-checkbox, +.card.selected .card-checkbox { + display: inline-block; +} + +/* ── Card Actions ──────────────────────────────────────────────── */ + +.card-actions { + position: absolute; + top: 8px; + right: 8px; + display: none; + gap: 4px; +} +.card:hover .card-actions { display: flex; } + +.card-action-btn { + width: 28px; + height: 28px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg-secondary); + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + transition: all 0.15s; +} +.card-action-btn:hover { background: var(--accent-blue); color: #fff; border-color: var(--accent-blue); } +.card-action-btn.danger:hover { background: var(--accent-orange); border-color: var(--accent-orange); } +.card-action-btn.delete:hover { background: var(--accent-red); border-color: var(--accent-red); } + +/* ── Badges ────────────────────────────────────────────────────── */ + +.badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + background: rgba(255,255,255,0.06); +} +.badge-claude { background: rgba(96, 165, 250, 0.15); color: var(--accent-blue); } +.badge-codex { background: rgba(34, 211, 238, 0.15); color: var(--accent-cyan); } + +[data-theme="light"] .badge { background: rgba(0,0,0,0.05); } + +/* ── Star Button ───────────────────────────────────────────────── */ + +.star-btn { + background: none; + border: none; + cursor: pointer; + font-size: 16px; + color: var(--text-muted); + transition: color 0.15s, transform 0.15s; + padding: 2px 4px; + line-height: 1; +} +.star-btn:hover { + color: #fbbf24; + transform: scale(1.15); +} +.star-btn.active { + color: #fbbf24; +} + +/* ── Tag Pills ─────────────────────────────────────────────────── */ + +.tag-pill { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 10px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.3px; + line-height: 1.6; + white-space: nowrap; +} +.tag-pill.tag-bug { background: rgba(248,113,113,0.18); color: var(--accent-red); } +.tag-pill.tag-feature { background: rgba(96,165,250,0.18); color: var(--accent-blue); } +.tag-pill.tag-research { background: rgba(192,132,252,0.18); color: var(--accent-purple); } +.tag-pill.tag-infra { background: rgba(251,146,60,0.18); color: var(--accent-orange); } +.tag-pill.tag-deploy { background: rgba(74,222,128,0.18); color: var(--accent-green); } +.tag-pill.tag-review { background: rgba(34,211,238,0.18); color: var(--accent-cyan); } + +[data-theme="light"] .tag-pill.tag-bug { background: rgba(255,59,48,0.12); } +[data-theme="light"] .tag-pill.tag-feature { background: rgba(0,122,255,0.12); } +[data-theme="light"] .tag-pill.tag-research { background: rgba(175,82,222,0.12); } +[data-theme="light"] .tag-pill.tag-infra { background: rgba(255,149,0,0.12); } +[data-theme="light"] .tag-pill.tag-deploy { background: rgba(52,199,89,0.12); } +[data-theme="light"] .tag-pill.tag-review { background: rgba(0,180,216,0.12); } + +/* ── Tag Add Button & Dropdown ─────────────────────────────────── */ + +.tag-add-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 10px; + border: 1px dashed var(--text-muted); + background: none; + color: var(--text-muted); + font-size: 12px; + cursor: pointer; + transition: all 0.15s; + line-height: 1; +} +.tag-add-btn:hover { + border-color: var(--accent-blue); + color: var(--accent-blue); + background: rgba(96,165,250,0.1); +} + +.tag-dropdown { + position: fixed; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 8px; + padding: 6px; + min-width: 140px; + box-shadow: 0 8px 24px rgba(0,0,0,0.3); + z-index: 150; +} + +.tag-dropdown-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: 6px; + font-size: 12px; + cursor: pointer; + color: var(--text-secondary); + transition: background 0.1s; +} +.tag-dropdown-item:hover { + background: var(--bg-card-hover); + color: var(--text-primary); +} + +[data-theme="light"] .tag-dropdown { + box-shadow: 0 8px 24px rgba(0,0,0,0.12); +} + +/* ── Cost Badge ────────────────────────────────────────────────── */ + +.cost-badge { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 2px 7px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + background: rgba(74,222,128,0.15); + color: var(--accent-green); + white-space: nowrap; +} +[data-theme="light"] .cost-badge { + background: rgba(52,199,89,0.12); +} + +/* ── Date Range Inputs ─────────────────────────────────────────── */ + +/* ── Calendar popup ─────────────────────────────────────────── */ + +#dateBtn { display: flex; align-items: center; gap: 5px; } +#dateBtn.has-filter { background: rgba(96,165,250,0.15); color: var(--accent-blue); border-color: rgba(96,165,250,0.3); } + +.calendar-popup { + display: none; + position: fixed; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 10px; + padding: 16px; + width: 280px; + box-shadow: 0 8px 32px rgba(0,0,0,0.4); + z-index: 50; +} +.calendar-popup.open { display: block; } + +.cal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} +.cal-header span { color: var(--text-primary); font-size: 13px; font-weight: 600; } +.cal-nav { + background: none; border: none; color: var(--text-muted); font-size: 18px; + cursor: pointer; padding: 0 4px; line-height: 1; +} +.cal-nav:hover { color: var(--text-primary); } + +.cal-weekdays { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 2px; + text-align: center; + font-size: 11px; + color: var(--text-muted); + margin-bottom: 4px; +} + +.cal-days { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 2px; + text-align: center; +} + +.cal-day { + padding: 6px 0; + font-size: 12px; + border-radius: 4px; + cursor: pointer; + color: var(--text-secondary); + transition: all 0.1s; +} +.cal-day:hover { background: rgba(255,255,255,0.08); color: var(--text-primary); } +.cal-day.other-month { color: var(--text-muted); opacity: 0.3; } +.cal-day.today { font-weight: 700; color: var(--accent-blue); } +.cal-day.in-range { background: rgba(96,165,250,0.1); } +.cal-day.range-start, .cal-day.range-end { + background: rgba(96,165,250,0.3); color: #fff; font-weight: 600; +} +.cal-day.range-start { border-radius: 4px 0 0 4px; } +.cal-day.range-end { border-radius: 0 4px 4px 0; } +.cal-day.range-start.range-end { border-radius: 4px; } + +.cal-presets { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border); + display: flex; + gap: 6px; + flex-wrap: wrap; +} +.cal-preset { + font-size: 11px; + padding: 4px 10px; + border-radius: 4px; + background: rgba(255,255,255,0.04); + color: var(--text-muted); + border: none; + cursor: pointer; + transition: all 0.15s; +} +.cal-preset:hover { background: rgba(255,255,255,0.08); color: var(--text-primary); } + +/* ── Terminal Select ───────────────────────────────────────────── */ + +.terminal-select { + width: 100%; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 6px; + padding: 8px 10px; + color: var(--text-primary); + font-size: 13px; + outline: none; + margin-bottom: 10px; + cursor: pointer; +} +.terminal-select:focus { border-color: var(--accent-blue); } +.terminal-select option { background: var(--bg-card); color: var(--text-primary); } + +/* ── Confirm Dialog ────────────────────────────────────────────── */ + +.confirm-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0,0,0,0.6); + z-index: 300; + align-items: center; + justify-content: center; +} +.confirm-overlay.open { display: flex; } + +.confirm-box { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 12px; + padding: 24px; + max-width: 400px; + width: 90%; +} +.confirm-box h3 { font-size: 16px; margin-bottom: 8px; } +.confirm-box p { font-size: 13px; color: var(--text-secondary); margin-bottom: 16px; line-height: 1.5; } +.confirm-box .confirm-id { font-family: monospace; font-size: 11px; color: var(--text-muted); margin-bottom: 16px; word-break: break-all; } + +.confirm-btns { display: flex; gap: 8px; justify-content: flex-end; } +.confirm-btns button { + padding: 8px 18px; + border-radius: 8px; + border: 1px solid var(--border); + font-size: 13px; + cursor: pointer; + font-weight: 500; +} +.btn-cancel { background: var(--bg-card); color: var(--text-primary); } +.btn-cancel:hover { background: var(--bg-card-hover); } +.btn-delete { background: var(--accent-red); color: #fff; border-color: var(--accent-red); } +.btn-delete:hover { opacity: 0.85; } + +/* ── Detail Panel ──────────────────────────────────────────────── */ + +.detail-panel { + position: fixed; + top: 0; + right: 0; + width: 840px; + min-width: 400px; + max-width: 90vw; + height: 100vh; + background: var(--bg-secondary); + border-left: 1px solid var(--border); + transform: translateX(100%); + transition: transform 0.25s ease; + display: flex; + flex-direction: column; + z-index: 100; +} +.detail-panel.open { transform: translateX(0); } +.detail-panel.resizing { transition: none; } + +.detail-resize-handle { + position: absolute; + left: -3px; + top: 0; + width: 6px; + height: 100%; + cursor: col-resize; + z-index: 101; +} +.detail-resize-handle:hover, +.detail-resize-handle.active { + background: var(--accent-blue); + opacity: 0.5; +} + +.detail-header { + padding: 16px 20px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; +} + +.detail-close { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + font-size: 20px; + padding: 4px 8px; + border-radius: 4px; +} +.detail-close:hover { background: rgba(255,255,255,0.1); } + +.detail-body { + flex: 1; + overflow-y: auto; + padding: 16px 20px; +} + +.detail-info { + display: grid; + grid-template-columns: auto 1fr; + gap: 8px 16px; + font-size: 13px; + margin-bottom: 16px; +} +.detail-info dt { color: var(--text-muted); } +.detail-info dd { color: var(--text-primary); word-break: break-all; } + +/* ── Launch Section ────────────────────────────────────────────── */ + +.launch-section { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 10px; + padding: 14px; + margin-bottom: 16px; +} + +.launch-title { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 10px; +} + +.launch-options { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 12px; +} + +.launch-option { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + cursor: pointer; + user-select: none; +} + +.launch-option input[type="checkbox"] { + accent-color: var(--accent-blue); + width: 16px; + height: 16px; +} + +.launch-btns { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.launch-btn { + display: inline-flex; + align-items: center; + gap: 6px; + border: none; + border-radius: 8px; + padding: 9px 16px; + font-size: 13px; + cursor: pointer; + font-weight: 500; + transition: opacity 0.15s; +} +.launch-btn:hover { opacity: 0.85; } + +.btn-primary { background: var(--accent-blue); color: #fff; } +.btn-secondary { background: var(--bg-card-hover); color: var(--text-primary); border: 1px solid var(--border); } +.btn-codex { background: var(--accent-cyan); color: #000; } + +/* ── Commits List (Detail Panel) ───────────────────────────────── */ + +.commits-list { + list-style: none; + margin: 0; + padding: 0; +} + +.commits-list li { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 8px 0; + border-bottom: 1px solid var(--border); + font-size: 13px; + line-height: 1.4; +} +.commits-list li:last-child { border-bottom: none; } + +.commits-list .commit-hash { + font-family: 'SF Mono', 'Fira Code', monospace; + font-size: 11px; + color: var(--accent-purple); + background: rgba(192,132,252,0.1); + padding: 2px 6px; + border-radius: 4px; + white-space: nowrap; + flex-shrink: 0; +} + +.commits-list .commit-msg { + color: var(--text-secondary); + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── Message Bubbles ───────────────────────────────────────────── */ + +.msg-bubble { + margin-bottom: 12px; + padding: 10px 14px; + border-radius: 10px; + font-size: 13px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + max-height: 300px; + overflow-y: auto; +} +.msg-bubble.user { background: rgba(96,165,250,0.15); border: 1px solid rgba(96,165,250,0.2); } +.msg-bubble.assistant { background: rgba(74,222,128,0.1); border: 1px solid rgba(74,222,128,0.15); } + +.msg-role { + font-size: 11px; + font-weight: 600; + margin-bottom: 4px; + text-transform: uppercase; + letter-spacing: 0.5px; +} +.msg-role.user { color: var(--accent-blue); } +.msg-role.assistant { color: var(--accent-green); } + +/* ── Overlay ───────────────────────────────────────────────────── */ + +.overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.4); z-index: 99; } +.overlay.open { display: block; } + +/* ── Icon Colors ───────────────────────────────────────────────── */ + +.icon-green { background: rgba(74,222,128,0.2); color: var(--accent-green); } +.icon-blue { background: rgba(96,165,250,0.2); color: var(--accent-blue); } +.icon-orange { background: rgba(251,146,60,0.2); color: var(--accent-orange); } +.icon-purple { background: rgba(192,132,252,0.2); color: var(--accent-purple); } +.icon-red { background: rgba(248,113,113,0.2); color: var(--accent-red); } +.icon-cyan { background: rgba(34,211,238,0.2); color: var(--accent-cyan); } + +/* ── Empty State ───────────────────────────────────────────────── */ + +.empty-state { + text-align: center; + padding: 60px 20px; + color: var(--text-muted); +} +.empty-state svg { width: 48px; height: 48px; margin-bottom: 16px; opacity: 0.3; } +.empty-state p { font-size: 14px; } + +/* ── Toast Notifications ───────────────────────────────────────── */ + +.toast { + position: fixed; + bottom: 20px; + right: 20px; + background: var(--accent-green); + color: #000; + padding: 10px 18px; + border-radius: 8px; + font-size: 13px; + font-weight: 500; + transform: translateY(80px); + transition: transform 0.3s; + z-index: 200; +} +.toast.show { transform: translateY(0); } + +/* ── Heatmap ───────────────────────────────────────────────────── */ + +.heatmap-grid { + display: grid; + grid-template-columns: repeat(53, 12px); + grid-template-rows: repeat(7, 12px); + gap: 2px; + grid-auto-flow: column; +} + +.heatmap-cell { + width: 12px; + height: 12px; + border-radius: 2px; + background: var(--bg-card); + transition: background 0.15s; +} + +.heatmap-cell.level-0 { background: var(--bg-card); } +.heatmap-cell.level-1 { background: rgba(74,222,128,0.25); } +.heatmap-cell.level-2 { background: rgba(74,222,128,0.45); } +.heatmap-cell.level-3 { background: rgba(74,222,128,0.70); } +.heatmap-cell.level-4 { background: #4ade80; } + +[data-theme="light"] .heatmap-cell.level-0 { background: #ebedf0; } +[data-theme="light"] .heatmap-cell.level-1 { background: #9be9a8; } +[data-theme="light"] .heatmap-cell.level-2 { background: #40c463; } +[data-theme="light"] .heatmap-cell.level-3 { background: #30a14e; } +[data-theme="light"] .heatmap-cell.level-4 { background: #216e39; } + +[data-theme="monokai"] .heatmap-cell.level-1 { background: rgba(166,226,46,0.25); } +[data-theme="monokai"] .heatmap-cell.level-2 { background: rgba(166,226,46,0.45); } +[data-theme="monokai"] .heatmap-cell.level-3 { background: rgba(166,226,46,0.70); } +[data-theme="monokai"] .heatmap-cell.level-4 { background: #a6e22e; } + +/* ── Bulk Action Bar ───────────────────────────────────────────── */ + +.bulk-bar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 24px; + background: rgba(26,29,35,0.85); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-top: 1px solid var(--border); + z-index: 150; + transform: translateY(100%); + transition: transform 0.25s ease; +} +.bulk-bar.visible { transform: translateY(0); } + +.bulk-bar .bulk-count { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} + +.bulk-bar .bulk-actions { + display: flex; + gap: 8px; +} + +.bulk-bar button { + padding: 8px 16px; + border-radius: 8px; + border: 1px solid var(--border); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; + background: var(--bg-card); + color: var(--text-primary); +} +.bulk-bar button:hover { background: var(--bg-card-hover); } +.bulk-bar button.bulk-delete { + background: var(--accent-red); + color: #fff; + border-color: var(--accent-red); +} +.bulk-bar button.bulk-delete:hover { opacity: 0.85; } + +[data-theme="light"] .bulk-bar { + background: rgba(245,245,247,0.88); +} +[data-theme="monokai"] .bulk-bar { + background: rgba(39,40,34,0.88); +} + +/* ── Export Button ─────────────────────────────────────────────── */ + +.export-btn { + display: inline-flex; + align-items: center; + gap: 6px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + padding: 8px 14px; + color: var(--text-secondary); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} +.export-btn:hover { + background: var(--bg-card-hover); + color: var(--text-primary); +} +.export-btn svg { width: 16px; height: 16px; } + +/* ── Card structure (app.js layout) ─────────────────────────── */ + +.card-top { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + margin-bottom: 6px; + flex-wrap: wrap; +} + +.card-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + line-height: 1.4; + margin-bottom: 2px; + word-break: break-word; +} + +.card-body { + font-size: 13px; + color: var(--text-primary); + line-height: 1.5; + word-break: break-word; + margin-bottom: 8px; + flex: unset; + min-width: unset; +} + +.card-body-sub { + font-size: 12px; + color: var(--text-muted); + margin-bottom: 6px; +} + +.card-footer { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + font-size: 12px; + color: var(--text-muted); +} + +.card-id { + font-family: monospace; + font-size: 11px; + color: var(--text-muted); + opacity: 0.6; +} + +.card-project { + font-size: 12px; + font-weight: 600; +} + +.card-tags { + display: inline-flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; +} + +.card-time { + font-size: 12px; + color: var(--text-muted); + margin-left: auto; +} + +.card { display: flex; flex-direction: column; } + +/* ── Tool badges ────────────────────────────────────────────── */ + +.tool-badge { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 2px 8px; + border-radius: 4px; +} + +.tool-claude { + background: rgba(96, 165, 250, 0.15); + color: var(--accent-blue); +} + +.tool-codex { + background: rgba(34, 211, 238, 0.15); + color: var(--accent-cyan); +} + +.tool-opencode { + background: rgba(192, 132, 252, 0.15); + color: var(--accent-purple); +} + +.tool-kiro { + background: rgba(251, 146, 60, 0.15); + color: var(--accent-orange); +} + +.tool-cursor { + background: rgba(96, 165, 250, 0.15); + color: #4a9eff; +} + +.tool-copilot { + background: rgba(46, 160, 67, 0.15); + color: #3fb950; +} + +/* ── MCP / Skill badges ──────────────────────────────────────── */ + +.badge-mcp { + background: rgba(251, 146, 60, 0.15); + color: var(--accent-orange); +} + +.badge-skill { + background: rgba(139, 92, 246, 0.15); + color: var(--accent-purple); +} + +.card-tools { + padding: 8px 16px; + border-top: 1px solid var(--border); + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.message.has-tools { + padding: 0; + overflow: hidden; +} +.message.has-tools .msg-inner { + padding: 10px 14px; +} +.msg-tools { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 6px 14px; + border-top: 1px solid var(--border); +} + +/* ── Groups ─────────────────────────────────────────────────── */ + +.group { + margin-bottom: 16px; +} + +.group.collapsed .group-body { display: none; } +.group.collapsed .group-chevron { transform: rotate(-90deg); } + +.group-body { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + gap: 10px; + padding-top: 8px; +} + +.group-header { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 4px; + cursor: pointer; + user-select: none; + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); +} + +.group-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} + +.group-name { flex: 1; } + +.group-count { + font-size: 11px; + color: var(--text-muted); + background: rgba(255,255,255,0.06); + padding: 1px 8px; + border-radius: 10px; +} + +.group-chevron { + font-size: 10px; + transition: transform 0.2s; + color: var(--text-muted); +} + +/* ── GitHub-style Activity ──────────────────────────────────── */ + +.gh-activity { + padding: 20px; + max-width: 900px; +} + +.gh-header { + margin-bottom: 8px; +} + +.gh-total { + font-size: 14px; + color: var(--text-secondary); +} + +.gh-graph { + overflow-x: auto; + padding: 8px 0; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-card); + padding: 16px; +} + +.gh-graph svg rect { + outline: 1px solid rgba(27,31,35,0.04); + outline-offset: -1px; +} + +.gh-footer { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 8px; + font-size: 12px; +} + +.gh-link { + color: var(--text-muted); + text-decoration: none; +} + +.gh-legend { + display: flex; + align-items: center; + gap: 3px; + font-size: 11px; + color: var(--text-muted); +} + +.gh-legend-cell { + width: 11px; + height: 11px; + border-radius: 2px; + display: inline-block; +} + +/* Stats grid */ +.gh-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + gap: 12px; + margin-top: 20px; +} + +.gh-stat-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + padding: 14px; + text-align: center; +} + +.gh-stat-num { + font-size: 24px; + font-weight: 700; + color: var(--text-primary); +} + +.gh-stat-label { + font-size: 11px; + color: var(--text-muted); + margin-top: 2px; +} + +/* Tool breakdown */ +.gh-tools { + margin-top: 20px; +} + +.gh-tool-row { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 8px; +} + +.gh-tool-name { + width: 80px; + font-size: 13px; + font-weight: 600; + flex-shrink: 0; +} + +.gh-tool-bar { + flex: 1; + height: 8px; + background: var(--bg-card); + border-radius: 4px; + overflow: hidden; +} + +.gh-tool-fill { + height: 100%; + border-radius: 4px; + transition: width 0.5s ease; +} + +.gh-tool-val { + font-size: 12px; + color: var(--text-muted); + min-width: 80px; + text-align: right; +} + +/* Light theme overrides for SVG */ +[data-theme="light"] .gh-graph svg rect { outline-color: rgba(27,31,35,0.06); } + +/* ── Detail panel additions ─────────────────────────────────── */ + +.detail-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin: 16px 0; +} + +.detail-row { + display: flex; + align-items: baseline; + gap: 12px; + padding: 6px 0; + border-bottom: 1px solid var(--border); + font-size: 13px; + word-break: break-word; + min-width: 0; +} + +.detail-label { + width: 100px; + flex-shrink: 0; + color: var(--text-muted); + font-size: 12px; +} + +.git-branch { + font-weight: 600; + color: var(--accent-purple); +} +.git-dirty { + color: var(--accent-orange); + font-weight: 700; +} + +.detail-messages { + margin-top: 16px; +} + +.detail-commits { + margin-top: 16px; +} + +.detail-commits h3, +.detail-messages h3 { + font-size: 14px; + font-weight: 600; + margin-bottom: 12px; + color: var(--text-secondary); +} + +.detail-star { + font-size: 14px; + padding: 8px 14px; +} + +.mono { + font-family: monospace; + font-size: 12px; + word-break: break-all; +} + +.loading { + color: var(--text-muted); + font-size: 13px; + padding: 20px 0; +} + +/* ── Messages ───────────────────────────────────────────────── */ + +.message { + margin-bottom: 12px; + padding: 10px 14px; + border-radius: 10px; + font-size: 13px; + line-height: 1.5; + word-break: break-word; + max-height: 300px; + overflow-y: auto; +} + +.msg-user { + background: rgba(96, 165, 250, 0.12); + border: 1px solid rgba(96, 165, 250, 0.2); +} + +.msg-assistant { + background: rgba(74, 222, 128, 0.08); + border: 1px solid rgba(74, 222, 128, 0.15); +} + +.msg-role { + font-size: 11px; + font-weight: 600; + margin-bottom: 4px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted); +} + +.msg-content { + white-space: pre-wrap; +} + +/* ── Commits ────────────────────────────────────────────────── */ + +.commit-item { + display: flex; + gap: 10px; + padding: 6px 0; + border-bottom: 1px solid var(--border); + font-size: 13px; +} + +.commit-hash { + font-family: monospace; + font-size: 12px; + color: var(--accent-blue); + flex-shrink: 0; +} + +.commit-msg { + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ── Git Projects accordion ─────────────────────────────────── */ + +.git-projects { + display: flex; + flex-direction: column; + gap: 8px; +} + +.git-project-group { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; +} + +.git-project-header { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + cursor: pointer; + transition: background 0.12s; + user-select: none; +} +.git-project-header:hover { background: var(--bg-card-hover); } + +.git-project-name { + font-size: 14px; + font-weight: 700; + flex: 0 0 auto; +} + +.git-project-stats { + font-size: 12px; + color: var(--text-muted); + flex: 1; +} + +.git-project-group .group-chevron { + font-size: 10px; + color: var(--text-muted); + transition: transform 0.2s; +} +.git-project-group.collapsed .group-chevron { transform: rotate(-90deg); } + +/* ── QA session list ────────────────────────────────────────── */ + +.qa-list { + border-top: 1px solid var(--border); + display: flex; + flex-direction: column; +} +.git-project-group.collapsed .qa-list { display: none; } + +.qa-item { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 16px; + cursor: pointer; + border-bottom: 1px solid var(--border); + transition: background 0.12s; + min-width: 0; +} +.qa-item:last-child { border-bottom: none; } +.qa-item:hover { background: var(--bg-card-hover); } +.qa-item.selected { background: rgba(96, 165, 250, 0.08); } + +.qa-question { + flex: 1; + font-size: 13px; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.qa-meta { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.qa-msgs, .qa-time { + font-size: 12px; + color: var(--text-muted); + white-space: nowrap; +} + +/* ── Projects grid (kept for reference) ─────────────────────── */ + +.projects-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 12px; +} + +.project-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 10px; + padding: 16px; + cursor: pointer; + transition: all 0.15s; +} + +.project-card:hover { + background: var(--bg-card-hover); + transform: translateY(-1px); +} + +.project-card-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; +} + +.project-card-name { + font-size: 15px; + font-weight: 600; +} + +.project-card-stats { + display: flex; + gap: 12px; + font-size: 12px; + color: var(--text-muted); + margin-bottom: 6px; +} + +.project-card-time { + font-size: 12px; + color: var(--text-muted); +} + +/* ── Timeline ───────────────────────────────────────────────── */ + +.timeline { + display: flex; + flex-direction: column; + gap: 20px; +} + +.timeline-date { + display: flex; + flex-direction: column; + gap: 8px; +} + +.timeline-date-label { + font-size: 14px; + font-weight: 600; + color: var(--text-secondary); + padding-bottom: 8px; + border-bottom: 1px solid var(--border); +} + +.timeline-count { + font-size: 12px; + font-weight: 400; + color: var(--text-muted); + margin-left: 8px; +} + +/* ── Launch buttons ─────────────────────────────────────────── */ + +.launch-btn { + display: inline-flex; + align-items: center; + gap: 6px; + border: none; + border-radius: 8px; + padding: 8px 14px; + font-size: 13px; + cursor: pointer; + font-weight: 500; + background: var(--accent-blue); + color: #fff; + transition: opacity 0.15s; +} + +.launch-btn:hover { opacity: 0.85; } + +.launch-btn.btn-secondary { + background: var(--bg-card); + color: var(--text-primary); + border: 1px solid var(--border); +} + +.launch-btn.btn-delete { + background: var(--accent-red); + color: #fff; +} + +/* ── Live session badges ────────────────────────────────────── */ + +.live-badge { + font-size: 10px; + font-weight: 800; + letter-spacing: 0.8px; + padding: 3px 10px; + border-radius: 6px; + text-transform: uppercase; + display: inline-flex; + align-items: center; + gap: 5px; +} + +.live-badge::before { + content: ''; + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; + animation: dot-pulse 1.5s infinite; +} + +.live-active { + background: rgba(74, 222, 128, 0.25); + color: #22c55e; + border: 1px solid rgba(74, 222, 128, 0.5); + box-shadow: 0 0 12px rgba(74, 222, 128, 0.3); +} + +.live-active::before { + background: #22c55e; + box-shadow: 0 0 6px #22c55e; +} + +.live-waiting { + background: rgba(251, 191, 36, 0.2); + color: #f59e0b; + border: 1px solid rgba(251, 191, 36, 0.4); + box-shadow: 0 0 12px rgba(251, 191, 36, 0.25); +} + +.live-waiting::before { + background: #f59e0b; + box-shadow: 0 0 6px #f59e0b; +} + +@keyframes dot-pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.3; transform: scale(0.6); } +} + +/* Animated border wrapper for live sessions */ +.card-live-wrap { + position: relative; + border-radius: 12px; + padding: 2px; + display: flex; + flex-direction: column; +} + +.card-live-wrap > .card { + border: none; + position: relative; + z-index: 1; + flex: 1; +} + +.card-live-wrap .live-border { + position: absolute; + inset: 0; + border-radius: 12px; + z-index: 0; + background: conic-gradient( + from var(--border-angle, 0deg), + transparent 35%, + var(--live-color, rgba(74, 222, 128, 0.6)) 50%, + transparent 65% + ); + animation: border-spin 3s linear infinite; +} + +.card-live-wrap.live-waiting .live-border { + animation: none; + background: conic-gradient( + from 90deg, + transparent 35%, + var(--live-color, rgba(251, 191, 36, 0.4)) 50%, + transparent 65% + ); +} + +@keyframes border-spin { + to { --border-angle: 360deg; } +} + +@property --border-angle { + syntax: ''; + initial-value: 0deg; + inherits: false; +} + +/* ── Card expand preview ────────────────────────────────────── */ + +.card-gen-btn { + background: none; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-muted); + font-size: 11px; + cursor: pointer; + padding: 1px 5px; + transition: all 0.15s; +} +.card-gen-btn:hover { + color: var(--accent-purple); + border-color: var(--accent-purple); +} + +.card-expand-btn { + background: none; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-muted); + font-size: 12px; + cursor: pointer; + padding: 1px 6px; + margin-left: auto; + transition: all 0.15s; +} +.card-expand-btn:hover { + color: var(--text-primary); + border-color: var(--accent-blue); +} + +.card-preview-area { + display: none; + border-top: 1px solid var(--border); + margin-top: 10px; + padding-top: 10px; + max-height: 200px; + overflow-y: auto; + animation: fadeIn 0.2s ease; +} + +/* Ensure card clips expanded content */ +.card { overflow: hidden; } + +.card-preview-area.open { + display: block; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + +.preview-msg { + font-size: 12px; + line-height: 1.5; + padding: 4px 8px; + margin-bottom: 4px; + border-radius: 6px; + word-break: break-word; + white-space: pre-wrap; +} + +.preview-user { + background: rgba(96, 165, 250, 0.08); +} + +.preview-assistant { + background: rgba(74, 222, 128, 0.06); +} + +.preview-role { + font-weight: 600; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.preview-user .preview-role { color: var(--accent-blue); } +.preview-assistant .preview-role { color: var(--accent-green); } + +.preview-empty { + color: var(--text-muted); + font-size: 12px; + padding: 8px 0; +} + +/* ── Leaderboard ───────────────────────────────────────────── */ + +.leaderboard-container { padding: 24px; max-width: 700px; } + +.lb-hero { + display: flex; + align-items: center; + gap: 16px; + padding: 24px; + background: linear-gradient(135deg, rgba(99,102,241,0.15), rgba(168,85,247,0.15)); + border-radius: 16px; + margin-bottom: 24px; + border: 1px solid rgba(139,92,246,0.3); +} + +.lb-avatar { + width: 56px; + height: 56px; + border-radius: 50%; + background: linear-gradient(135deg, var(--accent-blue), var(--accent-purple)); + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + font-weight: 700; + color: #fff; + flex-shrink: 0; +} + +.lb-name { font-size: 20px; font-weight: 700; color: var(--text-primary); } +.lb-streak { font-size: 13px; color: var(--accent-orange); margin-top: 2px; } + +.lb-section-title { + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 1px; + margin: 20px 0 10px; +} + +.lb-stats-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; +} + +.lb-stat { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 12px; + padding: 16px; + text-align: center; +} + +.lb-stat-value { font-size: 24px; font-weight: 700; color: var(--text-primary); } +.lb-stat-label { font-size: 11px; color: var(--text-muted); margin-top: 4px; } + +.lb-agents { display: flex; flex-direction: column; gap: 8px; } + +.lb-agent-row { + display: flex; + align-items: center; + gap: 12px; +} +.lb-agent-row .tool-badge { min-width: 80px; text-align: center; } + +.lb-agent-bar { + flex: 1; + height: 8px; + background: var(--bg-card); + border-radius: 4px; + overflow: hidden; +} +.lb-agent-bar-fill { + height: 100%; + background: linear-gradient(90deg, var(--accent-blue), var(--accent-purple)); + border-radius: 4px; + transition: width 0.5s; +} +.lb-agent-count { font-size: 12px; color: var(--text-muted); min-width: 60px; } + +.lb-daily-chart { + display: flex; + align-items: flex-end; + gap: 6px; + height: 150px; + padding: 8px 0; +} + +.lb-bar-col { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + height: 100%; +} + +.lb-bar { + width: 100%; + max-width: 40px; + background: linear-gradient(180deg, var(--accent-blue), var(--accent-purple)); + border-radius: 4px 4px 0 0; + min-height: 4px; + cursor: default; +} +.lb-bar:hover { opacity: 0.8; } + +.lb-bar-label { font-size: 10px; color: var(--text-muted); margin-top: 4px; } + +.lb-footer { + margin-top: 24px; + padding-top: 16px; + border-top: 1px solid var(--border); + font-size: 11px; + color: var(--text-muted); + text-align: center; +} + +.lb-avatar-img { + width: 56px; + height: 56px; + border-radius: 50%; + border: 2px solid var(--accent-purple); + flex-shrink: 0; +} + +.lb-username { font-size: 13px; color: var(--text-muted); } + +.lb-github-btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + background: #24292e; + color: #fff; + border: none; + border-radius: 8px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + text-decoration: none; + transition: background 0.15s; + margin-left: auto; +} +.lb-github-btn:hover { background: #2f363d; } +[data-theme="light"] .lb-github-btn { background: #24292e; color: #fff; } + +.lb-auth-code { + font-size: 32px; + font-weight: 700; + font-family: monospace; + letter-spacing: 4px; + padding: 16px; + background: var(--bg-card); + border: 2px dashed var(--accent-blue); + border-radius: 12px; + color: var(--accent-blue); + user-select: all; +} + +.lb-network { + display: flex; + justify-content: center; + gap: 24px; + padding: 12px 16px; + background: linear-gradient(135deg, rgba(99,102,241,0.08), rgba(168,85,247,0.08)); + border: 1px solid rgba(139,92,246,0.2); + border-radius: 10px; + margin: 16px 0; + font-size: 13px; + color: var(--text-muted); +} +.lb-network span { font-weight: 600; } + +.lb-sync-bar { + text-align: center; + margin: 20px 0; +} +.lb-sync-btn { + padding: 12px 32px; + background: linear-gradient(135deg, var(--accent-blue), var(--accent-purple)); + color: #fff; + border: none; + border-radius: 10px; + font-size: 15px; + font-weight: 700; + cursor: pointer; + transition: opacity 0.15s, transform 0.15s; +} +.lb-sync-btn:hover { opacity: 0.9; transform: translateY(-1px); } +.lb-sync-btn:active { transform: translateY(0); } + +.lb-global-row { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 10px; + margin-bottom: 6px; + transition: border-color 0.2s; +} +.lb-global-row:hover { border-color: var(--accent-blue); } + +.lb-rank { font-size: 16px; font-weight: 700; width: 32px; text-align: center; color: var(--text-muted); } +.lb-rank-1 { color: #ffd700; } +.lb-rank-2 { color: #c0c0c0; } +.lb-rank-3 { color: #cd7f32; } + +.lb-global-avatar { width: 40px; height: 40px; border-radius: 50%; border: 2px solid var(--border); } +.lb-global-info { flex: 1; min-width: 0; } +.lb-global-name { font-weight: 600; font-size: 14px; } +.lb-global-handle { font-size: 12px; color: var(--text-muted); } +.lb-global-stats { display: flex; gap: 12px; font-size: 12px; color: var(--text-muted); flex-shrink: 0; } +.lb-global-stats strong { color: var(--text-primary); } +.lb-streak-badge { background: rgba(251,146,60,0.15); color: var(--accent-orange); padding: 2px 8px; border-radius: 10px; font-weight: 600; } +.lb-verified { color: var(--accent-green); font-size: 12px; } +.lb-devices { font-size: 10px; color: var(--text-muted); background: var(--bg-card); padding: 1px 6px; border-radius: 8px; margin-left: 4px; } + +.lb-tabs { display: flex; gap: 4px; margin-bottom: 12px; } +.lb-tab { + padding: 6px 16px; border: 1px solid var(--border); border-radius: 8px; + background: none; color: var(--text-muted); cursor: pointer; font-size: 13px; font-weight: 600; + transition: all 0.15s; +} +.lb-tab.active { background: var(--accent-blue); color: #fff; border-color: var(--accent-blue); } +.lb-tab:hover:not(.active) { border-color: var(--accent-blue); color: var(--text-primary); } + +.lb-global-name a { color: var(--text-primary); text-decoration: none; } +.lb-global-name a:hover { color: var(--accent-blue); text-decoration: underline; } + +.lb-global-agents { display: flex; gap: 4px; margin-top: 2px; } +.lb-agent-mini { font-size: 10px; padding: 1px 5px; border-radius: 4px; background: rgba(99,102,241,0.1); color: var(--text-muted); } + +/* ── Changelog ──────────────────────────────────────────────── */ + +.changelog-container { padding: 20px; max-width: 700px; } + +.changelog-entry { + border-left: 2px solid var(--border); + padding: 0 0 24px 20px; + margin-left: 8px; + position: relative; +} + +.changelog-entry::before { + content: ''; + position: absolute; + left: -5px; + top: 4px; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--text-muted); +} + +.changelog-latest { + border-left-color: var(--accent-green); +} + +.changelog-latest::before { + background: var(--accent-green); + box-shadow: 0 0 6px var(--accent-green); +} + +.changelog-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; +} + +.changelog-version { + font-size: 15px; + font-weight: 700; + color: var(--text-primary); +} + +.changelog-new { + font-size: 9px; + font-weight: 700; + letter-spacing: 0.5px; + padding: 2px 6px; + border-radius: 4px; + background: rgba(74, 222, 128, 0.2); + color: var(--accent-green); +} + +.changelog-date { + font-size: 12px; + color: var(--text-muted); + margin-left: auto; +} + +.changelog-title { + font-size: 14px; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 6px; +} + +.changelog-list { + list-style: none; + padding: 0; + margin: 0; +} + +.changelog-list li { + font-size: 13px; + color: var(--text-secondary); + padding: 2px 0; + line-height: 1.5; +} + +.changelog-list li::before { + content: '+'; + color: var(--accent-green); + font-weight: 700; + margin-right: 8px; +} + +/* ── Running Sessions View ──────────────────────────────────── */ + +.running-container { padding: 20px; } + +/* ── Kanban board ── */ +.kanban-board { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; + align-items: start; +} + +@media (max-width: 900px) { + .kanban-board { grid-template-columns: 1fr; } +} + +.kanban-col { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 12px; + padding: 12px; + min-height: 120px; +} + +.kanban-col-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 4px 12px; + margin-bottom: 4px; + border-bottom: 2px solid transparent; +} + +.kanban-running { border-bottom-color: var(--accent-green); } +.kanban-waiting { border-bottom-color: #f59e0b; } +.kanban-done { border-bottom-color: var(--text-muted); } + +.kanban-col-title { + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary); +} + +.kanban-col-count { + font-size: 12px; + font-weight: 700; + color: var(--text-muted); + background: var(--bg-card); + border-radius: 10px; + padding: 1px 8px; +} + +.kanban-empty { + font-size: 12px; + color: var(--text-muted); + text-align: center; + padding: 20px 0; +} + +.running-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 12px; + padding: 16px; + margin-bottom: 10px; +} + +.running-active { border-left: 3px solid var(--accent-green); } +.running-waiting { border-left: 3px solid #f59e0b; } +.running-done { border-left: 3px solid var(--text-muted); opacity: 0.85; } + +.live-done { + background: rgba(148,163,184,0.15); + color: var(--text-muted); + font-size: 10px; + font-weight: 700; + padding: 2px 7px; + border-radius: 4px; + letter-spacing: 0.05em; +} + +.running-card-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 14px; +} + +.running-project { font-size: 16px; font-weight: 600; } +.running-tool { font-size: 12px; color: var(--text-muted); margin-left: auto; } + +.running-stats { + display: flex; + gap: 24px; + margin-bottom: 12px; +} + +.running-stat { display: flex; flex-direction: column; gap: 2px; } +.running-stat-val { font-size: 18px; font-weight: 700; color: var(--text-primary); } +.running-stat-label { font-size: 11px; color: var(--text-muted); } + +.running-msg { + font-size: 13px; + color: var(--text-secondary); + padding: 10px; + background: rgba(255,255,255,0.03); + border-radius: 8px; + margin-bottom: 12px; + line-height: 1.5; +} + +.running-actions { display: flex; gap: 8px; } + +/* ── Session Replay ─────────────────────────────────────────── */ + +.replay-container { padding: 20px; } + +.replay-header { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 16px; +} + +.replay-title { + font-size: 16px; + font-weight: 600; + flex: 1; +} + +.replay-duration { + color: var(--text-muted); + font-size: 13px; +} + +.replay-controls { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 20px; + padding: 12px 16px; + background: var(--bg-card); + border-radius: 10px; + border: 1px solid var(--border); +} + +.replay-play-btn { + width: 36px; + height: 36px; + border-radius: 50%; + border: none; + background: var(--accent-blue); + color: #fff; + font-size: 14px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} +.replay-play-btn:hover { opacity: 0.85; } + +.replay-slider { + flex: 1; + height: 6px; + -webkit-appearance: none; + appearance: none; + background: var(--border); + border-radius: 3px; + outline: none; + cursor: pointer; +} +.replay-slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--accent-blue); + cursor: pointer; +} + +.replay-counter { + font-size: 12px; + color: var(--text-muted); + white-space: nowrap; + min-width: 60px; + text-align: right; +} + +.replay-messages { + max-height: calc(100vh - 200px); + overflow-y: auto; +} + +.replay-msg { + padding: 12px 16px; + margin-bottom: 8px; + border-radius: 10px; + animation: fadeIn 0.3s ease; +} + +.replay-latest { + box-shadow: 0 0 0 2px var(--accent-blue); +} + +.replay-msg-header { + display: flex; + justify-content: space-between; + margin-bottom: 4px; +} + +.replay-time { + font-size: 11px; + color: var(--text-muted); +} + +.replay-msg-content { + font-size: 13px; + line-height: 1.6; + white-space: pre-wrap; + word-break: break-word; +} + +/* ── Cost Analytics ─────────────────────────────────────────── */ + +.analytics-container { padding: 20px; } + +.analytics-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + margin-bottom: 24px; +} + +.analytics-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 10px; + padding: 16px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.analytics-val { + font-size: 24px; + font-weight: 700; + color: var(--accent-green); +} + +.analytics-label { + font-size: 12px; + color: var(--text-muted); +} + +.burn-rate-bar { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 10px; + padding: 14px 18px; + margin-bottom: 24px; + display: flex; + align-items: center; + gap: 24px; +} + +.burn-rate-title { + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + white-space: nowrap; +} + +.burn-rate-stats { + display: flex; + gap: 28px; + flex-wrap: wrap; +} + +.burn-stat { + display: flex; + align-items: baseline; + gap: 6px; +} + +.burn-val { + font-size: 18px; + font-weight: 700; + color: var(--text-primary); +} + +.burn-val.burn-low { color: var(--accent-green); } +.burn-val.burn-medium { color: #f59e0b; } +.burn-val.burn-high { color: #ef4444; } + +.burn-label { + font-size: 12px; + color: var(--text-muted); +} + +.burn-pace { + font-size: 11px; + font-weight: 600; + padding: 2px 6px; + border-radius: 4px; + margin-left: 2px; +} + +.burn-pace.burn-low { background: rgba(74,222,128,0.12); color: var(--accent-green); } +.burn-pace.burn-medium { background: rgba(245,158,11,0.12); color: #f59e0b; } +.burn-pace.burn-high { background: rgba(239,68,68,0.12); color: #ef4444; } + +.chart-section { + margin-bottom: 28px; +} + +.chart-section h3 { + font-size: 14px; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 12px; +} + +/* Bar chart (vertical) */ +.bar-chart { + display: flex; + align-items: flex-end; + gap: 3px; + height: 160px; + padding: 0 4px; + border-bottom: 1px solid var(--border); +} + +.bar-col { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + height: 100%; + justify-content: flex-end; + min-width: 0; +} + +.bar-fill { + width: 100%; + background: linear-gradient(to top, var(--accent-blue), var(--accent-purple)); + border-radius: 3px 3px 0 0; + min-height: 2px; + transition: height 0.3s ease; +} + +.bar-label { + font-size: 9px; + color: var(--text-muted); + margin-top: 6px; + transform: rotate(-45deg); + white-space: nowrap; +} + +/* Horizontal bar chart */ +.hbar-chart { + display: flex; + flex-direction: column; + gap: 8px; +} + +.hbar-row { + display: flex; + align-items: center; + gap: 12px; +} + +.hbar-name { + width: 140px; + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex-shrink: 0; +} + +.hbar-track { + flex: 1; + height: 24px; + background: var(--bg-card); + border-radius: 6px; + overflow: hidden; +} + +.hbar-fill { + height: 100%; + background: linear-gradient(to right, var(--accent-blue), var(--accent-green)); + border-radius: 6px; + transition: width 0.5s ease; +} + +.hbar-val { + font-size: 13px; + font-weight: 600; + color: var(--accent-green); + min-width: 70px; + text-align: right; +} + +/* Top sessions list */ +.top-sessions { display: flex; flex-direction: column; gap: 4px; } + +.top-session-row { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 12px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + cursor: pointer; + font-size: 13px; + transition: background 0.15s; +} +.top-session-row:hover { background: var(--bg-card-hover); } + +.top-session-cost { + font-weight: 700; + color: var(--accent-green); + min-width: 70px; +} + +.top-session-project { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.top-session-date { color: var(--text-muted); font-size: 12px; } +.top-session-id { font-family: monospace; font-size: 11px; color: var(--text-muted); } + +/* ── Data coverage indicators ──────────────────────────────── */ + +.analytics-coverage { + font-size: 12px; + color: var(--text-muted); + margin: -8px 0 16px; + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; +} + +.coverage-ok { color: var(--accent-green); } +.coverage-est { color: var(--accent-orange, #f59e0b); } +.coverage-none { color: var(--text-muted); opacity: 0.6; } + +/* ── Token breakdown grid ─────────────────────────────────── */ + +.token-breakdown-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 10px; +} + +.token-type-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px; + text-align: center; + display: flex; + flex-direction: column; + gap: 4px; +} + +.token-type-val { font-size: 18px; font-weight: 700; color: var(--text); } +.token-type-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; } +.token-type-pct { font-size: 12px; color: var(--text-muted); } + +.token-cache-read { border-color: rgba(96, 165, 250, 0.3); } +.token-cache-create { border-color: rgba(251, 191, 36, 0.3); } +.token-context { border-color: rgba(168, 85, 247, 0.3); } + +/* ── Subscription vs API ──────────────────────────────────── */ + +.subscription-section { margin-top: 8px; } + +.sub-comparison { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 10px; + margin-bottom: 12px; +} + +.sub-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + padding: 14px; + text-align: center; +} + +.sub-val { font-size: 20px; font-weight: 700; display: block; } +.sub-label { font-size: 11px; color: var(--text-muted); display: block; margin-top: 4px; } + +.sub-paid .sub-val { color: var(--text); } +.sub-api .sub-val { color: var(--accent-blue, #60a5fa); } +.sub-savings .sub-val { color: var(--accent-green); } +.sub-loss .sub-val { color: var(--accent-red, #f87171); } + +.sub-bar-track { + height: 8px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 4px; + margin-bottom: 14px; + overflow: hidden; +} + +.sub-bar-fill { + height: 100%; + background: var(--accent-green); + border-radius: 4px; + transition: width 0.3s; +} + +.sub-hint { + color: var(--text-muted); + font-size: 13px; + margin: 8px 0; +} + +.sub-entries { margin-bottom: 10px; } + +.sub-entry-row { + display: flex; + align-items: center; + gap: 12px; + padding: 6px 0; + border-bottom: 1px solid var(--border); + font-size: 13px; +} + +.sub-entry-service { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; color: var(--accent-blue); background: rgba(96,165,250,0.12); border-radius: 4px; padding: 2px 6px; } +.sub-entry-plan { font-weight: 600; min-width: 60px; } +.sub-entry-paid { color: var(--accent-green); min-width: 80px; } +.sub-entry-from { color: var(--text-muted); flex: 1; } +.sub-entry-remove { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 16px; + padding: 2px 6px; + border-radius: 4px; +} +.sub-entry-remove:hover { background: rgba(248, 113, 113, 0.15); color: var(--accent-red, #f87171); } + +.sub-add-form { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.sub-add-form input { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 6px; + padding: 6px 10px; + color: var(--text); + font-size: 13px; +} + +.sub-add-form select { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 6px; + padding: 6px 10px; + color: var(--text); + font-size: 13px; + cursor: pointer; +} +.sub-add-form select:first-child { width: 170px; } +.sub-add-form select:nth-child(2) { width: 150px; } +.sub-add-form input[type="number"] { width: 80px; } +.sub-add-form input[type="date"] { width: 140px; } + +.sub-add-form button { + background: var(--accent-blue, #60a5fa); + color: #fff; + border: none; + border-radius: 6px; + padding: 6px 14px; + cursor: pointer; + font-size: 13px; + font-weight: 600; +} +.sub-add-form button:hover { opacity: 0.85; } + +/* ── Update banner ──────────────────────────────────────────── */ + +.update-banner { + position: fixed; + top: 0; + left: 200px; + right: 0; + background: linear-gradient(135deg, var(--accent-blue), var(--accent-purple)); + color: #fff; + padding: 10px 20px; + display: flex; + align-items: center; + gap: 12px; + font-size: 13px; + z-index: 200; + animation: slideDown 0.3s ease; +} + +@keyframes slideDown { + from { transform: translateY(-100%); } + to { transform: translateY(0); } +} + +.update-btn { + background: rgba(255,255,255,0.2); + border: 1px solid rgba(255,255,255,0.3); + color: #fff; + padding: 4px 12px; + border-radius: 6px; + font-size: 12px; + cursor: pointer; + white-space: nowrap; +} +.update-btn:hover { background: rgba(255,255,255,0.3); } + +.update-dismiss { + background: none; + border: none; + color: rgba(255,255,255,0.7); + font-size: 18px; + cursor: pointer; + margin-left: auto; + padding: 0 4px; +} +.update-dismiss:hover { color: #fff; } + +/* ── List view ──────────────────────────────────────────────── */ + +.list-view { + display: flex; + flex-direction: column; + gap: 2px; +} + +.list-row { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 14px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + cursor: pointer; + transition: background 0.15s; + font-size: 13px; +} + +.list-row:hover { + background: var(--bg-card-hover); +} + +.list-row.selected { + border-color: var(--accent-blue); +} + +.list-row.focused { + outline: 2px solid var(--accent-blue); + outline-offset: -2px; +} + +.list-project { + font-weight: 600; + font-size: 12px; + width: 100px; + flex-shrink: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.list-msg { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-primary); +} + +.list-meta { + color: var(--text-muted); + font-size: 12px; + flex-shrink: 0; +} + +.list-time { + color: var(--text-muted); + font-size: 12px; + flex-shrink: 0; + width: 60px; + text-align: right; +} + +.grid-view { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + gap: 10px; +} + +.group-body-list { + display: flex; + flex-direction: column; + gap: 2px; +} From 12db6453376ca37c77269e0726322ad5a1c156dd Mon Sep 17 00:00:00 2001 From: Anykeyev Date: Thu, 9 Apr 2026 23:03:13 +0300 Subject: [PATCH 4/4] update docs --- docs/ARCHITECTURE.md | 726 ++++++++++++++++++++++--------------------- docs/README_RU.md | 93 +++--- 2 files changed, 422 insertions(+), 397 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1343794..10e7404 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,351 +1,375 @@ -# CodeDash Architecture - -## Overview - -CodeDash is a zero-dependency Node.js dashboard for AI coding agent sessions. Supports 6 agents: Claude Code, Claude Extension, Codex, Cursor, OpenCode, Kiro. Single process serves a web UI at `localhost:3847`. - -``` -Browser (localhost:3847) Node.js Server -+-----------------------------+ +-------------------------------+ -| index.html | | server.js (HTTP, 20+ routes) | -| +-- styles.css (inlined) | | | | -| +-- app.js (inlined) | <-->| +-- data.js | -| | | | (sessions, search, | -| Sidebar | Content | Detail | | | cost, active) | -+-----------------------------+ | +-- terminals.js | - | | (detect, launch, focus) | - bin/cli.js (CLI) | +-- html.js (assembly) | - +-------------------+ | +-- handoff.js | - | run/list/search/ | | +-- convert.js | - | show/handoff/ |------->| +-- migrate.js | - | convert/export/ | | +-- changelog.js | - | import/update | +-------------------------------+ - +-------------------+ | - reads from 5 locations: - ~/.claude/ ~/.codex/ ~/.cursor/ - ~/.local/share/opencode/opencode.db - ~/Library/Application Support/kiro-cli/data.sqlite3 -``` - -## Project Structure - -``` -bin/cli.js (12 KB) CLI entry point — all commands -src/ - server.js (12 KB) HTTP server + API routes - data.js (46 KB) Core: session loading, search index, cost, active detection - terminals.js (8.8 KB) Terminal detection + launch/focus - html.js (754 B) Template injection (CSS+JS into HTML) - handoff.js (4 KB) Handoff document generation - convert.js (8.3 KB) Cross-agent session conversion - migrate.js (5.9 KB) Export/import as tar.gz - changelog.js (6.7 KB) In-app changelog - frontend/ - index.html (10 KB) HTML template with {{STYLES}} / {{SCRIPT}} placeholders - styles.css (52 KB) All CSS (dark/light/monokai themes) - app.js (77 KB) All frontend logic (plain browser JS, no build step) -docs/ - ARCHITECTURE.md This file - README_RU.md Russian translation - README_ZH.md Chinese translation -``` - -Total source: ~235 KB. Zero npm dependencies — only Node.js stdlib + system `sqlite3` CLI. - ---- - -## Session Storage by Agent - -### 1. Claude Code (CLI) - -| Item | Location | -|------|----------| -| History index | `~/.claude/history.jsonl` | -| Session data | `~/.claude/projects//.jsonl` | -| PID files | `~/.claude/sessions/.json` | - -**PROJECT_KEY** encoding: full path with `/` and `.` replaced by `-`. -Example: `/Users/v.kovalskii/myproject` → `-Users-v-kovalskii-myproject` - -**history.jsonl** — one line per user message (index, no full content): -```json -{"sessionId": "uuid", "project": "/Users/v.kovalskii/myproject", "timestamp": 1712345678000, "display": "fix the login bug", "pastedContents": {}} -``` - -**Session JSONL** — full conversation, one JSON object per line: -```json -{"type": "permission-mode", "permissionMode": "default", "sessionId": "uuid"} -{"type": "user", "uuid": "uuid", "timestamp": "2026-04-06T10:00:00Z", "message": {"role": "user", "content": "fix the bug"}, "cwd": "/path", "entrypoint": "cli", "userType": "external"} -{"type": "assistant", "uuid": "uuid", "timestamp": "2026-04-06T10:00:05Z", "message": {"role": "assistant", "model": "claude-opus-4-6", "content": [...], "usage": {"input_tokens": 1500, "output_tokens": 800, "cache_creation_input_tokens": 500, "cache_read_input_tokens": 200}}} -``` - -Key fields in user messages: `entrypoint` ("cli" or "claude-vscode"), `cwd`, `userType`. -Key fields in assistant messages: `model`, `usage` (for cost calculation). - -**PID files** — active session tracking: -```json -{"pid": 12345, "sessionId": "uuid", "cwd": "/path", "startedAt": 1712345678000, "kind": "interactive"} -``` - -### 2. Claude Extension (VS Code / Cursor IDE) - -Same storage as Claude Code — files go to `~/.claude/projects//.jsonl`. The difference: - -- **No entry in `history.jsonl`** — Extension sessions are "orphan" (exist only as project session files) -- **`entrypoint` field = `"claude-vscode"`** instead of `"cli"` in user messages -- CodeDash scans all project dirs for `.jsonl` files not found in history, reads `entrypoint` from first user message, and assigns `tool: "claude-ext"` if not "cli" - -Detection logic in `data.js`: -``` -1. Load sessions from history.jsonl (all get tool: "claude") -2. Enrich with detail files — if entrypoint !== "cli", change to "claude-ext" -3. Scan project dirs for orphan .jsonl files not in history -4. Read entrypoint from first user message → "claude-ext" if not "cli" -5. Read cwd from user messages for correct project path -``` - -### 3. Codex CLI - -| Item | Location | -|------|----------| -| History index | `~/.codex/history.jsonl` | -| Session data | `~/.codex/sessions///
/rollout--.jsonl` | - -**history.jsonl**: -```json -{"session_id": "uuid", "ts": 1712345678, "text": "user prompt", "display": "...", "project": "/path", "cwd": "/path"} -``` -Note: `ts` is in **seconds** (not milliseconds like Claude). - -**Session JSONL** — first line is metadata, rest are messages: -```json -{"type": "session_meta", "payload": {"id": "uuid", "cwd": "/path", "timestamp": "2026-04-06T10:00:00Z"}} -{"type": "response_item", "payload": {"role": "user", "content": [{"type": "input_text", "text": "fix the bug"}]}} -{"type": "response_item", "payload": {"role": "assistant", "content": [{"type": "text", "text": "I'll fix..."}]}} -``` - -Session ID extracted from filename: `rollout-20260406-.jsonl` → UUID part. - -### 4. Cursor (Agent Mode) - -| Item | Location | -|------|----------| -| Projects format | `~/.cursor/projects//agent-transcripts//.jsonl` | -| Chats format | `~/.cursor/chats//.jsonl` or `.json` | - -**Two storage formats** — "projects" (macOS) and "chats" (Linux alternative). - -**PROJECT_KEY decoding**: ambiguous (`-` could be `/`, `.`, or literal `-`). CodeDash tries progressive path resolution — testing filesystem existence with different separator combinations. - -**Session JSONL**: -```json -{"role": "user", "message": {"content": [{"type": "text", "text": "fix the bug"}]}} -{"role": "assistant", "message": {"content": [{"type": "text", "text": "I'll fix..."}]}} -``` - -User messages wrapped in `...` tags — stripped during parsing. - -### 5. OpenCode - -| Item | Location | -|------|----------| -| Database | `~/.local/share/opencode/opencode.db` (SQLite) | - -Accessed via system `sqlite3` CLI (no Node driver): - -**Sessions scan**: -```sql -SELECT s.id, s.title, s.directory, s.time_created, s.time_updated, COUNT(m.id) -FROM session s LEFT JOIN message m ON m.session_id = s.id -GROUP BY s.id ORDER BY s.time_updated DESC -``` - -**Message loading**: -```sql -SELECT m.data, GROUP_CONCAT(p.data, '|||') -FROM message m LEFT JOIN part p ON p.message_id = m.id -WHERE m.session_id = ? -GROUP BY m.id ORDER BY m.time_created -``` - -Tables: `session`, `message`, `part`. Message `data` is JSON with `{role, tokens, model}`. Part `data` is JSON with `{type, text}`. - -### 6. Kiro CLI - -| Item | Location | -|------|----------| -| Database | `~/Library/Application Support/kiro-cli/data.sqlite3` (SQLite) | - -**Sessions scan**: -```sql -SELECT key, conversation_id, created_at, updated_at, substr(value, 1, 500) -FROM conversations_v2 ORDER BY updated_at DESC -``` -- `key` = project directory -- `conversation_id` = session ID -- `value` = full conversation JSON (truncated for scan, full for detail) - -**Conversation JSON structure**: -```json -{ - "history": [ - { - "user": {"content": {"Prompt": {"prompt": "fix the bug"}}}, - "assistant": {"Response": {"content": "I'll fix...", "message_id": "uuid"}} - } - ] -} -``` - ---- - -## Data Flow - -### Session Loading (`data.js:loadSessions()`) - -``` -1. Read ~/.claude/history.jsonl → sessions{} keyed by sessionId (tool: "claude") -2. scanCodexSessions() → merge into sessions{} (tool: "codex") -3. scanOpenCodeSessions() → merge (tool: "opencode") -4. scanCursorSessions() → merge (tool: "cursor") -5. scanKiroSessions() → merge (tool: "kiro") -6. Enrich Claude sessions with detail files: - - Count messages, get file size - - Check entrypoint → change tool to "claude-ext" if not "cli" -7. Scan orphan sessions from ~/.claude/projects/ (Claude Extension) -8. Sort by last_ts DESC, format dates -``` - -### Search Index - -- Built in-memory on first `/api/search` call -- Reads all session detail files, extracts lowercased full text -- Cached 60 seconds (rebuild on expiry) -- Substring match on `fullText.indexOf(query)`, returns up to 3 snippets per session with +-50 char context - -### Cost Calculation - -Uses `usage` data from Claude assistant messages: -``` -cost = input_tokens * input_price - + cache_creation_input_tokens * cache_create_price - + cache_read_input_tokens * cache_read_price - + output_tokens * output_price -``` - -Model pricing in `MODEL_PRICING` object (per-token rates for opus, sonnet, haiku, codex-mini, gpt-5). -Codex fallback: estimate from file size (~4 bytes per token). - -### Active Session Detection - -``` -1. Read ~/.claude/sessions/*.json → PID-to-session map -2. ps aux | grep "claude|codex|opencode|kiro-cli|cursor-agent" -3. For each process: parse PID, CPU%, memory, state -4. Status: "active" (CPU >= 1%) or "waiting" (sleeping/stopped) -5. Map PID → sessionId via PID files -6. Frontend polls /api/active every 5 seconds -``` - ---- - -## HTML Assembly - -`html.js` reads three files and injects CSS+JS into HTML: -```javascript -template.split('{{STYLES}}').join(css).split('{{SCRIPT}}').join(js) -``` -Uses `split/join` instead of `String.replace` — avoids `$` character issues in JS code. -Result cached in memory (refreshed in `NODE_ENV=development`). - -Final page: ~130 KB (single HTML, no external requests). - ---- - -## Frontend Architecture - -Plain browser JavaScript — no modules, no build step, no ES6 imports. Uses `var` for compatibility. - -**State**: global variables (`allSessions`, `filteredSessions`, `currentView`, `toolFilter`, etc.) -**Persistence**: `localStorage` for stars, tags, theme, layout, terminal preference. -**Rendering**: string concatenation → `innerHTML`. No virtual DOM. - -Key features: -- Trigram fuzzy search (client-side, instant) + deep search (server-side, 600ms debounce) -- Grid/list layout toggle -- Group by project -- Active session polling with animated borders -- Inline message preview (expand) and hover tooltips -- Tag system (6 predefined: bug, feature, research, infra, deploy, review) -- Star system -- Dark/light/monokai themes -- Session replay with timeline slider -- Cost analytics charts - ---- - -## API Routes - -### Sessions -``` -GET /api/sessions All sessions (all agents) -GET /api/session/:id Full messages -GET /api/preview/:id?limit=N First N messages -GET /api/replay/:id Messages with timestamps -GET /api/cost/:id Token usage + real cost -DELETE /api/session/:id Delete session -POST /api/bulk-delete Delete multiple sessions -GET /api/session/:id/export Download as Markdown -``` - -### Search & Analytics -``` -GET /api/search?q=QUERY Full-text search (min 2 chars) -GET /api/analytics/cost Aggregated cost by day/week/project -GET /api/active Running agent processes -GET /api/git-commits Git commits in time range -``` - -### Actions -``` -POST /api/launch Open session in terminal -POST /api/focus Focus terminal window by PID -POST /api/open-ide Open project in Cursor/VS Code -POST /api/convert Convert session between formats -GET /api/handoff/:id Generate handoff document -``` - -### System -``` -GET / Dashboard HTML (inlined CSS+JS) -GET /favicon.ico SVG favicon -GET /api/version Current + latest npm version -GET /api/changelog Changelog entries -GET /api/terminals Available terminal apps -``` - ---- - -## Contributing - -### Git Workflow - -`main` is protected. All changes require a pull request with 1 approval. - -``` -main (protected) - ├── feat/session-titles → PR → merge - ├── fix/cursor-path → PR → merge - └── release/6.4.0 → PR → merge + npm publish -``` - -**Branch naming:** `feat/`, `fix/`, `chore/`, `release/` - -**Commit format:** Conventional — `feat:`, `fix:`, `chore:`, `docs:`, `perf:` - -### PR Guidelines - -- One feature or fix per PR -- Keep PRs under 5 files when possible -- Large features should be split into incremental PRs -- Test locally with `node -e "require('./src/server')"` before pushing +# CodeDash Architecture + +## Overview + +CodeDash is a zero-dependency Node.js dashboard for AI coding agent sessions. Supports 7 agents: Claude Code, Claude Extension, Codex, Cursor, GitHub Copilot, OpenCode, Kiro. Single process serves a web UI at `localhost:3847`. + +``` +Browser (localhost:3847) Node.js Server ++-----------------------------+ +-------------------------------+ +| index.html | | server.js (HTTP, 20+ routes) | +| +-- styles.css (inlined) | | | | +| +-- app.js (inlined) | <-->| +-- data.js | +| | | | (sessions, search, | +| Sidebar | Content | Detail | | | cost, active) | ++-----------------------------+ | +-- terminals.js | + | | (detect, launch, focus) | + bin/cli.js (CLI) | +-- html.js (assembly) | + +-------------------+ | +-- handoff.js | + | run/list/search/ | | +-- convert.js | + | show/handoff/ |------->| +-- migrate.js | + | convert/export/ | | +-- changelog.js | + | import/update | +-------------------------------+ + +-------------------+ | + reads from 6 locations: + ~/.claude/ ~/.codex/ ~/.cursor/ + VS Code workspaceStorage/chatSessions + ~/.local/share/opencode/opencode.db + ~/Library/Application Support/kiro-cli/data.sqlite3 +``` + +## Project Structure + +``` +bin/cli.js (12 KB) CLI entry point — all commands +src/ + server.js (12 KB) HTTP server + API routes + data.js (46 KB) Core: session loading, search index, cost, active detection + terminals.js (8.8 KB) Terminal detection + launch/focus + html.js (754 B) Template injection (CSS+JS into HTML) + handoff.js (4 KB) Handoff document generation + convert.js (8.3 KB) Cross-agent session conversion + migrate.js (5.9 KB) Export/import as tar.gz + changelog.js (6.7 KB) In-app changelog + frontend/ + index.html (10 KB) HTML template with {{STYLES}} / {{SCRIPT}} placeholders + styles.css (52 KB) All CSS (dark/light/monokai themes) + app.js (77 KB) All frontend logic (plain browser JS, no build step) +docs/ + ARCHITECTURE.md This file + README_RU.md Russian translation + README_ZH.md Chinese translation +``` + +Total source: ~235 KB. Zero npm dependencies — only Node.js stdlib + system `sqlite3` CLI. + +--- + +## Session Storage by Agent + +### 1. Claude Code (CLI) + +| Item | Location | +|------|----------| +| History index | `~/.claude/history.jsonl` | +| Session data | `~/.claude/projects//.jsonl` | +| PID files | `~/.claude/sessions/.json` | + +**PROJECT_KEY** encoding: full path with `/` and `.` replaced by `-`. +Example: `/Users/v.kovalskii/myproject` → `-Users-v-kovalskii-myproject` + +**history.jsonl** — one line per user message (index, no full content): +```json +{"sessionId": "uuid", "project": "/Users/v.kovalskii/myproject", "timestamp": 1712345678000, "display": "fix the login bug", "pastedContents": {}} +``` + +**Session JSONL** — full conversation, one JSON object per line: +```json +{"type": "permission-mode", "permissionMode": "default", "sessionId": "uuid"} +{"type": "user", "uuid": "uuid", "timestamp": "2026-04-06T10:00:00Z", "message": {"role": "user", "content": "fix the bug"}, "cwd": "/path", "entrypoint": "cli", "userType": "external"} +{"type": "assistant", "uuid": "uuid", "timestamp": "2026-04-06T10:00:05Z", "message": {"role": "assistant", "model": "claude-opus-4-6", "content": [...], "usage": {"input_tokens": 1500, "output_tokens": 800, "cache_creation_input_tokens": 500, "cache_read_input_tokens": 200}}} +``` + +Key fields in user messages: `entrypoint` ("cli" or "claude-vscode"), `cwd`, `userType`. +Key fields in assistant messages: `model`, `usage` (for cost calculation). + +**PID files** — active session tracking: +```json +{"pid": 12345, "sessionId": "uuid", "cwd": "/path", "startedAt": 1712345678000, "kind": "interactive"} +``` + +### 2. Claude Extension (VS Code / Cursor IDE) + +Same storage as Claude Code — files go to `~/.claude/projects//.jsonl`. The difference: + +- **No entry in `history.jsonl`** — Extension sessions are "orphan" (exist only as project session files) +- **`entrypoint` field = `"claude-vscode"`** instead of `"cli"` in user messages +- CodeDash scans all project dirs for `.jsonl` files not found in history, reads `entrypoint` from first user message, and assigns `tool: "claude-ext"` if not "cli" + +Detection logic in `data.js`: +``` +1. Load sessions from history.jsonl (all get tool: "claude") +2. Enrich with detail files — if entrypoint !== "cli", change to "claude-ext" +3. Scan project dirs for orphan .jsonl files not in history +4. Read entrypoint from first user message → "claude-ext" if not "cli" +5. Read cwd from user messages for correct project path +``` + +### 3. Codex CLI + +| Item | Location | +|------|----------| +| History index | `~/.codex/history.jsonl` | +| Session data | `~/.codex/sessions///
/rollout--.jsonl` | + +**history.jsonl**: +```json +{"session_id": "uuid", "ts": 1712345678, "text": "user prompt", "display": "...", "project": "/path", "cwd": "/path"} +``` +Note: `ts` is in **seconds** (not milliseconds like Claude). + +**Session JSONL** — first line is metadata, rest are messages: +```json +{"type": "session_meta", "payload": {"id": "uuid", "cwd": "/path", "timestamp": "2026-04-06T10:00:00Z"}} +{"type": "response_item", "payload": {"role": "user", "content": [{"type": "input_text", "text": "fix the bug"}]}} +{"type": "response_item", "payload": {"role": "assistant", "content": [{"type": "text", "text": "I'll fix..."}]}} +``` + +Session ID extracted from filename: `rollout-20260406-.jsonl` → UUID part. + +### 4. Cursor (Agent Mode) + +| Item | Location | +|------|----------| +| Projects format | `~/.cursor/projects//agent-transcripts//.jsonl` | +| Chats format | `~/.cursor/chats//.jsonl` or `.json` | + +**Two storage formats** — "projects" (macOS) and "chats" (Linux alternative). + +**PROJECT_KEY decoding**: ambiguous (`-` could be `/`, `.`, or literal `-`). CodeDash tries progressive path resolution — testing filesystem existence with different separator combinations. + +**Session JSONL**: +```json +{"role": "user", "message": {"content": [{"type": "text", "text": "fix the bug"}]}} +{"role": "assistant", "message": {"content": [{"type": "text", "text": "I'll fix..."}]}} +``` + +User messages wrapped in `...` tags — stripped during parsing. + +### 5. OpenCode + +| Item | Location | +|------|----------| +| Database | `~/.local/share/opencode/opencode.db` (SQLite) | + +Accessed via system `sqlite3` CLI (no Node driver): + +**Sessions scan**: +```sql +SELECT s.id, s.title, s.directory, s.time_created, s.time_updated, COUNT(m.id) +FROM session s LEFT JOIN message m ON m.session_id = s.id +GROUP BY s.id ORDER BY s.time_updated DESC +``` + +**Message loading**: +```sql +SELECT m.data, GROUP_CONCAT(p.data, '|||') +FROM message m LEFT JOIN part p ON p.message_id = m.id +WHERE m.session_id = ? +GROUP BY m.id ORDER BY m.time_created +``` + +Tables: `session`, `message`, `part`. Message `data` is JSON with `{role, tokens, model}`. Part `data` is JSON with `{type, text}`. + +### 6. GitHub Copilot (VS Code Chat) + +| Item | Location | +|------|----------| +| Session files | `%APPDATA%/Code/User/workspaceStorage//chatSessions/.jsonl` | +| Workspace map | `%APPDATA%/Code/User/workspaceStorage//workspace.json` | +| Editing sessions (optional) | `%APPDATA%/Code/User/workspaceStorage//chatEditingSessions//` | + +On macOS/Linux, the base path follows VS Code app data convention (`~/Library/Application Support/Code/User/workspaceStorage` or `~/.config/Code/User/workspaceStorage`). + +**Session JSONL uses delta updates**: +- `kind: 0` initializes base state (`creationDate`, `requests`, etc.) +- `kind: 1` updates a value by path (`k` array) +- `kind: 2` appends array items by path (`k` array) + +CodeDash reconstructs requests from these deltas, then extracts: +- user text from `requests[n].message.text` +- assistant text from `requests[n].response[*].value` (including streamed chunks) + +Copilot has no stable token usage in this format, so cost is reported as unavailable. + +### 7. Kiro CLI + +| Item | Location | +|------|----------| +| Database | `~/Library/Application Support/kiro-cli/data.sqlite3` (SQLite) | + +**Sessions scan**: +```sql +SELECT key, conversation_id, created_at, updated_at, substr(value, 1, 500) +FROM conversations_v2 ORDER BY updated_at DESC +``` +- `key` = project directory +- `conversation_id` = session ID +- `value` = full conversation JSON (truncated for scan, full for detail) + +**Conversation JSON structure**: +```json +{ + "history": [ + { + "user": {"content": {"Prompt": {"prompt": "fix the bug"}}}, + "assistant": {"Response": {"content": "I'll fix...", "message_id": "uuid"}} + } + ] +} +``` + +--- + +## Data Flow + +### Session Loading (`data.js:loadSessions()`) + +``` +1. Read ~/.claude/history.jsonl → sessions{} keyed by sessionId (tool: "claude") +2. scanCodexSessions() → merge into sessions{} (tool: "codex") +3. scanOpenCodeSessions() → merge (tool: "opencode") +4. scanCursorSessions() → merge (tool: "cursor") +5. scanCopilotSessions() → merge (tool: "copilot") +6. scanKiroSessions() → merge (tool: "kiro") +7. Enrich Claude sessions with detail files: + - Count messages, get file size + - Check entrypoint → change tool to "claude-ext" if not "cli" +8. Scan orphan sessions from ~/.claude/projects/ (Claude Extension) +9. Sort by last_ts DESC, format dates +``` + +### Search Index + +- Built in-memory on first `/api/search` call +- Reads all session detail files, extracts lowercased full text +- Cached 60 seconds (rebuild on expiry) +- Substring match on `fullText.indexOf(query)`, returns up to 3 snippets per session with +-50 char context + +### Cost Calculation + +Uses `usage` data from Claude assistant messages: +``` +cost = input_tokens * input_price + + cache_creation_input_tokens * cache_create_price + + cache_read_input_tokens * cache_read_price + + output_tokens * output_price +``` + +Model pricing in `MODEL_PRICING` object (per-token rates for opus, sonnet, haiku, codex-mini, gpt-5). +Codex fallback: estimate from file size (~4 bytes per token). +Formats without reliable token usage (`cursor`, `kiro`, `copilot`) are reported as no token data. + +### Active Session Detection + +``` +1. Read ~/.claude/sessions/*.json → PID-to-session map +2. ps aux | grep "claude|codex|opencode|kiro-cli|cursor-agent|copilot" +3. For each process: parse PID, CPU%, memory, state +4. Status: "active" (CPU >= 1%) or "waiting" (sleeping/stopped) +5. Map PID → sessionId via PID files +6. Frontend polls /api/active every 5 seconds +``` + +--- + +## HTML Assembly + +`html.js` reads three files and injects CSS+JS into HTML: +```javascript +template.split('{{STYLES}}').join(css).split('{{SCRIPT}}').join(js) +``` +Uses `split/join` instead of `String.replace` — avoids `$` character issues in JS code. +Result cached in memory (refreshed in `NODE_ENV=development`). + +Final page: ~130 KB (single HTML, no external requests). + +--- + +## Frontend Architecture + +Plain browser JavaScript — no modules, no build step, no ES6 imports. Uses `var` for compatibility. + +**State**: global variables (`allSessions`, `filteredSessions`, `currentView`, `toolFilter`, etc.) +**Persistence**: `localStorage` for stars, tags, theme, layout, terminal preference. +**Rendering**: string concatenation → `innerHTML`. No virtual DOM. + +Key features: +- Trigram fuzzy search (client-side, instant) + deep search (server-side, 600ms debounce) +- Grid/list layout toggle +- Group by project +- Active session polling with animated borders +- Inline message preview (expand) and hover tooltips +- Tag system (6 predefined: bug, feature, research, infra, deploy, review) +- Star system +- Dark/light/monokai themes +- Session replay with timeline slider +- Cost analytics charts + +--- + +## API Routes + +### Sessions +``` +GET /api/sessions All sessions (all agents) +GET /api/session/:id Full messages +GET /api/preview/:id?limit=N First N messages +GET /api/replay/:id Messages with timestamps +GET /api/cost/:id Token usage + real cost +DELETE /api/session/:id Delete session +POST /api/bulk-delete Delete multiple sessions +GET /api/session/:id/export Download as Markdown +``` + +### Search & Analytics +``` +GET /api/search?q=QUERY Full-text search (min 2 chars) +GET /api/analytics/cost Aggregated cost by day/week/project +GET /api/active Running agent processes +GET /api/git-commits Git commits in time range +``` + +### Actions +``` +POST /api/launch Open session in terminal +POST /api/focus Focus terminal window by PID +POST /api/open-ide Open project in Cursor/VS Code +POST /api/convert Convert session between formats +GET /api/handoff/:id Generate handoff document +``` + +### System +``` +GET / Dashboard HTML (inlined CSS+JS) +GET /favicon.ico SVG favicon +GET /api/version Current + latest npm version +GET /api/changelog Changelog entries +GET /api/terminals Available terminal apps +``` + +--- + +## Contributing + +### Git Workflow + +`main` is protected. All changes require a pull request with 1 approval. + +``` +main (protected) + ├── feat/session-titles → PR → merge + ├── fix/cursor-path → PR → merge + └── release/6.4.0 → PR → merge + npm publish +``` + +**Branch naming:** `feat/`, `fix/`, `chore/`, `release/` + +**Commit format:** Conventional — `feat:`, `fix:`, `chore:`, `docs:`, `perf:` + +### PR Guidelines + +- One feature or fix per PR +- Keep PRs under 5 files when possible +- Large features should be split into incremental PRs +- Test locally with `node -e "require('./src/server')"` before pushing diff --git a/docs/README_RU.md b/docs/README_RU.md index ba383f5..2ecf33a 100644 --- a/docs/README_RU.md +++ b/docs/README_RU.md @@ -1,46 +1,47 @@ -# CodeDash - -Дашборд + CLI для сессий AI-агентов. 5 агентов: Claude Code, Codex, Cursor, OpenCode, Kiro. - -[English](../README.md) | [Chinese / 中文](README_ZH.md) - -## Быстрый старт - -```bash -npm i -g codedash-app && codedash run -``` - -## Поддерживаемые агенты - -| Агент | Формат | Статус | Конвертация | Запуск | -|-------|--------|--------|-------------|--------| -| Claude Code | JSONL | LIVE/WAITING | Да | Терминал / cmux | -| Codex CLI | JSONL | LIVE/WAITING | Да | Терминал | -| Cursor | JSONL | LIVE/WAITING | - | Open in Cursor | -| OpenCode | SQLite | LIVE/WAITING | - | Терминал | -| Kiro CLI | SQLite | LIVE/WAITING | - | Терминал | - -## Возможности - -- Grid/List, группировка по проектам, trigram поиск + deep search -- GitHub-стиль SVG heatmap активности со стриками -- LIVE/WAITING бейджи для всех 5 агентов, анимированная рамка -- Session Replay с ползунком, hover превью, раскрытие карточек -- Аналитика стоимости из реальных usage данных -- Конвертация сессий Claude <-> Codex, Handoff между агентами -- Export/Import для миграции на другой ПК -- Темы: Dark, Light, System - -## CLI - -```bash -codedash run | search | show | handoff | convert | list | stats | export | import | update | restart | stop -``` - -## Требования - -- Node.js >= 18, macOS / Linux / Windows - -## Лицензия - -MIT +# CodeDash + +Дашборд + CLI для сессий AI-агентов. 6 агентов: Claude Code, Codex, Cursor, GitHub Copilot (VS Code), OpenCode, Kiro. + +[English](../README.md) | [Chinese / 中文](README_ZH.md) + +## Быстрый старт + +```bash +npm i -g codedash-app && codedash run +``` + +## Поддерживаемые агенты + +| Агент | Формат | Статус | Конвертация | Запуск | +|-------|--------|--------|-------------|--------| +| Claude Code | JSONL | LIVE/WAITING | Да | Терминал / cmux | +| Codex CLI | JSONL | LIVE/WAITING | Да | Терминал | +| Cursor | JSONL | LIVE/WAITING | - | Open in Cursor | +| GitHub Copilot (VS Code) | JSONL | - | - | Open in VS Code | +| OpenCode | SQLite | LIVE/WAITING | - | Терминал | +| Kiro CLI | SQLite | LIVE/WAITING | - | Терминал | + +## Возможности + +- Grid/List, группировка по проектам, trigram поиск + deep search +- GitHub-стиль SVG heatmap активности со стриками +- LIVE/WAITING бейджи для локальных agent-процессов, анимированная рамка +- Session Replay с ползунком, hover превью, раскрытие карточек +- Аналитика стоимости из реальных usage данных +- Конвертация сессий Claude <-> Codex, Handoff между агентами +- Export/Import для миграции на другой ПК +- Темы: Dark, Light, System + +## CLI + +```bash +codedash run | search | show | handoff | convert | list | stats | export | import | update | restart | stop +``` + +## Требования + +- Node.js >= 18, macOS / Linux / Windows + +## Лицензия + +MIT