@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.
@@ -0,0 +1,195 @@
1
+ const fs = require('fs').promises;
2
+ const path = require('path');
3
+ const os = require('os');
4
+
5
+ /**
6
+ * Service for managing session tags
7
+ * - Per-session tags: stored in {session.directory}/tags.json as ["tag1", "tag2"]
8
+ * - Global known tags: stored in ~/.session-viewer/known-tags.json as ["tag1", "tag2", ...]
9
+ */
10
+ class TagService {
11
+ constructor() {
12
+ this.knownTagsDir = path.join(os.homedir(), '.session-viewer');
13
+ this.knownTagsFilePath = path.join(this.knownTagsDir, 'known-tags.json');
14
+ }
15
+
16
+ /**
17
+ * Ensure known-tags directory and file exist
18
+ */
19
+ async ensureKnownTagsFile() {
20
+ try {
21
+ await fs.access(this.knownTagsFilePath);
22
+ } catch (err) {
23
+ // File doesn't exist, create directory and empty array
24
+ await fs.mkdir(this.knownTagsDir, { recursive: true });
25
+ await fs.writeFile(this.knownTagsFilePath, JSON.stringify([]), 'utf8');
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Read known tags from global file
31
+ * @returns {Promise<string[]>} Array of known tags
32
+ */
33
+ async readKnownTagsFile() {
34
+ await this.ensureKnownTagsFile();
35
+ try {
36
+ const content = await fs.readFile(this.knownTagsFilePath, 'utf8');
37
+ return JSON.parse(content);
38
+ } catch (err) {
39
+ console.error('Error reading known tags file:', err);
40
+ return [];
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Write known tags to global file
46
+ * @param {string[]} tags - Array of known tags
47
+ */
48
+ async writeKnownTagsFile(tags) {
49
+ await this.ensureKnownTagsFile();
50
+ await fs.writeFile(this.knownTagsFilePath, JSON.stringify(tags, null, 2), 'utf8');
51
+ }
52
+
53
+ /**
54
+ * Get tags file path for a session
55
+ * @param {Session} session - Session object with directory field
56
+ * @returns {string} Path to tags.json
57
+ */
58
+ getSessionTagsFilePath(session) {
59
+ if (!session.directory) {
60
+ throw new Error('Session must have a directory field');
61
+ }
62
+ return path.join(session.directory, 'tags.json');
63
+ }
64
+
65
+ /**
66
+ * Normalize tag name (lowercase, trim, max 30 chars)
67
+ * @param {string} tag - Tag name
68
+ * @returns {string} Normalized tag
69
+ */
70
+ normalizeTag(tag) {
71
+ return tag.trim().toLowerCase().substring(0, 30);
72
+ }
73
+
74
+ /**
75
+ * Get all known tags for autocomplete
76
+ * @returns {Promise<string[]>} Array of unique tags
77
+ */
78
+ async getAllKnownTags() {
79
+ const tags = await this.readKnownTagsFile();
80
+ return tags.sort();
81
+ }
82
+
83
+ /**
84
+ * Get tags for a specific session
85
+ * @param {Session} session - Session object with directory field
86
+ * @returns {Promise<string[]>} Array of tags
87
+ */
88
+ async getSessionTags(session) {
89
+ const tagsFilePath = this.getSessionTagsFilePath(session);
90
+
91
+ try {
92
+ await fs.access(tagsFilePath);
93
+ const content = await fs.readFile(tagsFilePath, 'utf8');
94
+ return JSON.parse(content);
95
+ } catch (err) {
96
+ // File doesn't exist or can't be read, return empty array
97
+ return [];
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Set tags for a specific session
103
+ * @param {Session} session - Session object with directory field
104
+ * @param {string[]} tags - Array of tag names
105
+ * @returns {Promise<string[]>} Normalized and saved tags
106
+ */
107
+ async setSessionTags(session, tags) {
108
+ if (!Array.isArray(tags)) {
109
+ throw new Error('Tags must be an array');
110
+ }
111
+
112
+ // Normalize tags (lowercase, trim, max 30 chars)
113
+ const normalizedTags = tags
114
+ .map(tag => this.normalizeTag(tag))
115
+ .filter(tag => tag.length > 0)
116
+ .filter((tag, index, self) => self.indexOf(tag) === index); // Remove duplicates
117
+
118
+ // Enforce max 10 tags per session
119
+ if (normalizedTags.length > 10) {
120
+ throw new Error('Maximum 10 tags per session');
121
+ }
122
+
123
+ const tagsFilePath = this.getSessionTagsFilePath(session);
124
+
125
+ if (normalizedTags.length === 0) {
126
+ // Remove tags file if no tags
127
+ try {
128
+ await fs.unlink(tagsFilePath);
129
+ } catch (err) {
130
+ // File doesn't exist, ignore
131
+ }
132
+ } else {
133
+ // Write tags to session directory
134
+ await fs.writeFile(tagsFilePath, JSON.stringify(normalizedTags, null, 2), 'utf8');
135
+
136
+ // Update known tags (append and deduplicate)
137
+ await this.updateKnownTags(normalizedTags);
138
+ }
139
+
140
+ return normalizedTags;
141
+ }
142
+
143
+ /**
144
+ * Update known tags by appending new tags and deduplicating
145
+ * @param {string[]} newTags - New tags to add to known tags
146
+ */
147
+ async updateKnownTags(newTags) {
148
+ const knownTags = await this.readKnownTagsFile();
149
+ const allTags = [...knownTags, ...newTags];
150
+ const uniqueTags = [...new Set(allTags)];
151
+ await this.writeKnownTagsFile(uniqueTags);
152
+ }
153
+
154
+ /**
155
+ * Add tags to a session (merge with existing)
156
+ * @param {Session} session - Session object with directory field
157
+ * @param {string[]} newTags - Tags to add
158
+ * @returns {Promise<string[]>} Updated tags array
159
+ */
160
+ async addSessionTags(session, newTags) {
161
+ const existingTags = await this.getSessionTags(session);
162
+ const mergedTags = [...existingTags, ...newTags];
163
+ return await this.setSessionTags(session, mergedTags);
164
+ }
165
+
166
+ /**
167
+ * Remove tags from a session
168
+ * @param {Session} session - Session object with directory field
169
+ * @param {string[]} tagsToRemove - Tags to remove
170
+ * @returns {Promise<string[]>} Updated tags array
171
+ */
172
+ async removeSessionTags(session, tagsToRemove) {
173
+ const existingTags = await this.getSessionTags(session);
174
+ const normalizedToRemove = tagsToRemove.map(tag => this.normalizeTag(tag));
175
+ const updatedTags = existingTags.filter(tag => !normalizedToRemove.includes(tag));
176
+ return await this.setSessionTags(session, updatedTags);
177
+ }
178
+
179
+ /**
180
+ * Get tags for multiple sessions (batch)
181
+ * @param {Session[]} sessions - Array of session objects
182
+ * @returns {Promise<Object>} Map of sessionId -> tags array
183
+ */
184
+ async getMultipleSessionTags(sessions) {
185
+ const result = {};
186
+
187
+ for (const session of sessions) {
188
+ result[session.id] = await this.getSessionTags(session);
189
+ }
190
+
191
+ return result;
192
+ }
193
+ }
194
+
195
+ module.exports = TagService;
@@ -8,11 +8,14 @@
8
8
  * @returns {Object} Metadata object
9
9
  */
10
10
  function buildMetadata(session) {
11
+ const json = session.toJSON ? session.toJSON() : {};
11
12
  return {
12
13
  type: session.type,
13
14
  source: session.source, // 'copilot' or 'claude'
15
+ sourceName: json.sourceName || session.source,
16
+ sourceBadgeClass: json.sourceBadgeClass || 'source-unknown',
14
17
  summary: session.summary,
15
- model: session.model,
18
+ model: session.selectedModel || session.model,
16
19
  repo: session.workspace?.repository,
17
20
  branch: session.workspace?.branch,
18
21
  cwd: session.workspace?.cwd,
package/views/index.ejs CHANGED
@@ -3,7 +3,7 @@
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>Copilot Session Viewer</title>
6
+ <title>Session Viewer</title>
7
7
  <style>
8
8
  * { margin: 0; padding: 0; box-sizing: border-box; }
9
9
  body {
@@ -161,7 +161,6 @@
161
161
  align-items: center;
162
162
  gap: 4px;
163
163
  flex-shrink: 0;
164
- margin-left: 12px;
165
164
  }
166
165
  .session-summary {
167
166
  color: #e6edf3;
@@ -216,7 +215,46 @@
216
215
  .session-info-item.workspace .session-info-value {
217
216
  font-weight: 500;
218
217
  }
219
-
218
+
219
+ /* Session tags */
220
+ .session-badges-tags {
221
+ display: flex;
222
+ flex-wrap: wrap;
223
+ align-items: center;
224
+ justify-content: flex-start;
225
+ gap: 4px;
226
+ margin-top: 4px;
227
+ }
228
+ .session-badges-tags .session-badges {
229
+ display: flex;
230
+ flex-wrap: wrap;
231
+ gap: 4px;
232
+ }
233
+ .session-badges-tags .session-tags {
234
+ display: flex;
235
+ flex-wrap: wrap;
236
+ gap: 4px;
237
+ margin-top: 0;
238
+ padding-top: 0;
239
+ border-top: none;
240
+ }
241
+ .session-tags {
242
+ display: flex;
243
+ flex-wrap: wrap;
244
+ gap: 4px;
245
+ margin-top: 8px;
246
+ padding-top: 8px;
247
+ border-top: 1px solid #21262d;
248
+ }
249
+ .session-tag {
250
+ display: inline-block;
251
+ padding: 3px 8px;
252
+ border-radius: 10px;
253
+ font-size: 11px;
254
+ font-weight: 500;
255
+ color: #fff;
256
+ }
257
+
220
258
  .status-badge {
221
259
  display: inline-block;
222
260
  font-size: 14px;
@@ -451,7 +489,7 @@
451
489
  <body>
452
490
  <div class="container">
453
491
  <h1>🤖 Session Viewer</h1>
454
- <p class="subtitle">View session logs from Copilot, Claude Code, and Pi-Mono</p>
492
+ <p class="subtitle">View session logs from Copilot CLI, Copilot Chat, Claude Code, and Pi-Mono</p>
455
493
 
456
494
  <form id="sessionForm">
457
495
  <div class="input-group">
@@ -467,10 +505,6 @@
467
505
  </div>
468
506
  </form>
469
507
 
470
- <p class="hint">
471
- Session ID can be found in <span class="hint-code">~/.copilot/session-state/</span>
472
- </p>
473
-
474
508
  <% if (sessions && sessions.length > 0) { %>
475
509
  <div class="recent-sessions">
476
510
  <div class="sessions-header">
@@ -480,11 +514,12 @@
480
514
  <a class="import-link" id="importLink">Import session from zip</a>
481
515
  </div>
482
516
  <div class="filter-pills">
483
- <button class="filter-pill active" data-source="copilot">Copilot</button>
517
+ <button class="filter-pill active" data-source="copilot">Copilot CLI</button>
518
+ <button class="filter-pill" data-source="vscode">Copilot Chat</button>
484
519
  <button class="filter-pill" data-source="claude">Claude</button>
485
520
  <button class="filter-pill" data-source="pi-mono">Pi</button>
486
- <!-- <button class="filter-pill" data-source="vscode">VSCode</button> -->
487
521
  </div>
522
+ <p class="hint source-hint" id="sourceHint"></p>
488
523
  <input
489
524
  type="file"
490
525
  id="fileInput"
@@ -548,12 +583,19 @@
548
583
 
549
584
  const data = await response.json();
550
585
  const existingIds = new Set(allSessions.map(s => s.id));
586
+ const newSessions = [];
551
587
  for (const s of data.sessions) {
552
- if (!existingIds.has(s.id)) allSessions.push(s);
588
+ if (!existingIds.has(s.id)) {
589
+ allSessions.push(s);
590
+ newSessions.push(s);
591
+ }
553
592
  }
554
593
  currentState().offset += data.sessions.length;
555
594
  currentState().hasMore = data.hasMore;
556
595
 
596
+ // Load tags for new sessions
597
+ await attachTagsToSessions(newSessions);
598
+
557
599
  renderAllSessions();
558
600
  } catch (err) {
559
601
  console.error('Error loading more sessions:', err);
@@ -758,7 +800,7 @@
758
800
  // Add source badge (use backend-provided metadata - Violation #3 & #5 fix)
759
801
  const sourceClass = session.sourceBadgeClass || 'source-copilot';
760
802
  const sourceLabel = session.sourceName || 'Copilot';
761
- badges += `<span class="status-badge ${sourceClass}" title="${sourceLabel} CLI">${sourceLabel}</span>`;
803
+ badges += `<span class="status-badge ${sourceClass}" title="${sourceLabel}">${sourceLabel}</span>`;
762
804
 
763
805
  if (session.sessionStatus === 'wip') {
764
806
  badges += '<span class="status-badge wip" title="Session in progress">🔄 WIP</span>';
@@ -767,7 +809,7 @@
767
809
  badges += '<span class="status-badge imported" title="Imported session">📥</span>';
768
810
  }
769
811
  if (session.hasInsight) {
770
- badges += '<span class="status-badge insight" title="Has Copilot Insight">💡</span>';
812
+ badges += '<span class="status-badge insight" title="Has Agent Review">💡</span>';
771
813
  }
772
814
  // Add model and version badges
773
815
  if (session.selectedModel) {
@@ -783,7 +825,7 @@
783
825
  badges += `<span class="status-badge model ${modelClass}" title="Model: ${escapeHtml(session.selectedModel)}">${escapeHtml(modelShort)}</span>`;
784
826
  }
785
827
  if (session.copilotVersion) {
786
- badges += `<span class="status-badge version" title="Copilot CLI version">${escapeHtml(session.copilotVersion)}</span>`;
828
+ badges += `<span class="status-badge version" title="CLI version">${escapeHtml(session.copilotVersion)}</span>`;
787
829
  }
788
830
 
789
831
  let summaryHtml = '';
@@ -798,7 +840,7 @@
798
840
  workspaceHtml = `
799
841
  <div class="session-info-item workspace" title="${escapeHtml(session.workspace.cwd)}">
800
842
  <svg viewBox="0 0 16 16" fill="currentColor"><path d="M1.75 1A1.75 1.75 0 000 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0016 13.25v-8.5A1.75 1.75 0 0014.25 3H7.5a.25.25 0 01-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75z"></path></svg>
801
- <span class="session-info-value">${escapeHtml(session.workspace.cwd)}</span>
843
+ <span class="session-info-value">${escapeHtml(session.workspace.cwd.replace(/^\/Users\/[^/]+/, '~'))}</span>
802
844
  </div>
803
845
  `;
804
846
  }
@@ -819,11 +861,24 @@
819
861
 
820
862
  const wipClass = session.sessionStatus === 'wip' ? ' recent-item-wip' : '';
821
863
 
864
+ // Render tags
865
+ let tagsHtml = '';
866
+ if (session.tags && session.tags.length > 0) {
867
+ const tagsItems = session.tags.map(tag => {
868
+ const color = getTagColor(tag);
869
+ return `<span class="session-tag" style="background-color: ${color}" title="${escapeHtml(tag)}">${escapeHtml(tag)}</span>`;
870
+ }).join('');
871
+ tagsHtml = `<div class="session-tags">${tagsItems}</div>`;
872
+ }
873
+
822
874
  return `
823
875
  <a href="/session/${session.id}" class="recent-item${wipClass}">
824
876
  <div class="session-id">
825
877
  <span class="session-id-text" title="${escapeHtml(session.id)}">${escapeHtml(session.id)}</span>
878
+ </div>
879
+ <div class="session-badges-tags">
826
880
  <div class="session-badges">${badges}</div>
881
+ ${tagsHtml}
827
882
  </div>
828
883
  ${summaryHtml}
829
884
  <div class="session-divider"></div>
@@ -849,14 +904,79 @@
849
904
  return div.innerHTML;
850
905
  }
851
906
 
907
+ // Tag colors (same as session-vue.ejs)
908
+ const tagColors = [
909
+ '#3b82f6', // blue
910
+ '#10b981', // green
911
+ '#f59e0b', // amber
912
+ '#ef4444', // red
913
+ '#8b5cf6', // purple
914
+ '#ec4899', // pink
915
+ '#06b6d4', // cyan
916
+ '#f97316' // orange
917
+ ];
918
+
919
+ function getTagColor(tag) {
920
+ let hash = 0;
921
+ for (let i = 0; i < tag.length; i++) {
922
+ hash = tag.charCodeAt(i) + ((hash << 5) - hash);
923
+ }
924
+ return tagColors[Math.abs(hash) % tagColors.length];
925
+ }
926
+
927
+ // Load tags for sessions
928
+ async function loadSessionTags(sessionIds) {
929
+ try {
930
+ const tagPromises = sessionIds.map(id =>
931
+ fetch(`/api/sessions/${id}/tags`)
932
+ .then(r => r.ok ? r.json() : { tags: [] })
933
+ .then(data => ({ id, tags: data.tags || [] }))
934
+ .catch(() => ({ id, tags: [] }))
935
+ );
936
+ const results = await Promise.all(tagPromises);
937
+ const tagsMap = {};
938
+ results.forEach(({ id, tags }) => {
939
+ tagsMap[id] = tags;
940
+ });
941
+ return tagsMap;
942
+ } catch (err) {
943
+ console.error('Error loading session tags:', err);
944
+ return {};
945
+ }
946
+ }
947
+
948
+ // Attach tags to sessions
949
+ async function attachTagsToSessions(sessions) {
950
+ const sessionIds = sessions.map(s => s.id);
951
+ const tagsMap = await loadSessionTags(sessionIds);
952
+ sessions.forEach(session => {
953
+ session.tags = tagsMap[session.id] || [];
954
+ });
955
+ }
956
+
957
+ // Source directory hints (from server, platform-aware)
958
+ const sourceHints = <%- sourceHints || '{}' %>;
959
+
960
+ function updateSourceHint(source) {
961
+ const hint = document.getElementById('sourceHint');
962
+ if (hint && sourceHints[source]) {
963
+ hint.innerHTML = 'Sessions from <span class="hint-code">' + sourceHints[source] + '</span>';
964
+ } else if (hint) {
965
+ hint.textContent = '';
966
+ }
967
+ }
968
+
852
969
  // Filter pill click handler
853
970
  function setupFilterPills() {
854
971
  const filterPills = document.querySelectorAll('.filter-pill');
972
+ // Show hint for initial active pill
973
+ updateSourceHint(currentSourceFilter);
855
974
  filterPills.forEach(pill => {
856
975
  pill.addEventListener('click', async () => {
857
976
  filterPills.forEach(p => p.classList.remove('active'));
858
977
  pill.classList.add('active');
859
978
  currentSourceFilter = pill.getAttribute('data-source');
979
+ updateSourceHint(currentSourceFilter);
860
980
 
861
981
  // Init per-source state if first visit
862
982
  if (!sourceState[currentSourceFilter]) {
@@ -875,11 +995,18 @@
875
995
  if (resp.ok) {
876
996
  const data = await resp.json();
877
997
  const existingIds = new Set(allSessions.map(s => s.id));
998
+ const newSessions = [];
878
999
  for (const s of (data.sessions || [])) {
879
- if (!existingIds.has(s.id)) allSessions.push(s);
1000
+ if (!existingIds.has(s.id)) {
1001
+ allSessions.push(s);
1002
+ newSessions.push(s);
1003
+ }
880
1004
  }
881
1005
  sourceState[currentSourceFilter].offset = (data.sessions || []).length;
882
1006
  sourceState[currentSourceFilter].hasMore = data.hasMore;
1007
+
1008
+ // Load tags for new sessions
1009
+ await attachTagsToSessions(newSessions);
883
1010
  }
884
1011
  } catch (e) {
885
1012
  console.error('Failed to load sessions for source:', currentSourceFilter, e);
@@ -894,7 +1021,10 @@
894
1021
  }
895
1022
 
896
1023
  // Render grouped sessions
897
- document.addEventListener('DOMContentLoaded', function() {
1024
+ document.addEventListener('DOMContentLoaded', async function() {
1025
+ // Load tags for initial sessions
1026
+ await attachTagsToSessions(allSessions);
1027
+
898
1028
  renderAllSessions();
899
1029
 
900
1030
  // Infinite scroll