backlog-mcp 0.30.0 → 0.32.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.
@@ -0,0 +1,27 @@
1
+ //#region src/events/event-bus.d.ts
2
+ /**
3
+ * EventBus interface and event types for real-time viewer updates.
4
+ *
5
+ * The interface is designed to be pluggable: start with in-process
6
+ * EventEmitter (LocalEventBus), swap to Redis Pub/Sub or NATS
7
+ * for cloud deployment without changing consumers.
8
+ */
9
+ type BacklogEventType = 'task_changed' | 'task_created' | 'task_deleted' | 'resource_changed';
10
+ interface BacklogEvent {
11
+ seq: number;
12
+ type: BacklogEventType;
13
+ id: string;
14
+ tool: string;
15
+ actor: string;
16
+ ts: string;
17
+ }
18
+ type BacklogEventCallback = (event: BacklogEvent) => void;
19
+ interface EventBus {
20
+ emit(event: Omit<BacklogEvent, 'seq'>): void;
21
+ subscribe(callback: BacklogEventCallback): void;
22
+ unsubscribe(callback: BacklogEventCallback): void;
23
+ replaySince(seq: number): BacklogEvent[];
24
+ }
25
+ //#endregion
26
+ export { BacklogEvent, BacklogEventCallback, BacklogEventType, EventBus };
27
+ //# sourceMappingURL=event-bus.d.mts.map
@@ -0,0 +1 @@
1
+ export { };
@@ -0,0 +1,9 @@
1
+ import { BacklogEvent, BacklogEventCallback, BacklogEventType, EventBus } from "./event-bus.mjs";
2
+ import { LocalEventBus } from "./local-event-bus.mjs";
3
+
4
+ //#region src/events/index.d.ts
5
+ /** Singleton event bus instance. Swap implementation for cloud deployment. */
6
+ declare const eventBus: LocalEventBus;
7
+ //#endregion
8
+ export { type BacklogEvent, type BacklogEventCallback, type BacklogEventType, type EventBus, LocalEventBus, eventBus };
9
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1,9 @@
1
+ import { LocalEventBus } from "./local-event-bus.mjs";
2
+
3
+ //#region src/events/index.ts
4
+ /** Singleton event bus instance. Swap implementation for cloud deployment. */
5
+ const eventBus = new LocalEventBus();
6
+
7
+ //#endregion
8
+ export { LocalEventBus, eventBus };
9
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../../src/events/index.ts"],"sourcesContent":["export type { BacklogEvent, BacklogEventType, BacklogEventCallback, EventBus } from './event-bus.js';\nexport { LocalEventBus } from './local-event-bus.js';\n\nimport { LocalEventBus } from './local-event-bus.js';\n\n/** Singleton event bus instance. Swap implementation for cloud deployment. */\nexport const eventBus = new LocalEventBus();\n"],"mappings":";;;;AAMA,MAAa,WAAW,IAAI,eAAe"}
@@ -0,0 +1,16 @@
1
+ import { BacklogEvent, BacklogEventCallback, EventBus } from "./event-bus.mjs";
2
+
3
+ //#region src/events/local-event-bus.d.ts
4
+ declare class LocalEventBus implements EventBus {
5
+ private emitter;
6
+ private seq;
7
+ private buffer;
8
+ constructor();
9
+ emit(event: Omit<BacklogEvent, 'seq'>): void;
10
+ subscribe(callback: BacklogEventCallback): void;
11
+ unsubscribe(callback: BacklogEventCallback): void;
12
+ replaySince(seq: number): BacklogEvent[];
13
+ }
14
+ //#endregion
15
+ export { LocalEventBus };
16
+ //# sourceMappingURL=local-event-bus.d.mts.map
@@ -0,0 +1,39 @@
1
+ import { EventEmitter } from "node:events";
2
+
3
+ //#region src/events/local-event-bus.ts
4
+ /**
5
+ * In-process EventBus implementation using Node.js EventEmitter.
6
+ * Maintains a ring buffer for replay on SSE reconnect.
7
+ */
8
+ const RING_BUFFER_SIZE = 1e3;
9
+ const EVENT_NAME = "backlog";
10
+ var LocalEventBus = class {
11
+ emitter = new EventEmitter();
12
+ seq = 0;
13
+ buffer = [];
14
+ constructor() {
15
+ this.emitter.setMaxListeners(100);
16
+ }
17
+ emit(event) {
18
+ const full = {
19
+ ...event,
20
+ seq: ++this.seq
21
+ };
22
+ if (this.buffer.length >= RING_BUFFER_SIZE) this.buffer.shift();
23
+ this.buffer.push(full);
24
+ this.emitter.emit(EVENT_NAME, full);
25
+ }
26
+ subscribe(callback) {
27
+ this.emitter.on(EVENT_NAME, callback);
28
+ }
29
+ unsubscribe(callback) {
30
+ this.emitter.off(EVENT_NAME, callback);
31
+ }
32
+ replaySince(seq) {
33
+ return this.buffer.filter((e) => e.seq > seq);
34
+ }
35
+ };
36
+
37
+ //#endregion
38
+ export { LocalEventBus };
39
+ //# sourceMappingURL=local-event-bus.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"local-event-bus.mjs","names":[],"sources":["../../src/events/local-event-bus.ts"],"sourcesContent":["/**\n * In-process EventBus implementation using Node.js EventEmitter.\n * Maintains a ring buffer for replay on SSE reconnect.\n */\n\nimport { EventEmitter } from 'node:events';\nimport type { BacklogEvent, BacklogEventCallback, EventBus } from './event-bus.js';\n\nconst RING_BUFFER_SIZE = 1000;\nconst EVENT_NAME = 'backlog';\n\nexport class LocalEventBus implements EventBus {\n private emitter = new EventEmitter();\n private seq = 0;\n private buffer: BacklogEvent[] = [];\n\n constructor() {\n this.emitter.setMaxListeners(100);\n }\n\n emit(event: Omit<BacklogEvent, 'seq'>): void {\n const full: BacklogEvent = { ...event, seq: ++this.seq };\n\n // Ring buffer: drop oldest when full\n if (this.buffer.length >= RING_BUFFER_SIZE) {\n this.buffer.shift();\n }\n this.buffer.push(full);\n\n this.emitter.emit(EVENT_NAME, full);\n }\n\n subscribe(callback: BacklogEventCallback): void {\n this.emitter.on(EVENT_NAME, callback);\n }\n\n unsubscribe(callback: BacklogEventCallback): void {\n this.emitter.off(EVENT_NAME, callback);\n }\n\n replaySince(seq: number): BacklogEvent[] {\n return this.buffer.filter(e => e.seq > seq);\n }\n}\n"],"mappings":";;;;;;;AAQA,MAAM,mBAAmB;AACzB,MAAM,aAAa;AAEnB,IAAa,gBAAb,MAA+C;CAC7C,AAAQ,UAAU,IAAI,cAAc;CACpC,AAAQ,MAAM;CACd,AAAQ,SAAyB,EAAE;CAEnC,cAAc;AACZ,OAAK,QAAQ,gBAAgB,IAAI;;CAGnC,KAAK,OAAwC;EAC3C,MAAM,OAAqB;GAAE,GAAG;GAAO,KAAK,EAAE,KAAK;GAAK;AAGxD,MAAI,KAAK,OAAO,UAAU,iBACxB,MAAK,OAAO,OAAO;AAErB,OAAK,OAAO,KAAK,KAAK;AAEtB,OAAK,QAAQ,KAAK,YAAY,KAAK;;CAGrC,UAAU,UAAsC;AAC9C,OAAK,QAAQ,GAAG,YAAY,SAAS;;CAGvC,YAAY,UAAsC;AAChD,OAAK,QAAQ,IAAI,YAAY,SAAS;;CAGxC,YAAY,KAA6B;AACvC,SAAO,KAAK,OAAO,QAAO,MAAK,EAAE,MAAM,IAAI"}
@@ -2,7 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
 
