@qiaolei81/copilot-session-viewer 0.2.2 → 0.2.5

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.
@@ -2,6 +2,7 @@ const BaseSessionParser = require('./base-parser');
2
2
  const CopilotSessionParser = require('./copilot-parser');
3
3
  const ClaudeSessionParser = require('./claude-parser');
4
4
  const PiMonoParser = require('./pi-mono-parser');
5
+ const VsCodeParser = require('./vscode-parser');
5
6
  const ParserFactory = require('./parser-factory');
6
7
 
7
8
  module.exports = {
@@ -9,5 +10,6 @@ module.exports = {
9
10
  CopilotSessionParser,
10
11
  ClaudeSessionParser,
11
12
  PiMonoParser,
13
+ VsCodeParser,
12
14
  ParserFactory
13
15
  };
@@ -0,0 +1,413 @@
1
+ const BaseSessionParser = require('./base-parser');
2
+
3
+ /**
4
+ * VSCode Copilot Chat Session Parser
5
+ *
6
+ * Parses chat sessions stored by VS Code's GitHub Copilot Chat extension.
7
+ * Location: ~/Library/Application Support/Code/User/workspaceStorage/<hash>/chatSessions/<uuid>.json
8
+ *
9
+ * Format: A single JSON object with a `requests` array. Each request has
10
+ * a `message` (user input) and a `response` array of typed content items.
11
+ */
12
+ class VsCodeParser extends BaseSessionParser {
13
+ /**
14
+ * VSCode sessions are passed as a plain JS object (already parsed JSON),
15
+ * not as an array of events. We detect them by the presence of `requests`.
16
+ * The ParserFactory passes raw events arrays for JSONL sources, so we
17
+ * keep canParse() returning false — VSCode sessions are loaded differently.
18
+ */
19
+ canParse(_events) {
20
+ // VSCode sessions are JSON objects, not event arrays.
21
+ // SessionRepository calls parseVsCode() directly; canParse is unused here.
22
+ return false;
23
+ }
24
+
25
+ /**
26
+ * Parse a VSCode chat session JSON object into the normalised format.
27
+ * @param {Object} sessionJson - Parsed JSON from chatSessions/<uuid>.json
28
+ * @returns {Object} Normalised session data
29
+ */
30
+ parseVsCode(sessionJson) {
31
+ const metadata = this._getMetadata(sessionJson);
32
+ const events = this._toEvents(sessionJson);
33
+ return {
34
+ metadata,
35
+ turns: this._extractTurns(events),
36
+ toolCalls: this._extractToolCalls(events),
37
+ allEvents: events,
38
+ };
39
+ }
40
+
41
+ // ---- required abstract methods (not used for VsCode path) ----
42
+ parse(_events) { return null; }
43
+ getMetadata(_events) { return null; }
44
+ extractTurns(_events) { return []; }
45
+ extractToolCalls(_events) { return []; }
46
+
47
+ // ---- private helpers ----
48
+
49
+ _getMetadata(sessionJson) {
50
+ const requests = sessionJson.requests || [];
51
+ const firstReq = requests[0] || {};
52
+ const lastReq = requests[requests.length - 1] || {};
53
+
54
+ // Derive workspace label from agent name or fallback
55
+ const agentName = firstReq.agent?.name || firstReq.agent?.id || 'vscode-copilot';
56
+
57
+ return {
58
+ sessionId: sessionJson.sessionId,
59
+ startTime: sessionJson.creationDate
60
+ ? new Date(sessionJson.creationDate).toISOString()
61
+ : (firstReq.timestamp ? new Date(firstReq.timestamp).toISOString() : null),
62
+ endTime: sessionJson.lastMessageDate
63
+ ? new Date(sessionJson.lastMessageDate).toISOString()
64
+ : (lastReq.timestamp ? new Date(lastReq.timestamp).toISOString() : null),
65
+ model: firstReq.modelId || null,
66
+ producer: 'vscode-copilot-chat',
67
+ version: firstReq.agent?.extensionVersion || null,
68
+ agentName,
69
+ requestCount: requests.length,
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Convert VSCode session JSON into a flat event array that matches the
75
+ * normalised event schema used by the rest of the viewer.
76
+ */
77
+ _toEvents(sessionJson) {
78
+ const events = [];
79
+ const requests = sessionJson.requests || [];
80
+
81
+ // session.start synthetic event
82
+ events.push({
83
+ type: 'session.start',
84
+ id: `${sessionJson.sessionId}-start`,
85
+ timestamp: sessionJson.creationDate
86
+ ? new Date(sessionJson.creationDate).toISOString()
87
+ : null,
88
+ data: {
89
+ sessionId: sessionJson.sessionId,
90
+ producer: 'vscode-copilot-chat',
91
+ selectedModel: requests[0]?.modelId || null,
92
+ },
93
+ });
94
+
95
+ for (const req of requests) {
96
+ const ts = req.timestamp ? new Date(req.timestamp).toISOString() : null;
97
+ // Use completedAt for assistant events — more accurate than request start time
98
+ const completedAt = req.modelState?.completedAt
99
+ ? new Date(req.modelState.completedAt).toISOString()
100
+ : ts;
101
+ const reqStartMs = ts ? new Date(ts).getTime() : null;
102
+ const reqEndMs = completedAt ? new Date(completedAt).getTime() : reqStartMs;
103
+ const reqDurationMs = (reqStartMs && reqEndMs) ? (reqEndMs - reqStartMs) : 0;
104
+
105
+ // Build subAgentInvocationId → agent name map from this request's response items
106
+ const responseItems = req.response || [];
107
+ const subAgentNames = this._buildSubAgentNameMap(responseItems);
108
+
109
+ // Count items per subagent (in first-appearance order) for proportional time estimation
110
+ const subAgentItemCounts = new Map(); // sid → count
111
+ const subAgentOrder = []; // ordered unique sids
112
+ for (const item of responseItems) {
113
+ if (!item || typeof item !== 'object') continue;
114
+ const sid = item.subAgentInvocationId;
115
+ if (!sid) continue;
116
+ if (!subAgentItemCounts.has(sid)) { subAgentItemCounts.set(sid, 0); subAgentOrder.push(sid); }
117
+ subAgentItemCounts.set(sid, subAgentItemCounts.get(sid) + 1);
118
+ }
119
+ const totalSubAgentItems = [...subAgentItemCounts.values()].reduce((a, b) => a + b, 0);
120
+
121
+ // Compute estimated start/end timestamps per subagent (proportional to item count)
122
+ const subAgentTimestamps = new Map(); // sid → { startTime, endTime }
123
+ if (reqStartMs && totalSubAgentItems > 0) {
124
+ let cursor = reqStartMs;
125
+ for (const sid of subAgentOrder) {
126
+ const fraction = subAgentItemCounts.get(sid) / totalSubAgentItems;
127
+ const duration = Math.round(reqDurationMs * fraction);
128
+ subAgentTimestamps.set(sid, {
129
+ startTime: new Date(cursor).toISOString(),
130
+ endTime: new Date(cursor + duration).toISOString(),
131
+ });
132
+ cursor += duration;
133
+ }
134
+ }
135
+
136
+ const seenSubAgents = new Set();
137
+
138
+ // user.message
139
+ const userText = this._extractUserText(req.message);
140
+ events.push({
141
+ type: 'user.message',
142
+ id: req.requestId,
143
+ timestamp: ts,
144
+ data: {
145
+ message: userText,
146
+ content: userText,
147
+ },
148
+ });
149
+
150
+ // assistant.turn_start
151
+ events.push({
152
+ type: 'assistant.turn_start',
153
+ id: `${req.requestId}-turn`,
154
+ timestamp: ts,
155
+ parentId: req.requestId,
156
+ data: {},
157
+ });
158
+
159
+ let assistantText = '';
160
+ let itemIndex = 0;
161
+ let currentSubAgentId = null;
162
+
163
+ const flushText = () => {
164
+ const trimmed = assistantText.trim().replace(/^`{3,}$/gm, '').trim();
165
+ assistantText = '';
166
+ if (!trimmed) return;
167
+ const sid = currentSubAgentId;
168
+ const _agentName = sid ? (subAgentNames[sid] || sid.slice(0, 8)) : null;
169
+ events.push({
170
+ type: 'assistant.message',
171
+ id: `${req.requestId}-text-${itemIndex}`,
172
+ timestamp: completedAt,
173
+ parentId: req.requestId,
174
+ data: {
175
+ message: trimmed,
176
+ content: trimmed,
177
+ tools: [],
178
+ subAgentId: null,
179
+ subAgentName: null,
180
+ parentToolCallId: null,
181
+ },
182
+ });
183
+ };
184
+
185
+ const emitSubAgentStart = (sid, _itemIdx) => {
186
+ if (!sid || seenSubAgents.has(sid)) return;
187
+ seenSubAgents.add(sid);
188
+ const agentName = subAgentNames[sid] || `subagent-${sid.slice(0, 8)}`;
189
+ const times = subAgentTimestamps.get(sid);
190
+ const startTs = times?.startTime || completedAt;
191
+ events.push({
192
+ type: 'subagent.started',
193
+ id: `${req.requestId}-subagent-${sid}`,
194
+ timestamp: startTs,
195
+ parentId: req.requestId,
196
+ data: {
197
+ subAgentId: sid,
198
+ subAgentName: agentName,
199
+ agentName: agentName,
200
+ agentDisplayName: agentName,
201
+ toolCallId: sid,
202
+ badgeLabel: agentName,
203
+ badgeClass: 'badge-subagent',
204
+ },
205
+ });
206
+ };
207
+
208
+ for (const item of responseItems) {
209
+ itemIndex++;
210
+ switch (item.kind) {
211
+ case 'thinking': {
212
+ const text = item.content?.value || item.content || '';
213
+ if (text) assistantText += text + '\n';
214
+ break;
215
+ }
216
+
217
+ case 'markdownContent': {
218
+ const text = item.content?.value || item.content || '';
219
+ if (text) assistantText += text + '\n';
220
+ break;
221
+ }
222
+
223
+ case undefined:
224
+ case null: {
225
+ // Plain markdown text item (no kind field, has 'value')
226
+ const text = item.value || '';
227
+ if (text) assistantText += text + '\n';
228
+ break;
229
+ }
230
+
231
+ case 'toolInvocationSerialized': {
232
+ flushText();
233
+ // Emit subagent.start on first appearance of this subagent
234
+ const sid = item.subAgentInvocationId;
235
+ emitSubAgentStart(sid, itemIndex);
236
+ if (sid) currentSubAgentId = sid;
237
+ const tool = this._normalizeTool(item);
238
+ if (tool) {
239
+ const agentName = sid ? (subAgentNames[sid] || sid.slice(0, 8)) : null;
240
+ events.push({
241
+ type: 'tool.invocation',
242
+ id: tool.id || `${req.requestId}-tool-${itemIndex}`,
243
+ timestamp: completedAt,
244
+ parentId: req.requestId,
245
+ data: {
246
+ tool,
247
+ subAgentId: sid || null,
248
+ subAgentName: agentName,
249
+ parentToolCallId: sid || null,
250
+ badgeLabel: tool.name,
251
+ badgeClass: tool.status === 'error' ? 'badge-error' : 'badge-tool',
252
+ },
253
+ });
254
+ }
255
+ break;
256
+ }
257
+
258
+ case 'textEditGroup': {
259
+ flushText();
260
+ const edits = item.edits || item.uri ? [item] : [];
261
+ events.push({
262
+ type: 'tool.invocation',
263
+ id: `${req.requestId}-edit-${itemIndex}`,
264
+ timestamp: completedAt,
265
+ parentId: req.requestId,
266
+ data: {
267
+ tool: {
268
+ type: 'tool_use',
269
+ id: `${req.requestId}-edit-${itemIndex}`,
270
+ name: 'textEdit',
271
+ startTime: ts,
272
+ endTime: ts,
273
+ status: 'completed',
274
+ input: { uri: item.uri, edits },
275
+ result: 'file edit',
276
+ error: null,
277
+ },
278
+ badgeLabel: 'textEdit',
279
+ badgeClass: 'badge-tool',
280
+ },
281
+ });
282
+ break;
283
+ }
284
+
285
+ case 'prepareToolInvocation':
286
+ case 'inlineReference':
287
+ case 'undoStop':
288
+ case 'codeblockUri':
289
+ case 'mcpServersStarting':
290
+ // Skip non-visible items
291
+ break;
292
+
293
+ default:
294
+ break;
295
+ }
296
+ }
297
+
298
+ flushText(); // flush any trailing text
299
+
300
+ // Emit subagent.completed events (proportional estimated endTime) after all items
301
+ for (const sid of seenSubAgents) {
302
+ const agentName = subAgentNames[sid] || `subagent-${sid.slice(0, 8)}`;
303
+ const times = subAgentTimestamps.get(sid);
304
+ const endTs = times?.endTime || completedAt;
305
+ events.push({
306
+ type: 'subagent.completed',
307
+ id: `${req.requestId}-subagent-${sid}-end`,
308
+ timestamp: endTs,
309
+ parentId: req.requestId,
310
+ data: {
311
+ toolCallId: sid,
312
+ agentDisplayName: agentName,
313
+ agentName: agentName,
314
+ },
315
+ });
316
+ }
317
+ }
318
+
319
+ return events;
320
+ }
321
+
322
+ /**
323
+ * Extract agent name from the first toolInvocationSerialized item for a given subAgentInvocationId.
324
+ * Looks for .agent.md filename in invocationMessage or resultDetails URIs.
325
+ */
326
+ _buildSubAgentNameMap(items) {
327
+ const nameMap = {};
328
+ for (const item of items) {
329
+ if (!item || typeof item !== 'object') continue;
330
+ const sid = item.subAgentInvocationId;
331
+ if (!sid || nameMap[sid]) continue;
332
+ if (item.kind !== 'toolInvocationSerialized') continue;
333
+
334
+ // Try invocationMessage text for agent file path
335
+ const msgObj = item.invocationMessage;
336
+ const msg = (msgObj && typeof msgObj === 'object') ? (msgObj.value || '') : '';
337
+ let m = msg.match(/agents\/([^/\]]+?)\.agent\.md/);
338
+ if (m) { nameMap[sid] = m[1]; continue; }
339
+
340
+ // Try resultDetails
341
+ for (const rd of (item.resultDetails || [])) {
342
+ if (typeof rd !== 'object') continue;
343
+ const fp = rd.fsPath || rd.path || '';
344
+ m = fp.match(/agents\/([^/]+?)\.agent\.md/);
345
+ if (m) { nameMap[sid] = m[1]; break; }
346
+ }
347
+ }
348
+ return nameMap;
349
+ }
350
+
351
+ _extractUserText(message) {
352
+ if (!message) return '';
353
+ if (typeof message.text === 'string') return message.text;
354
+ // parts[] may contain text fragments
355
+ if (Array.isArray(message.parts)) {
356
+ return message.parts
357
+ .filter(p => p.kind === 'text')
358
+ .map(p => p.text || '')
359
+ .join('');
360
+ }
361
+ return '';
362
+ }
363
+
364
+ _normalizeTool(item) {
365
+ if (!item.toolCallId) return null;
366
+
367
+ // toolSpecificData may hold input/output depending on tool type
368
+ const tsd = item.toolSpecificData || {};
369
+ const input = tsd.input || tsd.parameters || tsd.request || {};
370
+ const result = tsd.output || tsd.result || item.resultDetails || null;
371
+ const isError = item.isConfirmed === false;
372
+
373
+ return {
374
+ type: 'tool_use',
375
+ id: item.toolCallId,
376
+ name: item.toolId || 'unknown',
377
+ startTime: null,
378
+ endTime: null,
379
+ status: isError ? 'error' : (item.isComplete ? 'completed' : 'pending'),
380
+ input,
381
+ result: typeof result === 'string' ? result : JSON.stringify(result),
382
+ error: isError ? (item.resultDetails || 'Tool invocation not confirmed') : null,
383
+ };
384
+ }
385
+
386
+ _extractTurns(events) {
387
+ const turns = [];
388
+ let current = null;
389
+
390
+ for (const event of events) {
391
+ if (event.type === 'user.message') {
392
+ if (current) turns.push(current);
393
+ current = { userMessage: event, assistantMessages: [], toolCalls: [] };
394
+ } else if (current) {
395
+ if (event.type === 'assistant.message') {
396
+ current.assistantMessages.push(event);
397
+ } else if (event.type === 'tool.invocation') {
398
+ current.toolCalls.push(event.data?.tool);
399
+ }
400
+ }
401
+ }
402
+ if (current) turns.push(current);
403
+ return turns;
404
+ }
405
+
406
+ _extractToolCalls(events) {
407
+ return events
408
+ .filter(e => e.type === 'tool.invocation' && e.data?.tool)
409
+ .map(e => e.data.tool);
410
+ }
411
+ }
412
+
413
+ module.exports = VsCodeParser;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qiaolei81/copilot-session-viewer",
3
- "version": "0.2.2",
3
+ "version": "0.2.5",
4
4
  "description": "Web UI for viewing GitHub Copilot CLI session logs",
5
5
  "author": "Lei Qiao <qiaolei81@gmail.com>",
6
6
  "license": "MIT",
package/server.js CHANGED
@@ -12,25 +12,8 @@ module.exports = app;
12
12
  if (require.main === module) {
13
13
  const server = app.listen(config.PORT, () => {
14
14
  console.log(`🚀 Copilot Session Viewer running at http://localhost:${config.PORT}`);
15
- console.log('📂 Session directories (env vars):');
16
- console.log(` COPILOT_SESSION_DIR=${process.env.COPILOT_SESSION_DIR || 'not set'}`);
17
- console.log(` CLAUDE_SESSION_DIR=${process.env.CLAUDE_SESSION_DIR || 'not set'}`);
18
- console.log(` PI_MONO_SESSION_DIR=${process.env.PI_MONO_SESSION_DIR || 'not set'}`);
19
- console.log(` SESSION_DIR=${process.env.SESSION_DIR || 'not set'} (legacy)`);
20
15
  console.log(`🔧 Environment: ${config.NODE_ENV}`);
21
16
  console.log(`⚡ Active processes: ${processManager.getActiveCount()}`);
22
-
23
- // Log sessions found
24
- const SessionRepository = require('./src/services/sessionRepository');
25
- const repo = new SessionRepository();
26
- repo.findAll().then(sessions => {
27
- console.log(`📊 Sessions found: ${sessions.length}`);
28
- if (sessions.length > 0) {
29
- console.log(` First 5: ${sessions.slice(0, 5).map(s => s.id + ' (' + s.source + ')').join(', ')}`);
30
- }
31
- }).catch(err => {
32
- console.error('❌ Error loading sessions:', err.message);
33
- });
34
17
  });
35
18
 
36
19
  // Graceful shutdown
package/src/app.js CHANGED
@@ -50,12 +50,12 @@ function createApp(options = {}) {
50
50
  }));
51
51
 
52
52
  app.use(compression({
53
- level: 9, // Maximum compression for local use (CPU is not a bottleneck)
53
+ level: 1, // Fast compression (speed > ratio for local use)
54
54
  threshold: 1024, // Compress responses > 1KB
55
55
  filter: (req, res) => {
56
- // Always compress JSON responses
57
- if (res.getHeader('Content-Type')?.includes('application/json')) {
58
- return true;
56
+ // Skip compression for large JSON API responses (handled separately)
57
+ if (req.path.includes('/events') && res.getHeader('Content-Type')?.includes('application/json')) {
58
+ return false;
59
59
  }
60
60
  return compression.filter(req, res);
61
61
  }
@@ -1,5 +1,5 @@
1
1
  const SessionService = require('../services/sessionService');
2
- const { isValidSessionId } = require('../utils/helpers');
2
+ const { isValidSessionId, buildMetadata } = require('../utils/helpers');
3
3
  const AdmZip = require('adm-zip');
4
4
  const path = require('path');
5
5
  const fs = require('fs');
@@ -12,8 +12,8 @@ class SessionController {
12
12
  // Homepage with initial load (first batch)
13
13
  async getHomepage(req, res) {
14
14
  try {
15
- const initialLimit = 100; // Load first 100 sessions to ensure Pi-Mono sessions are included
16
- const paginationData = await this.sessionService.getPaginatedSessions(1, initialLimit);
15
+ // Only load default pill (copilot) first 20 sessions
16
+ const paginationData = await this.sessionService.getPaginatedSessions(1, 20, 'copilot');
17
17
 
18
18
  // Pass data for infinite scroll
19
19
  const templateData = {
@@ -34,19 +34,17 @@ class SessionController {
34
34
  try {
35
35
  const sessionId = req.params.id;
36
36
 
37
- // Validate session ID format
38
37
  if (!isValidSessionId(sessionId)) {
39
38
  return res.status(400).json({ error: 'Invalid session ID' });
40
39
  }
41
40
 
42
- const sessionData = await this.sessionService.getSessionWithEvents(sessionId);
43
-
44
- if (!sessionData) {
41
+ const session = await this.sessionService.sessionRepository.findById(sessionId);
42
+ if (!session) {
45
43
  return res.status(404).json({ error: 'Session not found' });
46
44
  }
47
45
 
48
- const { events, metadata } = sessionData;
49
- res.render('session-vue', { sessionId, events, metadata });
46
+ const metadata = buildMetadata(session);
47
+ res.render('session-vue', { sessionId, events: [], metadata });
50
48
  } catch (err) {
51
49
  console.error('Error loading session:', err);
52
50
  res.status(500).json({ error: 'Error loading session' });
@@ -58,20 +56,17 @@ class SessionController {
58
56
  try {
59
57
  const sessionId = req.params.id;
60
58
 
61
- // Validate session ID format
62
59
  if (!isValidSessionId(sessionId)) {
63
60
  return res.status(400).json({ error: 'Invalid session ID' });
64
61
  }
65
62
 
66
- const sessionData = await this.sessionService.getSessionWithEvents(sessionId);
67
-
68
- if (!sessionData) {
63
+ const session = await this.sessionService.sessionRepository.findById(sessionId);
64
+ if (!session) {
69
65
  return res.status(404).json({ error: 'Session not found' });
70
66
  }
71
67
 
72
- const { events, metadata } = sessionData;
73
- // Use original time-analyze view (supports all sources via normalized events)
74
- res.render('time-analyze', { sessionId, events, metadata });
68
+ const metadata = buildMetadata(session);
69
+ res.render('time-analyze', { sessionId, events: [], metadata });
75
70
  } catch (err) {
76
71
  console.error('Error loading time analysis:', err);
77
72
  res.status(500).json({ error: 'Error loading analysis' });
@@ -83,33 +78,26 @@ class SessionController {
83
78
  try {
84
79
  const page = req.query.page ? parseInt(req.query.page) : null;
85
80
  const limit = req.query.limit ? parseInt(req.query.limit) : null;
81
+ const sourceFilter = req.query.source || null;
86
82
 
87
83
  if (page && limit) {
88
84
  // Return paginated response
89
85
  if (page < 1 || limit < 1 || limit > 100) {
90
86
  return res.status(400).json({ error: 'Invalid pagination parameters' });
91
87
  }
92
- const paginationData = await this.sessionService.getPaginatedSessions(page, limit);
93
-
94
- // Set cache headers for paginated data (shorter cache)
95
- res.set({
96
- 'Cache-Control': 'public, max-age=60', // 1 minute cache
97
- 'ETag': `"sessions-page-${page}-${limit}-${Date.now()}"`,
98
- 'Vary': 'Accept-Encoding'
99
- });
100
-
88
+ const paginationData = await this.sessionService.getPaginatedSessions(page, limit, sourceFilter);
89
+ res.set({ 'Cache-Control': 'public, max-age=60' });
101
90
  res.json(paginationData);
91
+ } else if (sourceFilter && limit) {
92
+ // Source-filtered first page (for pill switching)
93
+ const sessions = await this.sessionService.getAllSessions(sourceFilter);
94
+ const sliced = sessions.slice(0, limit);
95
+ res.set({ 'Cache-Control': 'public, max-age=60' });
96
+ res.json({ sessions: sliced, hasMore: sessions.length > limit, totalSessions: sessions.length });
102
97
  } else {
103
98
  // Return all sessions for backward compatibility
104
- const sessions = await this.sessionService.getAllSessions();
105
-
106
- // Set cache headers for full session list
107
- res.set({
108
- 'Cache-Control': 'public, max-age=300', // 5 minute cache
109
- 'ETag': `"sessions-all-${Date.now()}"`,
110
- 'Vary': 'Accept-Encoding'
111
- });
112
-
99
+ const sessions = await this.sessionService.getAllSessions(sourceFilter);
100
+ res.set({ 'Cache-Control': 'public, max-age=300' });
113
101
  res.json(sessions);
114
102
  }
115
103
  } catch (err) {
@@ -118,11 +106,12 @@ class SessionController {
118
106
  }
119
107
  }
120
108
 
121
- // API: Load more sessions for infinite scroll
109
+ // API: Load more sessions for infinite scroll
122
110
  async loadMoreSessions(req, res) {
123
111
  try {
124
112
  const offset = parseInt(req.query.offset) || 0;
125
113
  const limit = parseInt(req.query.limit) || 20;
114
+ const sourceFilter = req.query.source || null;
126
115
 
127
116
  // Validate parameters
128
117
  if (offset < 0 || limit < 1 || limit > 50) {
@@ -131,7 +120,7 @@ class SessionController {
131
120
 
132
121
  // Calculate page number from offset
133
122
  const page = Math.floor(offset / limit) + 1;
134
- const paginationData = await this.sessionService.getPaginatedSessions(page, limit);
123
+ const paginationData = await this.sessionService.getPaginatedSessions(page, limit, sourceFilter);
135
124
 
136
125
  res.json({
137
126
  sessions: paginationData.sessions,
@@ -174,7 +163,7 @@ class SessionController {
174
163
  }
175
164
 
176
165
  // Get session metadata for ETag generation
177
- const session = await this.sessionService.getSessionById(sessionId);
166
+ const session = await this.sessionService.sessionRepository.findById(sessionId);
178
167
  if (!session) {
179
168
  return res.status(404).json({ error: 'Session not found' });
180
169
  }
@@ -10,7 +10,7 @@ const config = require('../config');
10
10
  class UploadController {
11
11
  constructor() {
12
12
  this.SESSION_DIR = process.env.SESSION_DIR || path.join(os.homedir(), '.copilot', 'session-state');
13
- this.uploadDir = path.join(os.tmpdir(), 'copilot-session-uploads');
13
+ this.uploadDir = process.env.UPLOAD_DIR || path.join(os.tmpdir(), 'copilot-session-uploads');
14
14
 
15
15
  // Multi-format session directories
16
16
  this.SESSION_DIRS = {
@@ -9,6 +9,7 @@ class Session {
9
9
  this.type = type; // 'directory' or 'file'
10
10
  this.source = options.source || 'copilot'; // 'copilot' or 'claude'
11
11
  this.directory = options.directory || null; // Full path to session directory
12
+ this.filePath = options.filePath || null; // Full path to session file (for file-based sessions)
12
13
  this.workspace = options.workspace || {};
13
14
  this.createdAt = options.createdAt;
14
15
  this.updatedAt = options.updatedAt;
@@ -124,7 +125,8 @@ class Session {
124
125
  const metadata = {
125
126
  'copilot': { name: 'Copilot', badgeClass: 'source-copilot' },
126
127
  'claude': { name: 'Claude', badgeClass: 'source-claude' },
127
- 'pi-mono': { name: 'Pi', badgeClass: 'source-pi-mono' }
128
+ 'pi-mono': { name: 'Pi', badgeClass: 'source-pi-mono' },
129
+ 'vscode': { name: 'VSCode', badgeClass: 'source-vscode' }
128
130
  };
129
131
  return metadata[source] || { name: source, badgeClass: 'source-unknown' };
130
132
  }
@@ -38,6 +38,11 @@ class SessionRepository {
38
38
  type: 'pi-mono',
39
39
  dir: process.env.PI_MONO_SESSION_DIR ||
40
40
  path.join(os.homedir(), '.pi', 'agent', 'sessions')
41
+ },
42
+ {
43
+ type: 'vscode',
44
+ dir: process.env.VSCODE_WORKSPACE_STORAGE_DIR ||
45
+ path.join(os.homedir(), 'Library', 'Application Support', 'Code', 'User', 'workspaceStorage')
41
46
  }
42
47
  ];
43
48
  }
@@ -46,13 +51,18 @@ class SessionRepository {
46
51
  }
47
52
 
48
53
  /**
49
- * Get all sessions from all sources
54
+ * Get all sessions from all sources (or a specific source)
55
+ * @param {string|null} sourceType - Optional source type filter ('copilot', 'claude', 'pi-mono', 'vscode')
50
56
  * @returns {Promise<Session[]>} Array of sessions sorted by updatedAt (newest first)
51
57
  */
52
- async findAll() {
58
+ async findAll(sourceType = null) {
53
59
  const allSessions = [];
54
60
 
55
- for (const source of this.sources) {
61
+ const sources = sourceType
62
+ ? this.sources.filter(s => s.type === sourceType)
63
+ : this.sources;
64
+
65
+ for (const source of sources) {
56
66
  try {
57
67
  const sessions = await this._scanSource(source);
58
68
  allSessions.push(...sessions);
@@ -100,6 +110,12 @@ class SessionRepository {
100
110
  if (stats.isDirectory()) {
101
111
  return this._scanPiMonoDir(fullPath, entry);
102
112
  }
113
+ } else if (source.type === 'vscode') {
114
+ // VSCode: workspaceStorage/<hash>/chatSessions/<uuid>.json
115
+ // Each top-level entry is a workspace hash directory
116
+ if (stats.isDirectory()) {
117
+ return this._scanVsCodeWorkspaceDir(fullPath);
118
+ }
103
119
  }
104
120
  return null;
105
121
  });
@@ -259,6 +275,8 @@ class SessionRepository {
259
275
  session = await this._findClaudeSession(sessionId, source.dir);
260
276
  } else if (source.type === 'pi-mono') {
261
277
  session = await this._findPiMonoSession(sessionId, source.dir);
278
+ } else if (source.type === 'vscode') {
279
+ session = await this._findVsCodeSession(sessionId, source.dir);
262
280
  }
263
281
 
264
282
  if (session) return session;
@@ -423,6 +441,66 @@ class SessionRepository {
423
441
  return null;
424
442
  }
425
443
 
444
+ /**
445
+ * Find VSCode session by ID in workspaceStorage
446
+ * @private
447
+ */
448
+ async _findVsCodeSession(sessionId, workspaceStorageDir) {
449
+ try {
450
+ const hashes = await fs.readdir(workspaceStorageDir);
451
+ for (const hash of hashes) {
452
+ const chatSessionsDir = path.join(workspaceStorageDir, hash, 'chatSessions');
453
+ try {
454
+ const files = await fs.readdir(chatSessionsDir);
455
+ const matchingFile = files.find(f => f === `${sessionId}.json` || f === `${sessionId}.jsonl` || f.replace(/\.jsonl?$/, '') === sessionId);
456
+ if (matchingFile) {
457
+ const fullPath = path.join(chatSessionsDir, matchingFile);
458
+ const stats = await fs.stat(fullPath);
459
+ const raw = await fs.readFile(fullPath, 'utf-8');
460
+ let sessionJson;
461
+ if (matchingFile.endsWith('.jsonl')) {
462
+ sessionJson = this._parseVsCodeJsonl(raw);
463
+ if (!sessionJson) continue;
464
+ } else {
465
+ sessionJson = JSON.parse(raw);
466
+ }
467
+ const requests = sessionJson.requests || [];
468
+ if (requests.length === 0) return null;
469
+
470
+ const firstReq = requests[0];
471
+ const createdAt = sessionJson.creationDate
472
+ ? new Date(sessionJson.creationDate)
473
+ : (firstReq.timestamp ? new Date(firstReq.timestamp) : stats.birthtime);
474
+ const updatedAt = sessionJson.lastMessageDate
475
+ ? new Date(sessionJson.lastMessageDate)
476
+ : stats.mtime;
477
+ const userText = this._extractVsCodeUserText(firstReq.message);
478
+
479
+ return new Session(sessionId, 'file', {
480
+ source: 'vscode',
481
+ filePath: fullPath,
482
+ createdAt,
483
+ updatedAt,
484
+ summary: userText ? userText.slice(0, 120) : `VSCode chat (${requests.length} requests)`,
485
+ hasEvents: true,
486
+ eventCount: requests.reduce((s, r) => s + (r.response || []).length, 0) + requests.length * 2 + 1,
487
+ duration: updatedAt - createdAt,
488
+ sessionStatus: 'completed',
489
+ model: firstReq.modelId || null,
490
+ workspace: { cwd: path.join(workspaceStorageDir, hash) },
491
+ });
492
+ }
493
+ } catch {
494
+ // No chatSessions dir or can't read — skip
495
+ }
496
+ }
497
+ } catch (err) {
498
+ console.error(`[VSCode findById] Error searching VSCode sessions: ${err.message}`, err.stack);
499
+ }
500
+ console.log(`[VSCode findById] Session ${sessionId} not found in vscode sessions`);
501
+ return null;
502
+ }
503
+
426
504
  /**
427
505
  * Create Claude session from subagents-only directory (no main events.jsonl)
428
506
  * @private
@@ -657,6 +735,200 @@ class SessionRepository {
657
735
  }
658
736
  }
659
737
 
738
+ /**
739
+ * Scan a VSCode workspaceStorage/<hash> directory for chatSessions/*.json files
740
+ * @private
741
+ */
742
+ /** Resolve the real project/workspace path from a VSCode workspaceStorage hash directory */
743
+ /** Parse a VSCode .jsonl file: read kind=0 for base state, merge kind=2 patches into response arrays */
744
+ _parseVsCodeJsonl(raw) {
745
+ const lines = raw.split('\n').filter(l => l.trim());
746
+ if (lines.length === 0) return null;
747
+
748
+ const first = JSON.parse(lines[0]);
749
+ const sessionJson = first.v || first; // kind=0 wraps in .v
750
+
751
+ // Apply kind=1 (field set) and kind=2 (array splice) patches
752
+ for (let idx = 1; idx < lines.length; idx++) {
753
+ try {
754
+ const patch = JSON.parse(lines[idx]);
755
+ const k = patch.k || [];
756
+ const v = patch.v;
757
+
758
+ if (patch.kind === 2 && Array.isArray(v)) {
759
+ // Navigate to parent object, then splice into the target array
760
+ let obj = sessionJson;
761
+ for (let ki = 0; ki < k.length - 1; ki++) {
762
+ const key = k[ki];
763
+ if (typeof key === 'number') {
764
+ obj = obj[key];
765
+ } else {
766
+ if (!obj[key]) obj[key] = {};
767
+ obj = obj[key];
768
+ }
769
+ }
770
+ const lastKey = k[k.length - 1];
771
+ if (lastKey !== undefined) {
772
+ if (!obj[lastKey]) obj[lastKey] = [];
773
+ const target = obj[lastKey];
774
+ const i = patch.i;
775
+ if (i === null || i === undefined) {
776
+ target.push(...v);
777
+ } else {
778
+ target.splice(i, 0, ...v);
779
+ }
780
+ } else {
781
+ // k is empty — splice into sessionJson itself (rare)
782
+ const i = patch.i;
783
+ if (i === null || i === undefined) sessionJson.push?.(...v);
784
+ }
785
+ } else if (patch.kind === 1 && k.length > 0) {
786
+ // Navigate to parent, set the final key
787
+ let obj = sessionJson;
788
+ for (let ki = 0; ki < k.length - 1; ki++) {
789
+ const key = k[ki];
790
+ if (typeof key === 'number') {
791
+ obj = obj[key];
792
+ } else {
793
+ if (!obj[key]) obj[key] = {};
794
+ obj = obj[key];
795
+ }
796
+ }
797
+ const lastKey = k[k.length - 1];
798
+ obj[lastKey] = v;
799
+ }
800
+ } catch {
801
+ // Skip malformed lines
802
+ }
803
+ }
804
+
805
+ return sessionJson;
806
+ }
807
+
808
+ async _resolveVsCodeWorkspacePath(workspaceHashDir) {
809
+ try {
810
+ const workspaceJsonPath = path.join(workspaceHashDir, 'workspace.json');
811
+ const raw = await fs.readFile(workspaceJsonPath, 'utf-8');
812
+ const meta = JSON.parse(raw);
813
+
814
+ if (meta.folder) {
815
+ // Single-folder workspace: file:///path/to/project
816
+ return decodeURIComponent(meta.folder.replace('file://', ''));
817
+ }
818
+
819
+ if (meta.workspace) {
820
+ // Multi-folder workspace: points to another .json with folders array
821
+ const wsFilePath = decodeURIComponent(meta.workspace.replace('file://', ''));
822
+ try {
823
+ const wsRaw = await fs.readFile(wsFilePath, 'utf-8');
824
+ const ws = JSON.parse(wsRaw);
825
+ if (Array.isArray(ws.folders) && ws.folders.length > 0) {
826
+ // Return first folder path
827
+ const wsDir = path.dirname(wsFilePath);
828
+ const resolved = path.resolve(wsDir, ws.folders[0].path);
829
+ return resolved;
830
+ }
831
+ } catch {
832
+ // Ignore nested read errors
833
+ }
834
+ }
835
+ } catch {
836
+ // No workspace.json or unreadable
837
+ }
838
+ return null;
839
+ }
840
+
841
+ async _scanVsCodeWorkspaceDir(workspaceHashDir) {
842
+ const chatSessionsDir = path.join(workspaceHashDir, 'chatSessions');
843
+ try {
844
+ await fs.access(chatSessionsDir);
845
+ } catch {
846
+ return []; // No chatSessions subfolder — skip silently
847
+ }
848
+
849
+ // Resolve the real project path from workspace.json
850
+ const realWorkspacePath = await this._resolveVsCodeWorkspacePath(workspaceHashDir);
851
+
852
+ const entries = await fs.readdir(chatSessionsDir);
853
+ const jsonFiles = entries.filter(e => (e.endsWith('.json') || e.endsWith('.jsonl')) && !shouldSkipEntry(e));
854
+ if (jsonFiles.length === 0) return [];
855
+
856
+ const sessions = [];
857
+ for (const file of jsonFiles) {
858
+ const fullPath = path.join(chatSessionsDir, file);
859
+ try {
860
+ const stats = await fs.stat(fullPath);
861
+ const raw = await fs.readFile(fullPath, 'utf-8');
862
+ // Support both .json (flat) and .jsonl (incremental patch: kind=0 + kind=2 patches)
863
+ let sessionJson;
864
+ if (file.endsWith('.jsonl')) {
865
+ sessionJson = this._parseVsCodeJsonl(raw);
866
+ if (!sessionJson) continue;
867
+ } else {
868
+ sessionJson = JSON.parse(raw);
869
+ }
870
+
871
+ const sessionId = sessionJson.sessionId || path.basename(file).replace(/\.jsonl?$/, '');
872
+ const requests = sessionJson.requests || [];
873
+ if (requests.length === 0) continue;
874
+
875
+ const firstReq = requests[0];
876
+ const lastReq = requests[requests.length - 1];
877
+ const createdAt = sessionJson.creationDate
878
+ ? new Date(sessionJson.creationDate)
879
+ : (firstReq.timestamp ? new Date(firstReq.timestamp) : stats.birthtime);
880
+ const updatedAt = sessionJson.lastMessageDate
881
+ ? new Date(sessionJson.lastMessageDate)
882
+ : (lastReq.timestamp ? new Date(lastReq.timestamp) : stats.mtime);
883
+
884
+ // Count tool invocations across all requests
885
+ let toolCount = 0;
886
+ for (const req of requests) {
887
+ toolCount += (req.response || []).filter(r => r.kind === 'toolInvocationSerialized').length;
888
+ }
889
+
890
+ const model = firstReq.modelId || null;
891
+ const agentId = firstReq.agent?.id || 'vscode-copilot';
892
+ const userText = this._extractVsCodeUserText(firstReq.message);
893
+
894
+ const session = new Session(
895
+ sessionId,
896
+ 'file',
897
+ {
898
+ source: 'vscode',
899
+ filePath: fullPath,
900
+ createdAt,
901
+ updatedAt,
902
+ summary: userText ? userText.slice(0, 120) : `VSCode chat (${requests.length} requests)`,
903
+ hasEvents: true,
904
+ eventCount: requests.reduce((s, r) => s + (r.response || []).length, 0) + requests.length * 2 + 1,
905
+ duration: updatedAt - createdAt,
906
+ sessionStatus: 'completed',
907
+ model,
908
+ agentId,
909
+ toolCount,
910
+ workspace: { cwd: realWorkspacePath || workspaceHashDir },
911
+ }
912
+ );
913
+
914
+ sessions.push(session);
915
+ } catch (err) {
916
+ // Skip malformed files silently
917
+ }
918
+ }
919
+ return sessions;
920
+ }
921
+
922
+ /** Extract plain text from a VSCode message object */
923
+ _extractVsCodeUserText(message) {
924
+ if (!message) return '';
925
+ if (typeof message.text === 'string') return message.text;
926
+ if (Array.isArray(message.parts)) {
927
+ return message.parts.filter(p => p.kind === 'text').map(p => p.text || '').join('');
928
+ }
929
+ return '';
930
+ }
931
+
660
932
  /**
661
933
  * Read first line of a file
662
934
  * @private
@@ -21,13 +21,13 @@ class SessionService {
21
21
  this.eventNormalizer = new EventNormalizer();
22
22
  }
23
23
 
24
- async getAllSessions() {
25
- const sessions = await this.sessionRepository.findAll();
24
+ async getAllSessions(sourceFilter = null) {
25
+ const sessions = await this.sessionRepository.findAll(sourceFilter);
26
26
  return sessions.map(s => s.toJSON());
27
27
  }
28
28
 
29
- async getPaginatedSessions(page = 1, limit = 20) {
30
- const allSessions = await this.sessionRepository.findAll();
29
+ async getPaginatedSessions(page = 1, limit = 20, sourceFilter = null) {
30
+ const allSessions = await this.sessionRepository.findAll(sourceFilter);
31
31
  const sessions = allSessions.map(s => s.toJSON());
32
32
 
33
33
  const startIndex = (page - 1) * limit;
@@ -151,6 +151,27 @@ class SessionService {
151
151
  console.error('Error searching Pi-Mono sessions:', err);
152
152
  return [];
153
153
  }
154
+ } else if (session.source === 'vscode') {
155
+ // VSCode format: read JSON file directly, convert to event array via VsCodeParser
156
+ const { VsCodeParser } = require('../../lib/parsers');
157
+ const vscodeParser = new VsCodeParser();
158
+ try {
159
+ const raw = await fs.promises.readFile(session.filePath, 'utf-8');
160
+ let sessionJson;
161
+ if (session.filePath.endsWith('.jsonl')) {
162
+ sessionJson = this.sessionRepository._parseVsCodeJsonl(raw);
163
+ } else {
164
+ sessionJson = JSON.parse(raw);
165
+ }
166
+ if (!sessionJson) return [];
167
+ const parsed = vscodeParser.parseVsCode(sessionJson);
168
+ // Convert tool.invocation events → assistant.message with data.tools
169
+ // so frontend can render them using the same tool-list component
170
+ return this._expandVsCodeEvents(parsed.allEvents);
171
+ } catch (err) {
172
+ console.error('Error reading VSCode session:', err);
173
+ return [];
174
+ }
154
175
  }
155
176
 
156
177
 
@@ -1434,6 +1455,54 @@ class SessionService {
1434
1455
  /**
1435
1456
  * Expand Copilot format (user/assistant) to timeline format with turn_start/complete
1436
1457
  * @private
1458
+ /**
1459
+ * Convert VSCode tool.invocation events into assistant.message events with data.tools,
1460
+ * so they render using the same frontend tool-list component.
1461
+ * Groups consecutive tool.invocation events under a single assistant.message when possible.
1462
+ */
1463
+ _expandVsCodeEvents(events) {
1464
+ const result = [];
1465
+ let pendingTools = [];
1466
+ let pendingParentId = null;
1467
+ let pendingTs = null;
1468
+ let pendingIdx = 0;
1469
+
1470
+ const flushTools = () => {
1471
+ if (pendingTools.length === 0) return;
1472
+ result.push({
1473
+ type: 'assistant.message',
1474
+ id: `vscode-tools-${pendingIdx}`,
1475
+ timestamp: pendingTs,
1476
+ parentId: pendingParentId,
1477
+ data: {
1478
+ message: '',
1479
+ content: '',
1480
+ tools: pendingTools,
1481
+ },
1482
+ _synthetic: true,
1483
+ });
1484
+ pendingTools = [];
1485
+ };
1486
+
1487
+ for (let i = 0; i < events.length; i++) {
1488
+ const ev = events[i];
1489
+ if (ev.type === 'tool.invocation') {
1490
+ if (pendingTools.length === 0) {
1491
+ pendingParentId = ev.parentId;
1492
+ pendingTs = ev.timestamp;
1493
+ pendingIdx = i;
1494
+ }
1495
+ if (ev.data?.tool) pendingTools.push(ev.data.tool);
1496
+ } else {
1497
+ flushTools();
1498
+ result.push(ev);
1499
+ }
1500
+ }
1501
+ flushTools();
1502
+ return result;
1503
+ }
1504
+
1505
+ /**
1437
1506
  * @param {Array} events - Normalized Copilot events
1438
1507
  * @returns {Array} Expanded events with turn_start/complete
1439
1508
  */
package/views/index.ejs CHANGED
@@ -308,6 +308,15 @@
308
308
  font-weight: 600;
309
309
  font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
310
310
  }
311
+ .status-badge.source-vscode {
312
+ padding: 2px 8px;
313
+ background: rgba(0, 122, 204, 0.15);
314
+ color: #4fc3f7;
315
+ border-radius: 12px;
316
+ font-size: 11px;
317
+ font-weight: 600;
318
+ font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
319
+ }
311
320
 
312
321
  /* Filter pills */
313
322
  .filter-pills {
@@ -474,6 +483,7 @@
474
483
  <button class="filter-pill active" data-source="copilot">Copilot</button>
475
484
  <button class="filter-pill" data-source="claude">Claude</button>
476
485
  <button class="filter-pill" data-source="pi-mono">Pi</button>
486
+ <!-- <button class="filter-pill" data-source="vscode">VSCode</button> -->
477
487
  </div>
478
488
  <input
479
489
  type="file"
@@ -485,10 +495,7 @@
485
495
  <div id="importStatus" class="import-status"></div>
486
496
  <div id="sessions-container"></div>
487
497
 
488
- <!-- Load more button and loading indicator -->
489
- <div id="load-more-section" style="text-align: center; margin-top: 20px; display: none;">
490
- <button id="load-more-btn" class="load-more-btn">Load More Sessions</button>
491
- </div>
498
+ <!-- Loading indicator for infinite scroll -->
492
499
  <div id="loading-indicator" style="text-align: center; margin-top: 20px; display: none;">
493
500
  <div class="loading-spinner">Loading more sessions...</div>
494
501
  </div>
@@ -507,57 +514,56 @@
507
514
  const totalSessionsFromServer = metaData.totalSessions;
508
515
  const hasMoreFromServer = metaData.hasMore;
509
516
 
510
- // Infinite scroll state
517
+ // Infinite scroll state — per-source
511
518
  let allSessions = [...initialSessions];
512
- let currentOffset = initialSessions.length;
513
- let hasMore = hasMoreFromServer;
519
+ // Per-source pagination state
520
+ const sourceState = {};
521
+ // Initialize from initial server load (copilot is default active pill)
522
+ sourceState['copilot'] = { offset: initialSessions.length, hasMore: hasMoreFromServer };
523
+
514
524
  let isLoading = false;
515
525
 
516
526
  // Filter state
517
527
  let currentSourceFilter = 'copilot';
518
- // Load more sessions
528
+
529
+ function currentState() {
530
+ if (!sourceState[currentSourceFilter]) {
531
+ sourceState[currentSourceFilter] = { offset: 0, hasMore: true };
532
+ }
533
+ return sourceState[currentSourceFilter];
534
+ }
535
+
536
+ // Load more sessions for current source
519
537
  async function loadMoreSessions() {
520
- if (isLoading || !hasMore) return;
538
+ const state = currentState();
539
+ if (isLoading || !state.hasMore) return;
521
540
 
522
541
  isLoading = true;
523
- const loadMoreBtn = document.getElementById('load-more-btn');
524
542
  const loadingIndicator = document.getElementById('loading-indicator');
525
- const loadMoreSection = document.getElementById('load-more-section');
526
-
527
- // Show loading, hide button
528
- loadMoreSection.style.display = 'none';
529
543
  loadingIndicator.style.display = 'block';
530
544
 
531
545
  try {
532
- const response = await fetch(`/api/sessions/load-more?offset=${currentOffset}&limit=20`);
533
- if (!response.ok) {
534
- throw new Error('Failed to load more sessions');
535
- }
546
+ const response = await fetch(`/api/sessions/load-more?offset=${currentState().offset}&limit=20&source=${encodeURIComponent(currentSourceFilter)}`);
547
+ if (!response.ok) throw new Error('Failed to load more sessions');
536
548
 
537
549
  const data = await response.json();
538
- allSessions.push(...data.sessions);
539
- currentOffset += data.sessions.length;
540
- hasMore = data.hasMore;
550
+ const existingIds = new Set(allSessions.map(s => s.id));
551
+ for (const s of data.sessions) {
552
+ if (!existingIds.has(s.id)) allSessions.push(s);
553
+ }
554
+ currentState().offset += data.sessions.length;
555
+ currentState().hasMore = data.hasMore;
541
556
 
542
- // Re-render all sessions
543
557
  renderAllSessions();
544
- updateLoadMoreButton();
545
-
546
558
  } catch (err) {
547
559
  console.error('Error loading more sessions:', err);
548
- // Show button again on error
549
- loadMoreSection.style.display = hasMore ? 'block' : 'none';
550
560
  } finally {
551
561
  isLoading = false;
552
562
  loadingIndicator.style.display = 'none';
553
563
  }
554
564
  }
555
565
 
556
- // Update load more button visibility
557
- function updateLoadMoreButton() {
558
- const loadMoreSection = document.getElementById('load-more-section');
559
- loadMoreSection.style.display = hasMore && !isLoading ? 'block' : 'none';
560
- }
566
+ // (load more button removed — pure infinite scroll)
561
567
 
562
568
  // Get filtered sessions based on current filter
563
569
  function getFilteredSessions() {
@@ -601,7 +607,7 @@
601
607
  const docHeight = document.documentElement.scrollHeight;
602
608
 
603
609
  // Load more when user is within 500px of bottom
604
- if (scrollTop + windowHeight >= docHeight - 500 && hasMore && !isLoading) {
610
+ if (scrollTop + windowHeight >= docHeight - 500 && currentState().hasMore && !isLoading) {
605
611
  loadMoreSessions();
606
612
  }
607
613
  }
@@ -847,16 +853,41 @@
847
853
  function setupFilterPills() {
848
854
  const filterPills = document.querySelectorAll('.filter-pill');
849
855
  filterPills.forEach(pill => {
850
- pill.addEventListener('click', () => {
851
- // Remove active class from all pills
856
+ pill.addEventListener('click', async () => {
852
857
  filterPills.forEach(p => p.classList.remove('active'));
853
- // Add active class to clicked pill
854
858
  pill.classList.add('active');
855
-
856
- // Update filter state
857
859
  currentSourceFilter = pill.getAttribute('data-source');
858
860
 
859
- // Re-render sessions with filter
861
+ // Init per-source state if first visit
862
+ if (!sourceState[currentSourceFilter]) {
863
+ sourceState[currentSourceFilter] = { offset: 0, hasMore: true };
864
+ }
865
+
866
+ // Always fetch first batch when switching to a new source (backend-filtered)
867
+ if (sourceState[currentSourceFilter].offset === 0 && !isLoading) {
868
+ isLoading = true;
869
+ // Show loading state immediately (clear old results)
870
+ const container = document.getElementById('sessions-container');
871
+ container.innerHTML = '<div style="text-align: center; color: #6e7681; padding: 40px; font-size: 14px;">⏳ Loading...</div>';
872
+ document.getElementById('loading-indicator').style.display = 'none';
873
+ try {
874
+ const resp = await fetch(`/api/sessions/load-more?offset=0&limit=20&source=${encodeURIComponent(currentSourceFilter)}`);
875
+ if (resp.ok) {
876
+ const data = await resp.json();
877
+ const existingIds = new Set(allSessions.map(s => s.id));
878
+ for (const s of (data.sessions || [])) {
879
+ if (!existingIds.has(s.id)) allSessions.push(s);
880
+ }
881
+ sourceState[currentSourceFilter].offset = (data.sessions || []).length;
882
+ sourceState[currentSourceFilter].hasMore = data.hasMore;
883
+ }
884
+ } catch (e) {
885
+ console.error('Failed to load sessions for source:', currentSourceFilter, e);
886
+ } finally {
887
+ isLoading = false;
888
+ }
889
+ }
890
+
860
891
  renderAllSessions();
861
892
  });
862
893
  });
@@ -864,16 +895,11 @@
864
895
 
865
896
  // Render grouped sessions
866
897
  document.addEventListener('DOMContentLoaded', function() {
867
- // Initial render
868
898
  renderAllSessions();
869
- updateLoadMoreButton();
870
899
 
871
- // Add scroll listener for infinite scroll
900
+ // Infinite scroll
872
901
  window.addEventListener('scroll', throttledScroll);
873
902
 
874
- // Add click listener for load more button
875
- document.getElementById('load-more-btn').addEventListener('click', loadMoreSessions);
876
-
877
903
  // Setup filter pills
878
904
  setupFilterPills();
879
905
  });
@@ -887,7 +913,6 @@
887
913
  margin-top: 32px;
888
914
  margin-bottom: 16px;
889
915
  padding-bottom: 8px;
890
- border-bottom: 2px solid #21262d;
891
916
  }
892
917
  .date-group-header:first-child {
893
918
  margin-top: 0;
@@ -242,6 +242,11 @@
242
242
  color: #a78bdb;
243
243
  border: 1px solid rgba(138, 102, 204, 0.4);
244
244
  }
245
+ .source-vscode {
246
+ background: rgba(0, 122, 204, 0.2);
247
+ color: #4fc3f7;
248
+ border: 1px solid rgba(0, 122, 204, 0.4);
249
+ }
245
250
 
246
251
  /* Turn buttons */
247
252
  .turn-buttons {
@@ -1318,6 +1323,15 @@
1318
1323
  }
1319
1324
  }
1320
1325
 
1326
+ // 6. Attribute tool.invocation events (VS Code format) via parentToolCallId
1327
+ for (const ev of sorted) {
1328
+ if (ev.type !== 'tool.invocation') continue;
1329
+ const ptcid = ev.data?.parentToolCallId;
1330
+ if (ptcid && subagentInfo.has(ptcid)) {
1331
+ ownerMap.set(ev.stableId, ptcid);
1332
+ }
1333
+ }
1334
+
1321
1335
  return { ownerMap, subagentInfo };
1322
1336
  });
1323
1337