@qiaolei81/copilot-session-viewer 0.2.5 → 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
@@ -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');
5
+ // const VsCodeParser = require('./vscode-parser'); // TODO: VSCode parser disabled
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,
13
+ // VsCodeParser, // TODO: VSCode parser disabled
14
14
  ParserFactory
15
15
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qiaolei81/copilot-session-viewer",
3
- "version": "0.2.5",
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,14 +15,19 @@ 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();
21
22
 
23
+ // Disable Express's automatic ETag generation (prevents 304 on live/active session files)
24
+ app.set('etag', false);
25
+
22
26
  // Create controller instances (with optional dependency injection)
23
27
  const sessionController = new SessionController(options.sessionService);
24
28
  const insightController = new InsightController(options.insightService, options.sessionService);
25
29
  const uploadController = new UploadController();
30
+ const tagController = new TagController(options.tagService);
26
31
 
27
32
  // Minimal security headers for local development tool
28
33
  // Custom CSP without upgrade-insecure-requests
@@ -93,6 +98,11 @@ function createApp(options = {}) {
93
98
  app.get('/api/sessions/:id/events', sessionController.getSessionEvents.bind(sessionController));
94
99
  app.get('/api/sessions/:id/timeline', sessionController.getTimeline.bind(sessionController));
95
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
+
96
106
  // Upload routes
97
107
  app.get('/session/:id/share', uploadController.shareSession.bind(uploadController));
98
108
  app.post('/session/import',
@@ -162,25 +162,12 @@ class SessionController {
162
162
  }
163
163
  }
164
164
 
165
- // Get session metadata for ETag generation
165
+ // Get session (needed for findById, no caching)
166
166
  const session = await this.sessionService.sessionRepository.findById(sessionId);
167
167
  if (!session) {
168
168
  return res.status(404).json({ error: 'Session not found' });
169
169
  }
170
170
 
171
- // Generate ETag from session ID + timestamp + pagination params (if used)
172
- const crypto = require('crypto');
173
- const etagBase = isPaginationRequested
174
- ? `${sessionId}-${session.updatedAt || session.createdAt}-${limit}-${offset}`
175
- : `${sessionId}-${session.updatedAt || session.createdAt}`;
176
- const etag = crypto.createHash('md5').update(etagBase).digest('hex');
177
-
178
- // Check If-None-Match header (client cache)
179
- const clientEtag = req.headers['if-none-match'];
180
- if (clientEtag === etag) {
181
- return res.status(304).end(); // Not Modified - use cached version
182
- }
183
-
184
171
  // Load events (with or without pagination)
185
172
  if (isPaginationRequested) {
186
173
  result = await this.sessionService.getSessionEvents(sessionId, { limit, offset });
@@ -190,10 +177,9 @@ class SessionController {
190
177
  result = events; // Direct array
191
178
  }
192
179
 
193
- // Set caching headers
180
+ // No caching for events - session files are live/active
194
181
  res.set({
195
- 'ETag': etag,
196
- 'Cache-Control': 'private, max-age=0, no-cache', // Disable cache during development
182
+ 'Cache-Control': 'no-store',
197
183
  'Vary': 'Accept-Encoding'
198
184
  });
199
185
 
@@ -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;
@@ -39,11 +39,12 @@ class SessionRepository {
39
39
  dir: process.env.PI_MONO_SESSION_DIR ||
40
40
  path.join(os.homedir(), '.pi', 'agent', 'sessions')
41
41
  },
42
- {
43
- type: 'vscode',
44
- dir: process.env.VSCODE_WORKSPACE_STORAGE_DIR ||
45
- path.join(os.homedir(), 'Library', 'Application Support', 'Code', 'User', 'workspaceStorage')
46
- }
42
+ // TODO: VSCode parser disabled
43
+ // {
44
+ // type: 'vscode',
45
+ // dir: process.env.VSCODE_WORKSPACE_STORAGE_DIR ||
46
+ // path.join(os.homedir(), 'Library', 'Application Support', 'Code', 'User', 'workspaceStorage')
47
+ // }
47
48
  ];
48
49
  }
49
50
 
@@ -110,12 +111,10 @@ class SessionRepository {
110
111
  if (stats.isDirectory()) {
111
112
  return this._scanPiMonoDir(fullPath, entry);
112
113
  }
113
- } else if (source.type === 'vscode') {
114
- // VSCode: workspaceStorage/<hash>/chatSessions/<uuid>.json
115
- // Each top-level entry is a workspace hash directory
116
- if (stats.isDirectory()) {
117
- return this._scanVsCodeWorkspaceDir(fullPath);
118
- }
114
+ // } else if (source.type === 'vscode') { // TODO: VSCode disabled
115
+ // if (stats.isDirectory()) {
116
+ // return this._scanVsCodeWorkspaceDir(fullPath);
117
+ // }
119
118
  }
