@qiaolei81/copilot-session-viewer 0.2.6 → 0.3.0

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,70 @@ 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.0] - 2026-03-07
9
+
10
+ ### Added
11
+ - **VSCode Copilot Chat Support** - Full support for VSCode Copilot Chat sessions as a new source (`vscode`)
12
+ - Session cards show model badge + repo basename
13
+ - Copilot Chat extension version badge
14
+ - WIP badge for active sessions
15
+ - SubAgent name badges on assistant messages (replaces generic ASSISTANT badge)
16
+ - UserReq rows in Gantt timeline
17
+ - Turn divider shows start time + duration
18
+ - **Dynamic Source Path Hints** - Clicking filter pills shows the source directory path (cross-platform)
19
+ - **Multi-Tool Branding** - Homepage wording updated to reflect multi-tool support (Copilot CLI, Claude Code, Pi-Mono, Copilot Chat)
20
+
21
+ ### Changed
22
+ - **Source Display Names** - `copilot` → "Copilot CLI", `vscode` → "Copilot Chat"
23
+ - **Session Info Layout** - Shows Model + Repo basename instead of CWD hash path
24
+ - **Tool Input Display** - URI objects simplified to filename only; edits collapsed to count
25
+ - **Tool Call Width** - Truncation increased to 200 chars with flex-wrap
26
+ - **SubAgent Badge** - Shows name only (no emoji prefix)
27
+ - **System Messages** - System-sourced `user.message` converted to `system.notification` type with SYSTEM badge
28
+
29
+ ### Fixed
30
+ - Claude `tool_result` user messages filtered from display
31
+ - VSCode parser: `resultDetails` iteration crash, request timestamps for subagent events
32
+ - VSCode subagent dedup by `subAgentId` instead of name
33
+ - VSCode `findById` uses `selectedModel` + `resolveWorkspacePath`
34
+ - VSCode duration estimation fix
35
+ - Session detail repo dedup, skill table width
36
+ - SESSION INFO duplicate Model/label
37
+ - SubAgent badge uses `agentName` from `toolSpecificData`
38
+ - Source path hints from server (cross-platform Windows/macOS/Linux)
39
+
40
+ ## [0.2.7] - 2026-03-05
41
+
42
+ ### Added
43
+ - **Session Tagging** - Add, remove, and filter sessions by custom tags from the session list and detail pages
44
+ - **Unit Tests for Tagging** - 70 new tests covering `tagService` and `tagController` (608 total)
45
+ - **E2E Tests for Tagging** - Playwright tests covering tagging API and UI flows
46
+
47
+ ## [0.2.6] - 2026-03-05
48
+
49
+ ### Fixed
50
+ - **304 Caching on Live Sessions** - Disabled ETag on events API (`Cache-Control: no-store`) so active/WIP sessions always return fresh data on page refresh
51
+ - **Updated Time Inaccuracy** - Session detail page now shows last event timestamp as "Updated" time instead of file mtime
52
+ - **E2E CI Stability** - `#loading-indicator` is always in DOM regardless of session count; removed conditional session count check that caused false negatives
53
+
54
+ ## [0.2.5] - 2026-03-04
55
+
56
+ ### Fixed
57
+ - **Flaky Unit Tests** - Upload directory now isolated per test via `UPLOAD_DIR` env var to prevent cross-test pollution
58
+ - **Timing Variance in Tests** - `ageMs` threshold relaxed from `>= 0` to `>= -100` to tolerate clock precision on fast CI runners
59
+
60
+ ## [0.2.4] - 2026-03-04
61
+
62
+ ### Fixed
63
+ - **E2E Skip on Empty Environment** - Tests now skip gracefully when no sessions exist in CI (no `~/.copilot/session-state/` data)
64
+ - **VSCode Filter Pill** - Temporarily hidden in UI (feature in progress)
65
+
66
+ ## [0.2.3] - 2026-03-04
67
+
68
+ ### Fixed
69
+ - **Lint Errors** - Fixed unused variable warnings in `vscode-parser.js` (`canParse`, `agentName`, `itemIdx`) and `sessionRepository.js` (unused `VsCodeParser` import)
70
+ - **Stale Unit Tests** - Updated test expectations to match current API signatures (`getPaginatedSessions(1, 20, "copilot")`)
71
+
8
72
  ## [0.2.2] - 2026-02-27