3
3
  //#region src/operations/middleware.d.ts
4
4
  /**
5
- * Wrap an MCP server to log tool operations.
5
+ * Wrap an MCP server to log tool operations and emit real-time events.
6
6
  * Returns a proxy that intercepts registerTool calls.
7
7
  */
8
8
  declare function withOperationLogging(server: McpServer): McpServer;
@@ -1,8 +1,21 @@
1
+ import { extractResourceId } from "./resource-id.mjs";
2
+ import { WRITE_TOOLS } from "./types.mjs";
1
3
  import { operationLogger } from "./logger.mjs";
4
+ import { eventBus } from "../events/index.mjs";
2
5
 
3
6
  //#region src/operations/middleware.ts
7
+ /** Declarative mapping from write tool to event type. */
8
+ const TOOL_EVENT_MAP = {
9
+ backlog_create: "task_created",
10
+ backlog_update: "task_changed",
11
+ backlog_delete: "task_deleted",
12
+ write_resource: "resource_changed"
13
+ };
14
+ function isWriteTool(name) {
15
+ return WRITE_TOOLS.includes(name);
16
+ }
4
17
  /**
5
- * Wrap an MCP server to log tool operations.
18
+ * Wrap an MCP server to log tool operations and emit real-time events.
6
19
  * Returns a proxy that intercepts registerTool calls.
7
20
  */