120
119
  return null;
121
120
  });
@@ -275,8 +274,8 @@ class SessionRepository {
275
274
  session = await this._findClaudeSession(sessionId, source.dir);
276
275
  } else if (source.type === 'pi-mono') {
277
276
  session = await this._findPiMonoSession(sessionId, source.dir);
278
- } else if (source.type === 'vscode') {
279
- session = await this._findVsCodeSession(sessionId, source.dir);
277
+ // } else if (source.type === 'vscode') { // TODO: VSCode disabled
278
+ // session = await this._findVsCodeSession(sessionId, source.dir);
280
279
  }
281
280
 
282
281
  if (session) return session;
@@ -151,27 +151,24 @@ class SessionService {
151
151
  console.error('Error searching Pi-Mono sessions:', err);
152
152
  return [];
153
153
  }
154
- } else if (session.source === 'vscode') {
155
- // VSCode format: read JSON file directly, convert to event array via VsCodeParser
156
- const { VsCodeParser } = require('../../lib/parsers');
157
- const vscodeParser = new VsCodeParser();
158
- try {
159
- const raw = await fs.promises.readFile(session.filePath, 'utf-8');
160
- let sessionJson;
161
- if (session.filePath.endsWith('.jsonl')) {
162
- sessionJson = this.sessionRepository._parseVsCodeJsonl(raw);
163
- } else {
164
- sessionJson = JSON.parse(raw);
165
- }
166
- if (!sessionJson) return [];
167
- const parsed = vscodeParser.parseVsCode(sessionJson);
168
- // Convert tool.invocation events → assistant.message with data.tools
169
- // so frontend can render them using the same tool-list component
170
- return this._expandVsCodeEvents(parsed.allEvents);
171
- } catch (err) {
172
- console.error('Error reading VSCode session:', err);
173
- return [];
174
- }
154
+ // } else if (session.source === 'vscode') { // TODO: VSCode disabled
155
+ // const { VsCodeParser } = require('../../lib/parsers');
156
+ // const vscodeParser = new VsCodeParser();
157
+ // try {
158
+ // const raw = await fs.promises.readFile(session.filePath, 'utf-8');
159
+ // let sessionJson;
160
+ // if (session.filePath.endsWith('.jsonl')) {
161
+ // sessionJson = this.sessionRepository._parseVsCodeJsonl(raw);
162
+ // } else {
163
+ // sessionJson = JSON.parse(raw);
164
+ // }
165
+ // if (!sessionJson) return [];
166
+ // const parsed = vscodeParser.parseVsCode(sessionJson);
167
+ // return this._expandVsCodeEvents(parsed.allEvents);
168
+ // } catch (err) {
169
+ // console.error('Error reading VSCode session:', err);
170
+ // return [];
171
+ // }
175
172
  }
176
173
 
177
174
 
@@ -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;
@@ -1864,6 +2006,15 @@
1864
2006
  }
1865
2007
 
1866
2008
  console.log('[Navigation] Events loaded:', loadedEvents.value.length);
2009
+
2010
+ // Update 'Updated' time from last event timestamp (more accurate than file mtime)
2011
+ if (loadedEvents.value.length > 0) {
2012
+ const lastEvent = loadedEvents.value[loadedEvents.value.length - 1];
2013
+ const lastTime = lastEvent.timestamp || lastEvent.time || lastEvent.data?.timestamp;
2014
+ if (lastTime) {
2015
+ metadata.value.updated = new Date(lastTime);
2016
+ }
2017
+ }
1867
2018
 
1868
2019
  // Check for URL query parameters and jump to event AFTER events are loaded
1869
2020
  const urlParams = new URLSearchParams(window.location.search);
@@ -2032,7 +2183,190 @@
2032
2183
  markdownCache.clear();
2033
2184
  });
2034
2185
  });
2035
-
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
+
2036
2370
  return {
2037
2371
  sessionId,
2038
2372
  metadata,
@@ -2078,7 +2412,27 @@
2078
2412
  jumpToTurn,
2079
2413
  getTurnNumber,
2080
2414
  escapeHtml,
2081
- 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
2082
2436
  };
2083
2437
  },
2084
2438
 
@@ -2161,8 +2515,62 @@
2161
2515
  </button>
2162
2516
  </div>
2163
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>
2164
2572
  </div>
2165
-
2573
+
2166
2574
  <div class="content">
2167
2575
  <div class="scroll-indicator">
2168
2576
  <div class="content-toolbar-left">