backlog-mcp 0.30.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/operations/storage.mjs +2 -1
- package/dist/operations/storage.mjs.map +1 -1
- package/dist/operations/types.d.mts +1 -0
- package/dist/operations/types.mjs.map +1 -1
- package/dist/server/viewer-routes.mjs +4 -3
- package/dist/server/viewer-routes.mjs.map +1 -1
- package/dist/viewer/main.css +54 -9
- package/dist/viewer/main.js +165 -51
- package/package.json +1 -1
- package/viewer/components/activity-panel.ts +92 -34
- package/viewer/components/activity-utils.test.ts +76 -4
- package/viewer/components/activity-utils.ts +116 -20
- package/viewer/components/spotlight-search.ts +7 -2
- package/viewer/styles.css +62 -9
|
@@ -43,9 +43,10 @@ var OperationStorage = class {
|
|
|
43
43
|
* Query operations with optional filtering.
|
|
44
44
|
*/
|
|
45
45
|
query(filter = {}) {
|
|
46
|
-
const { taskId, limit = 50 } = filter;
|
|
46
|
+
const { taskId, date, limit = 50 } = filter;
|
|
47
47
|
let entries = this.readAll();
|
|
48
48
|
if (taskId) entries = entries.filter((e) => e.resourceId === taskId);
|
|
49
|
+
if (date) entries = entries.filter((e) => e.ts.startsWith(date));
|
|
49
50
|
return entries.reverse().slice(0, limit);
|
|
50
51
|
}
|
|
51
52
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"storage.mjs","names":[],"sources":["../../src/operations/storage.ts"],"sourcesContent":["/**\n * JSONL storage for operation entries.\n * Single responsibility: read/write operations to disk.\n */\n\nimport { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';\nimport { dirname, join } from 'node:path';\nimport { paths } from '@/utils/paths.js';\nimport type { OperationEntry, OperationFilter } from './types.js';\n\nexport class OperationStorage {\n private logPath: string;\n\n constructor() {\n this.logPath = join(paths.backlogDataDir, '.internal', 'operations.jsonl');\n }\n\n /**\n * Append an operation entry to the log file.\n */\n append(entry: OperationEntry): void {\n try {\n const dir = dirname(this.logPath);\n if (!existsSync(dir)) {\n mkdirSync(dir, { recursive: true });\n }\n appendFileSync(this.logPath, JSON.stringify(entry) + '\\n', 'utf-8');\n } catch {\n // Fail silently - logging should not break tool execution\n }\n }\n\n /**\n * Read all operations from the log file.\n */\n readAll(): OperationEntry[] {\n if (!existsSync(this.logPath)) return [];\n\n try {\n const content = readFileSync(this.logPath, 'utf-8');\n const lines = content.trim().split('\\n').filter(Boolean);\n \n return lines\n .map(line => {\n try {\n return JSON.parse(line) as OperationEntry;\n } catch {\n return null;\n }\n })\n .filter((e): e is OperationEntry => e !== null);\n } catch {\n return [];\n }\n }\n\n /**\n * Query operations with optional filtering.\n */\n query(filter: OperationFilter = {}): OperationEntry[] {\n const { taskId, limit = 50 } = filter;\n \n let entries = this.readAll();\n\n if (taskId) {\n entries = entries.filter(e => e.resourceId === taskId);\n }\n\n // Return most recent first, limited\n return entries.reverse().slice(0, limit);\n }\n\n /**\n * Count operations for a specific task.\n */\n countForTask(taskId: string): number {\n return this.query({ taskId, limit: 1000 }).length;\n }\n}\n"],"mappings":";;;;;;;;;AAUA,IAAa,mBAAb,MAA8B;CAC5B,AAAQ;CAER,cAAc;AACZ,OAAK,UAAU,KAAK,MAAM,gBAAgB,aAAa,mBAAmB;;;;;CAM5E,OAAO,OAA6B;AAClC,MAAI;GACF,MAAM,MAAM,QAAQ,KAAK,QAAQ;AACjC,OAAI,CAAC,WAAW,IAAI,CAClB,WAAU,KAAK,EAAE,WAAW,MAAM,CAAC;AAErC,kBAAe,KAAK,SAAS,KAAK,UAAU,MAAM,GAAG,MAAM,QAAQ;UAC7D;;;;;CAQV,UAA4B;AAC1B,MAAI,CAAC,WAAW,KAAK,QAAQ,CAAE,QAAO,EAAE;AAExC,MAAI;AAIF,UAHgB,aAAa,KAAK,SAAS,QAAQ,CAC7B,MAAM,CAAC,MAAM,KAAK,CAAC,OAAO,QAAQ,CAGrD,KAAI,SAAQ;AACX,QAAI;AACF,YAAO,KAAK,MAAM,KAAK;YACjB;AACN,YAAO;;KAET,CACD,QAAQ,MAA2B,MAAM,KAAK;UAC3C;AACN,UAAO,EAAE;;;;;;CAOb,MAAM,SAA0B,EAAE,EAAoB;EACpD,MAAM,EAAE,QAAQ,QAAQ,OAAO;
|
|
1
|
+
{"version":3,"file":"storage.mjs","names":[],"sources":["../../src/operations/storage.ts"],"sourcesContent":["/**\n * JSONL storage for operation entries.\n * Single responsibility: read/write operations to disk.\n */\n\nimport { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';\nimport { dirname, join } from 'node:path';\nimport { paths } from '@/utils/paths.js';\nimport type { OperationEntry, OperationFilter } from './types.js';\n\nexport class OperationStorage {\n private logPath: string;\n\n constructor() {\n this.logPath = join(paths.backlogDataDir, '.internal', 'operations.jsonl');\n }\n\n /**\n * Append an operation entry to the log file.\n */\n append(entry: OperationEntry): void {\n try {\n const dir = dirname(this.logPath);\n if (!existsSync(dir)) {\n mkdirSync(dir, { recursive: true });\n }\n appendFileSync(this.logPath, JSON.stringify(entry) + '\\n', 'utf-8');\n } catch {\n // Fail silently - logging should not break tool execution\n }\n }\n\n /**\n * Read all operations from the log file.\n */\n readAll(): OperationEntry[] {\n if (!existsSync(this.logPath)) return [];\n\n try {\n const content = readFileSync(this.logPath, 'utf-8');\n const lines = content.trim().split('\\n').filter(Boolean);\n \n return lines\n .map(line => {\n try {\n return JSON.parse(line) as OperationEntry;\n } catch {\n return null;\n }\n })\n .filter((e): e is OperationEntry => e !== null);\n } catch {\n return [];\n }\n }\n\n /**\n * Query operations with optional filtering.\n */\n query(filter: OperationFilter = {}): OperationEntry[] {\n const { taskId, date, limit = 50 } = filter;\n \n let entries = this.readAll();\n\n if (taskId) {\n entries = entries.filter(e => e.resourceId === taskId);\n }\n\n if (date) {\n // Filter by date (YYYY-MM-DD matches start of ISO timestamp)\n entries = entries.filter(e => e.ts.startsWith(date));\n }\n\n // Return most recent first, limited\n return entries.reverse().slice(0, limit);\n }\n\n /**\n * Count operations for a specific task.\n */\n countForTask(taskId: string): number {\n return this.query({ taskId, limit: 1000 }).length;\n }\n}\n"],"mappings":";;;;;;;;;AAUA,IAAa,mBAAb,MAA8B;CAC5B,AAAQ;CAER,cAAc;AACZ,OAAK,UAAU,KAAK,MAAM,gBAAgB,aAAa,mBAAmB;;;;;CAM5E,OAAO,OAA6B;AAClC,MAAI;GACF,MAAM,MAAM,QAAQ,KAAK,QAAQ;AACjC,OAAI,CAAC,WAAW,IAAI,CAClB,WAAU,KAAK,EAAE,WAAW,MAAM,CAAC;AAErC,kBAAe,KAAK,SAAS,KAAK,UAAU,MAAM,GAAG,MAAM,QAAQ;UAC7D;;;;;CAQV,UAA4B;AAC1B,MAAI,CAAC,WAAW,KAAK,QAAQ,CAAE,QAAO,EAAE;AAExC,MAAI;AAIF,UAHgB,aAAa,KAAK,SAAS,QAAQ,CAC7B,MAAM,CAAC,MAAM,KAAK,CAAC,OAAO,QAAQ,CAGrD,KAAI,SAAQ;AACX,QAAI;AACF,YAAO,KAAK,MAAM,KAAK;YACjB;AACN,YAAO;;KAET,CACD,QAAQ,MAA2B,MAAM,KAAK;UAC3C;AACN,UAAO,EAAE;;;;;;CAOb,MAAM,SAA0B,EAAE,EAAoB;EACpD,MAAM,EAAE,QAAQ,MAAM,QAAQ,OAAO;EAErC,IAAI,UAAU,KAAK,SAAS;AAE5B,MAAI,OACF,WAAU,QAAQ,QAAO,MAAK,EAAE,eAAe,OAAO;AAGxD,MAAI,KAEF,WAAU,QAAQ,QAAO,MAAK,EAAE,GAAG,WAAW,KAAK,CAAC;AAItD,SAAO,QAAQ,SAAS,CAAC,MAAM,GAAG,MAAM;;;;;CAM1C,aAAa,QAAwB;AACnC,SAAO,KAAK,MAAM;GAAE;GAAQ,OAAO;GAAM,CAAC,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.mjs","names":[],"sources":["../../src/operations/types.ts"],"sourcesContent":["/**\n * Types for operation logging.\n */\n\nexport interface Actor {\n type: 'user' | 'agent';\n name: string;\n delegatedBy?: string;\n taskContext?: string;\n}\n\nexport type ToolName = 'backlog_create' | 'backlog_update' | 'backlog_delete' | 'write_resource';\n\nexport interface OperationEntry {\n ts: string;\n tool: string;\n params: Record<string, unknown>;\n result: unknown;\n resourceId?: string;\n actor: Actor;\n}\n\nexport interface OperationFilter {\n taskId?: string;\n limit?: number;\n}\n\nexport const WRITE_TOOLS: ToolName[] = ['backlog_create', 'backlog_update', 'backlog_delete', 'write_resource'];\n"],"mappings":";
|
|
1
|
+
{"version":3,"file":"types.mjs","names":[],"sources":["../../src/operations/types.ts"],"sourcesContent":["/**\n * Types for operation logging.\n */\n\nexport interface Actor {\n type: 'user' | 'agent';\n name: string;\n delegatedBy?: string;\n taskContext?: string;\n}\n\nexport type ToolName = 'backlog_create' | 'backlog_update' | 'backlog_delete' | 'write_resource';\n\nexport interface OperationEntry {\n ts: string;\n tool: string;\n params: Record<string, unknown>;\n result: unknown;\n resourceId?: string;\n actor: Actor;\n}\n\nexport interface OperationFilter {\n taskId?: string;\n date?: string; // YYYY-MM-DD - filter by date\n limit?: number;\n}\n\nexport const WRITE_TOOLS: ToolName[] = ['backlog_create', 'backlog_update', 'backlog_delete', 'write_resource'];\n"],"mappings":";AA4BA,MAAa,cAA0B;CAAC;CAAkB;CAAkB;CAAkB;CAAiB"}
|
|
@@ -142,10 +142,11 @@ function registerViewerRoutes(app) {
|
|
|
142
142
|
return reply.redirect(`/?resource=${encodeURIComponent(uri)}`);
|
|
143
143
|
});
|
|
144
144
|
app.get("/operations", async (request) => {
|
|
145
|
-
const { limit, task } = request.query;
|
|
145
|
+
const { limit, task, date } = request.query;
|
|
146
146
|
const operations = operationLogger.read({
|
|
147
|
-
limit: limit ? parseInt(limit) : 50,
|
|
148
|
-
taskId: task || void 0
|
|
147
|
+
limit: limit ? parseInt(limit) : date ? 1e3 : 50,
|
|
148
|
+
taskId: task || void 0,
|
|
149
|
+
date: date || void 0
|
|
149
150
|
});
|
|
150
151
|
const taskCache = /* @__PURE__ */ new Map();
|
|
151
152
|
const epicCache = /* @__PURE__ */ new Map();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"viewer-routes.mjs","names":[],"sources":["../../src/server/viewer-routes.ts"],"sourcesContent":["import type { FastifyInstance } from 'fastify';\nimport fastifyStatic from '@fastify/static';\nimport { exec } from 'node:child_process';\nimport { existsSync, readFileSync } from 'node:fs';\nimport matter from 'gray-matter';\nimport { storage } from '../storage/backlog-service.js';\nimport { resourceManager } from '../resources/manager.js';\nimport { paths } from '../utils/paths.js';\nimport { operationLogger } from '../operations/index.js';\n\nexport function registerViewerRoutes(app: FastifyInstance) {\n // Static files - serve from dist/viewer (built assets)\n app.register(fastifyStatic, {\n root: paths.viewerDist,\n prefix: '/',\n });\n\n // List tasks\n app.get('/tasks', async (request) => {\n const { filter, limit, q } = request.query as { filter?: string; limit?: string; q?: string };\n \n const statusMap: Record<string, any> = {\n active: { status: ['open', 'in_progress', 'blocked'] },\n completed: { status: ['done', 'cancelled'] },\n all: {},\n };\n \n const filterConfig = statusMap[filter || 'active'] || statusMap.active;\n const tasks = await storage.list({ \n ...filterConfig, \n query: q || undefined,\n limit: limit ? parseInt(limit) : 10000 \n });\n \n return tasks;\n });\n\n // Unified search API - returns proper SearchResult[] with item, score, type\n app.get('/search', async (request, reply) => {\n const { q, types, limit, sort } = request.query as { q?: string; types?: string; limit?: string; sort?: string };\n \n if (!q) {\n return reply.code(400).send({ error: 'Missing required query parameter: q' });\n }\n \n const typeFilter = types \n ? types.split(',').filter((t): t is 'task' | 'epic' | 'resource' => \n t === 'task' || t === 'epic' || t === 'resource')\n : undefined;\n \n const sortMode = sort === 'recent' ? 'recent' : 'relevant';\n \n const results = await storage.searchUnified(q, {\n types: typeFilter?.length ? typeFilter : undefined,\n limit: limit ? parseInt(limit) : 20,\n sort: sortMode,\n });\n \n return results;\n });\n\n // Get single task\n app.get('/tasks/:id', async (request, reply) => {\n const { id } = request.params as { id: string };\n const task = storage.get(id);\n \n if (!task) {\n return reply.code(404).send({ error: 'Task not found' });\n }\n \n // Include raw markdown for copy button\n const raw = storage.getMarkdown(id);\n \n return { ...task, raw };\n });\n\n // System status\n app.get('/api/status', async () => {\n const tasks = await storage.list({ limit: 10000 });\n const address = app.server.address();\n const port = typeof address === 'object' && address ? address.port : 3030;\n \n return {\n version: paths.getVersion(),\n port,\n dataDir: paths.backlogDataDir,\n taskCount: tasks.length,\n uptime: Math.floor(process.uptime())\n };\n });\n\n // Open task in editor\n app.get('/open/:id', async (request, reply) => {\n const { id } = request.params as { id: string };\n const filePath = storage.getFilePath(id);\n \n if (!filePath) {\n return reply.code(404).send({ error: 'Task not found' });\n }\n \n exec(`open \"${filePath}\"`);\n return { status: 'Opening...' };\n });\n\n // Resource proxy\n app.get('/resource', async (request, reply) => {\n const { path: filePath } = request.query as { path?: string };\n \n if (!filePath) {\n return reply.code(400).send({ error: 'Missing path parameter' });\n }\n \n if (!existsSync(filePath)) {\n return reply.code(404).send({ error: 'File not found', path: filePath });\n }\n \n try {\n const content = readFileSync(filePath, 'utf-8');\n const ext = filePath.split('.').pop()?.toLowerCase() || 'txt';\n const mimeMap: Record<string, string> = {\n md: 'text/markdown',\n ts: 'text/typescript',\n js: 'text/javascript',\n json: 'application/json',\n txt: 'text/plain',\n };\n \n let frontmatter = {};\n let bodyContent = content;\n \n // Parse frontmatter for markdown files\n if (ext === 'md') {\n const parsed = matter(content);\n frontmatter = parsed.data;\n bodyContent = parsed.content;\n }\n \n return {\n content: bodyContent,\n frontmatter,\n type: mimeMap[ext] || 'text/plain',\n path: filePath,\n fileUri: `file://${filePath}`,\n mcpUri: resourceManager.toUri(filePath),\n ext\n };\n } catch (error: any) {\n return reply.code(500).send({ error: 'Failed to read file', message: error.message });\n }\n });\n\n // MCP resource proxy\n app.get('/mcp/resource', async (request, reply) => {\n const { uri } = request.query as { uri?: string };\n \n if (!uri || !uri.startsWith('mcp://backlog/')) {\n return reply.code(400).send({ error: 'Invalid MCP URI' });\n }\n \n try {\n const resource = resourceManager.read(uri);\n const filePath = resourceManager.resolve(uri);\n const ext = filePath.split('.').pop()?.toLowerCase() || 'txt';\n \n return {\n content: resource.content,\n frontmatter: resource.frontmatter || {},\n type: resource.mimeType,\n path: filePath,\n fileUri: `file://${filePath}`,\n mcpUri: uri,\n ext\n };\n } catch (error: any) {\n return reply.code(404).send({ error: 'Resource not found', uri, message: error.message });\n }\n });\n\n // Open resource in viewer\n app.get('/open', async (request, reply) => {\n const { uri } = request.query as { uri?: string };\n \n if (!uri) {\n return reply.code(400).send({ error: 'Missing uri parameter' });\n }\n \n return reply.redirect(`/?resource=${encodeURIComponent(uri)}`);\n });\n\n // Operations API - recent activity (enriched with task titles and epic info)\n app.get('/operations', async (request) => {\n const { limit, task } = request.query as { limit?: string; task?: string };\n \n const operations = operationLogger.read({\n limit: limit ? parseInt(limit) : 50,\n taskId: task || undefined,\n });\n \n // Enrich operations with task titles and epic info\n // Use in-request cache to avoid duplicate storage lookups\n const taskCache = new Map<string, { title?: string; epicId?: string }>();\n const epicCache = new Map<string, string | undefined>();\n \n const enriched = operations.map(op => {\n if (op.resourceId) {\n if (!taskCache.has(op.resourceId)) {\n const taskData = storage.get(op.resourceId);\n taskCache.set(op.resourceId, {\n title: taskData?.title,\n epicId: taskData?.epic_id,\n });\n }\n const cached = taskCache.get(op.resourceId)!;\n \n // Resolve epic title if task has an epic\n let epicTitle: string | undefined;\n if (cached.epicId) {\n if (!epicCache.has(cached.epicId)) {\n const epicData = storage.get(cached.epicId);\n epicCache.set(cached.epicId, epicData?.title);\n }\n epicTitle = epicCache.get(cached.epicId);\n }\n \n return { ...op, resourceTitle: cached.title, epicId: cached.epicId, epicTitle };\n }\n return op;\n });\n \n return enriched;\n });\n\n // Operation count for a specific task (for badge)\n app.get('/operations/count/:taskId', async (request) => {\n const { taskId } = request.params as { taskId: string };\n return { count: operationLogger.countForTask(taskId) };\n });\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"viewer-routes.mjs","names":[],"sources":["../../src/server/viewer-routes.ts"],"sourcesContent":["import type { FastifyInstance } from 'fastify';\nimport fastifyStatic from '@fastify/static';\nimport { exec } from 'node:child_process';\nimport { existsSync, readFileSync } from 'node:fs';\nimport matter from 'gray-matter';\nimport { storage } from '../storage/backlog-service.js';\nimport { resourceManager } from '../resources/manager.js';\nimport { paths } from '../utils/paths.js';\nimport { operationLogger } from '../operations/index.js';\n\nexport function registerViewerRoutes(app: FastifyInstance) {\n // Static files - serve from dist/viewer (built assets)\n app.register(fastifyStatic, {\n root: paths.viewerDist,\n prefix: '/',\n });\n\n // List tasks\n app.get('/tasks', async (request) => {\n const { filter, limit, q } = request.query as { filter?: string; limit?: string; q?: string };\n \n const statusMap: Record<string, any> = {\n active: { status: ['open', 'in_progress', 'blocked'] },\n completed: { status: ['done', 'cancelled'] },\n all: {},\n };\n \n const filterConfig = statusMap[filter || 'active'] || statusMap.active;\n const tasks = await storage.list({ \n ...filterConfig, \n query: q || undefined,\n limit: limit ? parseInt(limit) : 10000 \n });\n \n return tasks;\n });\n\n // Unified search API - returns proper SearchResult[] with item, score, type\n app.get('/search', async (request, reply) => {\n const { q, types, limit, sort } = request.query as { q?: string; types?: string; limit?: string; sort?: string };\n \n if (!q) {\n return reply.code(400).send({ error: 'Missing required query parameter: q' });\n }\n \n const typeFilter = types \n ? types.split(',').filter((t): t is 'task' | 'epic' | 'resource' => \n t === 'task' || t === 'epic' || t === 'resource')\n : undefined;\n \n const sortMode = sort === 'recent' ? 'recent' : 'relevant';\n \n const results = await storage.searchUnified(q, {\n types: typeFilter?.length ? typeFilter : undefined,\n limit: limit ? parseInt(limit) : 20,\n sort: sortMode,\n });\n \n return results;\n });\n\n // Get single task\n app.get('/tasks/:id', async (request, reply) => {\n const { id } = request.params as { id: string };\n const task = storage.get(id);\n \n if (!task) {\n return reply.code(404).send({ error: 'Task not found' });\n }\n \n // Include raw markdown for copy button\n const raw = storage.getMarkdown(id);\n \n return { ...task, raw };\n });\n\n // System status\n app.get('/api/status', async () => {\n const tasks = await storage.list({ limit: 10000 });\n const address = app.server.address();\n const port = typeof address === 'object' && address ? address.port : 3030;\n \n return {\n version: paths.getVersion(),\n port,\n dataDir: paths.backlogDataDir,\n taskCount: tasks.length,\n uptime: Math.floor(process.uptime())\n };\n });\n\n // Open task in editor\n app.get('/open/:id', async (request, reply) => {\n const { id } = request.params as { id: string };\n const filePath = storage.getFilePath(id);\n \n if (!filePath) {\n return reply.code(404).send({ error: 'Task not found' });\n }\n \n exec(`open \"${filePath}\"`);\n return { status: 'Opening...' };\n });\n\n // Resource proxy\n app.get('/resource', async (request, reply) => {\n const { path: filePath } = request.query as { path?: string };\n \n if (!filePath) {\n return reply.code(400).send({ error: 'Missing path parameter' });\n }\n \n if (!existsSync(filePath)) {\n return reply.code(404).send({ error: 'File not found', path: filePath });\n }\n \n try {\n const content = readFileSync(filePath, 'utf-8');\n const ext = filePath.split('.').pop()?.toLowerCase() || 'txt';\n const mimeMap: Record<string, string> = {\n md: 'text/markdown',\n ts: 'text/typescript',\n js: 'text/javascript',\n json: 'application/json',\n txt: 'text/plain',\n };\n \n let frontmatter = {};\n let bodyContent = content;\n \n // Parse frontmatter for markdown files\n if (ext === 'md') {\n const parsed = matter(content);\n frontmatter = parsed.data;\n bodyContent = parsed.content;\n }\n \n return {\n content: bodyContent,\n frontmatter,\n type: mimeMap[ext] || 'text/plain',\n path: filePath,\n fileUri: `file://${filePath}`,\n mcpUri: resourceManager.toUri(filePath),\n ext\n };\n } catch (error: any) {\n return reply.code(500).send({ error: 'Failed to read file', message: error.message });\n }\n });\n\n // MCP resource proxy\n app.get('/mcp/resource', async (request, reply) => {\n const { uri } = request.query as { uri?: string };\n \n if (!uri || !uri.startsWith('mcp://backlog/')) {\n return reply.code(400).send({ error: 'Invalid MCP URI' });\n }\n \n try {\n const resource = resourceManager.read(uri);\n const filePath = resourceManager.resolve(uri);\n const ext = filePath.split('.').pop()?.toLowerCase() || 'txt';\n \n return {\n content: resource.content,\n frontmatter: resource.frontmatter || {},\n type: resource.mimeType,\n path: filePath,\n fileUri: `file://${filePath}`,\n mcpUri: uri,\n ext\n };\n } catch (error: any) {\n return reply.code(404).send({ error: 'Resource not found', uri, message: error.message });\n }\n });\n\n // Open resource in viewer\n app.get('/open', async (request, reply) => {\n const { uri } = request.query as { uri?: string };\n \n if (!uri) {\n return reply.code(400).send({ error: 'Missing uri parameter' });\n }\n \n return reply.redirect(`/?resource=${encodeURIComponent(uri)}`);\n });\n\n // Operations API - recent activity (enriched with task titles and epic info)\n app.get('/operations', async (request) => {\n const { limit, task, date } = request.query as { limit?: string; task?: string; date?: string };\n \n const operations = operationLogger.read({\n limit: limit ? parseInt(limit) : (date ? 1000 : 50), // Higher limit when filtering by date\n taskId: task || undefined,\n date: date || undefined,\n });\n \n // Enrich operations with task titles and epic info\n // Use in-request cache to avoid duplicate storage lookups\n const taskCache = new Map<string, { title?: string; epicId?: string }>();\n const epicCache = new Map<string, string | undefined>();\n \n const enriched = operations.map(op => {\n if (op.resourceId) {\n if (!taskCache.has(op.resourceId)) {\n const taskData = storage.get(op.resourceId);\n taskCache.set(op.resourceId, {\n title: taskData?.title,\n epicId: taskData?.epic_id,\n });\n }\n const cached = taskCache.get(op.resourceId)!;\n \n // Resolve epic title if task has an epic\n let epicTitle: string | undefined;\n if (cached.epicId) {\n if (!epicCache.has(cached.epicId)) {\n const epicData = storage.get(cached.epicId);\n epicCache.set(cached.epicId, epicData?.title);\n }\n epicTitle = epicCache.get(cached.epicId);\n }\n \n return { ...op, resourceTitle: cached.title, epicId: cached.epicId, epicTitle };\n }\n return op;\n });\n \n return enriched;\n });\n\n // Operation count for a specific task (for badge)\n app.get('/operations/count/:taskId', async (request) => {\n const { taskId } = request.params as { taskId: string };\n return { count: operationLogger.countForTask(taskId) };\n });\n}\n"],"mappings":"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"}
|
package/dist/viewer/main.css
CHANGED
|
@@ -1409,6 +1409,15 @@ copy-button {
|
|
|
1409
1409
|
color: #d4d4d4;
|
|
1410
1410
|
flex-shrink: 0;
|
|
1411
1411
|
}
|
|
1412
|
+
.activity-merged-badge {
|
|
1413
|
+
font-size: 11px;
|
|
1414
|
+
font-weight: 500;
|
|
1415
|
+
color: #58a6ff;
|
|
1416
|
+
background: rgba(88, 166, 255, 0.15);
|
|
1417
|
+
padding: 2px 6px;
|
|
1418
|
+
border-radius: 4px;
|
|
1419
|
+
margin-left: 6px;
|
|
1420
|
+
}
|
|
1412
1421
|
.activity-resource {
|
|
1413
1422
|
flex: 1;
|
|
1414
1423
|
color: #8b949e;
|
|
@@ -1562,22 +1571,37 @@ copy-button {
|
|
|
1562
1571
|
border-radius: 6px;
|
|
1563
1572
|
overflow: hidden;
|
|
1564
1573
|
margin-top: 8px;
|
|
1574
|
+
position: relative;
|
|
1565
1575
|
}
|
|
1566
1576
|
.activity-diff .d2h-file-header {
|
|
1567
1577
|
display: none;
|
|
1568
1578
|
}
|
|
1569
1579
|
.activity-diff .d2h-file-wrapper {
|
|
1570
1580
|
margin-bottom: 0;
|
|
1581
|
+
border: none;
|
|
1571
1582
|
}
|
|
1572
1583
|
.activity-diff .d2h-diff-table {
|
|
1573
1584
|
font-size: 12px;
|
|
1574
1585
|
}
|
|
1586
|
+
.activity-diff .d2h-code-linenumber {
|
|
1587
|
+
position: sticky;
|
|
1588
|
+
left: 0;
|
|
1589
|
+
}
|
|
1590
|
+
.activity-diff-stack {
|
|
1591
|
+
display: flex;
|
|
1592
|
+
flex-direction: column;
|
|
1593
|
+
gap: 4px;
|
|
1594
|
+
}
|
|
1595
|
+
.activity-diff-stack .activity-diff {
|
|
1596
|
+
margin-top: 0;
|
|
1597
|
+
}
|
|
1575
1598
|
.activity-day-separator {
|
|
1576
1599
|
display: flex;
|
|
1577
1600
|
align-items: center;
|
|
1578
1601
|
gap: 8px;
|
|
1579
|
-
padding: 12px 12px
|
|
1580
|
-
margin-top:
|
|
1602
|
+
padding: 12px 12px;
|
|
1603
|
+
margin-top: 20px;
|
|
1604
|
+
margin-bottom: 12px;
|
|
1581
1605
|
position: sticky;
|
|
1582
1606
|
top: 0;
|
|
1583
1607
|
background: #252526;
|
|
@@ -1587,7 +1611,6 @@ copy-button {
|
|
|
1587
1611
|
}
|
|
1588
1612
|
.activity-day-separator:first-child {
|
|
1589
1613
|
margin-top: 0;
|
|
1590
|
-
padding-top: 12px;
|
|
1591
1614
|
}
|
|
1592
1615
|
.activity-day-label {
|
|
1593
1616
|
font-weight: 700;
|
|
@@ -1757,19 +1780,23 @@ copy-button {
|
|
|
1757
1780
|
flex: 1;
|
|
1758
1781
|
overflow-y: auto;
|
|
1759
1782
|
padding: 12px;
|
|
1783
|
+
padding-bottom: 24px;
|
|
1760
1784
|
}
|
|
1761
1785
|
.activity-journal-section {
|
|
1762
|
-
margin-bottom:
|
|
1786
|
+
margin-bottom: 24px;
|
|
1787
|
+
background: #1e1e1e;
|
|
1788
|
+
border-radius: 8px;
|
|
1789
|
+
padding: 12px;
|
|
1763
1790
|
}
|
|
1764
1791
|
.activity-journal-section:last-child {
|
|
1765
|
-
margin-bottom:
|
|
1792
|
+
margin-bottom: 12px;
|
|
1766
1793
|
}
|
|
1767
1794
|
.activity-journal-section-title {
|
|
1768
1795
|
font-weight: 600;
|
|
1769
1796
|
color: #d4d4d4;
|
|
1770
1797
|
font-size: 14px;
|
|
1771
|
-
margin-bottom:
|
|
1772
|
-
padding-bottom:
|
|
1798
|
+
margin-bottom: 12px;
|
|
1799
|
+
padding-bottom: 8px;
|
|
1773
1800
|
border-bottom: 1px solid #30363d;
|
|
1774
1801
|
}
|
|
1775
1802
|
.activity-journal-list {
|
|
@@ -1781,7 +1808,16 @@ copy-button {
|
|
|
1781
1808
|
display: flex;
|
|
1782
1809
|
align-items: center;
|
|
1783
1810
|
gap: 8px;
|
|
1784
|
-
padding:
|
|
1811
|
+
padding: 8px 0;
|
|
1812
|
+
border-bottom: 1px solid #252525;
|
|
1813
|
+
flex-wrap: nowrap;
|
|
1814
|
+
min-width: 0;
|
|
1815
|
+
}
|
|
1816
|
+
.activity-journal-item:last-child {
|
|
1817
|
+
border-bottom: none;
|
|
1818
|
+
}
|
|
1819
|
+
.activity-journal-item task-badge {
|
|
1820
|
+
flex-shrink: 0;
|
|
1785
1821
|
}
|
|
1786
1822
|
.activity-journal-title {
|
|
1787
1823
|
color: #8b949e;
|
|
@@ -1789,6 +1825,8 @@ copy-button {
|
|
|
1789
1825
|
overflow: hidden;
|
|
1790
1826
|
text-overflow: ellipsis;
|
|
1791
1827
|
white-space: nowrap;
|
|
1828
|
+
flex: 1;
|
|
1829
|
+
min-width: 0;
|
|
1792
1830
|
}
|
|
1793
1831
|
.activity-journal-epic-group {
|
|
1794
1832
|
margin-bottom: 16px;
|
|
@@ -1801,11 +1839,18 @@ copy-button {
|
|
|
1801
1839
|
align-items: center;
|
|
1802
1840
|
gap: 8px;
|
|
1803
1841
|
padding: 8px 0 4px 0;
|
|
1842
|
+
flex-wrap: nowrap;
|
|
1843
|
+
}
|
|
1844
|
+
.activity-journal-epic-header task-badge {
|
|
1845
|
+
flex-shrink: 0;
|
|
1804
1846
|
}
|
|
1805
1847
|
.activity-journal-epic-title {
|
|
1806
1848
|
font-weight: 500;
|
|
1807
1849
|
color: #d4d4d4;
|
|
1808
1850
|
font-size: 13px;
|
|
1851
|
+
overflow: hidden;
|
|
1852
|
+
text-overflow: ellipsis;
|
|
1853
|
+
white-space: nowrap;
|
|
1809
1854
|
}
|
|
1810
1855
|
.activity-journal-epic-group .activity-journal-list {
|
|
1811
1856
|
padding-left: 12px;
|
|
@@ -1813,7 +1858,7 @@ copy-button {
|
|
|
1813
1858
|
margin-left: 4px;
|
|
1814
1859
|
}
|
|
1815
1860
|
.activity-journal-epic-group .activity-journal-item {
|
|
1816
|
-
padding:
|
|
1861
|
+
padding: 6px 0;
|
|
1817
1862
|
}
|
|
1818
1863
|
|
|
1819
1864
|
/* viewer/github-markdown.css */
|
package/dist/viewer/main.js
CHANGED
|
@@ -4151,6 +4151,10 @@ var SpotlightSearch = class extends HTMLElement {
|
|
|
4151
4151
|
if (sort) this.setSortMode(sort);
|
|
4152
4152
|
});
|
|
4153
4153
|
});
|
|
4154
|
+
this.attachTabListeners();
|
|
4155
|
+
}
|
|
4156
|
+
// Separate method for tab-related listeners that get re-rendered
|
|
4157
|
+
attachTabListeners() {
|
|
4154
4158
|
this.querySelectorAll(".spotlight-tab-btn").forEach((btn) => {
|
|
4155
4159
|
btn.addEventListener("click", (e) => {
|
|
4156
4160
|
const tab = e.target.dataset.tab;
|
|
@@ -4183,7 +4187,7 @@ var SpotlightSearch = class extends HTMLElement {
|
|
|
4183
4187
|
this.activeTab = tab;
|
|
4184
4188
|
this.selectedIndex = 0;
|
|
4185
4189
|
this.renderDefaultTabs();
|
|
4186
|
-
this.
|
|
4190
|
+
this.attachTabListeners();
|
|
4187
4191
|
}
|
|
4188
4192
|
selectTabItem(id, type) {
|
|
4189
4193
|
if (type === "resource") {
|
|
@@ -4215,7 +4219,7 @@ var SpotlightSearch = class extends HTMLElement {
|
|
|
4215
4219
|
this.isLoadingActivity = false;
|
|
4216
4220
|
if (this.query.length < 2) {
|
|
4217
4221
|
this.renderDefaultTabs();
|
|
4218
|
-
this.
|
|
4222
|
+
this.attachTabListeners();
|
|
4219
4223
|
}
|
|
4220
4224
|
}
|
|
4221
4225
|
}
|
|
@@ -6851,38 +6855,104 @@ function groupByTask(operations) {
|
|
|
6851
6855
|
}
|
|
6852
6856
|
return Array.from(groups.values()).sort((a, b2) => b2.mostRecentTs.localeCompare(a.mostRecentTs));
|
|
6853
6857
|
}
|
|
6858
|
+
var MERGE_WINDOW_MS = 3e4;
|
|
6859
|
+
function isStrReplace(op) {
|
|
6860
|
+
if (op.tool !== "write_resource") return false;
|
|
6861
|
+
const operation = op.params.operation;
|
|
6862
|
+
return operation?.type === "str_replace";
|
|
6863
|
+
}
|
|
6864
|
+
function mergeConsecutiveEdits(operations) {
|
|
6865
|
+
if (operations.length <= 1) return operations;
|
|
6866
|
+
const result = [];
|
|
6867
|
+
let i = 0;
|
|
6868
|
+
while (i < operations.length) {
|
|
6869
|
+
const current = operations[i];
|
|
6870
|
+
if (!isStrReplace(current)) {
|
|
6871
|
+
result.push(current);
|
|
6872
|
+
i++;
|
|
6873
|
+
continue;
|
|
6874
|
+
}
|
|
6875
|
+
const group = [current];
|
|
6876
|
+
const uri = current.params.uri;
|
|
6877
|
+
let j2 = i + 1;
|
|
6878
|
+
while (j2 < operations.length) {
|
|
6879
|
+
const next = operations[j2];
|
|
6880
|
+
if (!isStrReplace(next) || next.params.uri !== uri) break;
|
|
6881
|
+
const newerTs = new Date(group[group.length - 1].ts).getTime();
|
|
6882
|
+
const olderTs = new Date(next.ts).getTime();
|
|
6883
|
+
if (newerTs - olderTs > MERGE_WINDOW_MS) break;
|
|
6884
|
+
group.push(next);
|
|
6885
|
+
j2++;
|
|
6886
|
+
}
|
|
6887
|
+
if (group.length === 1) {
|
|
6888
|
+
result.push(current);
|
|
6889
|
+
} else {
|
|
6890
|
+
const newest = group[0];
|
|
6891
|
+
const oldest = group[group.length - 1];
|
|
6892
|
+
const merged = {
|
|
6893
|
+
...newest,
|
|
6894
|
+
params: {
|
|
6895
|
+
...newest.params,
|
|
6896
|
+
_mergedCount: group.length,
|
|
6897
|
+
_mergedOps: group,
|
|
6898
|
+
// Store all ops for stacked diff rendering
|
|
6899
|
+
_mergedRange: { from: oldest.ts, to: newest.ts }
|
|
6900
|
+
}
|
|
6901
|
+
};
|
|
6902
|
+
result.push(merged);
|
|
6903
|
+
}
|
|
6904
|
+
i = j2;
|
|
6905
|
+
}
|
|
6906
|
+
return result;
|
|
6907
|
+
}
|
|
6854
6908
|
function aggregateForJournal(operations) {
|
|
6855
6909
|
const completed = [];
|
|
6856
6910
|
const inProgress = [];
|
|
6857
6911
|
const created = [];
|
|
6858
6912
|
const updated = [];
|
|
6859
|
-
const
|
|
6860
|
-
const seenInProgress = /* @__PURE__ */ new Set();
|
|
6861
|
-
const seenCreated = /* @__PURE__ */ new Set();
|
|
6862
|
-
const seenUpdated = /* @__PURE__ */ new Set();
|
|
6913
|
+
const taskState = /* @__PURE__ */ new Map();
|
|
6863
6914
|
for (const op of operations) {
|
|
6864
6915
|
const resourceId = op.resourceId;
|
|
6865
6916
|
if (!resourceId) continue;
|
|
6866
6917
|
const title = op.resourceTitle || op.params.title || resourceId;
|
|
6867
6918
|
const epicId = op.epicId;
|
|
6868
6919
|
const epicTitle = op.epicTitle;
|
|
6869
|
-
|
|
6870
|
-
|
|
6871
|
-
|
|
6872
|
-
created.push({ resourceId, title, epicId, epicTitle });
|
|
6873
|
-
}
|
|
6874
|
-
} else if (op.tool === "backlog_update") {
|
|
6920
|
+
const entry = { title, epicId, epicTitle };
|
|
6921
|
+
const existing = taskState.get(resourceId);
|
|
6922
|
+
if (op.tool === "backlog_update") {
|
|
6875
6923
|
const status = op.params.status;
|
|
6876
|
-
if (status === "done"
|
|
6877
|
-
|
|
6878
|
-
|
|
6879
|
-
|
|
6880
|
-
|
|
6881
|
-
|
|
6882
|
-
|
|
6883
|
-
|
|
6884
|
-
|
|
6924
|
+
if (status === "done") {
|
|
6925
|
+
if (!existing || existing.state !== "completed") {
|
|
6926
|
+
taskState.set(resourceId, { state: "completed", ...entry });
|
|
6927
|
+
}
|
|
6928
|
+
} else if (status === "in_progress") {
|
|
6929
|
+
if (!existing || existing.state !== "completed" && existing.state !== "in_progress") {
|
|
6930
|
+
taskState.set(resourceId, { state: "in_progress", ...entry });
|
|
6931
|
+
}
|
|
6932
|
+
} else if (!existing) {
|
|
6933
|
+
taskState.set(resourceId, { state: "updated", ...entry });
|
|
6885
6934
|
}
|
|
6935
|
+
} else if (op.tool === "backlog_create") {
|
|
6936
|
+
if (!existing || existing.state === "updated") {
|
|
6937
|
+
taskState.set(resourceId, { state: "created", ...entry });
|
|
6938
|
+
}
|
|
6939
|
+
}
|
|
6940
|
+
}
|
|
6941
|
+
for (const [resourceId, data] of taskState) {
|
|
6942
|
+
const entry = { resourceId, title: data.title, epicId: data.epicId, epicTitle: data.epicTitle };
|
|
6943
|
+
switch (data.state) {
|
|
6944
|
+
case "completed":
|
|
6945
|
+
completed.push(entry);
|
|
6946
|
+
break;
|
|
6947
|
+
case "in_progress":
|
|
6948
|
+
inProgress.push(entry);
|
|
6949
|
+
break;
|
|
6950
|
+
case "created":
|
|
6951
|
+
created.push(entry);
|
|
6952
|
+
break;
|
|
6953
|
+
case "updated":
|
|
6954
|
+
updated.push(entry);
|
|
6955
|
+
break;
|
|
6886
6956
|
}
|
|
6887
6957
|
}
|
|
6888
6958
|
return { completed, inProgress, created, updated };
|
|
@@ -6939,6 +7009,7 @@ var ActivityPanel = class extends HTMLElement {
|
|
|
6939
7009
|
taskId = null;
|
|
6940
7010
|
operations = [];
|
|
6941
7011
|
expandedIndex = null;
|
|
7012
|
+
// Changed to timestamp-based ID
|
|
6942
7013
|
pollTimer = null;
|
|
6943
7014
|
visibilityHandler = null;
|
|
6944
7015
|
mode = "timeline";
|
|
@@ -6984,21 +7055,37 @@ var ActivityPanel = class extends HTMLElement {
|
|
|
6984
7055
|
this.loadOperations();
|
|
6985
7056
|
}
|
|
6986
7057
|
setMode(mode) {
|
|
7058
|
+
const wasJournal = this.mode === "journal";
|
|
6987
7059
|
this.mode = mode;
|
|
6988
7060
|
this.expandedIndex = null;
|
|
6989
7061
|
localStorage.setItem(MODE_STORAGE_KEY, mode);
|
|
6990
|
-
|
|
7062
|
+
if (mode === "journal" || wasJournal) {
|
|
7063
|
+
this.loadOperations();
|
|
7064
|
+
} else {
|
|
7065
|
+
this.render();
|
|
7066
|
+
}
|
|
6991
7067
|
}
|
|
6992
7068
|
setDate(dateKey) {
|
|
6993
7069
|
this.selectedDate = dateKey;
|
|
6994
|
-
this.
|
|
7070
|
+
if (this.mode === "journal") {
|
|
7071
|
+
this.loadOperations();
|
|
7072
|
+
} else {
|
|
7073
|
+
this.render();
|
|
7074
|
+
}
|
|
6995
7075
|
}
|
|
6996
7076
|
async loadOperations() {
|
|
6997
7077
|
if (this.operations.length === 0) {
|
|
6998
7078
|
this.innerHTML = '<div class="activity-loading">Loading activity...</div>';
|
|
6999
7079
|
}
|
|
7000
7080
|
try {
|
|
7001
|
-
|
|
7081
|
+
let url;
|
|
7082
|
+
if (this.taskId) {
|
|
7083
|
+
url = `/operations?task=${encodeURIComponent(this.taskId)}&limit=100`;
|
|
7084
|
+
} else if (this.mode === "journal") {
|
|
7085
|
+
url = `/operations?date=${this.selectedDate}`;
|
|
7086
|
+
} else {
|
|
7087
|
+
url = "/operations?limit=100";
|
|
7088
|
+
}
|
|
7002
7089
|
const res = await fetch(url);
|
|
7003
7090
|
this.operations = await res.json();
|
|
7004
7091
|
this.render();
|
|
@@ -7056,10 +7143,11 @@ var ActivityPanel = class extends HTMLElement {
|
|
|
7056
7143
|
`;
|
|
7057
7144
|
}
|
|
7058
7145
|
renderTaskGroup(taskGroup) {
|
|
7146
|
+
const mergedOps = mergeConsecutiveEdits(taskGroup.operations);
|
|
7059
7147
|
const isExpanded = this.expandedTaskGroups.has(taskGroup.resourceId);
|
|
7060
|
-
const hasMore =
|
|
7061
|
-
const visibleOps = isExpanded ?
|
|
7062
|
-
const hiddenCount =
|
|
7148
|
+
const hasMore = mergedOps.length > DEFAULT_VISIBLE_ITEMS;
|
|
7149
|
+
const visibleOps = isExpanded ? mergedOps : mergedOps.slice(0, DEFAULT_VISIBLE_ITEMS);
|
|
7150
|
+
const hiddenCount = mergedOps.length - DEFAULT_VISIBLE_ITEMS;
|
|
7063
7151
|
const mostRecentDate = new Date(taskGroup.mostRecentTs);
|
|
7064
7152
|
const mostRecentDateStr = formatDateTime(mostRecentDate);
|
|
7065
7153
|
return `
|
|
@@ -7076,10 +7164,7 @@ var ActivityPanel = class extends HTMLElement {
|
|
|
7076
7164
|
${taskGroup.title !== taskGroup.resourceId ? `<span class="activity-task-title">${this.escapeHtml(taskGroup.title)}</span>` : ""}
|
|
7077
7165
|
<span class="activity-task-recent">${mostRecentDateStr}</span>
|
|
7078
7166
|
</div>
|
|
7079
|
-
${visibleOps.map((op) =>
|
|
7080
|
-
const globalIndex = this.operations.indexOf(op);
|
|
7081
|
-
return this.renderOperation(op, globalIndex);
|
|
7082
|
-
}).join("")}
|
|
7167
|
+
${visibleOps.map((op) => this.renderOperation(op)).join("")}
|
|
7083
7168
|
${hasMore ? `
|
|
7084
7169
|
<button class="activity-toggle-btn" data-task-id="${taskGroup.resourceId}">
|
|
7085
7170
|
${isExpanded ? "Show less" : `Show ${hiddenCount} more`}
|
|
@@ -7153,19 +7238,22 @@ var ActivityPanel = class extends HTMLElement {
|
|
|
7153
7238
|
</div>
|
|
7154
7239
|
`;
|
|
7155
7240
|
}
|
|
7156
|
-
renderOperation(op
|
|
7157
|
-
const
|
|
7241
|
+
renderOperation(op) {
|
|
7242
|
+
const opId = op.ts;
|
|
7243
|
+
const isExpanded = this.expandedIndex === opId;
|
|
7158
7244
|
const time = new Date(op.ts);
|
|
7159
7245
|
const dateKey = getLocalDateKey(time);
|
|
7160
7246
|
const today = getTodayKey();
|
|
7161
7247
|
const timeStr = dateKey === today ? formatTime(time) : formatDateTime(time);
|
|
7248
|
+
const mergedCount = op.params._mergedCount;
|
|
7249
|
+
const mergedBadge = mergedCount && mergedCount > 1 ? `<span class="activity-merged-badge">${mergedCount} edits</span>` : "";
|
|
7162
7250
|
return `
|
|
7163
|
-
<div class="activity-item ${isExpanded ? "expanded" : ""}" data-
|
|
7251
|
+
<div class="activity-item ${isExpanded ? "expanded" : ""}" data-op-id="${opId}">
|
|
7164
7252
|
<div class="activity-item-header">
|
|
7165
7253
|
<div class="activity-item-left">
|
|
7166
7254
|
<span class="activity-icon">${getToolIcon(op.tool)}</span>
|
|
7167
7255
|
<div class="activity-item-info">
|
|
7168
|
-
<span class="activity-label">${getToolLabel(op.tool)}</span>
|
|
7256
|
+
<span class="activity-label">${getToolLabel(op.tool)}${mergedBadge}</span>
|
|
7169
7257
|
${this.renderActorInline(op.actor)}
|
|
7170
7258
|
</div>
|
|
7171
7259
|
</div>
|
|
@@ -7235,15 +7323,21 @@ var ActivityPanel = class extends HTMLElement {
|
|
|
7235
7323
|
}).join("");
|
|
7236
7324
|
} else if (op.tool === "backlog_delete") {
|
|
7237
7325
|
content += `<div class="activity-detail-row"><span class="activity-detail-value">Task permanently deleted</span></div>`;
|
|
7238
|
-
} else if (op.tool === "write_resource"
|
|
7239
|
-
const
|
|
7240
|
-
if (
|
|
7326
|
+
} else if (op.tool === "write_resource") {
|
|
7327
|
+
const mergedOps = op.params._mergedOps;
|
|
7328
|
+
if (mergedOps && mergedOps.length > 1) {
|
|
7241
7329
|
const uri = op.params.uri;
|
|
7242
7330
|
const filename = uri.split("/").pop() || "file";
|
|
7243
|
-
|
|
7331
|
+
let combinedDiff = "";
|
|
7332
|
+
for (const mergedOp of [...mergedOps].reverse()) {
|
|
7333
|
+
const operation = mergedOp.params.operation;
|
|
7334
|
+
if (operation.old_str !== void 0 && operation.new_str !== void 0) {
|
|
7335
|
+
combinedDiff += createUnifiedDiff(operation.old_str, operation.new_str, filename) + "\n";
|
|
7336
|
+
}
|
|
7337
|
+
}
|
|
7244
7338
|
content += `
|
|
7245
7339
|
<div class="activity-diff">
|
|
7246
|
-
${html2(
|
|
7340
|
+
${html2(combinedDiff, {
|
|
7247
7341
|
drawFileList: false,
|
|
7248
7342
|
matching: "lines",
|
|
7249
7343
|
outputFormat: "line-by-line",
|
|
@@ -7252,13 +7346,31 @@ var ActivityPanel = class extends HTMLElement {
|
|
|
7252
7346
|
})}
|
|
7253
7347
|
</div>
|
|
7254
7348
|
`;
|
|
7255
|
-
} else {
|
|
7256
|
-
|
|
7257
|
-
|
|
7258
|
-
|
|
7259
|
-
|
|
7260
|
-
|
|
7261
|
-
|
|
7349
|
+
} else if (op.params.operation) {
|
|
7350
|
+
const operation = op.params.operation;
|
|
7351
|
+
if (operation.type === "str_replace" && operation.old_str !== void 0 && operation.new_str !== void 0) {
|
|
7352
|
+
const uri = op.params.uri;
|
|
7353
|
+
const filename = uri.split("/").pop() || "file";
|
|
7354
|
+
const unifiedDiff = createUnifiedDiff(operation.old_str, operation.new_str, filename);
|
|
7355
|
+
content += `
|
|
7356
|
+
<div class="activity-diff">
|
|
7357
|
+
${html2(unifiedDiff, {
|
|
7358
|
+
drawFileList: false,
|
|
7359
|
+
matching: "lines",
|
|
7360
|
+
outputFormat: "line-by-line",
|
|
7361
|
+
diffStyle: "word",
|
|
7362
|
+
colorScheme: "dark"
|
|
7363
|
+
})}
|
|
7364
|
+
</div>
|
|
7365
|
+
`;
|
|
7366
|
+
} else {
|
|
7367
|
+
content += `
|
|
7368
|
+
<div class="activity-detail-row">
|
|
7369
|
+
<span class="activity-detail-label">Operation:</span>
|
|
7370
|
+
<span class="activity-detail-value">${operation.type}</span>
|
|
7371
|
+
</div>
|
|
7372
|
+
`;
|
|
7373
|
+
}
|
|
7262
7374
|
}
|
|
7263
7375
|
}
|
|
7264
7376
|
return `<div class="activity-expanded" onclick="event.stopPropagation()">${content}</div>`;
|
|
@@ -7297,15 +7409,17 @@ var ActivityPanel = class extends HTMLElement {
|
|
|
7297
7409
|
} else {
|
|
7298
7410
|
this.expandedTaskGroups.add(taskId);
|
|
7299
7411
|
}
|
|
7412
|
+
const scrollTop = this.scrollTop;
|
|
7300
7413
|
this.render();
|
|
7414
|
+
this.scrollTop = scrollTop;
|
|
7301
7415
|
}
|
|
7302
7416
|
});
|
|
7303
7417
|
});
|
|
7304
7418
|
this.querySelectorAll(".activity-item-header").forEach((header) => {
|
|
7305
7419
|
header.addEventListener("click", () => {
|
|
7306
7420
|
const item = header.closest(".activity-item");
|
|
7307
|
-
const
|
|
7308
|
-
this.toggleExpand(
|
|
7421
|
+
const opId = item?.getAttribute("data-op-id") || "";
|
|
7422
|
+
this.toggleExpand(opId);
|
|
7309
7423
|
});
|
|
7310
7424
|
});
|
|
7311
7425
|
this.querySelectorAll(".activity-task-link").forEach((link) => {
|
|
@@ -7332,8 +7446,8 @@ var ActivityPanel = class extends HTMLElement {
|
|
|
7332
7446
|
escapeHtml(str) {
|
|
7333
7447
|
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
7334
7448
|
}
|
|
7335
|
-
toggleExpand(
|
|
7336
|
-
this.expandedIndex = this.expandedIndex ===
|
|
7449
|
+
toggleExpand(opId) {
|
|
7450
|
+
this.expandedIndex = this.expandedIndex === opId ? null : opId;
|
|
7337
7451
|
this.render();
|
|
7338
7452
|
}
|
|
7339
7453
|
};
|
package/package.json
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
groupByEpic,
|
|
16
16
|
getToolLabel,
|
|
17
17
|
getToolIcon,
|
|
18
|
+
mergeConsecutiveEdits,
|
|
18
19
|
type OperationEntry,
|
|
19
20
|
type Actor,
|
|
20
21
|
type DayGroup,
|
|
@@ -36,7 +37,7 @@ const DEFAULT_VISIBLE_ITEMS = 2;
|
|
|
36
37
|
export class ActivityPanel extends HTMLElement {
|
|
37
38
|
private taskId: string | null = null;
|
|
38
39
|
private operations: OperationEntry[] = [];
|
|
39
|
-
private expandedIndex:
|
|
40
|
+
private expandedIndex: string | null = null; // Changed to timestamp-based ID
|
|
40
41
|
private pollTimer: number | null = null;
|
|
41
42
|
private visibilityHandler: (() => void) | null = null;
|
|
42
43
|
private mode: ViewMode = 'timeline';
|
|
@@ -90,16 +91,27 @@ export class ActivityPanel extends HTMLElement {
|
|
|
90
91
|
}
|
|
91
92
|
|
|
92
93
|
setMode(mode: ViewMode) {
|
|
94
|
+
const wasJournal = this.mode === 'journal';
|
|
93
95
|
this.mode = mode;
|
|
94
96
|
this.expandedIndex = null;
|
|
95
97
|
// Persist mode to localStorage
|
|
96
98
|
localStorage.setItem(MODE_STORAGE_KEY, mode);
|
|
97
|
-
|
|
99
|
+
// Reload when switching to/from journal mode (different data requirements)
|
|
100
|
+
if (mode === 'journal' || wasJournal) {
|
|
101
|
+
this.loadOperations();
|
|
102
|
+
} else {
|
|
103
|
+
this.render();
|
|
104
|
+
}
|
|
98
105
|
}
|
|
99
106
|
|
|
100
107
|
setDate(dateKey: string) {
|
|
101
108
|
this.selectedDate = dateKey;
|
|
102
|
-
|
|
109
|
+
// Reload operations for the new date when in journal mode
|
|
110
|
+
if (this.mode === 'journal') {
|
|
111
|
+
this.loadOperations();
|
|
112
|
+
} else {
|
|
113
|
+
this.render();
|
|
114
|
+
}
|
|
103
115
|
}
|
|
104
116
|
|
|
105
117
|
async loadOperations() {
|
|
@@ -108,9 +120,15 @@ export class ActivityPanel extends HTMLElement {
|
|
|
108
120
|
}
|
|
109
121
|
|
|
110
122
|
try {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
123
|
+
let url: string;
|
|
124
|
+
if (this.taskId) {
|
|
125
|
+
url = `/operations?task=${encodeURIComponent(this.taskId)}&limit=100`;
|
|
126
|
+
} else if (this.mode === 'journal') {
|
|
127
|
+
// In journal mode, fetch all operations for the selected date
|
|
128
|
+
url = `/operations?date=${this.selectedDate}`;
|
|
129
|
+
} else {
|
|
130
|
+
url = '/operations?limit=100';
|
|
131
|
+
}
|
|
114
132
|
|
|
115
133
|
const res = await fetch(url);
|
|
116
134
|
this.operations = await res.json();
|
|
@@ -178,12 +196,15 @@ export class ActivityPanel extends HTMLElement {
|
|
|
178
196
|
}
|
|
179
197
|
|
|
180
198
|
private renderTaskGroup(taskGroup: TaskGroup): string {
|
|
199
|
+
// Merge consecutive edits before rendering
|
|
200
|
+
const mergedOps = mergeConsecutiveEdits(taskGroup.operations);
|
|
201
|
+
|
|
181
202
|
const isExpanded = this.expandedTaskGroups.has(taskGroup.resourceId);
|
|
182
|
-
const hasMore =
|
|
203
|
+
const hasMore = mergedOps.length > DEFAULT_VISIBLE_ITEMS;
|
|
183
204
|
const visibleOps = isExpanded
|
|
184
|
-
?
|
|
185
|
-
:
|
|
186
|
-
const hiddenCount =
|
|
205
|
+
? mergedOps
|
|
206
|
+
: mergedOps.slice(0, DEFAULT_VISIBLE_ITEMS);
|
|
207
|
+
const hiddenCount = mergedOps.length - DEFAULT_VISIBLE_ITEMS;
|
|
187
208
|
|
|
188
209
|
// Format most recent activity date
|
|
189
210
|
const mostRecentDate = new Date(taskGroup.mostRecentTs);
|
|
@@ -203,10 +224,7 @@ export class ActivityPanel extends HTMLElement {
|
|
|
203
224
|
${taskGroup.title !== taskGroup.resourceId ? `<span class="activity-task-title">${this.escapeHtml(taskGroup.title)}</span>` : ''}
|
|
204
225
|
<span class="activity-task-recent">${mostRecentDateStr}</span>
|
|
205
226
|
</div>
|
|
206
|
-
${visibleOps.map(op =>
|
|
207
|
-
const globalIndex = this.operations.indexOf(op);
|
|
208
|
-
return this.renderOperation(op, globalIndex);
|
|
209
|
-
}).join('')}
|
|
227
|
+
${visibleOps.map(op => this.renderOperation(op)).join('')}
|
|
210
228
|
${hasMore ? `
|
|
211
229
|
<button class="activity-toggle-btn" data-task-id="${taskGroup.resourceId}">
|
|
212
230
|
${isExpanded ? 'Show less' : `Show ${hiddenCount} more`}
|
|
@@ -290,8 +308,9 @@ export class ActivityPanel extends HTMLElement {
|
|
|
290
308
|
`;
|
|
291
309
|
}
|
|
292
310
|
|
|
293
|
-
private renderOperation(op: OperationEntry
|
|
294
|
-
const
|
|
311
|
+
private renderOperation(op: OperationEntry): string {
|
|
312
|
+
const opId = op.ts; // Use timestamp as unique ID
|
|
313
|
+
const isExpanded = this.expandedIndex === opId;
|
|
295
314
|
const time = new Date(op.ts);
|
|
296
315
|
const dateKey = getLocalDateKey(time);
|
|
297
316
|
const today = getTodayKey();
|
|
@@ -300,14 +319,20 @@ export class ActivityPanel extends HTMLElement {
|
|
|
300
319
|
const timeStr = dateKey === today
|
|
301
320
|
? formatTime(time)
|
|
302
321
|
: formatDateTime(time);
|
|
322
|
+
|
|
323
|
+
// Check if this is a merged operation
|
|
324
|
+
const mergedCount = op.params._mergedCount as number | undefined;
|
|
325
|
+
const mergedBadge = mergedCount && mergedCount > 1
|
|
326
|
+
? `<span class="activity-merged-badge">${mergedCount} edits</span>`
|
|
327
|
+
: '';
|
|
303
328
|
|
|
304
329
|
return `
|
|
305
|
-
<div class="activity-item ${isExpanded ? 'expanded' : ''}" data-
|
|
330
|
+
<div class="activity-item ${isExpanded ? 'expanded' : ''}" data-op-id="${opId}">
|
|
306
331
|
<div class="activity-item-header">
|
|
307
332
|
<div class="activity-item-left">
|
|
308
333
|
<span class="activity-icon">${getToolIcon(op.tool)}</span>
|
|
309
334
|
<div class="activity-item-info">
|
|
310
|
-
<span class="activity-label">${getToolLabel(op.tool)}</span>
|
|
335
|
+
<span class="activity-label">${getToolLabel(op.tool)}${mergedBadge}</span>
|
|
311
336
|
${this.renderActorInline(op.actor)}
|
|
312
337
|
</div>
|
|
313
338
|
</div>
|
|
@@ -381,16 +406,26 @@ export class ActivityPanel extends HTMLElement {
|
|
|
381
406
|
}).join('');
|
|
382
407
|
} else if (op.tool === 'backlog_delete') {
|
|
383
408
|
content += `<div class="activity-detail-row"><span class="activity-detail-value">Task permanently deleted</span></div>`;
|
|
384
|
-
} else if (op.tool === 'write_resource'
|
|
385
|
-
|
|
409
|
+
} else if (op.tool === 'write_resource') {
|
|
410
|
+
// Check if this is a merged operation with multiple diffs
|
|
411
|
+
const mergedOps = op.params._mergedOps as OperationEntry[] | undefined;
|
|
386
412
|
|
|
387
|
-
if (
|
|
413
|
+
if (mergedOps && mergedOps.length > 1) {
|
|
414
|
+
// Concatenate all diffs into one unified diff (oldest first)
|
|
388
415
|
const uri = op.params.uri as string;
|
|
389
416
|
const filename = uri.split('/').pop() || 'file';
|
|
390
|
-
|
|
417
|
+
|
|
418
|
+
let combinedDiff = '';
|
|
419
|
+
for (const mergedOp of [...mergedOps].reverse()) {
|
|
420
|
+
const operation = mergedOp.params.operation as { type: string; old_str?: string; new_str?: string };
|
|
421
|
+
if (operation.old_str !== undefined && operation.new_str !== undefined) {
|
|
422
|
+
combinedDiff += createUnifiedDiff(operation.old_str, operation.new_str, filename) + '\n';
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
391
426
|
content += `
|
|
392
427
|
<div class="activity-diff">
|
|
393
|
-
${Diff2Html.html(
|
|
428
|
+
${Diff2Html.html(combinedDiff, {
|
|
394
429
|
drawFileList: false,
|
|
395
430
|
matching: 'lines',
|
|
396
431
|
outputFormat: 'line-by-line',
|
|
@@ -399,13 +434,33 @@ export class ActivityPanel extends HTMLElement {
|
|
|
399
434
|
})}
|
|
400
435
|
</div>
|
|
401
436
|
`;
|
|
402
|
-
} else {
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
437
|
+
} else if (op.params.operation) {
|
|
438
|
+
// Single operation
|
|
439
|
+
const operation = op.params.operation as { type: string; old_str?: string; new_str?: string };
|
|
440
|
+
|
|
441
|
+
if (operation.type === 'str_replace' && operation.old_str !== undefined && operation.new_str !== undefined) {
|
|
442
|
+
const uri = op.params.uri as string;
|
|
443
|
+
const filename = uri.split('/').pop() || 'file';
|
|
444
|
+
const unifiedDiff = createUnifiedDiff(operation.old_str, operation.new_str, filename);
|
|
445
|
+
content += `
|
|
446
|
+
<div class="activity-diff">
|
|
447
|
+
${Diff2Html.html(unifiedDiff, {
|
|
448
|
+
drawFileList: false,
|
|
449
|
+
matching: 'lines',
|
|
450
|
+
outputFormat: 'line-by-line',
|
|
451
|
+
diffStyle: 'word',
|
|
452
|
+
colorScheme: 'dark',
|
|
453
|
+
})}
|
|
454
|
+
</div>
|
|
455
|
+
`;
|
|
456
|
+
} else {
|
|
457
|
+
content += `
|
|
458
|
+
<div class="activity-detail-row">
|
|
459
|
+
<span class="activity-detail-label">Operation:</span>
|
|
460
|
+
<span class="activity-detail-value">${operation.type}</span>
|
|
461
|
+
</div>
|
|
462
|
+
`;
|
|
463
|
+
}
|
|
409
464
|
}
|
|
410
465
|
}
|
|
411
466
|
|
|
@@ -453,7 +508,10 @@ export class ActivityPanel extends HTMLElement {
|
|
|
453
508
|
} else {
|
|
454
509
|
this.expandedTaskGroups.add(taskId);
|
|
455
510
|
}
|
|
511
|
+
// Preserve scroll position during re-render
|
|
512
|
+
const scrollTop = this.scrollTop;
|
|
456
513
|
this.render();
|
|
514
|
+
this.scrollTop = scrollTop;
|
|
457
515
|
}
|
|
458
516
|
});
|
|
459
517
|
});
|
|
@@ -462,8 +520,8 @@ export class ActivityPanel extends HTMLElement {
|
|
|
462
520
|
this.querySelectorAll('.activity-item-header').forEach(header => {
|
|
463
521
|
header.addEventListener('click', () => {
|
|
464
522
|
const item = header.closest('.activity-item');
|
|
465
|
-
const
|
|
466
|
-
this.toggleExpand(
|
|
523
|
+
const opId = item?.getAttribute('data-op-id') || '';
|
|
524
|
+
this.toggleExpand(opId);
|
|
467
525
|
});
|
|
468
526
|
});
|
|
469
527
|
|
|
@@ -500,8 +558,8 @@ export class ActivityPanel extends HTMLElement {
|
|
|
500
558
|
.replace(/"/g, '"');
|
|
501
559
|
}
|
|
502
560
|
|
|
503
|
-
private toggleExpand(
|
|
504
|
-
this.expandedIndex = this.expandedIndex ===
|
|
561
|
+
private toggleExpand(opId: string) {
|
|
562
|
+
this.expandedIndex = this.expandedIndex === opId ? null : opId;
|
|
505
563
|
this.render();
|
|
506
564
|
}
|
|
507
565
|
}
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
groupByEpic,
|
|
7
7
|
getToolLabel,
|
|
8
8
|
getToolIcon,
|
|
9
|
+
mergeConsecutiveEdits,
|
|
9
10
|
type OperationEntry,
|
|
10
11
|
type JournalEntry,
|
|
11
12
|
} from './activity-utils.js';
|
|
@@ -188,8 +189,8 @@ describe('activity-utils', () => {
|
|
|
188
189
|
expect(journal.completed).toHaveLength(1);
|
|
189
190
|
});
|
|
190
191
|
|
|
191
|
-
it('shows
|
|
192
|
-
//
|
|
192
|
+
it('shows only highest priority state for each task', () => {
|
|
193
|
+
// Task goes through in_progress then done - should only show in completed
|
|
193
194
|
const operations: OperationEntry[] = [
|
|
194
195
|
{ ts: '2026-02-05T10:00:00Z', tool: 'backlog_update', params: { status: 'in_progress' }, result: {}, resourceId: 'TASK-0001' },
|
|
195
196
|
{ ts: '2026-02-05T11:00:00Z', tool: 'backlog_update', params: { status: 'done' }, result: {}, resourceId: 'TASK-0001' },
|
|
@@ -197,9 +198,9 @@ describe('activity-utils', () => {
|
|
|
197
198
|
|
|
198
199
|
const journal = aggregateForJournal(operations);
|
|
199
200
|
|
|
200
|
-
//
|
|
201
|
-
expect(journal.inProgress).toHaveLength(1);
|
|
201
|
+
// Only shows in completed (highest priority), not in_progress
|
|
202
202
|
expect(journal.completed).toHaveLength(1);
|
|
203
|
+
expect(journal.inProgress).toHaveLength(0);
|
|
203
204
|
});
|
|
204
205
|
|
|
205
206
|
it('skips operations without resourceId', () => {
|
|
@@ -325,4 +326,75 @@ describe('activity-utils', () => {
|
|
|
325
326
|
expect(getToolIcon('unknown_tool')).toBe('⚡');
|
|
326
327
|
});
|
|
327
328
|
});
|
|
329
|
+
|
|
330
|
+
describe('mergeConsecutiveEdits', () => {
|
|
331
|
+
it('merges consecutive str_replace ops within 30s window', () => {
|
|
332
|
+
const operations: OperationEntry[] = [
|
|
333
|
+
// Newest first (reverse chronological)
|
|
334
|
+
{ ts: '2026-02-05T10:00:20.000Z', tool: 'write_resource', params: { uri: 'mcp://backlog/tasks/TASK-0001.md', operation: { type: 'str_replace', old_str: 'B', new_str: 'C' } }, result: {} },
|
|
335
|
+
{ ts: '2026-02-05T10:00:10.000Z', tool: 'write_resource', params: { uri: 'mcp://backlog/tasks/TASK-0001.md', operation: { type: 'str_replace', old_str: 'A', new_str: 'B' } }, result: {} },
|
|
336
|
+
];
|
|
337
|
+
|
|
338
|
+
const merged = mergeConsecutiveEdits(operations);
|
|
339
|
+
|
|
340
|
+
expect(merged).toHaveLength(1);
|
|
341
|
+
expect(merged[0].params._mergedCount).toBe(2);
|
|
342
|
+
// Should store all ops for stacked rendering
|
|
343
|
+
const mergedOps = merged[0].params._mergedOps as OperationEntry[];
|
|
344
|
+
expect(mergedOps).toHaveLength(2);
|
|
345
|
+
expect((mergedOps[0].params.operation as { new_str: string }).new_str).toBe('C'); // newest
|
|
346
|
+
expect((mergedOps[1].params.operation as { old_str: string }).old_str).toBe('A'); // oldest
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('does not merge ops more than 30s apart', () => {
|
|
350
|
+
const operations: OperationEntry[] = [
|
|
351
|
+
{ ts: '2026-02-05T10:01:00.000Z', tool: 'write_resource', params: { uri: 'mcp://backlog/tasks/TASK-0001.md', operation: { type: 'str_replace', old_str: 'B', new_str: 'C' } }, result: {} },
|
|
352
|
+
{ ts: '2026-02-05T10:00:00.000Z', tool: 'write_resource', params: { uri: 'mcp://backlog/tasks/TASK-0001.md', operation: { type: 'str_replace', old_str: 'A', new_str: 'B' } }, result: {} },
|
|
353
|
+
];
|
|
354
|
+
|
|
355
|
+
const merged = mergeConsecutiveEdits(operations);
|
|
356
|
+
|
|
357
|
+
expect(merged).toHaveLength(2);
|
|
358
|
+
expect(merged[0].params._mergedCount).toBeUndefined();
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('does not merge ops on different URIs', () => {
|
|
362
|
+
const operations: OperationEntry[] = [
|
|
363
|
+
{ ts: '2026-02-05T10:00:10.000Z', tool: 'write_resource', params: { uri: 'mcp://backlog/tasks/TASK-0002.md', operation: { type: 'str_replace', old_str: 'X', new_str: 'Y' } }, result: {} },
|
|
364
|
+
{ ts: '2026-02-05T10:00:00.000Z', tool: 'write_resource', params: { uri: 'mcp://backlog/tasks/TASK-0001.md', operation: { type: 'str_replace', old_str: 'A', new_str: 'B' } }, result: {} },
|
|
365
|
+
];
|
|
366
|
+
|
|
367
|
+
const merged = mergeConsecutiveEdits(operations);
|
|
368
|
+
|
|
369
|
+
expect(merged).toHaveLength(2);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('preserves non-str_replace operations', () => {
|
|
373
|
+
const operations: OperationEntry[] = [
|
|
374
|
+
{ ts: '2026-02-05T10:00:20.000Z', tool: 'backlog_update', params: { id: 'TASK-0001' }, result: {} },
|
|
375
|
+
{ ts: '2026-02-05T10:00:10.000Z', tool: 'write_resource', params: { uri: 'mcp://backlog/tasks/TASK-0001.md', operation: { type: 'str_replace', old_str: 'A', new_str: 'B' } }, result: {} },
|
|
376
|
+
];
|
|
377
|
+
|
|
378
|
+
const merged = mergeConsecutiveEdits(operations);
|
|
379
|
+
|
|
380
|
+
expect(merged).toHaveLength(2);
|
|
381
|
+
expect(merged[0].tool).toBe('backlog_update');
|
|
382
|
+
expect(merged[1].tool).toBe('write_resource');
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('returns empty array for empty input', () => {
|
|
386
|
+
expect(mergeConsecutiveEdits([])).toEqual([]);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it('returns single op unchanged', () => {
|
|
390
|
+
const operations: OperationEntry[] = [
|
|
391
|
+
{ ts: '2026-02-05T10:00:00.000Z', tool: 'write_resource', params: { uri: 'mcp://backlog/tasks/TASK-0001.md', operation: { type: 'str_replace', old_str: 'A', new_str: 'B' } }, result: {} },
|
|
392
|
+
];
|
|
393
|
+
|
|
394
|
+
const merged = mergeConsecutiveEdits(operations);
|
|
395
|
+
|
|
396
|
+
expect(merged).toHaveLength(1);
|
|
397
|
+
expect(merged[0].params._mergedCount).toBeUndefined();
|
|
398
|
+
});
|
|
399
|
+
});
|
|
328
400
|
});
|
|
@@ -132,9 +132,89 @@ export function groupByTask(operations: OperationEntry[]): TaskGroup[] {
|
|
|
132
132
|
.sort((a, b) => b.mostRecentTs.localeCompare(a.mostRecentTs));
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
const MERGE_WINDOW_MS = 30000; // 30 seconds
|
|
136
|
+
|
|
137
|
+
interface StrReplaceOp {
|
|
138
|
+
type: 'str_replace';
|
|
139
|
+
old_str: string;
|
|
140
|
+
new_str: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function isStrReplace(op: OperationEntry): op is OperationEntry & { params: { uri: string; operation: StrReplaceOp } } {
|
|
144
|
+
if (op.tool !== 'write_resource') return false;
|
|
145
|
+
const operation = op.params.operation as { type?: string } | undefined;
|
|
146
|
+
return operation?.type === 'str_replace';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Merge consecutive str_replace operations on the same URI within a time window.
|
|
151
|
+
* Operations are in reverse chronological order (newest first).
|
|
152
|
+
* Stores all operations in _mergedOps for stacked diff rendering.
|
|
153
|
+
*/
|
|
154
|
+
export function mergeConsecutiveEdits(operations: OperationEntry[]): OperationEntry[] {
|
|
155
|
+
if (operations.length <= 1) return operations;
|
|
156
|
+
|
|
157
|
+
const result: OperationEntry[] = [];
|
|
158
|
+
let i = 0;
|
|
159
|
+
|
|
160
|
+
while (i < operations.length) {
|
|
161
|
+
const current = operations[i];
|
|
162
|
+
|
|
163
|
+
if (!isStrReplace(current)) {
|
|
164
|
+
result.push(current);
|
|
165
|
+
i++;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Start a merge group
|
|
170
|
+
const group: OperationEntry[] = [current];
|
|
171
|
+
const uri = current.params.uri;
|
|
172
|
+
let j = i + 1;
|
|
173
|
+
|
|
174
|
+
// Collect consecutive str_replace ops on same URI within time window
|
|
175
|
+
while (j < operations.length) {
|
|
176
|
+
const next = operations[j];
|
|
177
|
+
if (!isStrReplace(next) || next.params.uri !== uri) break;
|
|
178
|
+
|
|
179
|
+
// Check time gap (operations are newest-first, so group[last] is older)
|
|
180
|
+
const newerTs = new Date(group[group.length - 1].ts).getTime();
|
|
181
|
+
const olderTs = new Date(next.ts).getTime();
|
|
182
|
+
if (newerTs - olderTs > MERGE_WINDOW_MS) break;
|
|
183
|
+
|
|
184
|
+
group.push(next);
|
|
185
|
+
j++;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (group.length === 1) {
|
|
189
|
+
result.push(current);
|
|
190
|
+
} else {
|
|
191
|
+
// Create merged operation with all individual ops for stacked rendering
|
|
192
|
+
const newest = group[0];
|
|
193
|
+
const oldest = group[group.length - 1];
|
|
194
|
+
|
|
195
|
+
const merged: OperationEntry = {
|
|
196
|
+
...newest,
|
|
197
|
+
params: {
|
|
198
|
+
...newest.params,
|
|
199
|
+
_mergedCount: group.length,
|
|
200
|
+
_mergedOps: group, // Store all ops for stacked diff rendering
|
|
201
|
+
_mergedRange: { from: oldest.ts, to: newest.ts },
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
result.push(merged);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
i = j;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return result;
|
|
211
|
+
}
|
|
212
|
+
|
|
135
213
|
/**
|
|
136
214
|
* Aggregate operations for journal view.
|
|
137
215
|
* Categorizes tasks by their most significant status change.
|
|
216
|
+
* Each task appears in only ONE category (highest priority wins):
|
|
217
|
+
* completed > in_progress > created > updated
|
|
138
218
|
*/
|
|
139
219
|
export function aggregateForJournal(operations: OperationEntry[]): JournalData {
|
|
140
220
|
const completed: JournalEntry[] = [];
|
|
@@ -142,40 +222,56 @@ export function aggregateForJournal(operations: OperationEntry[]): JournalData {
|
|
|
142
222
|
const created: JournalEntry[] = [];
|
|
143
223
|
const updated: JournalEntry[] = [];
|
|
144
224
|
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
const seenCreated = new Set<string>();
|
|
148
|
-
const seenUpdated = new Set<string>();
|
|
225
|
+
// Track the highest priority state for each task
|
|
226
|
+
const taskState = new Map<string, { state: 'completed' | 'in_progress' | 'created' | 'updated'; title: string; epicId?: string; epicTitle?: string }>();
|
|
149
227
|
|
|
228
|
+
// Process operations (newest first) to find the latest state
|
|
150
229
|
for (const op of operations) {
|
|
151
230
|
const resourceId = op.resourceId;
|
|
152
231
|
if (!resourceId) continue;
|
|
153
232
|
|
|
154
|
-
// Use resourceTitle from server enrichment, fall back to params.title or resourceId
|
|
155
233
|
const title = op.resourceTitle || (op.params.title as string) || resourceId;
|
|
156
234
|
const epicId = op.epicId;
|
|
157
235
|
const epicTitle = op.epicTitle;
|
|
236
|
+
const entry = { title, epicId, epicTitle };
|
|
158
237
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
created.push({ resourceId, title, epicId, epicTitle });
|
|
163
|
-
}
|
|
164
|
-
} else if (op.tool === 'backlog_update') {
|
|
238
|
+
const existing = taskState.get(resourceId);
|
|
239
|
+
|
|
240
|
+
if (op.tool === 'backlog_update') {
|
|
165
241
|
const status = op.params.status as string | undefined;
|
|
166
|
-
if (status === 'done'
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
242
|
+
if (status === 'done') {
|
|
243
|
+
// Completed is highest priority - always set
|
|
244
|
+
if (!existing || existing.state !== 'completed') {
|
|
245
|
+
taskState.set(resourceId, { state: 'completed', ...entry });
|
|
246
|
+
}
|
|
247
|
+
} else if (status === 'in_progress') {
|
|
248
|
+
// In progress beats created/updated
|
|
249
|
+
if (!existing || (existing.state !== 'completed' && existing.state !== 'in_progress')) {
|
|
250
|
+
taskState.set(resourceId, { state: 'in_progress', ...entry });
|
|
251
|
+
}
|
|
252
|
+
} else if (!existing) {
|
|
253
|
+
// Generic update - lowest priority
|
|
254
|
+
taskState.set(resourceId, { state: 'updated', ...entry });
|
|
255
|
+
}
|
|
256
|
+
} else if (op.tool === 'backlog_create') {
|
|
257
|
+
// Created beats updated only
|
|
258
|
+
if (!existing || existing.state === 'updated') {
|
|
259
|
+
taskState.set(resourceId, { state: 'created', ...entry });
|
|
175
260
|
}
|
|
176
261
|
}
|
|
177
262
|
}
|
|
178
263
|
|
|
264
|
+
// Build result arrays
|
|
265
|
+
for (const [resourceId, data] of taskState) {
|
|
266
|
+
const entry: JournalEntry = { resourceId, title: data.title, epicId: data.epicId, epicTitle: data.epicTitle };
|
|
267
|
+
switch (data.state) {
|
|
268
|
+
case 'completed': completed.push(entry); break;
|
|
269
|
+
case 'in_progress': inProgress.push(entry); break;
|
|
270
|
+
case 'created': created.push(entry); break;
|
|
271
|
+
case 'updated': updated.push(entry); break;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
179
275
|
return { completed, inProgress, created, updated };
|
|
180
276
|
}
|
|
181
277
|
|
|
@@ -113,6 +113,11 @@ class SpotlightSearch extends HTMLElement {
|
|
|
113
113
|
});
|
|
114
114
|
});
|
|
115
115
|
|
|
116
|
+
this.attachTabListeners();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Separate method for tab-related listeners that get re-rendered
|
|
120
|
+
private attachTabListeners() {
|
|
116
121
|
// Tab buttons
|
|
117
122
|
this.querySelectorAll('.spotlight-tab-btn').forEach(btn => {
|
|
118
123
|
btn.addEventListener('click', (e) => {
|
|
@@ -151,7 +156,7 @@ class SpotlightSearch extends HTMLElement {
|
|
|
151
156
|
this.activeTab = tab;
|
|
152
157
|
this.selectedIndex = 0;
|
|
153
158
|
this.renderDefaultTabs();
|
|
154
|
-
this.
|
|
159
|
+
this.attachTabListeners();
|
|
155
160
|
}
|
|
156
161
|
|
|
157
162
|
private selectTabItem(id: string, type: 'task' | 'epic' | 'resource') {
|
|
@@ -190,7 +195,7 @@ class SpotlightSearch extends HTMLElement {
|
|
|
190
195
|
this.isLoadingActivity = false;
|
|
191
196
|
if (this.query.length < 2) {
|
|
192
197
|
this.renderDefaultTabs();
|
|
193
|
-
this.
|
|
198
|
+
this.attachTabListeners();
|
|
194
199
|
}
|
|
195
200
|
}
|
|
196
201
|
}
|
package/viewer/styles.css
CHANGED
|
@@ -1560,6 +1560,16 @@ copy-button {
|
|
|
1560
1560
|
flex-shrink: 0;
|
|
1561
1561
|
}
|
|
1562
1562
|
|
|
1563
|
+
.activity-merged-badge {
|
|
1564
|
+
font-size: 11px;
|
|
1565
|
+
font-weight: 500;
|
|
1566
|
+
color: #58a6ff;
|
|
1567
|
+
background: rgba(88, 166, 255, 0.15);
|
|
1568
|
+
padding: 2px 6px;
|
|
1569
|
+
border-radius: 4px;
|
|
1570
|
+
margin-left: 6px;
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1563
1573
|
.activity-resource {
|
|
1564
1574
|
flex: 1;
|
|
1565
1575
|
color: #8b949e;
|
|
@@ -1746,6 +1756,7 @@ copy-button {
|
|
|
1746
1756
|
border-radius: 6px;
|
|
1747
1757
|
overflow: hidden;
|
|
1748
1758
|
margin-top: 8px;
|
|
1759
|
+
position: relative; /* Fix for d2h-code-linenumber absolute positioning */
|
|
1749
1760
|
}
|
|
1750
1761
|
|
|
1751
1762
|
.activity-diff .d2h-file-header {
|
|
@@ -1754,19 +1765,37 @@ copy-button {
|
|
|
1754
1765
|
|
|
1755
1766
|
.activity-diff .d2h-file-wrapper {
|
|
1756
1767
|
margin-bottom: 0;
|
|
1768
|
+
border: none;
|
|
1757
1769
|
}
|
|
1758
1770
|
|
|
1759
1771
|
.activity-diff .d2h-diff-table {
|
|
1760
1772
|
font-size: 12px;
|
|
1761
1773
|
}
|
|
1762
1774
|
|
|
1775
|
+
.activity-diff .d2h-code-linenumber {
|
|
1776
|
+
position: sticky;
|
|
1777
|
+
left: 0;
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
/* Stacked diffs container */
|
|
1781
|
+
.activity-diff-stack {
|
|
1782
|
+
display: flex;
|
|
1783
|
+
flex-direction: column;
|
|
1784
|
+
gap: 4px;
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
.activity-diff-stack .activity-diff {
|
|
1788
|
+
margin-top: 0;
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1763
1791
|
/* Day grouping styles */
|
|
1764
1792
|
.activity-day-separator {
|
|
1765
1793
|
display: flex;
|
|
1766
1794
|
align-items: center;
|
|
1767
1795
|
gap: 8px;
|
|
1768
|
-
padding: 12px 12px
|
|
1769
|
-
margin-top:
|
|
1796
|
+
padding: 12px 12px;
|
|
1797
|
+
margin-top: 20px;
|
|
1798
|
+
margin-bottom: 12px;
|
|
1770
1799
|
position: sticky;
|
|
1771
1800
|
top: 0;
|
|
1772
1801
|
background: #252526;
|
|
@@ -1777,7 +1806,6 @@ copy-button {
|
|
|
1777
1806
|
|
|
1778
1807
|
.activity-day-separator:first-child {
|
|
1779
1808
|
margin-top: 0;
|
|
1780
|
-
padding-top: 12px;
|
|
1781
1809
|
}
|
|
1782
1810
|
|
|
1783
1811
|
.activity-day-label {
|
|
@@ -1978,22 +2006,26 @@ copy-button {
|
|
|
1978
2006
|
flex: 1;
|
|
1979
2007
|
overflow-y: auto;
|
|
1980
2008
|
padding: 12px;
|
|
2009
|
+
padding-bottom: 24px;
|
|
1981
2010
|
}
|
|
1982
2011
|
|
|
1983
2012
|
.activity-journal-section {
|
|
1984
|
-
margin-bottom:
|
|
2013
|
+
margin-bottom: 24px;
|
|
2014
|
+
background: #1e1e1e;
|
|
2015
|
+
border-radius: 8px;
|
|
2016
|
+
padding: 12px;
|
|
1985
2017
|
}
|
|
1986
2018
|
|
|
1987
2019
|
.activity-journal-section:last-child {
|
|
1988
|
-
margin-bottom:
|
|
2020
|
+
margin-bottom: 12px;
|
|
1989
2021
|
}
|
|
1990
2022
|
|
|
1991
2023
|
.activity-journal-section-title {
|
|
1992
2024
|
font-weight: 600;
|
|
1993
2025
|
color: #d4d4d4;
|
|
1994
2026
|
font-size: 14px;
|
|
1995
|
-
margin-bottom:
|
|
1996
|
-
padding-bottom:
|
|
2027
|
+
margin-bottom: 12px;
|
|
2028
|
+
padding-bottom: 8px;
|
|
1997
2029
|
border-bottom: 1px solid #30363d;
|
|
1998
2030
|
}
|
|
1999
2031
|
|
|
@@ -2007,7 +2039,18 @@ copy-button {
|
|
|
2007
2039
|
display: flex;
|
|
2008
2040
|
align-items: center;
|
|
2009
2041
|
gap: 8px;
|
|
2010
|
-
padding:
|
|
2042
|
+
padding: 8px 0;
|
|
2043
|
+
border-bottom: 1px solid #252525;
|
|
2044
|
+
flex-wrap: nowrap;
|
|
2045
|
+
min-width: 0;
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
.activity-journal-item:last-child {
|
|
2049
|
+
border-bottom: none;
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
.activity-journal-item task-badge {
|
|
2053
|
+
flex-shrink: 0;
|
|
2011
2054
|
}
|
|
2012
2055
|
|
|
2013
2056
|
.activity-journal-title {
|
|
@@ -2016,6 +2059,8 @@ copy-button {
|
|
|
2016
2059
|
overflow: hidden;
|
|
2017
2060
|
text-overflow: ellipsis;
|
|
2018
2061
|
white-space: nowrap;
|
|
2062
|
+
flex: 1;
|
|
2063
|
+
min-width: 0;
|
|
2019
2064
|
}
|
|
2020
2065
|
|
|
2021
2066
|
/* Epic grouping in journal */
|
|
@@ -2032,12 +2077,20 @@ copy-button {
|
|
|
2032
2077
|
align-items: center;
|
|
2033
2078
|
gap: 8px;
|
|
2034
2079
|
padding: 8px 0 4px 0;
|
|
2080
|
+
flex-wrap: nowrap;
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
.activity-journal-epic-header task-badge {
|
|
2084
|
+
flex-shrink: 0;
|
|
2035
2085
|
}
|
|
2036
2086
|
|
|
2037
2087
|
.activity-journal-epic-title {
|
|
2038
2088
|
font-weight: 500;
|
|
2039
2089
|
color: #d4d4d4;
|
|
2040
2090
|
font-size: 13px;
|
|
2091
|
+
overflow: hidden;
|
|
2092
|
+
text-overflow: ellipsis;
|
|
2093
|
+
white-space: nowrap;
|
|
2041
2094
|
}
|
|
2042
2095
|
|
|
2043
2096
|
.activity-journal-epic-group .activity-journal-list {
|
|
@@ -2047,5 +2100,5 @@ copy-button {
|
|
|
2047
2100
|
}
|
|
2048
2101
|
|
|
2049
2102
|
.activity-journal-epic-group .activity-journal-item {
|
|
2050
|
-
padding:
|
|
2103
|
+
padding: 6px 0;
|
|
2051
2104
|
}
|