@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 +68 -0
- package/lib/parsers/index.js +2 -2
- package/lib/parsers/vscode-parser.js +272 -100
- package/package.json +1 -1
- package/src/app.js +7 -0
- package/src/controllers/sessionController.js +19 -1
- package/src/controllers/tagController.js +105 -0
- package/src/models/Session.js +2 -2
- package/src/services/insightService.js +6 -0
- package/src/services/sessionRepository.js +77 -28
- package/src/services/sessionService.js +180 -21
- package/src/services/tagService.js +195 -0
- package/src/utils/helpers.js +4 -1
- package/views/index.ejs +147 -17
- package/views/session-vue.ejs +473 -17
- package/views/time-analyze.ejs +296 -19
|
@@ -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;
|
package/src/utils/helpers.js
CHANGED
|
@@ -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>
|
|
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))
|
|
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}
|
|
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
|
|
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="
|
|
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))
|
|
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
|