From 7935f4c157d350db0e2df6de419ea299fa4ce9a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B0=D0=BA=D1=81=D0=B8=D0=BC=20=D0=A0=D1=8F=D0=B1?= =?UTF-8?q?=D0=BE=D0=B2?= Date: Fri, 10 Apr 2026 17:16:57 +0300 Subject: [PATCH 1/7] feat: add GitHub Copilot session discovery and detail loading Scan VS Code workspaceStorage for Copilot Chat sessions (JSON and JSONL mutation format). Includes workspace-to-project mapping with disk cache, targeted JSONL replay for request/response extraction, and integration into loadSessions/findSessionFile/loadSessionDetail/computeSessionCost. --- src/data.js | 320 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 319 insertions(+), 1 deletion(-) diff --git a/src/data.js b/src/data.js index 5fae4e7..e0061d7 100644 --- a/src/data.js +++ b/src/data.js @@ -164,6 +164,13 @@ const CURSOR_APP_DATA = process.platform === 'darwin' : 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'); +// VS Code storage for Copilot Chat sessions (same path structure as Cursor but for Code) +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 VSCODE_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'); @@ -1218,6 +1225,271 @@ function loadKiroDetail(conversationId) { } } +// ── Copilot Chat (VS Code extension) ───────────────────────── + +// Build workspace-hash -> project path mapping for VS Code workspaceStorage +let _copilotWsMapCache = null; +const COPILOT_WS_MAP_CACHE_FILE = path.join(os.tmpdir(), 'codedash-copilot-ws-map.json'); +const COPILOT_WS_MAP_TTL = 600000; // 10 minutes + +function buildCopilotWorkspaceMap() { + if (_copilotWsMapCache) return _copilotWsMapCache; + + // Try disk cache first + try { + if (fs.existsSync(COPILOT_WS_MAP_CACHE_FILE)) { + const cached = JSON.parse(fs.readFileSync(COPILOT_WS_MAP_CACHE_FILE, 'utf8')); + if (cached._ts && (Date.now() - cached._ts) < COPILOT_WS_MAP_TTL) { + delete cached._ts; + _copilotWsMapCache = cached; + return cached; + } + } + } catch {} + + const map = {}; // hash -> { folder, chatDir } + if (!fs.existsSync(VSCODE_WORKSPACE_STORAGE)) return map; + + try { + for (const hash of fs.readdirSync(VSCODE_WORKSPACE_STORAGE)) { + const chatDir = path.join(VSCODE_WORKSPACE_STORAGE, hash, 'chatSessions'); + if (!fs.existsSync(chatDir)) continue; + + // Read workspace.json for project path + let folder = ''; + try { + const wsJson = path.join(VSCODE_WORKSPACE_STORAGE, hash, 'workspace.json'); + const wsData = JSON.parse(fs.readFileSync(wsJson, 'utf8')); + folder = wsData.folder || ''; + if (folder.startsWith('file://')) { + folder = decodeURIComponent(folder.replace('file://', '')); + // Windows: /D:/path -> D:/path + if (process.platform === 'win32' && /^\/[a-zA-Z]:/.test(folder)) { + folder = folder.slice(1); + } + } else if (folder.startsWith('vscode-remote://')) { + const m = folder.match(/vscode-remote:\/\/[^/]+(\/.*)/); + folder = m ? decodeURIComponent(m[1]) : ''; + } + } catch {} + + map[hash] = { folder, chatDir }; + } + } catch {} + + _copilotWsMapCache = map; + + try { + const toSave = {}; + for (const k of Object.keys(map)) toSave[k] = map[k]; + toSave._ts = Date.now(); + fs.writeFileSync(COPILOT_WS_MAP_CACHE_FILE, JSON.stringify(toSave)); + } catch {} + + return map; +} + +// Extract text from a Copilot response array (mix of text, thinking, tool invocations) +function extractCopilotResponseText(response) { + if (!Array.isArray(response)) return ''; + const parts = []; + for (const item of response) { + // Text responses: kind is absent, 'text', or 'markdownContent' + if ((!item.kind || item.kind === 'text' || item.kind === 'markdownContent') && typeof item.value === 'string') { + parts.push(item.value); + } + } + return parts.join('').trim(); +} + +// Parse a Copilot JSON session file — returns { requests, creationDate, sessionId } +function parseCopilotJson(filePath) { + const raw = fs.readFileSync(filePath, 'utf8'); + return JSON.parse(raw); +} + +// Parse a Copilot JSONL mutation file — targeted extraction of requests & responses +function parseCopilotJsonl(filePath) { + const lines = readLines(filePath); + let base = { requests: [] }; + const extraResponses = {}; // requestIndex -> [response items...] + + for (const line of lines) { + let entry; + try { entry = JSON.parse(line); } catch { continue; } + + if (entry.kind === 0) { + // Initial state + base = entry.v || { requests: [] }; + } else if (entry.kind === 2) { + const k = entry.k || []; + if (k.length === 1 && k[0] === 'requests' && Array.isArray(entry.v)) { + // Splice new requests into requests array + for (const req of entry.v) { + base.requests.push(req); + } + } else if (k.length === 3 && k[0] === 'requests' && k[2] === 'response' && Array.isArray(entry.v)) { + // Append response items to a specific request + const idx = parseInt(k[1]); + if (!isNaN(idx)) { + if (!extraResponses[idx]) extraResponses[idx] = []; + for (const item of entry.v) extraResponses[idx].push(item); + } + } + } + // kind:1 mutations (inputState, modelState, etc.) are not needed for message extraction + } + + // Merge extra responses into requests + for (const idx of Object.keys(extraResponses)) { + const i = parseInt(idx); + if (base.requests[i]) { + if (!base.requests[i].response) base.requests[i].response = []; + for (const item of extraResponses[idx]) { + base.requests[i].response.push(item); + } + } + } + + return base; +} + +function scanCopilotSessions() { + const sessions = []; + if (!fs.existsSync(VSCODE_WORKSPACE_STORAGE)) return sessions; + + const wsMap = buildCopilotWorkspaceMap(); + + for (const hash of Object.keys(wsMap)) { + const { folder, chatDir } = wsMap[hash]; + let files; + try { files = fs.readdirSync(chatDir); } catch { continue; } + + for (const file of files) { + if (!file.endsWith('.json') && !file.endsWith('.jsonl')) continue; + const filePath = path.join(chatDir, file); + let stat; + try { stat = fs.statSync(filePath); } catch { continue; } + if (stat.size < 10) continue; // skip empty files + + let firstMsg = ''; + let msgCount = 0; + let creationDate = 0; + + try { + if (file.endsWith('.json')) { + // Full JSON: quick-parse for metadata + const data = JSON.parse(fs.readFileSync(filePath, 'utf8')); + const reqs = data.requests || []; + msgCount = reqs.length; + creationDate = data.creationDate || 0; + if (reqs.length > 0 && reqs[0].message) { + firstMsg = (reqs[0].message.text || '').slice(0, 200); + } + } else { + // JSONL: read first line for init, scan for request splices + const lines = readLines(filePath); + for (const line of lines) { + let entry; + try { entry = JSON.parse(line); } catch { continue; } + if (entry.kind === 0 && entry.v) { + creationDate = entry.v.creationDate || 0; + } + if (entry.kind === 2 && Array.isArray(entry.k) && entry.k.length === 1 && entry.k[0] === 'requests' && Array.isArray(entry.v)) { + for (const req of entry.v) { + msgCount++; + if (!firstMsg && req.message && req.message.text) { + firstMsg = req.message.text.slice(0, 200); + } + } + } + } + } + } catch {} + + if (msgCount === 0) continue; // skip sessions with no user messages + + const sessionId = 'copilot-' + file.replace(/\.(json|jsonl)$/, ''); + sessions.push({ + id: sessionId, + tool: 'copilot', + project: folder, + project_short: folder.replace(os.homedir(), '~'), + first_ts: creationDate || stat.birthtimeMs || stat.mtimeMs, + last_ts: stat.mtimeMs, + messages: msgCount * 2, // each request has user + assistant + first_message: firstMsg, + has_detail: true, + file_size: stat.size, + detail_messages: msgCount * 2, + _file: filePath, + }); + } + } + + return sessions; +} + +function loadCopilotDetail(sessionId) { + // Find file: check loaded sessions first, then scan + const sessions = _sessionsCache || []; + let filePath = null; + for (const s of (Array.isArray(sessions) ? sessions : Object.values(sessions))) { + if (s.id === sessionId && s._file) { filePath = s._file; break; } + } + + if (!filePath) { + // Fallback: search workspace storage + const baseName = sessionId.replace(/^copilot-/, ''); + const wsMap = buildCopilotWorkspaceMap(); + for (const hash of Object.keys(wsMap)) { + const { chatDir } = wsMap[hash]; + for (const ext of ['.json', '.jsonl']) { + const candidate = path.join(chatDir, baseName + ext); + if (fs.existsSync(candidate)) { filePath = candidate; break; } + } + if (filePath) break; + } + } + + if (!filePath || !fs.existsSync(filePath)) return { messages: [] }; + + try { + let data; + if (filePath.endsWith('.jsonl')) { + data = parseCopilotJsonl(filePath); + } else { + data = parseCopilotJson(filePath); + } + + const messages = []; + for (const req of (data.requests || [])) { + // User message + if (req.message && req.message.text) { + messages.push({ + role: 'user', + content: req.message.text.slice(0, 2000), + uuid: req.requestId || '', + }); + } + + // Assistant response: concatenate text parts from response array + const respText = extractCopilotResponseText(req.response); + if (respText) { + messages.push({ + role: 'assistant', + content: respText.slice(0, 2000), + uuid: req.responseId || '', + }); + } + } + + 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 @@ -2128,6 +2400,14 @@ function loadSessions() { } } catch {} + // Load Copilot Chat 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 { @@ -2358,6 +2638,11 @@ function loadSessionDetail(sessionId, project) { return loadKiloCliDetail(sessionId); } + // Copilot Chat (JSON/JSONL) + if (found.format === 'copilot-chat') { + return loadCopilotDetail(sessionId); + } + const messages = []; const lines = readLines(found.file); @@ -2669,6 +2954,26 @@ function _buildSessionFileIndex() { } catch {} } + // Index Copilot chat session files + if (fs.existsSync(VSCODE_WORKSPACE_STORAGE)) { + try { + const wsMap = buildCopilotWorkspaceMap(); + for (const hash of Object.keys(wsMap)) { + const { chatDir } = wsMap[hash]; + try { + for (const f of fs.readdirSync(chatDir)) { + if (f.endsWith('.json') || f.endsWith('.jsonl')) { + const sid = 'copilot-' + f.replace(/\.(json|jsonl)$/, ''); + if (!_sessionFileIndex[sid]) { + _sessionFileIndex[sid] = { file: path.join(chatDir, f), format: 'copilot' }; + } + } + } + } catch {} + } + } catch {} + } + _sessionFileIndexTs = now; } @@ -2832,6 +3137,19 @@ function findSessionFile(sessionId, project) { } catch {} } + // Try Copilot Chat (file-based, prefixed IDs) + if (sessionId.startsWith('copilot-')) { + const baseName = sessionId.replace(/^copilot-/, ''); + const wsMap = buildCopilotWorkspaceMap(); + for (const hash of Object.keys(wsMap)) { + const { chatDir } = wsMap[hash]; + for (const ext of ['.json', '.jsonl']) { + const candidate = path.join(chatDir, baseName + ext); + if (fs.existsSync(candidate)) return { file: candidate, format: 'copilot-chat' }; + } + } + } + return null; } @@ -3420,7 +3738,7 @@ function computeSessionCost(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; } + 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(); From e95c16b44f3c85cd5791c34974c08131ff12c066 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B0=D0=BA=D1=81=D0=B8=D0=BC=20=D0=A0=D1=8F=D0=B1?= =?UTF-8?q?=D0=BE=D0=B2?= Date: Fri, 10 Apr 2026 17:17:22 +0300 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20add=20Copilot=20to=20frontend=20?= =?UTF-8?q?=E2=80=94=20sidebar,=20filter,=20analytics,=20styles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add sidebar item with copilot-only filter, .tool-copilot CSS class, calendar filter toggle, analytics label, heatmap color, and AGENT_INSTALL entry for GitHub Copilot. --- src/data.js | 6 +++--- src/frontend/app.js | 10 +++++++++- src/frontend/calendar.js | 3 +++ src/frontend/heatmap.js | 3 ++- src/frontend/index.html | 4 ++++ src/frontend/styles.css | 5 +++++ 6 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/data.js b/src/data.js index e0061d7..bc1b310 100644 --- a/src/data.js +++ b/src/data.js @@ -1412,7 +1412,7 @@ function scanCopilotSessions() { const sessionId = 'copilot-' + file.replace(/\.(json|jsonl)$/, ''); sessions.push({ id: sessionId, - tool: 'copilot', + tool: 'copilot-chat', project: folder, project_short: folder.replace(os.homedir(), '~'), first_ts: creationDate || stat.birthtimeMs || stat.mtimeMs, @@ -2965,7 +2965,7 @@ function _buildSessionFileIndex() { if (f.endsWith('.json') || f.endsWith('.jsonl')) { const sid = 'copilot-' + f.replace(/\.(json|jsonl)$/, ''); if (!_sessionFileIndex[sid]) { - _sessionFileIndex[sid] = { file: path.join(chatDir, f), format: 'copilot' }; + _sessionFileIndex[sid] = { file: path.join(chatDir, f), format: 'copilot-chat' }; } } } @@ -3738,7 +3738,7 @@ function computeSessionCost(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; } + if (found.format === 'cursor' || found.format === 'kiro' || found.format === 'copilot-chat') { _costMemCache[sessionId] = EMPTY_COST; return EMPTY_COST; } // Check disk cache (keyed by file path + mtime + size for JSONL, sessionId for SQLite) _loadCostDiskCache(); diff --git a/src/frontend/app.js b/src/frontend/app.js index f639d59..880e59a 100644 --- a/src/frontend/app.js +++ b/src/frontend/app.js @@ -111,7 +111,9 @@ var TOOL_META = { qwen: { label: 'Qwen Code', shortLabel: 'qwen', color: '#fbbf24' }, cursor: { label: 'Cursor', shortLabel: 'cursor', color: '#4a9eff' }, opencode: { label: 'OpenCode', shortLabel: 'opencode', color: '#c084fc' }, - kiro: { label: 'Kiro', shortLabel: 'kiro', color: '#fb923c' } + kiro: { label: 'Kiro', shortLabel: 'kiro', color: '#fb923c' }, + kilo: { label: 'Kilo CLI', shortLabel: 'kilo', color: '#34d399' }, + 'copilot-chat': { label: 'Copilot Chat', shortLabel: 'copilot', color: '#8b6fc0' } }; function getToolLabel(tool, shortLabel) { @@ -1972,6 +1974,12 @@ var AGENT_INSTALL = { alt: null, url: 'https://kilo.ai', }, + 'copilot-chat': { + name: 'Copilot Chat (VS Code)', + cmd: null, + alt: null, + url: 'https://github.com/features/copilot', + }, }; function installAgent(agent) { diff --git a/src/frontend/calendar.js b/src/frontend/calendar.js index f0f3ce8..ba814cf 100644 --- a/src/frontend/calendar.js +++ b/src/frontend/calendar.js @@ -193,6 +193,9 @@ function setView(view) { } else if (view === 'kiro-only') { toolFilter = toolFilter === 'kiro' ? null : 'kiro'; currentView = 'sessions'; + } else if (view === 'copilot-chat-only') { + toolFilter = toolFilter === 'copilot-chat' ? null : 'copilot-chat'; + currentView = 'sessions'; } else if (view === 'opencode-only') { toolFilter = toolFilter === 'opencode' ? null : 'opencode'; currentView = 'sessions'; diff --git a/src/frontend/heatmap.js b/src/frontend/heatmap.js index 0f6f8a3..ee6d4f1 100644 --- a/src/frontend/heatmap.js +++ b/src/frontend/heatmap.js @@ -187,7 +187,8 @@ function renderHeatmap(container) { cursor: '#4a9eff', opencode: '#c084fc', kiro: '#fb923c', - kilo: '#34d399' + kilo: '#34d399', + 'copilot-chat': '#8b6fc0' }; html += '
'; Object.keys(toolTotals).sort(function(a,b) { return toolTotals[b] - toolTotals[a]; }).forEach(function(tool) { diff --git a/src/frontend/index.html b/src/frontend/index.html index 605e803..73253fa 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -72,6 +72,10 @@ Cursor
+