@qiaolei81/copilot-session-viewer 0.2.6 → 0.2.7

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,31 @@ 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.2.6] - 2026-03-05
9
+
10
+ ### Fixed
11
+ - **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
12
+ - **Updated Time Inaccuracy** - Session detail page now shows last event timestamp as "Updated" time instead of file mtime
13
+ - **E2E CI Stability** - `#loading-indicator` is always in DOM regardless of session count; removed conditional session count check that caused false negatives
14
+
15
+ ## [0.2.5] - 2026-03-04
16
+
17
+ ### Fixed
18
+ - **Flaky Unit Tests** - Upload directory now isolated per test via `UPLOAD_DIR` env var to prevent cross-test pollution
19
+ - **Timing Variance in Tests** - `ageMs` threshold relaxed from `>= 0` to `>= -100` to tolerate clock precision on fast CI runners
20
+
21
+ ## [0.2.4] - 2026-03-04
22
+
23
+ ### Fixed
24
+ - **E2E Skip on Empty Environment** - Tests now skip gracefully when no sessions exist in CI (no `~/.copilot/session-state/` data)
25
+ - **VSCode Filter Pill** - Temporarily hidden in UI (feature in progress)
26
+
27
+ ## [0.2.3] - 2026-03-04
28
+
29
+ ### Fixed
30
+ - **Lint Errors** - Fixed unused variable warnings in `vscode-parser.js` (`canParse`, `agentName`, `itemIdx`) and `sessionRepository.js` (unused `VsCodeParser` import)
31
+ - **Stale Unit Tests** - Updated test expectations to match current API signatures (`getPaginatedSessions(1, 20, "copilot")`)
32
+
8
33
  ## [0.2.2] - 2026-02-27
9
34
 
10
35
  ### Fixed
@@ -190,6 +215,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
190
215
  - CORS restricted to localhost
191
216
  - File upload size limits (50MB)
192
217
 
218
+ [0.2.6]: https://github.com/qiaolei81/copilot-session-viewer/releases/tag/v0.2.6
219
+ [0.2.5]: https://github.com/qiaolei81/copilot-session-viewer/releases/tag/v0.2.5
220
+ [0.2.4]: https://github.com/qiaolei81/copilot-session-viewer/releases/tag/v0.2.4
221
+ [0.2.3]: https://github.com/qiaolei81/copilot-session-viewer/releases/tag/v0.2.3
193
222
  [0.1.7]: https://github.com/qiaolei81/copilot-session-viewer/releases/tag/v0.1.7
194
223
  [0.1.6]: https://github.com/qiaolei81/copilot-session-viewer/releases/tag/v0.1.6
