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.
@@ -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;EAE/B,IAAI,UAAU,KAAK,SAAS;AAE5B,MAAI,OACF,WAAU,QAAQ,QAAO,MAAK,EAAE,eAAe,OAAO;AAIxD,SAAO,QAAQ,SAAS,CAAC,MAAM,GAAG,MAAM;;;;;CAM1C,aAAa,QAAwB;AACnC,SAAO,KAAK,MAAM;GAAE;GAAQ,OAAO;GAAM,CAAC,CAAC"}
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"}
@@ -19,6 +19,7 @@ interface OperationEntry {
19
19
  }
20
20
  interface OperationFilter {
21
21
  taskId?: string;
22
+ date?: string;
22
23
  limit?: number;
23
24
  }
24
25
  declare const WRITE_TOOLS: ToolName[];
@@ -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":";AA2BA,MAAa,cAA0B;CAAC;CAAkB;CAAkB;CAAkB;CAAiB"}
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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"}
@@ -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 10px;
1580
- margin-top: 16px;
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: 20px;
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: 0;
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: 8px;
1772
- padding-bottom: 4px;
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: 6px 0;
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: 4px 0;
1861
+ padding: 6px 0;
1817
1862
  }
1818
1863
 
1819
1864
  /* viewer/github-markdown.css */
@@ -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.attachEventListeners();
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.attachEventListeners();
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 seenCompleted = /* @__PURE__ */ new Set();
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
- if (op.tool === "backlog_create") {
6870
- if (!seenCreated.has(resourceId)) {
6871
- seenCreated.add(resourceId);
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" && !seenCompleted.has(resourceId)) {
6877
- seenCompleted.add(resourceId);
6878
- completed.push({ resourceId, title, epicId, epicTitle });
6879
- } else if (status === "in_progress" && !seenInProgress.has(resourceId)) {
6880
- seenInProgress.add(resourceId);
6881
- inProgress.push({ resourceId, title, epicId, epicTitle });
6882
- } else if (!seenUpdated.has(resourceId) && !seenCompleted.has(resourceId) && !seenInProgress.has(resourceId)) {
6883
- seenUpdated.add(resourceId);
6884
- updated.push({ resourceId, title, epicId, epicTitle });
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
- this.render();
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.render();
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
- const url = this.taskId ? `/operations?task=${encodeURIComponent(this.taskId)}&limit=100` : "/operations?limit=100";
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 = taskGroup.operations.length > DEFAULT_VISIBLE_ITEMS;
7061
- const visibleOps = isExpanded ? taskGroup.operations : taskGroup.operations.slice(0, DEFAULT_VISIBLE_ITEMS);
7062
- const hiddenCount = taskGroup.operations.length - DEFAULT_VISIBLE_ITEMS;
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, index) {
7157
- const isExpanded = this.expandedIndex === index;
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-index="${index}">
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" && op.params.operation) {
7239
- const operation = op.params.operation;
7240
- if (operation.type === "str_replace" && operation.old_str && operation.new_str) {
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
- const unifiedDiff = createUnifiedDiff(operation.old_str, operation.new_str, filename);
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(unifiedDiff, {
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
- content += `
7257
- <div class="activity-detail-row">
7258
- <span class="activity-detail-label">Operation:</span>
7259
- <span class="activity-detail-value">${operation.type}</span>
7260
- </div>
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 index = parseInt(item?.getAttribute("data-index") || "0");
7308
- this.toggleExpand(index);
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
7334
7448
  }
7335
- toggleExpand(index) {
7336
- this.expandedIndex = this.expandedIndex === index ? null : index;
7449
+ toggleExpand(opId) {
7450
+ this.expandedIndex = this.expandedIndex === opId ? null : opId;
7337
7451
  this.render();
7338
7452
  }
7339
7453
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backlog-mcp",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
4
4
  "description": "Minimal task backlog MCP server for Claude and AI agents",
5
5
  "keywords": [
6
6
  "mcp",
@@ -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: number | null = null;
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
- this.render();
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
- this.render();
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
- const url = this.taskId
112
- ? `/operations?task=${encodeURIComponent(this.taskId)}&limit=100`
113
- : '/operations?limit=100';
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 = taskGroup.operations.length > DEFAULT_VISIBLE_ITEMS;
203
+ const hasMore = mergedOps.length > DEFAULT_VISIBLE_ITEMS;
183
204
  const visibleOps = isExpanded
184
- ? taskGroup.operations
185
- : taskGroup.operations.slice(0, DEFAULT_VISIBLE_ITEMS);
186
- const hiddenCount = taskGroup.operations.length - DEFAULT_VISIBLE_ITEMS;
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, index: number): string {
294
- const isExpanded = this.expandedIndex === index;
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-index="${index}">
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' && op.params.operation) {
385
- const operation = op.params.operation as { type: string; old_str?: string; new_str?: string };
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 (operation.type === 'str_replace' && operation.old_str && operation.new_str) {
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
- const unifiedDiff = createUnifiedDiff(operation.old_str, operation.new_str, filename);
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(unifiedDiff, {
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
- content += `
404
- <div class="activity-detail-row">
405
- <span class="activity-detail-label">Operation:</span>
406
- <span class="activity-detail-value">${operation.type}</span>
407
- </div>
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 index = parseInt(item?.getAttribute('data-index') || '0');
466
- this.toggleExpand(index);
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, '&quot;');
501
559
  }
502
560
 
503
- private toggleExpand(index: number) {
504
- this.expandedIndex = this.expandedIndex === index ? null : index;
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 first status change for each task', () => {
192
- // Operations are processed in order - first status wins for each category
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
- // Both statuses are recorded since they're different categories
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
- const seenCompleted = new Set<string>();
146
- const seenInProgress = new Set<string>();
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
- if (op.tool === 'backlog_create') {
160
- if (!seenCreated.has(resourceId)) {
161
- seenCreated.add(resourceId);
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' && !seenCompleted.has(resourceId)) {
167
- seenCompleted.add(resourceId);
168
- completed.push({ resourceId, title, epicId, epicTitle });
169
- } else if (status === 'in_progress' && !seenInProgress.has(resourceId)) {
170
- seenInProgress.add(resourceId);
171
- inProgress.push({ resourceId, title, epicId, epicTitle });
172
- } else if (!seenUpdated.has(resourceId) && !seenCompleted.has(resourceId) && !seenInProgress.has(resourceId)) {
173
- seenUpdated.add(resourceId);
174
- updated.push({ resourceId, title, epicId, epicTitle });
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.attachEventListeners();
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.attachEventListeners();
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 10px;
1769
- margin-top: 16px;
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: 20px;
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: 0;
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: 8px;
1996
- padding-bottom: 4px;
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: 6px 0;
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: 4px 0;
2103
+ padding: 6px 0;
2051
2104
  }