@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 +29 -0
- package/package.json +1 -1
- package/src/app.js +7 -0
- package/src/controllers/tagController.js +105 -0
- package/src/services/tagService.js +195 -0
- package/views/index.ejs +123 -5
- package/views/session-vue.ejs +403 -4
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
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))
|
|
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))
|
|
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
|
package/views/session-vue.ejs
CHANGED
|
@@ -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">
|