@qiaolei81/copilot-session-viewer 0.3.0 β 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +20 -17
- package/lib/parsers/vscode-parser.js +8 -2
- package/package.json +1 -1
- package/src/controllers/sessionController.js +69 -45
- package/src/models/Session.js +1 -0
- package/src/services/insightService.js +10 -9
- package/src/services/sessionRepository.js +70 -5
- package/src/services/tagService.js +13 -3
- package/views/time-analyze.ejs +15 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.3.1] - 2026-03-07
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **Session Deduplication** - VSCode sessions with the same ID across multiple workspaces are now deduplicated (keeps most recently updated)
|
|
12
|
+
- **WIP Status Accuracy** - VSCode agentic sessions now also check file mtime for WIP detection; threshold increased from 5 to 15 minutes
|
|
13
|
+
- **Timeline Bar Positioning** - UserReq rows with 0 tools no longer render at the start of the timeline
|
|
14
|
+
- **Tag Isolation** - Tags now use filePath-based storage to prevent shared directory collisions (Claude, Pi-Mono, Copilot CLI)
|
|
15
|
+
- **Per-Session Insight Files** - Agent review files use `{sessionId}.agent-review.md` naming to avoid collisions
|
|
16
|
+
- **Export All Sources** - Session export works for all sources including VSCode; file-based exports include `.tags.json`
|
|
17
|
+
- **Inline References in Markdown** - VSCode `inlineReference` items (file/folder links) now rendered as code references instead of being silently dropped, fixing broken markdown tables
|
|
18
|
+
|
|
19
|
+
### Performance
|
|
20
|
+
- **60s Cache + Request Dedup** - `SessionRepository.findAll()` results cached for 60 seconds with concurrent request deduplication, reducing TTFB from ~11s to <100ms on cache hit
|
|
21
|
+
|
|
8
22
|
## [0.3.0] - 2026-03-07
|
|
9
23
|
|
|
10
24
|
### Added
|
package/README.md
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
[](https://nodejs.org/)
|
|
7
7
|
|
|
8
8
|
**Multi-Tool Session Log Viewer & Analyzer**
|
|
9
|
-
View and analyze AI coding assistant sessions from **GitHub Copilot CLI**, **Claude Code CLI**, and **Pi-Mono** with time analysis, virtual scrolling, and AI-powered insights.
|
|
9
|
+
View and analyze AI coding assistant sessions from **GitHub Copilot CLI**, **Copilot Chat (VSCode)**, **Claude Code CLI**, and **Pi-Mono** with time analysis, virtual scrolling, and AI-powered insights.
|
|
10
10
|
|
|
11
|
-
A modern web-based viewer for analyzing AI coding assistant session logs with virtual scrolling, infinite loading, time analysis, and AI-powered insights. Supports **GitHub Copilot CLI**, **Claude Code CLI**, and **Pi-Mono** sessions.
|
|
11
|
+
A modern web-based viewer for analyzing AI coding assistant session logs with virtual scrolling, infinite loading, time analysis, and AI-powered insights. Supports **GitHub Copilot CLI**, **Copilot Chat (VSCode)**, **Claude Code CLI**, and **Pi-Mono** sessions.
|
|
12
12
|
|
|
13
13
|
### Session List
|
|
14
14
|

|
|
@@ -57,7 +57,7 @@ copilot-session-viewer
|
|
|
57
57
|
- **π Virtual Scrolling** - Handle 1000+ events smoothly
|
|
58
58
|
- **βΎοΈ Infinite Scroll** - Progressive session loading for better performance
|
|
59
59
|
- **π€ AI Insights** - LLM-powered session analysis
|
|
60
|
-
- **π Multi-Format Support** - Copilot, Claude Code, and Pi-Mono sessions
|
|
60
|
+
- **π Multi-Format Support** - Copilot CLI, Copilot Chat (VSCode), Claude Code, and Pi-Mono sessions
|
|
61
61
|
|
|
62
62
|
### π¨ **User Experience**
|
|
63
63
|
- **π Dark Theme** - GitHub-inspired interface
|
|
@@ -69,7 +69,7 @@ copilot-session-viewer
|
|
|
69
69
|
- **Vue 3** - Reactive virtual scrolling
|
|
70
70
|
- **Express.js** - Robust backend API
|
|
71
71
|
- **ZIP Import/Export** - Session sharing capabilities with security validation
|
|
72
|
-
- **Multi-Source Support** - Copilot (`~/.copilot/session-state/`), Claude (`~/.claude/projects/`), Pi-Mono (`~/.pi/agent/sessions/`)
|
|
72
|
+
- **Multi-Source Support** - Copilot CLI (`~/.copilot/session-state/`), Copilot Chat (`~/Library/Application Support/Code/User/workspaceStorage/`), Claude (`~/.claude/projects/`), Pi-Mono (`~/.pi/agent/sessions/`)
|
|
73
73
|
- **Unified Event Format** - Consistent schema across all sources
|
|
74
74
|
- **Memory Pagination** - Efficient handling of large sessions
|
|
75
75
|
- **XSS Protection** - DOMPurify-based HTML sanitization
|
|
@@ -81,7 +81,8 @@ copilot-session-viewer
|
|
|
81
81
|
|
|
82
82
|
1. **Generate Sessions** - Use GitHub Copilot CLI, Claude Code CLI, or Pi-Mono to create session logs
|
|
83
83
|
2. **Auto-Discovery** - Sessions are automatically detected from:
|
|
84
|
-
- Copilot: `~/.copilot/session-state/`
|
|
84
|
+
- Copilot CLI: `~/.copilot/session-state/`
|
|
85
|
+
- Copilot Chat: `~/Library/Application Support/Code/User/workspaceStorage/`
|
|
85
86
|
- Claude: `~/.claude/projects/`
|
|
86
87
|
- Pi-Mono: `~/.pi/agent/sessions/`
|
|
87
88
|
3. **Browse & Analyze** - View sessions with infinite scroll and detailed event streams
|
|
@@ -124,8 +125,8 @@ This project includes comprehensive unit and E2E test coverage with CI/CD integr
|
|
|
124
125
|
|
|
125
126
|
### Test Coverage
|
|
126
127
|
|
|
127
|
-
- **
|
|
128
|
-
- **Unified Format Tests** - Mock data validation for all sources (Copilot, Claude, Pi-Mono)
|
|
128
|
+
- **700+ Tests** (622 unit + 80 E2E)
|
|
129
|
+
- **Unified Format Tests** - Mock data validation for all sources (Copilot CLI, Copilot Chat, Claude, Pi-Mono)
|
|
129
130
|
- **Security Tests** - XSS prevention, ZIP bomb defense
|
|
130
131
|
- **Integration Tests** - Session import/export, file operations
|
|
131
132
|
- **CI-Friendly** - Mock data generation for reproducible tests
|
|
@@ -153,15 +154,15 @@ npm run test:all
|
|
|
153
154
|
|
|
154
155
|
GitHub Actions workflow includes:
|
|
155
156
|
1. **Linting** - ESLint code quality checks
|
|
156
|
-
2. **Unit Tests** -
|
|
157
|
+
2. **Unit Tests** - 622 Jest tests with coverage
|
|
157
158
|
3. **Mock Data Generation** - Reproducible test session fixtures
|
|
158
|
-
4. **E2E Tests** -
|
|
159
|
+
4. **E2E Tests** - 80 Playwright tests with Chromium
|
|
159
160
|
5. **Artifact Upload** - Test results on failure
|
|
160
161
|
|
|
161
162
|
**Test Data Strategy:**
|
|
162
163
|
- β
CI uses generated mock data (fast, reliable, no external dependencies)
|
|
163
164
|
- β
Local development can use real sessions for integration testing
|
|
164
|
-
- β
Fixtures cover all event formats (Copilot, Claude, Pi-Mono)
|
|
165
|
+
- β
Fixtures cover all event formats (Copilot CLI, Copilot Chat, Claude, Pi-Mono)
|
|
165
166
|
|
|
166
167
|
---
|
|
167
168
|
|
|
@@ -187,9 +188,10 @@ GitHub Actions workflow includes:
|
|
|
187
188
|
β File System
|
|
188
189
|
βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
189
190
|
β Data Layer (Multi-Source) β
|
|
190
|
-
β β’ Copilot: ~/.copilot/session-state/
|
|
191
|
-
β β’
|
|
192
|
-
β β’
|
|
191
|
+
β β’ Copilot CLI: ~/.copilot/session-state/ β
|
|
192
|
+
β β’ Copilot Chat: ~/Library/.../workspaceStorage/ β
|
|
193
|
+
β β’ Claude: ~/.claude/projects/ β
|
|
194
|
+
β β’ Pi-Mono: ~/.pi/agent/sessions/ β
|
|
193
195
|
βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
194
196
|
```
|
|
195
197
|
|
|
@@ -304,12 +306,13 @@ MIT License - see [LICENSE](LICENSE) file for details
|
|
|
304
306
|
- [DOMPurify](https://github.com/cure53/DOMPurify) - XSS protection
|
|
305
307
|
- [Playwright](https://playwright.dev/) - E2E testing
|
|
306
308
|
|
|
307
|
-
**Recent Updates (v0.
|
|
308
|
-
- β¨
|
|
309
|
+
**Recent Updates (v0.3.0):**
|
|
310
|
+
- β¨ VSCode Copilot Chat support (4th source)
|
|
311
|
+
- π·οΈ Session tagging system
|
|
312
|
+
- π Multi-tool branding (Copilot CLI, Copilot Chat, Claude Code, Pi-Mono)
|
|
309
313
|
- π XSS protection with DOMPurify
|
|
310
314
|
- π‘οΈ ZIP bomb defense (4-layer validation)
|
|
311
|
-
-
|
|
312
|
-
- π§ͺ 470+ tests with CI/CD integration
|
|
315
|
+
- π§ͺ 700+ tests with CI/CD integration
|
|
313
316
|
- π Comprehensive documentation
|
|
314
317
|
|
|
315
318
|
---
|
|
@@ -348,7 +348,14 @@ class VsCodeParser extends BaseSessionParser {
|
|
|
348
348
|
case null: {
|
|
349
349
|
// Plain markdown text item (no kind field, has 'value')
|
|
350
350
|
const text = item.value || '';
|
|
351
|
-
if (text) assistantText += text
|
|
351
|
+
if (text) assistantText += text;
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
case 'inlineReference': {
|
|
356
|
+
// File/folder reference inline in markdown β append name as code reference
|
|
357
|
+
const name = item.name || '';
|
|
358
|
+
if (name) assistantText += '`' + name + '`';
|
|
352
359
|
break;
|
|
353
360
|
}
|
|
354
361
|
|
|
@@ -413,7 +420,6 @@ class VsCodeParser extends BaseSessionParser {
|
|
|
413
420
|
}
|
|
414
421
|
|
|
415
422
|
case 'prepareToolInvocation':
|
|
416
|
-
case 'inlineReference':
|
|
417
423
|
case 'undoStop':
|
|
418
424
|
case 'codeblockUri':
|
|
419
425
|
case 'mcpServersStarting':
|
package/package.json
CHANGED
|
@@ -276,59 +276,72 @@ class SessionController {
|
|
|
276
276
|
let sessionPath;
|
|
277
277
|
let isDirectory = false;
|
|
278
278
|
|
|
279
|
-
if (session.
|
|
280
|
-
|
|
281
|
-
if (!copilotSource) {
|
|
282
|
-
return res.status(404).json({ error: 'Copilot source not found' });
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
const basePath = path.join(copilotSource.dir, sessionId);
|
|
279
|
+
if (session.directory) {
|
|
280
|
+
// Try session directory first (copilot dirs, vscode dirs)
|
|
286
281
|
try {
|
|
287
|
-
const stats = await fs.promises.stat(
|
|
282
|
+
const stats = await fs.promises.stat(session.directory);
|
|
288
283
|
if (stats.isDirectory()) {
|
|
289
|
-
sessionPath =
|
|
284
|
+
sessionPath = session.directory;
|
|
290
285
|
isDirectory = true;
|
|
291
|
-
} else {
|
|
292
|
-
sessionPath = `${basePath}.jsonl`;
|
|
293
286
|
}
|
|
294
287
|
} catch {
|
|
295
|
-
|
|
296
|
-
}
|
|
297
|
-
} else if (session.source === 'claude') {
|
|
298
|
-
const claudeSource = this.sessionService.sessionRepository.sources.find(s => s.type === 'claude');
|
|
299
|
-
if (!claudeSource) {
|
|
300
|
-
return res.status(404).json({ error: 'Claude source not found' });
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// Claude sessions are in projects/*/sessionId.jsonl
|
|
304
|
-
const projectDirs = await fs.promises.readdir(path.join(claudeSource.dir, 'projects'));
|
|
305
|
-
for (const projectDir of projectDirs) {
|
|
306
|
-
const candidatePath = path.join(claudeSource.dir, 'projects', projectDir, `${sessionId}.jsonl`);
|
|
307
|
-
try {
|
|
308
|
-
await fs.promises.access(candidatePath);
|
|
309
|
-
sessionPath = candidatePath;
|
|
310
|
-
break;
|
|
311
|
-
} catch {
|
|
312
|
-
// Try next project
|
|
313
|
-
}
|
|
288
|
+
// Fall through to filePath
|
|
314
289
|
}
|
|
290
|
+
}
|
|
315
291
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
292
|
+
if (!sessionPath && session.filePath) {
|
|
293
|
+
// File-based sessions (claude .jsonl, pi-mono .jsonl, copilot .jsonl)
|
|
294
|
+
try {
|
|
295
|
+
await fs.promises.access(session.filePath);
|
|
296
|
+
sessionPath = session.filePath;
|
|
297
|
+
} catch {
|
|
298
|
+
// Not accessible
|
|
323
299
|
}
|
|
300
|
+
}
|
|
324
301
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
302
|
+
// Legacy source-specific lookup as fallback
|
|
303
|
+
if (!sessionPath) {
|
|
304
|
+
if (session.source === 'copilot') {
|
|
305
|
+
const copilotSource = this.sessionService.sessionRepository.sources.find(s => s.type === 'copilot');
|
|
306
|
+
if (copilotSource) {
|
|
307
|
+
const basePath = path.join(copilotSource.dir, sessionId);
|
|
308
|
+
try {
|
|
309
|
+
const stats = await fs.promises.stat(basePath);
|
|
310
|
+
if (stats.isDirectory()) {
|
|
311
|
+
sessionPath = basePath;
|
|
312
|
+
isDirectory = true;
|
|
313
|
+
} else {
|
|
314
|
+
sessionPath = `${basePath}.jsonl`;
|
|
315
|
+
}
|
|
316
|
+
} catch {
|
|
317
|
+
sessionPath = `${basePath}.jsonl`;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
} else if (session.source === 'claude') {
|
|
321
|
+
const claudeSource = this.sessionService.sessionRepository.sources.find(s => s.type === 'claude');
|
|
322
|
+
if (claudeSource) {
|
|
323
|
+
const projectDirs = await fs.promises.readdir(path.join(claudeSource.dir, 'projects'));
|
|
324
|
+
for (const projectDir of projectDirs) {
|
|
325
|
+
const candidatePath = path.join(claudeSource.dir, 'projects', projectDir, `${sessionId}.jsonl`);
|
|
326
|
+
try {
|
|
327
|
+
await fs.promises.access(candidatePath);
|
|
328
|
+
sessionPath = candidatePath;
|
|
329
|
+
break;
|
|
330
|
+
} catch {
|
|
331
|
+
// Try next project
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
} else if (session.source === 'pi-mono') {
|
|
336
|
+
const piMonoSource = this.sessionService.sessionRepository.sources.find(s => s.type === 'pi-mono');
|
|
337
|
+
if (piMonoSource) {
|
|
338
|
+
const files = await fs.promises.readdir(piMonoSource.dir);
|
|
339
|
+
const matchingFile = files.find(f => f.includes(sessionId) && f.endsWith('.jsonl'));
|
|
340
|
+
if (matchingFile) {
|
|
341
|
+
sessionPath = path.join(piMonoSource.dir, matchingFile);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
330
344
|
}
|
|
331
|
-
sessionPath = path.join(piMonoSource.dir, matchingFile);
|
|
332
345
|
}
|
|
333
346
|
|
|
334
347
|
if (!sessionPath) {
|
|
@@ -346,12 +359,23 @@ class SessionController {
|
|
|
346
359
|
const zip = new AdmZip();
|
|
347
360
|
|
|
348
361
|
if (isDirectory) {
|
|
349
|
-
// Add entire directory
|
|
362
|
+
// Add entire directory (includes tags.json if present)
|
|
350
363
|
zip.addLocalFolder(sessionPath, sessionId);
|
|
351
364
|
} else {
|
|
352
|
-
// Add
|
|
365
|
+
// Add session file
|
|
353
366
|
const fileName = path.basename(sessionPath);
|
|
354
367
|
zip.addLocalFile(sessionPath, '', fileName);
|
|
368
|
+
|
|
369
|
+
// Also include tags file if it exists
|
|
370
|
+
const TagService = require('../services/tagService');
|
|
371
|
+
const tagService = new TagService();
|
|
372
|
+
const tagsFilePath = tagService.getSessionTagsFilePath(session);
|
|
373
|
+
try {
|
|
374
|
+
await fs.promises.access(tagsFilePath);
|
|
375
|
+
zip.addLocalFile(tagsFilePath, '', path.basename(tagsFilePath));
|
|
376
|
+
} catch {
|
|
377
|
+
// No tags file, skip
|
|
378
|
+
}
|
|
355
379
|
}
|
|
356
380
|
|
|
357
381
|
// Send zip file
|
package/src/models/Session.js
CHANGED
|
@@ -72,6 +72,7 @@ class Session {
|
|
|
72
72
|
*/
|
|
73
73
|
static fromFile(filePath, id, stats, eventCount, summary, duration, copilotVersion, selectedModel, sessionStatus) {
|
|
74
74
|
return new Session(id, 'file', {
|
|
75
|
+
filePath: filePath,
|
|
75
76
|
directory: path.dirname(filePath), // Directory containing the file
|
|
76
77
|
createdAt: stats.birthtime,
|
|
77
78
|
updatedAt: stats.mtime,
|
|
@@ -60,8 +60,9 @@ class InsightService {
|
|
|
60
60
|
* @returns {Promise<Object>} Insight status and report
|
|
61
61
|
*/
|
|
62
62
|
async generateInsight(sessionId, sessionPath, source = 'copilot', forceRegenerate = false) {
|
|
63
|
-
|
|
64
|
-
const
|
|
63
|
+
// Use per-session insight file to avoid collisions in shared directories
|
|
64
|
+
const insightFile = path.join(sessionPath, `${sessionId}.agent-review.md`);
|
|
65
|
+
const lockFile = path.join(sessionPath, `${sessionId}.agent-review.md.lock`);
|
|
65
66
|
|
|
66
67
|
// Determine events file location based on directory structure
|
|
67
68
|
// Try standard events.jsonl first, then <sessionId>.jsonl (for file-type sessions),
|
|
@@ -184,7 +185,7 @@ class InsightService {
|
|
|
184
185
|
await fs.mkdir(tmpDir, { recursive: true});
|
|
185
186
|
|
|
186
187
|
const prompt = this._buildPrompt(insightFile, eventsFile);
|
|
187
|
-
const outputFile = path.join(sessionPath,
|
|
188
|
+
const outputFile = path.join(sessionPath, `${sessionId}.agent-review.md.tmp`);
|
|
188
189
|
|
|
189
190
|
// Spawn analysis tool directly (no shell)
|
|
190
191
|
const cliPath = toolConfig.cli;
|
|
@@ -451,17 +452,17 @@ IMPORTANT CONSTRAINTS:
|
|
|
451
452
|
* @returns {Promise<Object>} Status object
|
|
452
453
|
*/
|
|
453
454
|
async getInsightStatus(sessionId, sessionPath, _source = 'copilot') {
|
|
454
|
-
return await this._getStatusForSource(sessionPath);
|
|
455
|
+
return await this._getStatusForSource(sessionId, sessionPath);
|
|
455
456
|
}
|
|
456
457
|
|
|
457
458
|
/**
|
|
458
459
|
* Get status for a specific session directory
|
|
459
460
|
* @private
|
|
460
461
|
*/
|
|
461
|
-
async _getStatusForSource(sessionPath) {
|
|
462
|
-
const insightFile = path.join(sessionPath,
|
|
463
|
-
const lockFile = path.join(sessionPath,
|
|
464
|
-
const tmpFile = path.join(sessionPath,
|
|
462
|
+
async _getStatusForSource(sessionId, sessionPath) {
|
|
463
|
+
const insightFile = path.join(sessionPath, `${sessionId}.agent-review.md`);
|
|
464
|
+
const lockFile = path.join(sessionPath, `${sessionId}.agent-review.md.lock`);
|
|
465
|
+
const tmpFile = path.join(sessionPath, `${sessionId}.agent-review.md.tmp`);
|
|
465
466
|
|
|
466
467
|
try {
|
|
467
468
|
const report = await fs.readFile(insightFile, 'utf-8');
|
|
@@ -517,7 +518,7 @@ IMPORTANT CONSTRAINTS:
|
|
|
517
518
|
* @returns {Promise<Object>} Result object
|
|
518
519
|
*/
|
|
519
520
|
async deleteInsight(sessionId, sessionPath, _source = 'copilot') {
|
|
520
|
-
const insightFile = path.join(sessionPath,
|
|
521
|
+
const insightFile = path.join(sessionPath, `${sessionId}.agent-review.md`);
|
|
521
522
|
|
|
522
523
|
try {
|
|
523
524
|
await fs.unlink(insightFile);
|
|
@@ -48,6 +48,23 @@ class SessionRepository {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
this.parserFactory = new ParserFactory();
|
|
51
|
+
|
|
52
|
+
// Cache: keyed by sourceType (null = all sources)
|
|
53
|
+
this._cache = new Map();
|
|
54
|
+
this._cacheTTL = 60 * 1000; // 60 seconds
|
|
55
|
+
this._pendingScans = new Map(); // dedup concurrent requests
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Invalidate cache (call after tag/insight changes if needed)
|
|
60
|
+
*/
|
|
61
|
+
invalidateCache(sourceType = null) {
|
|
62
|
+
if (sourceType) {
|
|
63
|
+
this._cache.delete(sourceType);
|
|
64
|
+
this._cache.delete(null); // also invalidate "all" cache
|
|
65
|
+
} else {
|
|
66
|
+
this._cache.clear();
|
|
67
|
+
}
|
|
51
68
|
}
|
|
52
69
|
|
|
53
70
|
/**
|
|
@@ -56,6 +73,36 @@ class SessionRepository {
|
|
|
56
73
|
* @returns {Promise<Session[]>} Array of sessions sorted by updatedAt (newest first)
|
|
57
74
|
*/
|
|
58
75
|
async findAll(sourceType = null) {
|
|
76
|
+
const cacheKey = sourceType || '__all__';
|
|
77
|
+
|
|
78
|
+
// Check cache
|
|
79
|
+
const cached = this._cache.get(cacheKey);
|
|
80
|
+
if (cached && (Date.now() - cached.timestamp < this._cacheTTL)) {
|
|
81
|
+
return cached.data;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Dedup concurrent scans for same key
|
|
85
|
+
if (this._pendingScans.has(cacheKey)) {
|
|
86
|
+
return this._pendingScans.get(cacheKey);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const scanPromise = this._doFindAll(sourceType).then(result => {
|
|
90
|
+
this._cache.set(cacheKey, { data: result, timestamp: Date.now() });
|
|
91
|
+
this._pendingScans.delete(cacheKey);
|
|
92
|
+
return result;
|
|
93
|
+
}).catch(err => {
|
|
94
|
+
this._pendingScans.delete(cacheKey);
|
|
95
|
+
throw err;
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
this._pendingScans.set(cacheKey, scanPromise);
|
|
99
|
+
return scanPromise;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @private
|
|
104
|
+
*/
|
|
105
|
+
async _doFindAll(sourceType = null) {
|
|
59
106
|
const allSessions = [];
|
|
60
107
|
|
|
61
108
|
const sources = sourceType
|
|
@@ -71,7 +118,23 @@ class SessionRepository {
|
|
|
71
118
|
}
|
|
72
119
|
}
|
|
73
120
|
|
|
74
|
-
return this._sortByUpdatedAt(allSessions);
|
|
121
|
+
return this._sortByUpdatedAt(this._deduplicateSessions(allSessions));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Deduplicate sessions with the same ID (e.g. VSCode sessions in multiple workspaces).
|
|
126
|
+
* Keeps the most recently updated session for each ID.
|
|
127
|
+
* @private
|
|
128
|
+
*/
|
|
129
|
+
_deduplicateSessions(sessions) {
|
|
130
|
+
const seen = new Map();
|
|
131
|
+
for (const session of sessions) {
|
|
132
|
+
const existing = seen.get(session.id);
|
|
133
|
+
if (!existing || (session.updatedAt && existing.updatedAt && new Date(session.updatedAt) > new Date(existing.updatedAt))) {
|
|
134
|
+
seen.set(session.id, session);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return Array.from(seen.values());
|
|
75
138
|
}
|
|
76
139
|
|
|
77
140
|
/**
|
|
@@ -234,6 +297,7 @@ class SessionRepository {
|
|
|
234
297
|
|
|
235
298
|
return new Session(sessionId, 'file', {
|
|
236
299
|
source: 'claude',
|
|
300
|
+
filePath: fullPath,
|
|
237
301
|
directory: path.dirname(fullPath), // Directory containing the session file
|
|
238
302
|
workspace: {
|
|
239
303
|
summary: metadata.model ? `Claude Code session (${metadata.model})` : 'Claude Code session',
|
|
@@ -415,6 +479,7 @@ class SessionRepository {
|
|
|
415
479
|
'directory',
|
|
416
480
|
{
|
|
417
481
|
source: 'pi-mono',
|
|
482
|
+
filePath: filePath,
|
|
418
483
|
directory: projectPath, // Project directory containing the session file
|
|
419
484
|
workspace: { cwd: sessionEvent.cwd || projectName },
|
|
420
485
|
createdAt: new Date(sessionEvent.timestamp),
|
|
@@ -506,7 +571,7 @@ class SessionRepository {
|
|
|
506
571
|
hasEvents: true,
|
|
507
572
|
eventCount: requests.reduce((s, r) => s + (r.response || []).length, 0) + requests.length * 2 + 1,
|
|
508
573
|
duration: effectiveEnd2.getTime() - createdAt.getTime(),
|
|
509
|
-
sessionStatus: (Date.now() - effectiveEnd2.getTime()) <
|
|
574
|
+
sessionStatus: ((Date.now() - effectiveEnd2.getTime()) < 15 * 60 * 1000 || (Date.now() - stats.mtime.getTime()) < 15 * 60 * 1000) ? 'wip' : 'completed',
|
|
510
575
|
selectedModel: firstReq.modelId || null,
|
|
511
576
|
copilotVersion: copilotChatVersion,
|
|
512
577
|
workspace: { cwd: realWorkspacePath || path.join(workspaceStorageDir, hash) },
|
|
@@ -592,7 +657,7 @@ class SessionRepository {
|
|
|
592
657
|
const workspaceFile = path.join(fullPath, 'workspace.yaml');
|
|
593
658
|
const eventsFile = path.join(fullPath, 'events.jsonl');
|
|
594
659
|
const importedMarkerFile = path.join(fullPath, '.imported');
|
|
595
|
-
const insightReportFile = path.join(fullPath,
|
|
660
|
+
const insightReportFile = path.join(fullPath, `${entry}.agent-review.md`);
|
|
596
661
|
|
|
597
662
|
// Parse workspace.yaml if exists, otherwise use defaults
|
|
598
663
|
const workspace = await fileExists(workspaceFile)
|
|
@@ -662,7 +727,7 @@ class SessionRepository {
|
|
|
662
727
|
return 'completed';
|
|
663
728
|
}
|
|
664
729
|
if (metadata.lastEventTime !== null && metadata.lastEventTime !== undefined) {
|
|
665
|
-
const WIP_THRESHOLD_MS =
|
|
730
|
+
const WIP_THRESHOLD_MS = 15 * 60 * 1000;
|
|
666
731
|
if ((Date.now() - metadata.lastEventTime) < WIP_THRESHOLD_MS) {
|
|
667
732
|
return 'wip';
|
|
668
733
|
}
|
|
@@ -950,7 +1015,7 @@ class SessionRepository {
|
|
|
950
1015
|
hasEvents: true,
|
|
951
1016
|
eventCount: requests.reduce((s, r) => s + (r.response || []).length, 0) + requests.length * 2 + 1,
|
|
952
1017
|
duration: effectiveEndTime.getTime() - createdAt.getTime(),
|
|
953
|
-
sessionStatus: (Date.now() - effectiveEndTime.getTime()) <
|
|
1018
|
+
sessionStatus: ((Date.now() - effectiveEndTime.getTime()) < 15 * 60 * 1000 || (Date.now() - stats.mtime.getTime()) < 15 * 60 * 1000) ? 'wip' : 'completed',
|
|
954
1019
|
selectedModel: model,
|
|
955
1020
|
agentId,
|
|
956
1021
|
toolCount,
|
|
@@ -56,10 +56,18 @@ class TagService {
|
|
|
56
56
|
* @returns {string} Path to tags.json
|
|
57
57
|
*/
|
|
58
58
|
getSessionTagsFilePath(session) {
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
// File-based sessions (e.g. Claude .jsonl): per-file tags to avoid sharing
|
|
60
|
+
if (session.filePath) {
|
|
61
|
+
const dir = path.dirname(session.filePath);
|
|
62
|
+
const base = path.basename(session.filePath, path.extname(session.filePath));
|
|
63
|
+
return path.join(dir, `${base}.tags.json`);
|
|
61
64
|
}
|
|
62
|
-
|
|
65
|
+
// Directory-based sessions (e.g. Copilot CLI): tags.json inside session dir
|
|
66
|
+
if (session.directory) {
|
|
67
|
+
return path.join(session.directory, 'tags.json');
|
|
68
|
+
}
|
|
69
|
+
// Fallback: store in central location by session id
|
|
70
|
+
return path.join(this.knownTagsDir, 'session-tags', `${session.id}.tags.json`);
|
|
63
71
|
}
|
|
64
72
|
|
|
65
73
|
/**
|
|
@@ -130,6 +138,8 @@ class TagService {
|
|
|
130
138
|
// File doesn't exist, ignore
|
|
131
139
|
}
|
|
132
140
|
} else {
|
|
141
|
+
// Ensure directory exists for tags file
|
|
142
|
+
await fs.mkdir(path.dirname(tagsFilePath), { recursive: true });
|
|
133
143
|
// Write tags to session directory
|
|
134
144
|
await fs.writeFile(tagsFilePath, JSON.stringify(normalizedTags, null, 2), 'utf8');
|
|
135
145
|
|
package/views/time-analyze.ejs
CHANGED
|
@@ -2211,7 +2211,21 @@
|
|
|
2211
2211
|
if (items[i].rowType === 'user-req') break; // next user-req
|
|
2212
2212
|
if (items[i].rowType === 'subagent') children.push(items[i]);
|
|
2213
2213
|
}
|
|
2214
|
-
if (children.length === 0)
|
|
2214
|
+
if (children.length === 0) {
|
|
2215
|
+
// No subagents β position this user-req at the end of previous subagents
|
|
2216
|
+
// by finding cumulative tool count up to this point
|
|
2217
|
+
const subagentItems = items.filter(it => it.rowType !== 'user-req');
|
|
2218
|
+
const totalToolCount = subagentItems.reduce((sum, it) => sum + (it.toolCount || 0), 0);
|
|
2219
|
+
if (totalToolCount === 0) return { left: '0%', width: '0%' };
|
|
2220
|
+
// Sum tools of all subagents before this user-req in the items array
|
|
2221
|
+
let cumTools = 0;
|
|
2222
|
+
for (let i = 0; i < idx; i++) {
|
|
2223
|
+
if (items[i].rowType === 'subagent') cumTools += (items[i].toolCount || 0);
|
|
2224
|
+
}
|
|
2225
|
+
const leftPct = (cumTools / totalToolCount) * 100;
|
|
2226
|
+
// Minimal width bar (at least 1%)
|
|
2227
|
+
return { left: leftPct + '%', width: Math.max(1, (1 / totalToolCount) * 100) + '%' };
|
|
2228
|
+
}
|
|
2215
2229
|
|
|
2216
2230
|
const firstPos = ganttSequencePosition(children[0]);
|
|
2217
2231
|
const lastPos = ganttSequencePosition(children[children.length - 1]);
|