8
21
  function withOperationLogging(server) {
@@ -10,7 +23,15 @@ function withOperationLogging(server) {
10
23
  server.registerTool = function(name, config, callback) {
11
24
  const wrappedCallback = async (...args) => {
12
25
  const result = await callback(...args);
13
- operationLogger.log(name, args[0] || {}, result);
26
+ const params = args[0] || {};
27
+ operationLogger.log(name, params, result);
28
+ if (isWriteTool(name)) eventBus.emit({
29
+ type: TOOL_EVENT_MAP[name],
30
+ id: extractResourceId(name, params, result) || "",
31
+ tool: name,
32
+ actor: process.env.BACKLOG_ACTOR_NAME || process.env.USER || "unknown",
33
+ ts: (/* @__PURE__ */ new Date()).toISOString()
34
+ });
14
35
  return result;
15
36
  };
16
37
  return originalRegisterTool(name, config, wrappedCallback);
@@ -1 +1 @@
1
- {"version":3,"file":"middleware.mjs","names":[],"sources":["../../src/operations/middleware.ts"],"sourcesContent":["import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\nimport { operationLogger } from './logger.js';\n\n/**\n * Wrap an MCP server to log tool operations.\n * Returns a proxy that intercepts registerTool calls.\n */\nexport function withOperationLogging(server: McpServer): McpServer {\n const originalRegisterTool = server.registerTool.bind(server);\n\n // Override registerTool to wrap callbacks with logging\n (server as any).registerTool = function(\n name: string,\n config: any,\n callback: (...args: any[]) => any\n ) {\n const wrappedCallback = async (...args: any[]) => {\n const result = await callback(...args);\n // args[0] is the params object for tool callbacks\n operationLogger.log(name, args[0] || {}, result);\n return result;\n };\n\n return originalRegisterTool(name, config, wrappedCallback as any);\n };\n\n return server;\n}\n"],"mappings":";;;;;;;AAOA,SAAgB,qBAAqB,QAA8B;CACjE,MAAM,uBAAuB,OAAO,aAAa,KAAK,OAAO;AAG7D,CAAC,OAAe,eAAe,SAC7B,MACA,QACA,UACA;EACA,MAAM,kBAAkB,OAAO,GAAG,SAAgB;GAChD,MAAM,SAAS,MAAM,SAAS,GAAG,KAAK;AAEtC,mBAAgB,IAAI,MAAM,KAAK,MAAM,EAAE,EAAE,OAAO;AAChD,UAAO;;AAGT,SAAO,qBAAqB,MAAM,QAAQ,gBAAuB;;AAGnE,QAAO"}
1
+ {"version":3,"file":"middleware.mjs","names":[],"sources":["../../src/operations/middleware.ts"],"sourcesContent":["import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\nimport { operationLogger } from './logger.js';\nimport { eventBus } from '../events/index.js';\nimport type { BacklogEventType } from '../events/index.js';\nimport { extractResourceId } from './resource-id.js';\nimport { WRITE_TOOLS, type ToolName } from './types.js';\n\n/** Declarative mapping from write tool to event type. */\nconst TOOL_EVENT_MAP: Record<ToolName, BacklogEventType> = {\n backlog_create: 'task_created',\n backlog_update: 'task_changed',\n backlog_delete: 'task_deleted',\n write_resource: 'resource_changed',\n};\n\nfunction isWriteTool(name: string): name is ToolName {\n return WRITE_TOOLS.includes(name as ToolName);\n}\n\n/**\n * Wrap an MCP server to log tool operations and emit real-time events.\n * Returns a proxy that intercepts registerTool calls.\n */\nexport function withOperationLogging(server: McpServer): McpServer {\n const originalRegisterTool = server.registerTool.bind(server);\n\n // Override registerTool to wrap callbacks with logging\n (server as any).registerTool = function(\n name: string,\n config: any,\n callback: (...args: any[]) => any\n ) {\n const wrappedCallback = async (...args: any[]) => {\n const result = await callback(...args);\n const params = args[0] || {};\n\n // Log operation to disk\n operationLogger.log(name, params, result);\n\n // Emit real-time event for SSE consumers\n if (isWriteTool(name)) {\n eventBus.emit({\n type: TOOL_EVENT_MAP[name],\n id: extractResourceId(name, params, result) || '',\n tool: name,\n actor: process.env.BACKLOG_ACTOR_NAME || process.env.USER || 'unknown',\n ts: new Date().toISOString(),\n });\n }\n\n return result;\n };\n\n return originalRegisterTool(name, config, wrappedCallback as any);\n };\n\n return server;\n}\n"],"mappings":";;;;;;;AAQA,MAAM,iBAAqD;CACzD,gBAAgB;CAChB,gBAAgB;CAChB,gBAAgB;CAChB,gBAAgB;CACjB;AAED,SAAS,YAAY,MAAgC;AACnD,QAAO,YAAY,SAAS,KAAiB;;;;;;AAO/C,SAAgB,qBAAqB,QAA8B;CACjE,MAAM,uBAAuB,OAAO,aAAa,KAAK,OAAO;AAG7D,CAAC,OAAe,eAAe,SAC7B,MACA,QACA,UACA;EACA,MAAM,kBAAkB,OAAO,GAAG,SAAgB;GAChD,MAAM,SAAS,MAAM,SAAS,GAAG,KAAK;GACtC,MAAM,SAAS,KAAK,MAAM,EAAE;AAG5B,mBAAgB,IAAI,MAAM,QAAQ,OAAO;AAGzC,OAAI,YAAY,KAAK,CACnB,UAAS,KAAK;IACZ,MAAM,eAAe;IACrB,IAAI,kBAAkB,MAAM,QAAQ,OAAO,IAAI;IAC/C,MAAM;IACN,OAAO,QAAQ,IAAI,sBAAsB,QAAQ,IAAI,QAAQ;IAC7D,qBAAI,IAAI,MAAM,EAAC,aAAa;IAC7B,CAAC;AAGJ,UAAO;;AAGT,SAAO,qBAAqB,MAAM,QAAQ,gBAAuB;;AAGnE,QAAO"}
@@ -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"}
@@ -2,6 +2,7 @@ import { paths } from "../utils/paths.mjs";
2
2
  import { resourceManager } from "../resources/manager.mjs";
3
3
  import { storage } from "../storage/backlog-service.mjs";
4
4
  import { operationLogger } from "../operations/logger.mjs";
5
+ import { eventBus } from "../events/index.mjs";
5
6
  import "../operations/index.mjs";
6
7
  import { existsSync, readFileSync } from "node:fs";
7
8
  import matter from "gray-matter";
@@ -9,6 +10,7 @@ import fastifyStatic from "@fastify/static";
9
10
  import { exec } from "node:child_process";
10
11
 
11
12
  //#region src/server/viewer-routes.ts
13
+ const SSE_HEARTBEAT_MS = 3e4;
12
14
  function registerViewerRoutes(app) {
13
15
  app.register(fastifyStatic, {
14
16
  root: paths.viewerDist,
@@ -142,10 +144,11 @@ function registerViewerRoutes(app) {
142
144
  return reply.redirect(`/?resource=${encodeURIComponent(uri)}`);
143
145
  });
144
146
  app.get("/operations", async (request) => {
145
- const { limit, task } = request.query;
147
+ const { limit, task, date } = request.query;
146
148
  const operations = operationLogger.read({
147
- limit: limit ? parseInt(limit) : 50,
148
- taskId: task || void 0
149
+ limit: limit ? parseInt(limit) : date ? 1e3 : 50,
150
+ taskId: task || void 0,
151
+ date: date || void 0
149
152
  });
150
153
  const taskCache = /* @__PURE__ */ new Map();
151
154
  const epicCache = /* @__PURE__ */ new Map();
@@ -181,6 +184,36 @@ function registerViewerRoutes(app) {
181
184
  const { taskId } = request.params;
182
185
  return { count: operationLogger.countForTask(taskId) };
183
186
  });
187
+ app.get("/events", (request, reply) => {
188
+ const lastEventId = request.headers["last-event-id"];
189
+ reply.hijack();
190
+ const raw = reply.raw;
191
+ raw.writeHead(200, {
192
+ "Content-Type": "text/event-stream",
193
+ "Cache-Control": "no-cache",
194
+ "Connection": "keep-alive",
195
+ "X-Accel-Buffering": "no"
196
+ });
197
+ if (lastEventId) {
198
+ const seq = parseInt(lastEventId, 10);
199
+ if (!isNaN(seq)) {
200
+ const missed = eventBus.replaySince(seq);
201
+ for (const event of missed) raw.write(`id: ${event.seq}\ndata: ${JSON.stringify(event)}\n\n`);
202
+ }
203
+ }
204
+ raw.write(`: connected\n\n`);
205
+ const onEvent = (event) => {
206
+ raw.write(`id: ${event.seq}\ndata: ${JSON.stringify(event)}\n\n`);
207
+ };
208
+ eventBus.subscribe(onEvent);
209
+ const heartbeat = setInterval(() => {
210
+ raw.write(`: heartbeat\n\n`);
211
+ }, SSE_HEARTBEAT_MS);
212
+ request.raw.on("close", () => {
213
+ clearInterval(heartbeat);
214
+ eventBus.unsubscribe(onEvent);
215
+ });
216
+ });
184
217
  }
185
218
 
186
219
  //#endregion
@@ -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';\nimport { eventBus } from '../events/index.js';\nimport type { BacklogEvent } from '../events/index.js';\n\nconst SSE_HEARTBEAT_MS = 30_000;\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 // SSE endpoint for real-time viewer updates\n app.get('/events', (request, reply) => {\n const lastEventId = request.headers['last-event-id'] as string | undefined;\n\n reply.hijack();\n const raw = reply.raw;\n\n raw.writeHead(200, {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache',\n 'Connection': 'keep-alive',\n 'X-Accel-Buffering': 'no', // Disable nginx buffering\n });\n\n // Replay missed events if client reconnected with Last-Event-ID\n if (lastEventId) {\n const seq = parseInt(lastEventId, 10);\n if (!isNaN(seq)) {\n const missed = eventBus.replaySince(seq);\n for (const event of missed) {\n raw.write(`id: ${event.seq}\\ndata: ${JSON.stringify(event)}\\n\\n`);\n }\n }\n }\n\n // Send initial connected event\n raw.write(`: connected\\n\\n`);\n\n // Subscribe to new events\n const onEvent = (event: BacklogEvent) => {\n raw.write(`id: ${event.seq}\\ndata: ${JSON.stringify(event)}\\n\\n`);\n };\n eventBus.subscribe(onEvent);\n\n // Heartbeat to keep connection alive through proxies\n const heartbeat = setInterval(() => {\n raw.write(`: heartbeat\\n\\n`);\n }, SSE_HEARTBEAT_MS);\n\n // Cleanup on disconnect\n request.raw.on('close', () => {\n clearInterval(heartbeat);\n eventBus.unsubscribe(onEvent);\n });\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 */