diff --git a/README.md b/README.md index 2956888..485fbdd 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,27 @@ npm i -g codedash-app codedash run ``` +## Docker + +Docker files live under `docker/`. Use `./in-docker.sh` to select which host agent data sources should be mounted into the container. + +```bash +./in-docker.sh --claude --codex +./in-docker.sh --cursor -- up -d +./in-docker.sh --all -- up --build +``` + +Supported flags: +- `--claude` mounts `~/.claude` to `/root/.claude` +- `--codex` mounts `~/.codex` to `/root/.codex` +- `--cursor` mounts `~/.cursor` to `/root/.cursor` +- `--opencode` mounts `~/.local/share/opencode/opencode.db` +- `--kiro` mounts `~/Library/Application Support/kiro-cli/data.sqlite3` +- `--all` enables every supported mount + +The base container always listens on `0.0.0.0:3847`, so it can be reached via the host machine LAN IP when Docker publishes that port. +Mounted agent data sources are writable inside the container so CodeDash actions like delete, convert, and settings updates work the same way as a host run. + ## Supported Agents | Agent | Sessions | Preview | Search | Live Status | Convert | Handoff | Launch | @@ -72,6 +93,16 @@ codedash restart codedash stop ``` +Bind host can be configured with `--host=ADDR` or `CODEDASH_HOST`: + +```bash +codedash run +codedash run --host=0.0.0.0 +CODEDASH_HOST=0.0.0.0 codedash run +``` + +If both are set, `--host` takes precedence. + **Keyboard Shortcuts**: `/` search, `j/k` navigate, `Enter` open, `x` star, `d` delete, `s` select, `g` group, `r` refresh, `Esc` close ## Data Sources @@ -84,7 +115,7 @@ codedash stop ~/Library/Application Support/kiro-cli/ Kiro CLI (SQLite) ``` -Zero dependencies. Everything runs on `localhost`. +Zero dependencies. By default everything runs on `localhost`. Set `--host=0.0.0.0` or `CODEDASH_HOST=0.0.0.0` to listen on all interfaces, including your host machine LAN IP. ## Install Agents diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..ab7b5b8 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine + +RUN apk add --no-cache sqlite + +WORKDIR /app + +COPY package.json ./ +COPY bin ./bin +COPY src ./src + +EXPOSE 3847 + +CMD ["node", "bin/cli.js", "run", "--no-browser", "--port=3847"] diff --git a/docker/docker-compose.claude.yml b/docker/docker-compose.claude.yml new file mode 100644 index 0000000..f6ffe98 --- /dev/null +++ b/docker/docker-compose.claude.yml @@ -0,0 +1,6 @@ +services: + codedash: + volumes: + - type: bind + source: ${HOST_CLAUDE_DIR} + target: /root/.claude diff --git a/docker/docker-compose.codex.yml b/docker/docker-compose.codex.yml new file mode 100644 index 0000000..0158dd9 --- /dev/null +++ b/docker/docker-compose.codex.yml @@ -0,0 +1,6 @@ +services: + codedash: + volumes: + - type: bind + source: ${HOST_CODEX_DIR} + target: /root/.codex diff --git a/docker/docker-compose.cursor.yml b/docker/docker-compose.cursor.yml new file mode 100644 index 0000000..60aad96 --- /dev/null +++ b/docker/docker-compose.cursor.yml @@ -0,0 +1,6 @@ +services: + codedash: + volumes: + - type: bind + source: ${HOST_CURSOR_DIR} + target: /root/.cursor diff --git a/docker/docker-compose.kiro.yml b/docker/docker-compose.kiro.yml new file mode 100644 index 0000000..0e86d93 --- /dev/null +++ b/docker/docker-compose.kiro.yml @@ -0,0 +1,6 @@ +services: + codedash: + volumes: + - type: bind + source: ${HOST_KIRO_DB} + target: /root/Library/Application Support/kiro-cli/data.sqlite3 diff --git a/docker/docker-compose.opencode.yml b/docker/docker-compose.opencode.yml new file mode 100644 index 0000000..33b52a0 --- /dev/null +++ b/docker/docker-compose.opencode.yml @@ -0,0 +1,6 @@ +services: + codedash: + volumes: + - type: bind + source: ${HOST_OPENCODE_DB} + target: /root/.local/share/opencode/opencode.db diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..de94762 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,14 @@ +services: + codedash: + image: ghcr.io/vakovalskii/codedash + build: + context: .. + dockerfile: docker/Dockerfile + container_name: codedash + restart: unless-stopped + environment: + HOME: /root + CODEDASH_HOST: 0.0.0.0 + CODEDASH_LOG: "1" + ports: + - "3847:3847" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1343794..bcfce85 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,7 +2,7 @@ ## 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`. +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` by default, or another bind host via `--host=ADDR` / `CODEDASH_HOST`. ``` Browser (localhost:3847) Node.js Server diff --git a/in-docker.sh b/in-docker.sh new file mode 100755 index 0000000..f016b6c --- /dev/null +++ b/in-docker.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +DOCKER_DIR="$SCRIPT_DIR/docker" + +BASE_COMPOSE="$DOCKER_DIR/docker-compose.yml" + +USE_CLAUDE=0 +USE_CODEX=0 +USE_CURSOR=0 +USE_KIRO=0 +USE_OPENCODE=0 + +print_usage() { + cat <<'EOF' +Usage: ./in-docker.sh [options] [-- docker-compose-args...] + +Options: + --claude Mount ~/.claude + --codex Mount ~/.codex + --cursor Mount ~/.cursor + --kiro Mount ~/Library/Application Support/kiro-cli/data.sqlite3 + --opencode Mount ~/.local/share/opencode/opencode.db + --all Enable all supported mounts + -h, --help Show this help + +Examples: + ./in-docker.sh --claude --codex + ./in-docker.sh --all -- up --build + ./in-docker.sh --cursor -- up -d + +If no docker-compose args are provided, the script runs: + docker compose ... up --build +EOF +} + +require_path() { + label=$1 + path_value=$2 + if [ ! -e "$path_value" ]; then + printf '%s\n' "Missing $label path: $path_value" >&2 + exit 1 + fi +} + +while [ $# -gt 0 ]; do + case "$1" in + --claude) + USE_CLAUDE=1 + shift + ;; + --codex) + USE_CODEX=1 + shift + ;; + --cursor) + USE_CURSOR=1 + shift + ;; + --kiro) + USE_KIRO=1 + shift + ;; + --opencode) + USE_OPENCODE=1 + shift + ;; + --all) + USE_CLAUDE=1 + USE_CODEX=1 + USE_CURSOR=1 + USE_KIRO=1 + USE_OPENCODE=1 + shift + ;; + -h|--help) + print_usage + exit 0 + ;; + --) + shift + break + ;; + *) + break + ;; + esac +done + +compose_files=(-f "$BASE_COMPOSE") +compose_args=("$@") + +if [ "${#compose_args[@]}" -eq 0 ]; then + compose_args=(up --build) +fi + +if [ "$USE_CLAUDE" -eq 1 ]; then + HOST_CLAUDE_DIR="${HOST_CLAUDE_DIR:-$HOME/.claude}" + require_path "Claude" "$HOST_CLAUDE_DIR" + export HOST_CLAUDE_DIR + compose_files+=(-f "$DOCKER_DIR/docker-compose.claude.yml") +fi + +if [ "$USE_CODEX" -eq 1 ]; then + HOST_CODEX_DIR="${HOST_CODEX_DIR:-$HOME/.codex}" + require_path "Codex" "$HOST_CODEX_DIR" + export HOST_CODEX_DIR + compose_files+=(-f "$DOCKER_DIR/docker-compose.codex.yml") +fi + +if [ "$USE_CURSOR" -eq 1 ]; then + HOST_CURSOR_DIR="${HOST_CURSOR_DIR:-$HOME/.cursor}" + require_path "Cursor" "$HOST_CURSOR_DIR" + export HOST_CURSOR_DIR + compose_files+=(-f "$DOCKER_DIR/docker-compose.cursor.yml") +fi + +if [ "$USE_KIRO" -eq 1 ]; then + HOST_KIRO_DB="${HOST_KIRO_DB:-$HOME/Library/Application Support/kiro-cli/data.sqlite3}" + require_path "Kiro" "$HOST_KIRO_DB" + export HOST_KIRO_DB + compose_files+=(-f "$DOCKER_DIR/docker-compose.kiro.yml") +fi + +if [ "$USE_OPENCODE" -eq 1 ]; then + HOST_OPENCODE_DB="${HOST_OPENCODE_DB:-$HOME/.local/share/opencode/opencode.db}" + require_path "OpenCode" "$HOST_OPENCODE_DB" + export HOST_OPENCODE_DB + compose_files+=(-f "$DOCKER_DIR/docker-compose.opencode.yml") +fi + +exec docker compose "${compose_files[@]}" "${compose_args[@]}" diff --git a/src/frontend/app.js b/src/frontend/app.js index 6fd757a..735709c 100644 --- a/src/frontend/app.js +++ b/src/frontend/app.js @@ -122,6 +122,43 @@ function showToast(msg) { setTimeout(() => el.classList.remove('show'), 2500); } +function fallbackCopyText(text) { + try { + var ta = document.createElement('textarea'); + ta.value = text; + ta.setAttribute('readonly', ''); + ta.style.position = 'fixed'; + ta.style.top = '-9999px'; + ta.style.left = '-9999px'; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + var ok = document.execCommand('copy'); + document.body.removeChild(ta); + return ok; + } catch (e) { + return false; + } +} + +function copyText(text, successMsg) { + var done = function () { + showToast(successMsg || ('Copied: ' + text)); + return true; + }; + var fail = function () { + if (fallbackCopyText(text)) return done(); + prompt('Copy this command:', text); + showToast(window.isSecureContext ? 'Clipboard copy failed' : 'Clipboard unavailable on non-secure origin'); + return false; + }; + + if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { + return navigator.clipboard.writeText(text).then(done).catch(fail); + } + return Promise.resolve(fail()); +} + function formatBytes(bytes) { if (!bytes || bytes < 1024) return (bytes || 0) + ' B'; if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'; @@ -137,28 +174,38 @@ function estimateCost(fileSize) { // ── 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 } - ]} + '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() { @@ -170,7 +217,7 @@ function onSubServiceChange() { planEl.innerHTML = ''; paidEl.value = ''; if (service && SERVICE_PLANS[service]) { - SERVICE_PLANS[service].plans.forEach(function(p) { + SERVICE_PLANS[service].plans.forEach(function (p) { var opt = document.createElement('option'); opt.value = p.name; opt.textContent = p.name + ' ($' + p.price + '/mo)'; @@ -186,7 +233,7 @@ function onSubPlanChange() { 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; }); + var found = SERVICE_PLANS[service].plans.find(function (p) { return p.name === planName; }); if (found && paidEl) paidEl.value = found.price; } } @@ -200,7 +247,7 @@ function getSubscriptionConfig() { 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 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'); @@ -210,7 +257,7 @@ function addSubEntry() { 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||'');}); + cfg.entries.sort(function (a, b) { return (a.from || '').localeCompare(b.from || ''); }); saveSubscriptionConfig(cfg); render(); } @@ -234,11 +281,11 @@ const TAG_OPTIONS = ['bug', 'feature', 'research', 'infra', 'deploy', 'review']; function showTagDropdown(event, sessionId) { event.stopPropagation(); - document.querySelectorAll('.tag-dropdown').forEach(function(el) { el.remove(); }); + 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) { + dd.innerHTML = TAG_OPTIONS.map(function (t) { var has = existingTags.indexOf(t) >= 0; return '
' + @@ -251,8 +298,8 @@ function showTagDropdown(event, sessionId) { dd.style.left = rect.left + 'px'; document.body.appendChild(dd); - setTimeout(function() { - document.addEventListener('click', function() { dd.remove(); }, { once: true }); + setTimeout(function () { + document.addEventListener('click', function () { dd.remove(); }, { once: true }); }, 0); } @@ -260,13 +307,13 @@ 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(); }); + 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; }); + 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(); @@ -298,7 +345,7 @@ function saveGroupingMode(mode) { } function loadLLMSettings() { - fetch('/api/llm-config').then(function(r) { return r.json(); }).then(function(c) { + 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'); @@ -318,27 +365,27 @@ function saveLLMSettings() { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(config), - }).then(function() { + }).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; }); + 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) { + }).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); }); + }).catch(function (e) { showToast('Connection failed: ' + e.message); }); } function generateTitle(sessionId, project) { @@ -346,7 +393,7 @@ function generateTitle(sessionId, project) { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: sessionId, project: project }), - }).then(function(r) { return r.json(); }).then(function(d) { + }).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)); @@ -354,22 +401,22 @@ function generateTitle(sessionId, project) { } else { showToast('Title generation failed: ' + (d.error || 'unknown')); } - }).catch(function(e) { showToast('Error: ' + e.message); }); + }).catch(function (e) { showToast('Error: ' + e.message); }); } function generateAllTitles() { - var sessions = filteredSessions.filter(function(s) { + 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) { + 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) { + }).then(function (r) { return r.json(); }).then(function (d) { done++; if (d.ok && d.title) { sessionTitles[s.id] = d.title; @@ -379,7 +426,7 @@ function generateAllTitles() { render(); showToast('Generated ' + done + ' titles'); } - }).catch(function() { done++; }); + }).catch(function () { done++; }); }); } @@ -412,7 +459,7 @@ async function loadTerminals() { if (!sel) return; sel.innerHTML = ''; var saved = localStorage.getItem('codedash-terminal') || ''; - availableTerminals.forEach(function(t) { + availableTerminals.forEach(function (t) { if (!t.available) return; var opt = document.createElement('option'); opt.value = t.id; @@ -421,7 +468,7 @@ async function loadTerminals() { sel.appendChild(opt); }); if (!saved && availableTerminals.length > 0) { - var first = availableTerminals.find(function(t) { return t.available; }); + var first = availableTerminals.find(function (t) { return t.available; }); if (first) sel.value = first.id; } } catch (e) { @@ -444,19 +491,19 @@ async function pollActiveSessions() { // Build new state var newActive = {}; - data.forEach(function(a) { + 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(','); + 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) { + document.querySelectorAll('.card').forEach(function (card) { var id = card.getAttribute('data-id'); var existing = card.querySelector('.live-badge'); var parent = card.parentElement; @@ -505,7 +552,7 @@ async function pollActiveSessions() { } } }); - } catch {} + } catch { } } var activeInterval = null; @@ -606,7 +653,7 @@ function applyFilters() { } // Sort: starred first, then by search score (if searching), then by time - scored.sort(function(a, b) { + 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; @@ -614,7 +661,7 @@ function applyFilters() { return b.session.last_ts - a.session.last_ts; }); - filteredSessions = scored.map(function(x) { return x.session; }); + filteredSessions = scored.map(function (x) { return x.session; }); render(); @@ -627,7 +674,7 @@ function onSearch(val) { // Trigger deep search after debounce clearTimeout(deepSearchTimeout); if (val && val.length >= 3) { - deepSearchTimeout = setTimeout(function() { deepSearch(val); }, 600); + deepSearchTimeout = setTimeout(function () { deepSearch(val); }, 600); } } @@ -663,7 +710,7 @@ function renderCard(s, idx) { var checkboxStyle = selectMode ? 'display:inline-block' : ''; - var tagHtml = sessionTags.map(function(t) { + var tagHtml = sessionTags.map(function (t) { return '' + escHtml(t) + ' ×'; }).join(''); @@ -711,12 +758,12 @@ function renderCard(s, idx) { 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) { + s.mcp_servers.forEach(function (m) { html += '' + escHtml(m) + ''; }); } if (s.skills) { - s.skills.forEach(function(sk) { + s.skills.forEach(function (sk) { html += '' + escHtml(sk) + ''; }); } @@ -757,12 +804,12 @@ function renderListCard(s, idx) { 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) { + s.mcp_servers.forEach(function (m) { html += '' + escHtml(m) + ''; }); } if (s.skills && s.skills.length > 0) { - s.skills.forEach(function(sk) { + s.skills.forEach(function (sk) { html += '' + escHtml(sk) + ''; }); } @@ -800,7 +847,7 @@ async function toggleExpand(sessionId, project, btn) { area.innerHTML = '
No messages
'; } else { var html = ''; - messages.forEach(function(m) { + messages.forEach(function (m) { var cls = m.role === 'user' ? 'preview-user' : 'preview-assistant'; var label = m.role === 'user' ? 'You' : 'AI'; html += '
'; @@ -836,21 +883,21 @@ async function deepSearch(query) { var results = await resp.json(); deepSearchCache[query] = results; applyDeepSearchResults(results); - } catch {} + } 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; }); + 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) { + filteredSessions.forEach(function (s) { if (matchIds.indexOf(s.id) >= 0) { - s._deepMatch = results.find(function(r) { return r.sessionId === s.id; }); + s._deepMatch = results.find(function (r) { return r.sessionId === s.id; }); boosted.push(s); } else { rest.push(s); @@ -858,11 +905,11 @@ function applyDeepSearchResults(results) { }); // 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; }); + 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; }); + s._deepMatch = results.find(function (r) { return r.sessionId === id; }); boosted.push(s); } } @@ -882,7 +929,7 @@ function onCardClick(id, event) { if (selectMode) { toggleSelect(id, event); } else { - var s = allSessions.find(function(x) { return x.id === id; }); + var s = allSessions.find(function (x) { return x.id === id; }); if (s) openDetail(s); } } @@ -940,13 +987,13 @@ function render() { } if (currentView === 'starred') { - var starredSessions = sessions.filter(function(s) { return stars.indexOf(s.id) >= 0; }); + 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(''); + content.innerHTML = starredSessions.map(function (s) { return renderCard(s, idx++); }).join(''); return; } @@ -976,7 +1023,7 @@ function render() { } else { var idx2 = 0; var wrapClass = layout === 'list' ? 'list-view' : 'grid-view'; - content.innerHTML = '
' + visible.map(function(s) { return renderFn(s, idx2++); }).join('') + '
'; + content.innerHTML = '
' + visible.map(function (s) { return renderFn(s, idx2++); }).join('') + '
'; } if (hasMore) { @@ -992,19 +1039,19 @@ function loadMoreCards() { function renderGrouped(container, sessions, renderFn) { renderFn = renderFn || renderCard; var groups = {}; - sessions.forEach(function(s) { + 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) { + 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) { + sortedKeys.forEach(function (key) { var group = groups[key]; var color = getProjectColor(key); html += '
'; @@ -1016,7 +1063,7 @@ function renderGrouped(container, sessions, renderFn) { html += '
'; var bodyClass = layout === 'list' ? 'group-body group-body-list' : 'group-body'; html += '
'; - group.sessions.forEach(function(s) { + group.sessions.forEach(function (s) { html += renderFn(s, globalIdx++); }); html += '
'; @@ -1027,7 +1074,7 @@ function renderGrouped(container, sessions, renderFn) { function renderTimeline(container, sessions) { // Group by date var byDate = {}; - sessions.forEach(function(s) { + sessions.forEach(function (s) { var d = s.date || 'unknown'; if (!byDate[d]) byDate[d] = []; byDate[d].push(s); @@ -1042,12 +1089,12 @@ function renderTimeline(container, sessions) { var renderFn = layout === 'list' ? renderListCard : renderCard; var globalIdx = 0; var html = '
'; - dates.forEach(function(d) { + 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) { + byDate[d].forEach(function (s) { html += renderFn(s, globalIdx++); }); html += '
'; @@ -1079,13 +1126,13 @@ function renderQACard(s, idx) { function renderProjects(container, sessions) { var byGit = {}; - sessions.forEach(function(s) { + 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) { + var sorted = Object.entries(byGit).sort(function (a, b) { return b[1][0].last_ts - a[1][0].last_ts; }); @@ -1096,12 +1143,12 @@ function renderProjects(container, sessions) { var globalIdx = 0; var html = '
'; - sorted.forEach(function(entry) { + 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 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 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 += '
'; @@ -1112,7 +1159,7 @@ function renderProjects(container, sessions) { html += ''; html += '
'; html += '
'; - list.forEach(function(s) { html += renderQACard(s, globalIdx++); }); + list.forEach(function (s) { html += renderQACard(s, globalIdx++); }); html += '
'; html += '
'; }); @@ -1120,9 +1167,501 @@ function renderProjects(container, sessions) { container.innerHTML = html; } -// → moved to heatmap.js +// ── Activity Heatmap ─────────────────────────────────────────── + +function localISO(date) { + var y = date.getFullYear(); + var m = String(date.getMonth() + 1).padStart(2, '0'); + var d = String(date.getDate()).padStart(2, '0'); + return y + '-' + m + '-' + d; +} + +function renderHeatmap(container) { + var now = new Date(); + var oneYearAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate()); + + // Count sessions per day + by tool + var counts = {}; + var toolCounts = {}; + allSessions.forEach(function (s) { + var d = s.date; + if (!d) return; + counts[d] = (counts[d] || 0) + 1; + if (!toolCounts[d]) toolCounts[d] = {}; + toolCounts[d][s.tool] = (toolCounts[d][s.tool] || 0) + 1; + }); + + // Build weeks array — GitHub style: columns are weeks, rows are days + var d = new Date(oneYearAgo); + d.setDate(d.getDate() - d.getDay()); // align to Sunday + + var endDate = new Date(now); + endDate.setDate(endDate.getDate() + (6 - endDate.getDay())); + + var weeks = []; + var week = []; + while (d <= endDate) { + var iso = localISO(d); + var count = counts[iso] || 0; + var level = count >= 8 ? 4 : count >= 4 ? 3 : count >= 2 ? 2 : count >= 1 ? 1 : 0; + var tools = toolCounts[iso] || {}; + var toolTip = Object.keys(tools).map(function (t) { return t + ': ' + tools[t]; }).join(', '); + week.push({ date: iso, count: count, level: level, day: d.getDay(), toolTip: toolTip }); + if (week.length === 7) { weeks.push(week); week = []; } + d = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1); + } + if (week.length) weeks.push(week); + + // SVG dimensions — GitHub exact sizes + var cell = 11; + var gap = 3; + var step = cell + gap; + var labelW = 36; + var headerH = 20; + var svgW = labelW + weeks.length * step + 10; + var svgH = headerH + 7 * step + 5; + + // Month labels + var monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + var monthLabels = []; + var lastMonth = -1; + weeks.forEach(function (w, wi) { + var m = parseInt(w[0].date.slice(5, 7)) - 1; + if (m !== lastMonth) { + monthLabels.push({ x: labelW + wi * step, label: monthNames[m] }); + lastMonth = m; + } + }); + + // Summary stats + var yearStart = localISO(oneYearAgo); + var totalThisYear = 0; + var maxDay = ''; + var maxCount = 0; + var activeDays = 0; + Object.keys(counts).forEach(function (d) { + if (d >= yearStart) { + totalThisYear += counts[d]; + activeDays++; + if (counts[d] > maxCount) { maxCount = counts[d]; maxDay = d; } + } + }); + + // Streaks + var currentStreak = 0; + var longestStreak = 0; + var tempStreak = 0; + var checkDate = new Date(now); + while (true) { + var ciso = localISO(checkDate); + if (counts[ciso] && counts[ciso] > 0) { + currentStreak++; + checkDate = new Date(checkDate.getFullYear(), checkDate.getMonth(), checkDate.getDate() - 1); + } else break; + } + // Longest streak + var streakD = new Date(oneYearAgo); + while (streakD <= now) { + if (counts[localISO(streakD)]) { tempStreak++; if (tempStreak > longestStreak) longestStreak = tempStreak; } + else { tempStreak = 0; } + streakD = new Date(streakD.getFullYear(), streakD.getMonth(), streakD.getDate() + 1); + } + + // Colors + var colors = ['#161b22', '#0e4429', '#006d32', '#26a641', '#39d353']; + + // Build SVG + var svg = ''; + + // Month labels + monthLabels.forEach(function (ml) { + svg += '' + ml.label + ''; + }); + + // Day labels + var dayLabels = [{ row: 1, label: 'Mon' }, { row: 3, label: 'Wed' }, { row: 5, label: 'Fri' }]; + dayLabels.forEach(function (dl) { + svg += '' + dl.label + ''; + }); + + // Cells + weeks.forEach(function (w, wi) { + w.forEach(function (day, di) { + var x = labelW + wi * step; + var y = headerH + di * step; + var fill = colors[day.level]; + var rx = 2; + svg += ''; + svg += '' + day.count + ' sessions on ' + day.date + (day.toolTip ? ' (' + day.toolTip + ')' : '') + ''; + svg += ''; + }); + }); + + svg += ''; + + // Full page + var html = '
'; + html += '
'; + html += '' + totalThisYear + ' sessions in the last year'; + html += '
'; + html += '
' + svg + '
'; + + // Legend + html += ''; + + // Stats grid + html += '
'; + html += '
'; + html += '
' + totalThisYear + '
'; + html += '
Total sessions
'; + html += '
'; + html += '
'; + html += '
' + activeDays + '
'; + html += '
Active days
'; + html += '
'; + html += '
'; + html += '
' + currentStreak + '
'; + html += '
Current streak
'; + html += '
'; + html += '
'; + html += '
' + longestStreak + '
'; + html += '
Longest streak
'; + html += '
'; + html += '
'; + html += '
' + maxCount + '
'; + html += '
Best day (' + (maxDay || '-') + ')
'; + html += '
'; + html += '
'; + html += '
' + (totalThisYear / Math.max(activeDays, 1)).toFixed(1) + '
'; + html += '
Avg per active day
'; + html += '
'; + html += '
'; + + // Per-tool breakdown + var toolTotals = {}; + allSessions.forEach(function (s) { if (s.date >= yearStart) { toolTotals[s.tool] = (toolTotals[s.tool] || 0) + 1; } }); + var toolColors = { claude: '#60a5fa', codex: '#22d3ee', opencode: '#c084fc', kiro: '#fb923c' }; + html += '
'; + Object.keys(toolTotals).sort(function (a, b) { return toolTotals[b] - toolTotals[a]; }).forEach(function (tool) { + var pct = (toolTotals[tool] / Math.max(totalThisYear, 1) * 100).toFixed(0); + var color = toolColors[tool] || '#6b7280'; + html += '
'; + html += '' + tool + ''; + html += '
'; + html += '' + toolTotals[tool] + ' (' + pct + '%)'; + html += '
'; + }); + html += '
'; + + html += '
'; + container.innerHTML = html; +} + +// ── 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; + panel.classList.add('resizing'); + handle.classList.add('active'); + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }); + function onMove(e) { + var newW = startW + (startX - e.clientX); + newW = Math.max(400, Math.min(newW, window.innerWidth * 0.9)); + panel.style.width = newW + 'px'; + panel.style.right = '0'; + } + function onUp() { + panel.classList.remove('resizing'); + handle.classList.remove('active'); + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + 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 []; + } +} -// → moved to detail.js +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)); +} // ── Delete ───────────────────────────────────────────────────── @@ -1136,7 +1675,7 @@ function showDeleteConfirm(sessionId, project) { var btn = document.getElementById('confirmAction'); btn.textContent = 'Delete'; btn.className = 'btn-delete'; - btn.onclick = function() { confirmDelete(); }; + btn.onclick = function () { confirmDelete(); }; } function closeConfirm() { @@ -1156,12 +1695,12 @@ async function confirmDelete() { var data = await resp.json(); if (data.ok) { showToast('Session deleted'); - allSessions = allSessions.filter(function(s) { return s.id !== pendingDelete.id; }); + allSessions = allSessions.filter(function (s) { return s.id !== pendingDelete.id; }); // Clear search if no more results if (searchQuery) { - var remaining = allSessions.filter(function(s) { + var remaining = allSessions.filter(function (s) { return (s.project || '').toLowerCase().indexOf(searchQuery.toLowerCase()) >= 0 || - (s.first_message || '').toLowerCase().indexOf(searchQuery.toLowerCase()) >= 0; + (s.first_message || '').toLowerCase().indexOf(searchQuery.toLowerCase()) >= 0; }); if (remaining.length === 0) { searchQuery = ''; @@ -1224,8 +1763,8 @@ function clearSelection() { 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; }); + selectedIds.forEach(function (id) { + var s = allSessions.find(function (x) { return x.id === id; }); sessions.push({ id: id, project: s ? s.project : '' }); }); try { @@ -1237,7 +1776,7 @@ async function bulkDelete() { var data = await resp.json(); if (data.ok) { showToast('Deleted ' + sessions.length + ' sessions'); - allSessions = allSessions.filter(function(s) { return !selectedIds.has(s.id); }); + allSessions = allSessions.filter(function (s) { return !selectedIds.has(s.id); }); clearSelection(); applyFilters(); } @@ -1252,7 +1791,7 @@ function openProject(name) { currentView = 'sessions'; searchQuery = name; document.querySelector('.search-box').value = name; - document.querySelectorAll('.sidebar-item').forEach(function(el) { + document.querySelectorAll('.sidebar-item').forEach(function (el) { el.classList.toggle('active', el.getAttribute('data-view') === 'sessions'); }); applyFilters(); @@ -1291,7 +1830,7 @@ 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) { + cards.forEach(function (c, i) { c.classList.toggle('focused', i === focusedIndex); }); if (cards[focusedIndex]) { @@ -1304,7 +1843,7 @@ function openFocusedCard() { 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; }); + var s = allSessions.find(function (x) { return x.id === id; }); if (s) { if (selectMode) { toggleSelect(id); @@ -1326,11 +1865,11 @@ function deleteFocused() { 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; }); + var s = allSessions.find(function (x) { return x.id === id; }); if (s) showDeleteConfirm(s.id, s.project || ''); } -document.addEventListener('keydown', function(e) { +document.addEventListener('keydown', function (e) { if (e.key === 'Escape') { if (pendingDelete) { closeConfirm(); @@ -1434,7 +1973,7 @@ function renderDoneCard(s) { if (s.last_time) html += '
' + s.last_time.slice(11) + 'ended
'; html += '
'; html += '
'; - html += ''; + html += ''; html += '
'; html += '
'; return html; @@ -1442,10 +1981,10 @@ function renderDoneCard(s) { 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 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) { + var done = sessions.filter(function (s) { return !activeSessions[s.id] && s.last_ts >= cutoff; }).slice(0, 8); @@ -1464,9 +2003,9 @@ function renderRunning(container, sessions) { if (running.length === 0) { html += '
No active sessions
'; } else { - running.forEach(function(sid) { + running.forEach(function (sid) { var a = activeSessions[sid]; - var s = allSessions.find(function(x) { return x.id === sid; }); + var s = allSessions.find(function (x) { return x.id === sid; }); html += renderRunningCard(a, s); }); } @@ -1478,9 +2017,9 @@ function renderRunning(container, sessions) { if (waiting.length === 0) { html += '
No sessions waiting
'; } else { - waiting.forEach(function(sid) { + waiting.forEach(function (sid) { var a = activeSessions[sid]; - var s = allSessions.find(function(x) { return x.id === sid; }); + var s = allSessions.find(function (x) { return x.id === sid; }); html += renderRunningCard(a, s); }); } @@ -1492,7 +2031,7 @@ function renderRunning(container, sessions) { if (done.length === 0) { html += '
No recent sessions
'; } else { - done.forEach(function(s) { html += renderDoneCard(s); }); + done.forEach(function (s) { html += renderDoneCard(s); }); } html += '
'; @@ -1515,7 +2054,7 @@ function focusSession(sessionId) { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pid: a.pid }) - }).then(function(r) { return r.json(); }).then(function(data) { + }).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() : ''; @@ -1523,7 +2062,7 @@ function focusSession(sessionId) { } else { showToast('Could not focus — try clicking the terminal manually'); } - }).catch(function() { + }).catch(function () { showToast('Focus failed'); }); } @@ -1543,7 +2082,7 @@ function renderSettings(container) { html += '
'; html += ''; html += '
'; - ['dark', 'light', 'system'].forEach(function(t) { + ['dark', 'light', 'system'].forEach(function (t) { var active = savedTheme === t ? ' active' : ''; html += ''; }); @@ -1555,7 +2094,7 @@ function renderSettings(container) { html += ''; html += '