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.
- package/dist/events/event-bus.d.mts +27 -0
- package/dist/events/event-bus.mjs +1 -0
- package/dist/events/index.d.mts +9 -0
- package/dist/events/index.mjs +9 -0
- package/dist/events/index.mjs.map +1 -0
- package/dist/events/local-event-bus.d.mts +16 -0
- package/dist/events/local-event-bus.mjs +39 -0
- package/dist/events/local-event-bus.mjs.map +1 -0
- package/dist/operations/middleware.d.mts +1 -1
- package/dist/operations/middleware.mjs +23 -2
- package/dist/operations/middleware.mjs.map +1 -1
- package/dist/operations/storage.mjs +2 -1
- package/dist/operations/storage.mjs.map +1 -1
- package/dist/operations/types.d.mts +1 -0
- package/dist/operations/types.mjs.map +1 -1
- package/dist/server/viewer-routes.mjs +36 -3
- package/dist/server/viewer-routes.mjs.map +1 -1
- package/dist/viewer/main.css +54 -9
- package/dist/viewer/main.js +215 -81
- package/package.json +1 -1
- package/viewer/components/activity-panel.ts +96 -65
- package/viewer/components/activity-utils.test.ts +76 -4
- package/viewer/components/activity-utils.ts +116 -20
- package/viewer/components/spotlight-search.ts +7 -2
- package/viewer/components/task-detail.ts +12 -1
- package/viewer/components/task-list.ts +33 -26
- package/viewer/main.ts +4 -0
- package/viewer/services/event-source-client.ts +62 -0
- package/viewer/styles.css +62 -9
|
@@ -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
|
-
|
|
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
|
|
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;
|
|
1
|
+
{"version":3,"file":"storage.mjs","names":[],"sources":["../../src/operations/storage.ts"],"sourcesContent":["/**\n * JSONL storage for operation entries.\n * Single responsibility: read/write operations to disk.\n */\n\nimport { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';\nimport { dirname, join } from 'node:path';\nimport { paths } from '@/utils/paths.js';\nimport type { OperationEntry, OperationFilter } from './types.js';\n\nexport class OperationStorage {\n private logPath: string;\n\n constructor() {\n this.logPath = join(paths.backlogDataDir, '.internal', 'operations.jsonl');\n }\n\n /**\n * Append an operation entry to the log file.\n */\n append(entry: OperationEntry): void {\n try {\n const dir = dirname(this.logPath);\n if (!existsSync(dir)) {\n mkdirSync(dir, { recursive: true });\n }\n appendFileSync(this.logPath, JSON.stringify(entry) + '\\n', 'utf-8');\n } catch {\n // Fail silently - logging should not break tool execution\n }\n }\n\n /**\n * Read all operations from the log file.\n */\n readAll(): OperationEntry[] {\n if (!existsSync(this.logPath)) return [];\n\n try {\n const content = readFileSync(this.logPath, 'utf-8');\n const lines = content.trim().split('\\n').filter(Boolean);\n \n return lines\n .map(line => {\n try {\n return JSON.parse(line) as OperationEntry;\n } catch {\n return null;\n }\n })\n .filter((e): e is OperationEntry => e !== null);\n } catch {\n return [];\n }\n }\n\n /**\n * Query operations with optional filtering.\n */\n query(filter: OperationFilter = {}): OperationEntry[] {\n const { taskId, date, limit = 50 } = filter;\n \n let entries = this.readAll();\n\n if (taskId) {\n entries = entries.filter(e => e.resourceId === taskId);\n }\n\n if (date) {\n // Filter by date (YYYY-MM-DD matches start of ISO timestamp)\n entries = entries.filter(e => e.ts.startsWith(date));\n }\n\n // Return most recent first, limited\n return entries.reverse().slice(0, limit);\n }\n\n /**\n * Count operations for a specific task.\n */\n countForTask(taskId: string): number {\n return this.query({ taskId, limit: 1000 }).length;\n }\n}\n"],"mappings":";;;;;;;;;AAUA,IAAa,mBAAb,MAA8B;CAC5B,AAAQ;CAER,cAAc;AACZ,OAAK,UAAU,KAAK,MAAM,gBAAgB,aAAa,mBAAmB;;;;;CAM5E,OAAO,OAA6B;AAClC,MAAI;GACF,MAAM,MAAM,QAAQ,KAAK,QAAQ;AACjC,OAAI,CAAC,WAAW,IAAI,CAClB,WAAU,KAAK,EAAE,WAAW,MAAM,CAAC;AAErC,kBAAe,KAAK,SAAS,KAAK,UAAU,MAAM,GAAG,MAAM,QAAQ;UAC7D;;;;;CAQV,UAA4B;AAC1B,MAAI,CAAC,WAAW,KAAK,QAAQ,CAAE,QAAO,EAAE;AAExC,MAAI;AAIF,UAHgB,aAAa,KAAK,SAAS,QAAQ,CAC7B,MAAM,CAAC,MAAM,KAAK,CAAC,OAAO,QAAQ,CAGrD,KAAI,SAAQ;AACX,QAAI;AACF,YAAO,KAAK,MAAM,KAAK;YACjB;AACN,YAAO;;KAET,CACD,QAAQ,MAA2B,MAAM,KAAK;UAC3C;AACN,UAAO,EAAE;;;;;;CAOb,MAAM,SAA0B,EAAE,EAAoB;EACpD,MAAM,EAAE,QAAQ,MAAM,QAAQ,OAAO;EAErC,IAAI,UAAU,KAAK,SAAS;AAE5B,MAAI,OACF,WAAU,QAAQ,QAAO,MAAK,EAAE,eAAe,OAAO;AAGxD,MAAI,KAEF,WAAU,QAAQ,QAAO,MAAK,EAAE,GAAG,WAAW,KAAK,CAAC;AAItD,SAAO,QAAQ,SAAS,CAAC,MAAM,GAAG,MAAM;;;;;CAM1C,aAAa,QAAwB;AACnC,SAAO,KAAK,MAAM;GAAE;GAAQ,OAAO;GAAM,CAAC,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.mjs","names":[],"sources":["../../src/operations/types.ts"],"sourcesContent":["/**\n * Types for operation logging.\n */\n\nexport interface Actor {\n type: 'user' | 'agent';\n name: string;\n delegatedBy?: string;\n taskContext?: string;\n}\n\nexport type ToolName = 'backlog_create' | 'backlog_update' | 'backlog_delete' | 'write_resource';\n\nexport interface OperationEntry {\n ts: string;\n tool: string;\n params: Record<string, unknown>;\n result: unknown;\n resourceId?: string;\n actor: Actor;\n}\n\nexport interface OperationFilter {\n taskId?: string;\n limit?: number;\n}\n\nexport const WRITE_TOOLS: ToolName[] = ['backlog_create', 'backlog_update', 'backlog_delete', 'write_resource'];\n"],"mappings":";
|
|
1
|
+
{"version":3,"file":"types.mjs","names":[],"sources":["../../src/operations/types.ts"],"sourcesContent":["/**\n * Types for operation logging.\n */\n\nexport interface Actor {\n type: 'user' | 'agent';\n name: string;\n delegatedBy?: string;\n taskContext?: string;\n}\n\nexport type ToolName = 'backlog_create' | 'backlog_update' | 'backlog_delete' | 'write_resource';\n\nexport interface OperationEntry {\n ts: string;\n tool: string;\n params: Record<string, unknown>;\n result: unknown;\n resourceId?: string;\n actor: Actor;\n}\n\nexport interface OperationFilter {\n taskId?: string;\n date?: string; // YYYY-MM-DD - filter by date\n limit?: number;\n}\n\nexport const WRITE_TOOLS: ToolName[] = ['backlog_create', 'backlog_update', 'backlog_delete', 'write_resource'];\n"],"mappings":";AA4BA,MAAa,cAA0B;CAAC;CAAkB;CAAkB;CAAkB;CAAiB"}
|
|
@@ -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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"}
|
package/dist/viewer/main.css
CHANGED
|
@@ -1409,6 +1409,15 @@ copy-button {
|
|
|
1409
1409
|
color: #d4d4d4;
|
|
1410
1410
|
flex-shrink: 0;
|
|
1411
1411
|
}
|
|
1412
|
+
.activity-merged-badge {
|
|
1413
|
+
font-size: 11px;
|
|
1414
|
+
font-weight: 500;
|
|
1415
|
+
color: #58a6ff;
|
|
1416
|
+
background: rgba(88, 166, 255, 0.15);
|
|
1417
|
+
padding: 2px 6px;
|
|
1418
|
+
border-radius: 4px;
|
|
1419
|
+
margin-left: 6px;
|
|
1420
|
+
}
|
|
1412
1421
|
.activity-resource {
|
|
1413
1422
|
flex: 1;
|
|
1414
1423
|
color: #8b949e;
|
|
@@ -1562,22 +1571,37 @@ copy-button {
|
|
|
1562
1571
|
border-radius: 6px;
|
|
1563
1572
|
overflow: hidden;
|
|
1564
1573
|
margin-top: 8px;
|
|
1574
|
+
position: relative;
|
|
1565
1575
|
}
|
|
1566
1576
|
.activity-diff .d2h-file-header {
|
|
1567
1577
|
display: none;
|
|
1568
1578
|
}
|
|
1569
1579
|
.activity-diff .d2h-file-wrapper {
|
|
1570
1580
|
margin-bottom: 0;
|
|
1581
|
+
border: none;
|
|
1571
1582
|
}
|
|
1572
1583
|
.activity-diff .d2h-diff-table {
|
|
1573
1584
|
font-size: 12px;
|
|
1574
1585
|
}
|
|
1586
|
+
.activity-diff .d2h-code-linenumber {
|
|
1587
|
+
position: sticky;
|
|
1588
|
+
left: 0;
|
|
1589
|
+
}
|
|
1590
|
+
.activity-diff-stack {
|
|
1591
|
+
display: flex;
|
|
1592
|
+
flex-direction: column;
|
|
1593
|
+
gap: 4px;
|
|
1594
|
+
}
|
|
1595
|
+
.activity-diff-stack .activity-diff {
|
|
1596
|
+
margin-top: 0;
|
|
1597
|
+
}
|
|
1575
1598
|
.activity-day-separator {
|
|
1576
1599
|
display: flex;
|
|
1577
1600
|
align-items: center;
|
|
1578
1601
|
gap: 8px;
|
|
1579
|
-
padding: 12px 12px
|
|
1580
|
-
margin-top:
|
|
1602
|
+
padding: 12px 12px;
|
|
1603
|
+
margin-top: 20px;
|
|
1604
|
+
margin-bottom: 12px;
|
|
1581
1605
|
position: sticky;
|
|
1582
1606
|
top: 0;
|
|
1583
1607
|
background: #252526;
|
|
@@ -1587,7 +1611,6 @@ copy-button {
|
|
|
1587
1611
|
}
|
|
1588
1612
|
.activity-day-separator:first-child {
|
|
1589
1613
|
margin-top: 0;
|
|
1590
|
-
padding-top: 12px;
|
|
1591
1614
|
}
|
|
1592
1615
|
.activity-day-label {
|
|
1593
1616
|
font-weight: 700;
|
|
@@ -1757,19 +1780,23 @@ copy-button {
|
|
|
1757
1780
|
flex: 1;
|
|
1758
1781
|
overflow-y: auto;
|
|
1759
1782
|
padding: 12px;
|
|
1783
|
+
padding-bottom: 24px;
|
|
1760
1784
|
}
|
|
1761
1785
|
.activity-journal-section {
|
|
1762
|
-
margin-bottom:
|
|
1786
|
+
margin-bottom: 24px;
|
|
1787
|
+
background: #1e1e1e;
|
|
1788
|
+
border-radius: 8px;
|
|
1789
|
+
padding: 12px;
|
|
1763
1790
|
}
|
|
1764
1791
|
.activity-journal-section:last-child {
|
|
1765
|
-
margin-bottom:
|
|
1792
|
+
margin-bottom: 12px;
|
|
1766
1793
|
}
|
|
1767
1794
|
.activity-journal-section-title {
|
|
1768
1795
|
font-weight: 600;
|
|
1769
1796
|
color: #d4d4d4;
|
|
1770
1797
|
font-size: 14px;
|
|
1771
|
-
margin-bottom:
|
|
1772
|
-
padding-bottom:
|
|
1798
|
+
margin-bottom: 12px;
|
|
1799
|
+
padding-bottom: 8px;
|
|
1773
1800
|
border-bottom: 1px solid #30363d;
|
|
1774
1801
|
}
|
|
1775
1802
|
.activity-journal-list {
|
|
@@ -1781,7 +1808,16 @@ copy-button {
|
|
|
1781
1808
|
display: flex;
|
|
1782
1809
|
align-items: center;
|
|
1783
1810
|
gap: 8px;
|
|
1784
|
-
padding:
|
|
1811
|
+
padding: 8px 0;
|
|
1812
|
+
border-bottom: 1px solid #252525;
|
|
1813
|
+
flex-wrap: nowrap;
|
|
1814
|
+
min-width: 0;
|
|
1815
|
+
}
|
|
1816
|
+
.activity-journal-item:last-child {
|
|
1817
|
+
border-bottom: none;
|
|
1818
|
+
}
|
|
1819
|
+
.activity-journal-item task-badge {
|
|
1820
|
+
flex-shrink: 0;
|
|
1785
1821
|
}
|
|
1786
1822
|
.activity-journal-title {
|
|
1787
1823
|
color: #8b949e;
|
|
@@ -1789,6 +1825,8 @@ copy-button {
|
|
|
1789
1825
|
overflow: hidden;
|
|
1790
1826
|
text-overflow: ellipsis;
|
|
1791
1827
|
white-space: nowrap;
|
|
1828
|
+
flex: 1;
|
|
1829
|
+
min-width: 0;
|
|
1792
1830
|
}
|
|
1793
1831
|
.activity-journal-epic-group {
|
|
1794
1832
|
margin-bottom: 16px;
|
|
@@ -1801,11 +1839,18 @@ copy-button {
|
|
|
1801
1839
|
align-items: center;
|
|
1802
1840
|
gap: 8px;
|
|
1803
1841
|
padding: 8px 0 4px 0;
|
|
1842
|
+
flex-wrap: nowrap;
|
|
1843
|
+
}
|
|
1844
|
+
.activity-journal-epic-header task-badge {
|
|
1845
|
+
flex-shrink: 0;
|
|
1804
1846
|
}
|
|
1805
1847
|
.activity-journal-epic-title {
|
|
1806
1848
|
font-weight: 500;
|
|
1807
1849
|
color: #d4d4d4;
|
|
1808
1850
|
font-size: 13px;
|
|
1851
|
+
overflow: hidden;
|
|
1852
|
+
text-overflow: ellipsis;
|
|
1853
|
+
white-space: nowrap;
|
|
1809
1854
|
}
|
|
1810
1855
|
.activity-journal-epic-group .activity-journal-list {
|
|
1811
1856
|
padding-left: 12px;
|
|
@@ -1813,7 +1858,7 @@ copy-button {
|
|
|
1813
1858
|
margin-left: 4px;
|
|
1814
1859
|
}
|
|
1815
1860
|
.activity-journal-epic-group .activity-journal-item {
|
|
1816
|
-
padding:
|
|
1861
|
+
padding: 6px 0;
|
|
1817
1862
|
}
|
|
1818
1863
|
|
|
1819
1864
|
/* viewer/github-markdown.css */
|