@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 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
  [![Node.js Version](https://img.shields.io/badge/node-%3E%3D18.0.0-brightgreen)](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
  ![Session List](https://raw.githubusercontent.com/qiaolei81/copilot-session-viewer/main/docs/images/homepage.png)
@@ -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
- - **470+ Tests** (411 unit + 59 E2E)
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** - 411 Jest tests with coverage
157
+ 2. **Unit Tests** - 622 Jest tests with coverage
157
158
  3. **Mock Data Generation** - Reproducible test session fixtures
158
- 4. **E2E Tests** - 59 Playwright tests with Chromium
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
- β”‚ β€’ Claude: ~/.claude/projects/ β”‚
192
- β”‚ β€’ Pi-Mono: ~/.pi/agent/sessions/ β”‚
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.1.9+):**
308
- - ✨ Multi-source support (Copilot, Claude, Pi-Mono)
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
- - πŸ“„ Memory pagination API
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 + '\n';
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qiaolei81/copilot-session-viewer",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Web UI for viewing GitHub Copilot CLI session logs",
5
5
  "author": "Lei Qiao <qiaolei81@gmail.com>",
6
6
  "license": "MIT",
@@ -276,59 +276,72 @@ class SessionController {
276
276
  let sessionPath;
277
277
  let isDirectory = false;
278
278
 
279
- if (session.source === 'copilot') {
280
- const copilotSource = this.sessionService.sessionRepository.sources.find(s => s.type === 'copilot');
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(basePath);
282
+ const stats = await fs.promises.stat(session.directory);
288
283
  if (stats.isDirectory()) {
289
- sessionPath = basePath;
284
+ sessionPath = session.directory;
290
285
  isDirectory = true;
291
- } else {
292
- sessionPath = `${basePath}.jsonl`;
293
286
  }
294
287
  } catch {
295
- sessionPath = `${basePath}.jsonl`;
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
- if (!sessionPath) {
317
- return res.status(404).json({ error: 'Session file not found' });
318
- }
319
- } else if (session.source === 'pi-mono') {
320
- const piMonoSource = this.sessionService.sessionRepository.sources.find(s => s.type === 'pi-mono');
321
- if (!piMonoSource) {
322
- return res.status(404).json({ error: 'Pi-Mono source not found' });
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
- // Pi-Mono sessions are timestamp-based JSONL files
326
- const files = await fs.promises.readdir(piMonoSource.dir);
327
- const matchingFile = files.find(f => f.includes(sessionId) && f.endsWith('.jsonl'));
328
- if (!matchingFile) {
329
- return res.status(404).json({ error: 'Session file not found' });
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 single file
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
@@ -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
- const insightFile = path.join(sessionPath, 'agent-review.md');
64
- const lockFile = path.join(sessionPath, 'agent-review.md.lock');
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, 'agent-review.md.tmp');
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, 'agent-review.md');
463
- const lockFile = path.join(sessionPath, 'agent-review.md.lock');
464
- const tmpFile = path.join(sessionPath, 'agent-review.md.tmp');
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, 'agent-review.md');
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()) < 5 * 60 * 1000 ? 'wip' : 'completed',
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, 'agent-review.md');
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 = 5 * 60 * 1000;
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()) < 5 * 60 * 1000 ? 'wip' : 'completed',
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
- if (!session.directory) {
60
- throw new Error('Session must have a directory field');
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
- return path.join(session.directory, 'tags.json');
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
 
@@ -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) return { left: '0%', width: '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]);