195
224
  [0.1.3]: https://github.com/qiaolei81/copilot-session-viewer/releases/tag/v0.1.3
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.2.7",
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',
@@ -0,0 +1,105 @@
1
+ const TagService = require('../services/tagService');
2
+ const SessionRepository = require('../services/sessionRepository');
3
+ const { isValidSessionId } = require('../utils/helpers');
4
+
5
+ class TagController {
6
+ constructor(tagService = null, sessionRepository = null) {
7
+ this.tagService = tagService || new TagService();
8
+ this.sessionRepository = sessionRepository || new SessionRepository();
9
+ }
10
+
11
+ /**
12
+ * GET /api/tags
13
+ * Get all unique tags across all sessions (for autocomplete)
14
+ */
15
+ async getAllTags(req, res) {
16
+ try {
17
+ const tags = await this.tagService.getAllKnownTags();
18
+ res.json({ tags });
19
+ } catch (err) {
20
+ console.error('Error getting all tags:', err);
21
+ res.status(500).json({ error: 'Error loading tags' });
22
+ }
23
+ }
24
+
25
+ /**
26
+ * GET /api/sessions/:id/tags
27
+ * Get tags for a specific session
28
+ */
29
+ async getSessionTags(req, res) {
30
+ try {
31
+ const sessionId = req.params.id;
32
+
33
+ if (!isValidSessionId(sessionId)) {
34
+ return res.status(400).json({ error: 'Invalid session ID' });
35
+ }
36
+
37
+ // Find session by ID
38
+ const session = await this.sessionRepository.findById(sessionId);
39
+ if (!session) {
40
+ return res.status(404).json({ error: 'Session not found' });
41
+ }
42
+
43
+ const tags = await this.tagService.getSessionTags(session);
44
+ res.json({ tags });
45
+ } catch (err) {
46
+ console.error('Error getting session tags:', err);
47
+ res.status(500).json({ error: 'Error loading session tags' });
48
+ }
49
+ }
50
+
51
+ /**
52
+ * PUT /api/sessions/:id/tags
53
+ * Set tags for a specific session
54
+ * Body: { tags: ["tag1", "tag2"] }
55
+ */
56
+ async setSessionTags(req, res) {
57
+ try {
58
+ const sessionId = req.params.id;
59
+ const { tags } = req.body;
60
+
61
+ if (!isValidSessionId(sessionId)) {
62
+ return res.status(400).json({ error: 'Invalid session ID' });
63
+ }
64
+
65
+ if (!Array.isArray(tags)) {
66
+ return res.status(400).json({ error: 'Tags must be an array' });
67
+ }
68
+
69
+ // Validate tag count
70
+ if (tags.length > 10) {
71
+ return res.status(400).json({ error: 'Maximum 10 tags per session' });
72
+ }
73
+
74
+ // Validate tag length
75
+ for (const tag of tags) {
76
+ if (typeof tag !== 'string' || tag.trim().length === 0) {
77
+ return res.status(400).json({ error: 'Tags must be non-empty strings' });
78
+ }
79
+ if (tag.length > 30) {
80
+ return res.status(400).json({ error: 'Tag length must not exceed 30 characters' });
81
+ }
82
+ }
83
+
84
+ // Find session by ID
85
+ const session = await this.sessionRepository.findById(sessionId);
86
+ if (!session) {
87
+ return res.status(404).json({ error: 'Session not found' });
88
+ }
89
+
90
+ const savedTags = await this.tagService.setSessionTags(session, tags);
91
+ res.json({ tags: savedTags });
92
+ } catch (err) {
93
+ console.error('Error setting session tags:', err);
94
+ if (err.message === 'Maximum 10 tags per session') {
95
+ return res.status(400).json({ error: err.message });
96
+ }
97
+ if (err.message === 'Session must have a directory field') {
98
+ return res.status(400).json({ error: 'Session does not support tagging' });
99
+ }
100
+ res.status(500).json({ error: 'Error saving session tags' });
101
+ }
102
+ }
103
+ }
104
+
105
+ module.exports = TagController;
@@ -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/views/index.ejs CHANGED
@@ -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;
@@ -548,12 +586,19 @@
548
586
 
549
587
  const data = await response.json();
550
588
  const existingIds = new Set(allSessions.map(s => s.id));
589
+ const newSessions = [];
551
590
  for (const s of data.sessions) {
552
- if (!existingIds.has(s.id)) allSessions.push(s);
591
+ if (!existingIds.has(s.id)) {
592
+ allSessions.push(s);
593
+ newSessions.push(s);
594
+ }
553
595
  }
554
596
  currentState().offset += data.sessions.length;
555
597
  currentState().hasMore = data.hasMore;
556
598
 
599
+ // Load tags for new sessions
600
+ await attachTagsToSessions(newSessions);
601
+
557
602
  renderAllSessions();