9
73
 
10
74
  ### Fixed
@@ -190,6 +254,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
190
254
  - CORS restricted to localhost
191
255
  - File upload size limits (50MB)
192
256
 
257
+ [0.2.6]: https://github.com/qiaolei81/copilot-session-viewer/releases/tag/v0.2.6
258
+ [0.2.5]: https://github.com/qiaolei81/copilot-session-viewer/releases/tag/v0.2.5
259
+ [0.2.4]: https://github.com/qiaolei81/copilot-session-viewer/releases/tag/v0.2.4
260
+ [0.2.3]: https://github.com/qiaolei81/copilot-session-viewer/releases/tag/v0.2.3
193
261
  [0.1.7]: https://github.com/qiaolei81/copilot-session-viewer/releases/tag/v0.1.7
194
262
  [0.1.6]: https://github.com/qiaolei81/copilot-session-viewer/releases/tag/v0.1.6
195
263
  [0.1.3]: https://github.com/qiaolei81/copilot-session-viewer/releases/tag/v0.1.3
@@ -2,7 +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'); // TODO: VSCode parser disabled
5
+ const VsCodeParser = require('./vscode-parser');
6
6
  const ParserFactory = require('./parser-factory');
7
7
 
8
8
  module.exports = {
@@ -10,6 +10,6 @@ module.exports = {
10
10
  CopilotSessionParser,
11
11
  ClaudeSessionParser,
12
12
  PiMonoParser,
13
- // VsCodeParser, // TODO: VSCode parser disabled
13
+ VsCodeParser,
14
14
  ParserFactory
15
15
  };
@@ -11,14 +11,26 @@ const BaseSessionParser = require('./base-parser');
11
11
  */
12
12
  class VsCodeParser extends BaseSessionParser {
13
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.
14
+ * VSCode sessions can be either:
15
+ * 1. Old format: Plain JSON object with `requests` array
16
+ * 2. New format: JSONL with ObjectMutationLog entries (kind: 0|1|2|3)
17
+ *
18
+ * For JSONL format, the first line has kind=0 with v.sessionId
18
19
  */
19
- canParse(_events) {
20
- // VSCode sessions are JSON objects, not event arrays.
21
- // SessionRepository calls parseVsCode() directly; canParse is unused here.
20
+ canParse(lines) {
21
+ if (!Array.isArray(lines) || lines.length === 0) return false;
22
+
23
+ // Check if this is JSONL format (array of parsed lines)
24
+ const firstLine = lines[0];
25
+
26
+ // New JSONL format: first line has kind=0 and v.sessionId
27
+ if (firstLine && typeof firstLine === 'object' &&
28
+ firstLine.kind === 0 &&
29
+ firstLine.v &&
30
+ firstLine.v.sessionId) {
31
+ return true;
32
+ }
33
+
22
34
  return false;
23
35
  }
24
36
 
@@ -38,11 +50,172 @@ class VsCodeParser extends BaseSessionParser {
38
50
  };
39
51
  }
40
52
 
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 []; }
53
+ /**
54
+ * Parse JSONL format (ObjectMutationLog) - new VS Code format
55
+ * @param {Array} lines - Array of parsed JSON objects from JSONL file
56
+ * @returns {Object} Normalised session data
57
+ */
58
+ parseJsonl(lines) {
59
+ // Replay mutations to reconstruct the session state
60
+ const sessionState = this.replayMutations(lines);
61
+
62
+ // Use existing methods to convert to events
63
+ const metadata = this._getMetadata(sessionState);
64
+ const events = this._toEvents(sessionState);
65
+
66
+ return {
67
+ metadata,
68
+ turns: this._extractTurns(events),
69
+ toolCalls: this._extractToolCalls(events),
70
+ allEvents: events,
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Replay ObjectMutationLog entries to reconstruct session state
76
+ * @param {Array} lines - Array of mutation entries { kind, k, v, i }
77
+ * @returns {Object} Reconstructed session state
78
+ */
79
+ replayMutations(lines) {
80
+ let state = null;
81
+
82
+ for (const entry of lines) {
83
+ if (!entry || typeof entry !== 'object') continue;
84
+
85
+ const { kind, k, v, i } = entry;
86
+
87
+ switch (kind) {
88
+ case 0: // Initial - set entire state
89
+ state = v;
90
+ break;
91
+
92
+ case 1: // Set - update property at path
93
+ if (k && Array.isArray(k)) {
94
+ this._applySet(state, k, v);
95
+ }
96
+ break;
97
+
98
+ case 2: // Push - append to array (with optional truncate)
99
+ if (k && Array.isArray(k)) {
100
+ this._applyPush(state, k, v, i);
101
+ }
102
+ break;
103
+
104
+ case 3: // Delete - remove property at path
105
+ if (k && Array.isArray(k)) {
106
+ this._applyDelete(state, k);
107
+ }
108
+ break;
109
+
110
+ default:
111
+ console.warn(`[VsCodeParser] Unknown mutation kind: ${kind}`);
112
+ }
113
+ }
114
+
115
+ return state;
116
+ }
117
+
118
+ /**
119
+ * Apply Set mutation: set value at path k in state
120
+ * @private
121
+ */
122
+ _applySet(state, path, value) {
123
+ if (!state || path.length === 0) return;
124
+
125
+ let current = state;
126
+ for (let i = 0; i < path.length - 1; i++) {
127
+ const key = path[i];
128
+ if (!current[key]) {
129
+ // Create intermediate object or array based on next key type
130
+ current[key] = typeof path[i + 1] === 'number' ? [] : {};
131
+ }
132
+ current = current[key];
133
+ }
134
+
135
+ const lastKey = path[path.length - 1];
136
+ current[lastKey] = value;
137
+ }
138
+
139
+ /**
140
+ * Apply Push mutation: append items to array at path k
141
+ * If i is set, truncate array to index i first
142
+ * @private
143
+ */
144
+ _applyPush(state, path, values, startIndex) {
145
+ if (!state || path.length === 0) return;
146
+
147
+ let current = state;
148
+ for (let i = 0; i < path.length - 1; i++) {
149
+ const key = path[i];
150
+ if (!current[key]) {
151
+ current[key] = typeof path[i + 1] === 'number' ? [] : {};
152
+ }
153
+ current = current[key];
154
+ }
155
+
156
+ const lastKey = path[path.length - 1];
157
+ if (!current[lastKey]) {
158
+ current[lastKey] = [];
159
+ }
160
+
161
+ const arr = current[lastKey];
162
+ if (!Array.isArray(arr)) {
163
+ console.warn(`[VsCodeParser] Push target is not an array: ${path.join('.')}`);
164
+ return;
165
+ }
166
+
167
+ // Truncate if startIndex is specified
168
+ if (startIndex !== undefined && startIndex !== null) {
169
+ arr.length = startIndex;
170
+ }
171
+
172
+ // Append new values
173
+ if (values && Array.isArray(values) && values.length > 0) {
174
+ arr.push(...values);
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Apply Delete mutation: remove property at path k
180
+ * @private
181
+ */
182
+ _applyDelete(state, path) {
183
+ if (!state || path.length === 0) return;
184
+
185
+ let current = state;
186
+ for (let i = 0; i < path.length - 1; i++) {
187
+ const key = path[i];
188
+ if (!current[key]) return; // Path doesn't exist
189
+ current = current[key];
190
+ }
191
+
192
+ const lastKey = path[path.length - 1];
193
+ delete current[lastKey];
194
+ }
195
+
196
+ // ---- required abstract methods (for ParserFactory interface) ----
197
+ parse(events) {
198
+ // For JSONL format, use parseJsonl
199
+ if (Array.isArray(events) && events.length > 0 && this.canParse(events)) {
200
+ return this.parseJsonl(events);
201
+ }
202
+ return null;
203
+ }
204
+
205
+ getMetadata(events) {
206
+ const parsed = this.parse(events);
207
+ return parsed ? parsed.metadata : null;
208
+ }
209
+
210
+ extractTurns(events) {
211
+ const parsed = this.parse(events);
212
+ return parsed ? parsed.turns : [];
213
+ }
214
+
215
+ extractToolCalls(events) {
216
+ const parsed = this.parse(events);
217
+ return parsed ? parsed.toolCalls : [];
218
+ }
46
219
 
47
220
  // ---- private helpers ----
48
221
 
@@ -98,42 +271,16 @@ class VsCodeParser extends BaseSessionParser {
98
271
  const completedAt = req.modelState?.completedAt
99
272
  ? new Date(req.modelState.completedAt).toISOString()
100
273
  : 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;
274
+
104
275
 
105
276
  // Build subAgentInvocationId → agent name map from this request's response items
106
- const responseItems = req.response || [];
277
+ const responseItems = Array.isArray(req.response) ? req.response : [];
107
278
  const subAgentNames = this._buildSubAgentNameMap(responseItems);
108
279
 
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();
280
+ // Note: VS Code JSONL does not record per-subagent timestamps.
281
+ // All subagents in a request span the same wall-clock window (they run in parallel).
282
+ // Use request start as START and request completedAt as COMPLETE for all subagents,
283
+ // which is accurate for parallel dispatch and avoids interleaved/crossed timelines.
137
284
 
138
285
  // user.message
139
286
  const userText = this._extractUserText(req.message);
@@ -165,7 +312,7 @@ class VsCodeParser extends BaseSessionParser {
165
312
  assistantText = '';
166
313
  if (!trimmed) return;
167
314
  const sid = currentSubAgentId;
168
- const _agentName = sid ? (subAgentNames[sid] || sid.slice(0, 8)) : null;
315
+ const agentName = sid ? (subAgentNames[sid] || sid.slice(0, 8)) : null;
169
316
  events.push({
170
317
  type: 'assistant.message',
171
318
  id: `${req.requestId}-text-${itemIndex}`,
@@ -175,32 +322,9 @@ class VsCodeParser extends BaseSessionParser {
175
322
  message: trimmed,
176
323
  content: trimmed,
177
324
  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,
325
+ subAgentId: sid || null,
198
326
  subAgentName: agentName,
199
- agentName: agentName,
200
- agentDisplayName: agentName,
201
- toolCallId: sid,
202
- badgeLabel: agentName,
203
- badgeClass: 'badge-subagent',
327
+ parentToolCallId: null,
204
328
  },
205
329
  });
206
330
  };
@@ -230,9 +354,15 @@ class VsCodeParser extends BaseSessionParser {
230
354
 
231
355
  case 'toolInvocationSerialized': {
232
356
  flushText();
233
- // Emit subagent.start on first appearance of this subagent
234
- const sid = item.subAgentInvocationId;
235
- emitSubAgentStart(sid, itemIndex);
357
+ // runSubagent items: toolId='runSubagent', toolCallId=subagent-id, subAgentInvocationId=null
358
+ // Regular tool items: toolId=e.g. 'copilot_readFile', subAgentInvocationId=owning-subagent-id
359
+ const sid = item.toolId === 'runSubagent'
360
+ ? item.toolCallId // the subagent being launched
361
+ : item.subAgentInvocationId; // the subagent that launched this tool
362
+ if (item.toolId === 'runSubagent') {
363
+ // Mark current context as this subagent (for subsequent tool items)
364
+ currentSubAgentId = sid;
365
+ }
236
366
  if (sid) currentSubAgentId = sid;
237
367
  const tool = this._normalizeTool(item);
238
368
  if (tool) {
@@ -296,49 +426,44 @@ class VsCodeParser extends BaseSessionParser {
296
426
  }
297
427
 
298
428
  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
429
  }
318
430
 
319
431
  return events;
320
432
  }
321
433
 
322
434
  /**
323
- * Extract agent name from the first toolInvocationSerialized item for a given subAgentInvocationId.
324
- * Looks for .agent.md filename in invocationMessage or resultDetails URIs.
435
+ * Build a map of subagent id agent name from runSubagent tool invocations.
436
+ * runSubagent items: toolId='runSubagent', toolCallId=subagent-id
437
+ * Agent name is extracted from invocationMessage or resultDetails.
325
438
  */
326
439
  _buildSubAgentNameMap(items) {
327
440
  const nameMap = {};
328
441
  for (const item of items) {
329
442
  if (!item || typeof item !== 'object') continue;
330
- const sid = item.subAgentInvocationId;
331
- if (!sid || nameMap[sid]) continue;
332
443
  if (item.kind !== 'toolInvocationSerialized') continue;
444
+ if (item.toolId !== 'runSubagent') continue;
445
+
446
+ const sid = item.toolCallId;
447
+ if (!sid || nameMap[sid]) continue;
448
+
449
+ // Prefer agentName from toolSpecificData (e.g. "FoundationAgent")
450
+ const agentName = item.toolSpecificData?.agentName;
451
+ if (agentName) { nameMap[sid] = agentName; continue; }
333
452
 
334
- // Try invocationMessage text for agent file path
453
+ // Fallback: Use invocationMessage as agent display name
335
454
  const msgObj = item.invocationMessage;
336
- const msg = (msgObj && typeof msgObj === 'object') ? (msgObj.value || '') : '';
455
+ const msg = typeof msgObj === 'string' ? msgObj
456
+ : (msgObj && typeof msgObj === 'object') ? (msgObj.value || '') : '';
457
+ if (msg) { nameMap[sid] = msg; continue; }
458
+
459
+ // Fallback: try agent file path in message
337
460
  let m = msg.match(/agents\/([^/\]]+?)\.agent\.md/);
338
461
  if (m) { nameMap[sid] = m[1]; continue; }
339
462
 
340
463
  // Try resultDetails
341
- for (const rd of (item.resultDetails || [])) {
464
+ const resultDetails = item.resultDetails;
465
+ const rdList = Array.isArray(resultDetails) ? resultDetails : (resultDetails ? [resultDetails] : []);
466
+ for (const rd of rdList) {
342
467
  if (typeof rd !== 'object') continue;
343
468
  const fp = rd.fsPath || rd.path || '';
344
469
  m = fp.match(/agents\/([^/]+?)\.agent\.md/);
@@ -366,10 +491,57 @@ class VsCodeParser extends BaseSessionParser {
366
491
 
367
492
  // toolSpecificData may hold input/output depending on tool type
368
493
  const tsd = item.toolSpecificData || {};
369
- const input = tsd.input || tsd.parameters || tsd.request || {};
370
- const result = tsd.output || tsd.result || item.resultDetails || null;
494
+ let input = tsd.input || tsd.parameters || tsd.request || {};
495
+ let result = tsd.output || tsd.result || null;
371
496
  const isError = item.isConfirmed === false;
372
497
 
498
+ // vscode tools don't serialize input/result into toolSpecificData.
499
+ // Use invocationMessage as a human-readable description of what the tool did,
500
+ // and generatedTitle / resultDetails as the result summary.
501
+ if (!result && (item.generatedTitle || item.resultDetails)) {
502
+ // resultDetails is an array of URIs (e.g. files found/read)
503
+ if (item.resultDetails) {
504
+ const rdList = Array.isArray(item.resultDetails) ? item.resultDetails : [item.resultDetails];
505
+ const paths = rdList.map(rd => rd.fsPath || rd.path || rd.external || JSON.stringify(rd)).filter(Boolean);
506
+ result = paths.length > 0 ? paths.join('\n') : item.generatedTitle || null;
507
+ } else {
508
+ result = item.generatedTitle || null;
509
+ }
510
+ }
511
+ // Use invocationMessage (plain text) as input description if input is empty
512
+ if (Object.keys(input).length === 0 && item.invocationMessage) {
513
+ const msg = item.invocationMessage;
514
+ const msgText = typeof msg === 'string' ? msg
515
+ : (msg && typeof msg === 'object') ? (msg.value || '') : '';
516
+ if (msgText) input = { description: msgText };
517
+ }
518
+
519
+ // Simplify URI objects: replace {$mid, fsPath, external, path, scheme} with just the filename
520
+ const simplifyUri = (uri) => {
521
+ if (!uri || typeof uri !== 'object') return uri;
522
+ const p = uri.fsPath || uri.path || uri.external || '';
523
+ return p ? p.replace(/.*\//, '') : JSON.stringify(uri);
524
+ };
525
+ const simplifyInput = (obj) => {
526
+ if (!obj || typeof obj !== 'object') return obj;
527
+ const out = {};
528
+ for (const [k, v] of Object.entries(obj)) {
529
+ if (k === '$mid' || k === 'external' || k === 'scheme') continue; // skip internal URI fields
530
+ if (k === 'fsPath' || k === 'path') { out['file'] = v.replace(/.*\//, ''); continue; }
531
+ if (v && typeof v === 'object' && ('fsPath' in v || '$mid' in v)) {
532
+ out[k] = simplifyUri(v);
533
+ } else if (Array.isArray(v) && k === 'edits') {
534
+ out['edits'] = `${v.length} edit(s)`;
535
+ } else {
536
+ out[k] = v;
537
+ }
538
+ }
539
+ return out;
540
+ };
541
+ if (input && typeof input === 'object' && !input.description) {
542
+ input = simplifyInput(input);
543
+ }
544
+
373
545
  return {
374
546
  type: 'tool_use',
375
547
  id: item.toolCallId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qiaolei81/copilot-session-viewer",
3
- "version": "0.2.6",
3
+ "version": "0.3.0",
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/src/app.js CHANGED
@@ -15,6 +15,7 @@ const { requestTimeout, developmentCors, errorHandler, notFoundHandler } = requi
15
15
  const SessionController = require('./controllers/sessionController');
16
16
  const InsightController = require('./controllers/insightController');
17
17
  const UploadController = require('./controllers/uploadController');
18
+ const TagController = require('./controllers/tagController');
18
19
 
19
20
  function createApp(options = {}) {
20
21
  const app = express();
@@ -26,6 +27,7 @@ function createApp(options = {}) {
26
27
  const sessionController = new SessionController(options.sessionService);
27
28
  const insightController = new InsightController(options.insightService, options.sessionService);
28
29
  const uploadController = new UploadController();
30
+ const tagController = new TagController(options.tagService);
29
31
 
30
32
  // Minimal security headers for local development tool
31
33
  // Custom CSP without upgrade-insecure-requests
@@ -96,6 +98,11 @@ function createApp(options = {}) {
96
98
  app.get('/api/sessions/:id/events', sessionController.getSessionEvents.bind(sessionController));
97
99
  app.get('/api/sessions/:id/timeline', sessionController.getTimeline.bind(sessionController));
98
100
 
101
+ // Tag routes
102
+ app.get('/api/tags', tagController.getAllTags.bind(tagController));
103
+ app.get('/api/sessions/:id/tags', tagController.getSessionTags.bind(tagController));
104
+ app.put('/api/sessions/:id/tags', tagController.setSessionTags.bind(tagController));
105
+
99
106
  // Upload routes
100
107
  app.get('/session/:id/share', uploadController.shareSession.bind(uploadController));
101
108
  app.post('/session/import',
@@ -15,11 +15,29 @@ class SessionController {
15
15
  // Only load default pill (copilot) first 20 sessions
16
16
  const paginationData = await this.sessionService.getPaginatedSessions(1, 20, 'copilot');
17
17
 
18
+ // Build source path hints from repository config
19
+ const sourceHints = {};
20
+ if (this.sessionService.sessionRepository && this.sessionService.sessionRepository.sources) {
21
+ for (const src of this.sessionService.sessionRepository.sources) {
22
+ // Replace home dir with ~ for display
23
+ const home = require('os').homedir();
24
+ let displayPath = src.dir;
25
+ if (displayPath.startsWith(home)) {
26
+ displayPath = '~' + displayPath.slice(home.length);
27
+ }
28
+ // Normalize path separators for display
29
+ displayPath = displayPath.replace(/\\/g, '/');
30
+ if (!displayPath.endsWith('/')) displayPath += '/';
31
+ sourceHints[src.type] = displayPath;
32
+ }
33
+ }
34
+
18
35
  // Pass data for infinite scroll
19
36
  const templateData = {
20
37
  sessions: paginationData.sessions,
21
38
  hasMore: paginationData.hasNextPage,
22
- totalSessions: paginationData.totalSessions
39
+ totalSessions: paginationData.totalSessions,
40
+ sourceHints: JSON.stringify(sourceHints)
23
41
  };
24
42
 
25
43
  res.render('index', templateData);