558
603
  } catch (err) {
559
604
  console.error('Error loading more sessions:', err);
@@ -819,11 +864,24 @@
819
864
 
820
865
  const wipClass = session.sessionStatus === 'wip' ? ' recent-item-wip' : '';
821
866
 
867
+ // Render tags
868
+ let tagsHtml = '';
869
+ if (session.tags && session.tags.length > 0) {
870
+ const tagsItems = session.tags.map(tag => {
871
+ const color = getTagColor(tag);
872
+ return `<span class="session-tag" style="background-color: ${color}" title="${escapeHtml(tag)}">${escapeHtml(tag)}</span>`;
873
+ }).join('');
874
+ tagsHtml = `<div class="session-tags">${tagsItems}</div>`;
875
+ }
876
+
822
877
  return `
823
878
  <a href="/session/${session.id}" class="recent-item${wipClass}">
824
879
  <div class="session-id">
825
880
  <span class="session-id-text" title="${escapeHtml(session.id)}">${escapeHtml(session.id)}</span>
881
+ </div>
882
+ <div class="session-badges-tags">
826
883
  <div class="session-badges">${badges}</div>
884
+ ${tagsHtml}
827
885
  </div>
828
886
  ${summaryHtml}
829
887
  <div class="session-divider"></div>
@@ -849,6 +907,56 @@
849
907
  return div.innerHTML;
850
908
  }
851
909
 
910
+ // Tag colors (same as session-vue.ejs)
911
+ const tagColors = [
912
+ '#3b82f6', // blue
913
+ '#10b981', // green
914
+ '#f59e0b', // amber
915
+ '#ef4444', // red
916
+ '#8b5cf6', // purple
917
+ '#ec4899', // pink
918
+ '#06b6d4', // cyan
919
+ '#f97316' // orange
920
+ ];
921
+
922
+ function getTagColor(tag) {
923
+ let hash = 0;
924
+ for (let i = 0; i < tag.length; i++) {
925
+ hash = tag.charCodeAt(i) + ((hash << 5) - hash);
926
+ }
927
+ return tagColors[Math.abs(hash) % tagColors.length];
928
+ }
929
+
930
+ // Load tags for sessions
931
+ async function loadSessionTags(sessionIds) {
932
+ try {
933
+ const tagPromises = sessionIds.map(id =>
934
+ fetch(`/api/sessions/${id}/tags`)
935
+ .then(r => r.ok ? r.json() : { tags: [] })
936
+ .then(data => ({ id, tags: data.tags || [] }))
937
+ .catch(() => ({ id, tags: [] }))
938
+ );
939
+ const results = await Promise.all(tagPromises);
940
+ const tagsMap = {};
941
+ results.forEach(({ id, tags }) => {
942
+ tagsMap[id] = tags;
943
+ });
944
+ return tagsMap;
945
+ } catch (err) {
946
+ console.error('Error loading session tags:', err);
947
+ return {};
948
+ }
949
+ }
950
+
951
+ // Attach tags to sessions
952
+ async function attachTagsToSessions(sessions) {
953
+ const sessionIds = sessions.map(s => s.id);
954
+ const tagsMap = await loadSessionTags(sessionIds);
955
+ sessions.forEach(session => {
956
+ session.tags = tagsMap[session.id] || [];
957
+ });
958
+ }
959
+
852
960
  // Filter pill click handler
853
961
  function setupFilterPills() {
854
962
  const filterPills = document.querySelectorAll('.filter-pill');
@@ -875,11 +983,18 @@
875
983
  if (resp.ok) {
876
984
  const data = await resp.json();
877
985
  const existingIds = new Set(allSessions.map(s => s.id));
986
+ const newSessions = [];
878
987
  for (const s of (data.sessions || [])) {
879
- if (!existingIds.has(s.id)) allSessions.push(s);
988
+ if (!existingIds.has(s.id)) {
989
+ allSessions.push(s);
990
+ newSessions.push(s);
991
+ }
880
992
  }
881
993
  sourceState[currentSourceFilter].offset = (data.sessions || []).length;
882
994
  sourceState[currentSourceFilter].hasMore = data.hasMore;
995
+
996
+ // Load tags for new sessions
997
+ await attachTagsToSessions(newSessions);
883
998
  }
884
999
  } catch (e) {
885
1000
  console.error('Failed to load sessions for source:', currentSourceFilter, e);
@@ -894,7 +1009,10 @@
894
1009
  }
895
1010
 
896
1011
  // Render grouped sessions
897
- document.addEventListener('DOMContentLoaded', function() {
1012
+ document.addEventListener('DOMContentLoaded', async function() {
1013
+ // Load tags for initial sessions
1014
+ await attachTagsToSessions(allSessions);
1015
+
898
1016
  renderAllSessions();
899
1017
 
900
1018
  // Infinite scroll
@@ -330,7 +330,149 @@
330
330
  background: #161b22;
331
331
  color: #6e7681;
332
332
  }
333
-
333
+
334
+ /* Session Tags */
335
+ .session-tags-container {
336
+ margin-top: 16px;
337
+ }
338
+ .tags-display {
339
+ display: flex;
340
+ flex-wrap: wrap;
341
+ gap: 6px;
342
+ min-height: 28px;
343
+ align-items: flex-start;
344
+ }
345
+ .tag-label {
346
+ display: inline-flex;
347
+ align-items: center;
348
+ gap: 4px;
349
+ padding: 4px 10px;
350
+ border-radius: 12px;
351
+ font-size: 12px;
352
+ font-weight: 500;
353
+ color: #fff;
354
+ cursor: default;
355
+ transition: opacity 0.2s;
356
+ }
357
+ .tag-label:hover {
358
+ opacity: 0.8;
359
+ }
360
+ .tag-remove {
361
+ background: none;
362
+ border: none;
363
+ color: rgba(255, 255, 255, 0.7);
364
+ cursor: pointer;
365
+ font-size: 14px;
366
+ line-height: 1;
367
+ padding: 0;
368
+ margin-left: 2px;
369
+ transition: color 0.2s;
370
+ }
371
+ .tag-remove:hover {
372
+ color: #fff;
373
+ }
374
+ .tags-edit-btn {
375
+ background: none;
376
+ border: 1px solid #30363d;
377
+ border-radius: 4px;
378
+ color: #8b949e;
379
+ cursor: pointer;
380
+ padding: 4px 8px;
381
+ font-size: 12px;
382
+ transition: all 0.2s;
383
+ display: inline-flex;
384
+ align-items: center;
385
+ gap: 4px;
386
+ }
387
+ .tags-edit-btn:hover {
388
+ border-color: #58a6ff;
389
+ color: #58a6ff;
390
+ }
391
+ .tags-dropdown {
392
+ position: relative;
393
+ margin-top: 8px;
394
+ }
395
+ .tags-input-container {
396
+ display: flex;
397
+ flex-wrap: wrap;
398
+ gap: 6px;
399
+ padding: 8px;
400
+ background: #161b22;
401
+ border: 1px solid #30363d;
402
+ border-radius: 6px;
403
+ min-height: 38px;
404
+ }
405
+ .tags-input-container:focus-within {
406
+ border-color: #58a6ff;
407
+ }
408
+ .tag-input-chip {
409
+ display: inline-flex;
410
+ align-items: center;
411
+ gap: 4px;
412
+ padding: 4px 8px;
413
+ border-radius: 12px;
414
+ font-size: 12px;
415
+ font-weight: 500;
416
+ color: #fff;
417
+ }
418
+ .tag-input-chip button {
419
+ background: none;
420
+ border: none;
421
+ color: rgba(255, 255, 255, 0.7);
422
+ cursor: pointer;
423
+ font-size: 12px;
424
+ padding: 0;
425
+ margin-left: 2px;
426
+ }
427
+ .tag-input-chip button:hover {
428
+ color: #fff;
429
+ }
430
+ .tags-text-input {
431
+ flex: 1;
432
+ min-width: 120px;
433
+ background: transparent;
434
+ border: none;
435
+ outline: none;
436
+ color: #c9d1d9;
437
+ font-size: 13px;
438
+ padding: 4px;
439
+ }
440
+ .tags-text-input::placeholder {
441
+ color: #6e7681;
442
+ }
443
+ .tags-autocomplete {
444
+ position: absolute;
445
+ top: 100%;
446
+ left: 0;
447
+ right: 0;
448
+ background: #161b22;
449
+ border: 1px solid #30363d;
450
+ border-radius: 6px;
451
+ margin-top: 4px;
452
+ max-height: 200px;
453
+ overflow-y: auto;
454
+ z-index: 100;
455
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
456
+ }
457
+ .tags-autocomplete-item {
458
+ padding: 8px 12px;
459
+ font-size: 13px;
460
+ color: #c9d1d9;
461
+ cursor: pointer;
462
+ transition: background 0.2s;
463
+ }
464
+ .tags-autocomplete-item:hover {
465
+ background: rgba(88, 166, 255, 0.15);
466
+ }
467
+ .tags-autocomplete-item.selected {
468
+ background: rgba(88, 166, 255, 0.25);
469
+ }
470
+ .tags-error {
471
+ margin-top: 6px;
472
+ font-size: 12px;
473
+ color: #f85149;
474
+ }
475
+
334
476
  /* Content */
335
477
  .content {
336
478
  flex: 1;
@@ -2041,7 +2183,190 @@
2041
2183
  markdownCache.clear();
2042
2184
  });
2043
2185
  });
2044
-
2186
+
2187
+ // Session Tags
2188
+ const sessionTags = ref([]);
2189
+ const allTags = ref([]);
2190
+ const tagsEditing = ref(false);
2191
+ const editingTags = ref([]);
2192
+ const tagInputValue = ref('');
2193
+ const tagInputRef = ref(null);
2194
+ const tagsError = ref('');
2195
+ const showAutocomplete = ref(false);
2196
+ const autocompleteOptions = ref([]);
2197
+ const autocompleteSelectedIndex = ref(0);
2198
+
2199
+ // Tag colors (6 colors cycling based on hash)
2200
+ const tagColors = [
2201
+ '#3b82f6', // blue
2202
+ '#10b981', // green
2203
+ '#f59e0b', // amber
2204
+ '#ef4444', // red
2205
+ '#8b5cf6', // purple
2206
+ '#ec4899', // pink
2207
+ '#06b6d4', // cyan
2208
+ '#f97316' // orange
2209
+ ];
2210
+
2211
+ const getTagColor = (tag) => {
2212
+ let hash = 0;
2213
+ for (let i = 0; i < tag.length; i++) {
2214
+ hash = tag.charCodeAt(i) + ((hash << 5) - hash);
2215
+ }
2216
+ return tagColors[Math.abs(hash) % tagColors.length];
2217
+ };
2218
+
2219
+ const loadTags = async () => {
2220
+ try {
2221
+ const response = await fetch(`/api/sessions/${sessionId.value}/tags`);
2222
+ if (response.ok) {
2223
+ const data = await response.json();
2224
+ sessionTags.value = data.tags || [];
2225
+ }
2226
+ } catch (err) {
2227
+ console.error('Error loading tags:', err);
2228
+ }
2229
+ };
2230
+
2231
+ const loadAllTags = async () => {
2232
+ try {
2233
+ const response = await fetch('/api/tags');
2234
+ if (response.ok) {
2235
+ const data = await response.json();
2236
+ allTags.value = data.tags || [];
2237
+ }
2238
+ } catch (err) {
2239
+ console.error('Error loading all tags:', err);
2240
+ }
2241
+ };
2242
+
2243
+ const saveTags = async (tags) => {
2244
+ try {
2245
+ const response = await fetch(`/api/sessions/${sessionId.value}/tags`, {
2246
+ method: 'PUT',
2247
+ headers: { 'Content-Type': 'application/json' },
2248
+ body: JSON.stringify({ tags })
2249
+ });
2250
+ if (response.ok) {
2251
+ const data = await response.json();
2252
+ sessionTags.value = data.tags || [];
2253
+ tagsError.value = '';
2254
+ return true;
2255
+ } else {
2256
+ const error = await response.json();
2257
+ tagsError.value = error.error || 'Failed to save tags';
2258
+ return false;
2259
+ }
2260
+ } catch (err) {
2261
+ console.error('Error saving tags:', err);
2262
+ tagsError.value = 'Network error';
2263
+ return false;
2264
+ }
2265
+ };
2266
+
2267
+ const startEditTags = () => {
2268
+ editingTags.value = [...sessionTags.value];
2269
+ tagsEditing.value = true;
2270
+ tagsError.value = '';
2271
+ setTimeout(() => {
2272
+ if (tagInputRef.value) {
2273
+ tagInputRef.value.focus();
2274
+ }
2275
+ }, 10);
2276
+ };
2277
+
2278
+ const cancelEditTags = () => {
2279
+ tagsEditing.value = false;
2280
+ editingTags.value = [];
2281
+ tagInputValue.value = '';
2282
+ showAutocomplete.value = false;
2283
+ tagsError.value = '';
2284
+ };
2285
+
2286
+ const addTag = () => {
2287
+ const tag = tagInputValue.value.trim().toLowerCase();
2288
+ if (!tag) return;
2289
+
2290
+ if (tag.length > 30) {
2291
+ tagsError.value = 'Tag must be 30 characters or less';
2292
+ return;
2293
+ }
2294
+
2295
+ if (editingTags.value.length >= 10) {
2296
+ tagsError.value = 'Maximum 10 tags per session';
2297
+ return;
2298
+ }
2299
+
2300
+ if (editingTags.value.includes(tag)) {
2301
+ tagsError.value = 'Tag already added';
2302
+ tagInputValue.value = '';
2303
+ return;
2304
+ }
2305
+
2306
+ editingTags.value.push(tag);
2307
+ tagInputValue.value = '';
2308
+ showAutocomplete.value = false;
2309
+ tagsError.value = '';
2310
+ };
2311
+
2312
+ const removeTagFromEdit = (tag) => {
2313
+ editingTags.value = editingTags.value.filter(t => t !== tag);
2314
+ tagsError.value = '';
2315
+ };
2316
+
2317
+ const updateAutocomplete = () => {
2318
+ const input = tagInputValue.value.trim().toLowerCase();
2319
+ if (!input) {
2320
+ showAutocomplete.value = false;
2321
+ autocompleteOptions.value = [];
2322
+ return;
2323
+ }
2324
+
2325
+ const filtered = allTags.value
2326
+ .filter(tag =>
2327
+ tag.toLowerCase().includes(input) &&
2328
+ !editingTags.value.includes(tag)
2329
+ )
2330
+ .slice(0, 5);
2331
+
2332
+ if (filtered.length > 0) {
2333
+ showAutocomplete.value = true;
2334
+ autocompleteOptions.value = filtered;
2335
+ autocompleteSelectedIndex.value = 0;
2336
+ } else {
2337
+ showAutocomplete.value = false;
2338
+ autocompleteOptions.value = [];
2339
+ }
2340
+ };
2341
+
2342
+ const selectAutocompleteOption = (option) => {
2343
+ tagInputValue.value = option;
2344
+ addTag();
2345
+ };
2346
+
2347
+ const saveTagsOnBlur = async () => {
2348
+ // Small delay to allow click events on autocomplete
2349
+ setTimeout(async () => {
2350
+ if (!tagsEditing.value) return;
2351
+
2352
+ const success = await saveTags(editingTags.value);
2353
+ if (success) {
2354
+ tagsEditing.value = false;
2355
+ editingTags.value = [];
2356
+ tagInputValue.value = '';
2357
+ showAutocomplete.value = false;
2358
+ // Reload all tags for autocomplete
2359
+ await loadAllTags();
2360
+ }
2361
+ }, 200);
2362
+ };
2363
+
2364
+ // Load tags on mount
2365
+ onMounted(async () => {
2366
+ await loadTags();
2367
+ await loadAllTags();
2368
+ });
2369
+
2045
2370
  return {
2046
2371
  sessionId,
2047
2372
  metadata,
@@ -2087,7 +2412,27 @@
2087
2412
  jumpToTurn,
2088
2413
  getTurnNumber,
2089
2414
  escapeHtml,
2090
- exportSession
2415
+ exportSession,
2416
+ searchResultCount,
2417
+ // Tags
2418
+ sessionTags,
2419
+ allTags,
2420
+ tagsEditing,
2421
+ editingTags,
2422
+ tagInputValue,
2423
+ tagInputRef,
2424
+ tagsError,
2425
+ showAutocomplete,
2426
+ autocompleteOptions,
2427
+ autocompleteSelectedIndex,
2428
+ getTagColor,
2429
+ startEditTags,
2430
+ cancelEditTags,
2431
+ addTag,
2432
+ removeTagFromEdit,
2433
+ updateAutocomplete,
2434
+ selectAutocompleteOption,
2435
+ saveTagsOnBlur
2091
2436
  };
2092
2437
  },
2093
2438
 
@@ -2170,8 +2515,62 @@
2170
2515
  </button>
2171
2516
  </div>
2172
2517
  </div>
2518
+
2519
+ <!-- Session Tags -->
2520
+ <div class="sidebar-section session-tags-container">
2521
+ <div class="sidebar-section-title">Tags</div>
2522
+ <div v-if="!tagsEditing" class="tags-display">
2523
+ <span
2524
+ v-for="tag in sessionTags"
2525
+ :key="tag"
2526
+ class="tag-label"
2527
+ :style="{ backgroundColor: getTagColor(tag) }"
2528
+ >
2529
+ {{ tag }}
2530
+ </span>
2531
+ <button class="tags-edit-btn" @click="startEditTags" title="Edit tags">
2532
+ ✏️
2533
+ </button>
2534
+ </div>
2535
+ <div v-else class="tags-dropdown">
2536
+ <div class="tags-input-container">
2537
+ <span
2538
+ v-for="tag in editingTags"
2539
+ :key="tag"
2540
+ class="tag-input-chip"
2541
+ :style="{ backgroundColor: getTagColor(tag) }"
2542
+ >
2543
+ {{ tag }}
2544
+ <button @click="removeTagFromEdit(tag)" title="Remove tag">×</button>
2545
+ </span>
2546
+ <input
2547
+ ref="tagInputRef"
2548
+ v-model="tagInputValue"
2549
+ @keydown.enter.prevent="addTag"
2550
+ @keydown.escape="cancelEditTags"
2551
+ @blur="saveTagsOnBlur"
2552
+ @input="updateAutocomplete"
2553
+ class="tags-text-input"
2554
+ placeholder="Type tag name..."
2555
+ maxlength="30"
2556
+ />
2557
+ </div>
2558
+ <div v-if="showAutocomplete && autocompleteOptions.length > 0" class="tags-autocomplete">
2559
+ <div
2560
+ v-for="(option, index) in autocompleteOptions"
2561
+ :key="option"
2562
+ :class="['tags-autocomplete-item', { selected: index === autocompleteSelectedIndex }]"
2563
+ @click="selectAutocompleteOption(option)"
2564
+ @mouseenter="autocompleteSelectedIndex = index"
2565
+ >
2566
+ {{ option }}
2567
+ </div>
2568
+ </div>
2569
+ <div v-if="tagsError" class="tags-error">{{ tagsError }}</div>
2570
+ </div>
2571
+ </div>
2173
2572
  </div>
2174
-
2573
+
2175
2574
  <div class="content">
2176
2575
  <div class="scroll-indicator">
2177
2576
  <div class="content-toolbar-left">