backlog-mcp 0.26.1 → 0.27.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.
@@ -20,8 +20,7 @@ function registerViewerRoutes(app) {
20
20
  "in_progress",
21
21
  "blocked"
22
22
  ] },
23
- done: { status: ["done"] },
24
- cancelled: { status: ["cancelled"] },
23
+ completed: { status: ["done", "cancelled"] },
25
24
  all: {}
26
25
  };
27
26
  const filterConfig = statusMap[filter || "active"] || statusMap.active;
@@ -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.js';\nimport { resourceManager } from '../resources/manager.js';\nimport { paths } from '../utils/paths.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 } = request.query as { filter?: string; limit?: string };\n \n const statusMap: Record<string, any> = {\n active: { status: ['open', 'in_progress', 'blocked'] },\n done: { status: ['done'] },\n cancelled: { status: ['cancelled'] },\n all: {},\n };\n \n const filterConfig = statusMap[filter || 'active'] || statusMap.active;\n const tasks = storage.list({ ...filterConfig, limit: limit ? parseInt(limit) : 100 });\n \n return tasks;\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 = 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"],"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.js';\nimport { resourceManager } from '../resources/manager.js';\nimport { paths } from '../utils/paths.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 } = request.query as { filter?: string; limit?: 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 = storage.list({ ...filterConfig, limit: limit ? parseInt(limit) : 100 });\n \n return tasks;\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 = 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"],"mappings":"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"}
@@ -20,7 +20,7 @@
20
20
  <!-- Left Pane: Task List -->
21
21
  <div class="left-pane" id="left-pane">
22
22
  <div class="pane-header">
23
- <div class="pane-title">
23
+ <div class="pane-title" id="home-button" style="cursor: pointer;" title="Go to All Tasks">
24
24
  <img src="./logo.svg" class="logo" alt="">
25
25
  Backlog
26
26
  </div>
@@ -134,7 +134,7 @@ body {
134
134
  }
135
135
  task-filter-bar {
136
136
  display: block;
137
- margin-bottom: 16px;
137
+ margin-bottom: 0;
138
138
  padding: 12px;
139
139
  background: #252526;
140
140
  border-radius: 8px;
@@ -175,6 +175,41 @@ task-filter-bar {
175
175
  border-color: #007acc;
176
176
  color: white;
177
177
  }
178
+ .breadcrumb {
179
+ display: flex;
180
+ align-items: center;
181
+ gap: 8px;
182
+ padding: 12px 0;
183
+ margin-bottom: 8px;
184
+ border-bottom: 1px solid #3e3e42;
185
+ font-size: 13px;
186
+ }
187
+ .breadcrumb-segment {
188
+ background: none;
189
+ border: none;
190
+ color: #888;
191
+ cursor: pointer;
192
+ padding: 4px 8px;
193
+ border-radius: 4px;
194
+ transition: all 0.2s;
195
+ font-size: 13px;
196
+ max-width: 200px;
197
+ overflow: hidden;
198
+ text-overflow: ellipsis;
199
+ white-space: nowrap;
200
+ }
201
+ .breadcrumb-segment:hover {
202
+ background: #2d2d30;
203
+ color: #d4d4d4;
204
+ }
205
+ .breadcrumb-segment:last-child {
206
+ color: #d4d4d4;
207
+ font-weight: 500;
208
+ }
209
+ .breadcrumb-separator {
210
+ color: #555;
211
+ user-select: none;
212
+ }
178
213
  .task-list {
179
214
  display: flex;
180
215
  flex-direction: column;
@@ -209,10 +244,6 @@ task-filter-bar {
209
244
  width: 1px;
210
245
  background: #3e3e42;
211
246
  }
212
- .task-item-wrapper.pinned .task-item {
213
- border-color: #f0b429;
214
- background: #3d3522;
215
- }
216
247
  .task-item {
217
248
  flex: 1;
218
249
  display: flex;
@@ -251,6 +282,17 @@ task-filter-bar {
251
282
  padding: 2px 6px;
252
283
  border-radius: 10px;
253
284
  }
285
+ .enter-icon {
286
+ font-size: 14px;
287
+ color: #888;
288
+ margin-left: auto;
289
+ opacity: 0.6;
290
+ transition: opacity 0.2s;
291
+ }
292
+ .task-item.type-epic:hover .enter-icon {
293
+ opacity: 1;
294
+ color: #007acc;
295
+ }
254
296
  .task-item:hover {
255
297
  background: #2d2d30;
256
298
  border-color: #007acc;
@@ -259,26 +301,50 @@ task-filter-bar {
259
301
  background: #094771;
260
302
  border-color: #007acc;
261
303
  }
262
- .task-item-wrapper > .pin-btn {
263
- background: #252526;
264
- border: 1px solid #3e3e42;
265
- border-radius: 6px;
266
- padding: 0 8px;
267
- cursor: pointer;
268
- color: #888;
304
+ .task-item.current-epic {
305
+ }
306
+ .epic-separator {
269
307
  display: flex;
270
308
  align-items: center;
271
- transition: all 0.2s;
309
+ margin: 12px 0;
310
+ gap: 12px;
272
311
  }
273
- .task-item-wrapper > .pin-btn:hover {
274
- background: #2d2d30;
275
- border-color: #f0b429;
276
- color: #f0b429;
312
+ .epic-separator::before,
313
+ .epic-separator::after {
314
+ content: "";
315
+ flex: 1;
316
+ height: 1px;
317
+ background:
318
+ linear-gradient(
319
+ 90deg,
320
+ transparent,
321
+ rgba(9, 105, 218, 0.3),
322
+ transparent);
277
323
  }
278
- .task-item-wrapper > .pin-btn.pinned {
279
- background: #3d3522;
280
- border-color: #f0b429;
281
- color: #f0b429;
324
+ .epic-separator::before {
325
+ background:
326
+ linear-gradient(
327
+ 90deg,
328
+ transparent,
329
+ rgba(9, 105, 218, 0.3));
330
+ }
331
+ .epic-separator::after {
332
+ background:
333
+ linear-gradient(
334
+ 90deg,
335
+ rgba(9, 105, 218, 0.3),
336
+ transparent);
337
+ }
338
+ .separator-icon {
339
+ width: 10px !important;
340
+ height: 10px !important;
341
+ flex-shrink: 0;
342
+ background:
343
+ linear-gradient(
344
+ 135deg,
345
+ #00d4ff,
346
+ #7b2dff,
347
+ #ff2d7b) !important;
282
348
  }
283
349
  .task-id {
284
350
  font-size: 11px;
@@ -462,6 +528,11 @@ task-filter-bar {
462
528
  padding: 60px 20px;
463
529
  color: #888;
464
530
  }
531
+ .empty-state-inline {
532
+ text-align: center;
533
+ padding: 40px 20px;
534
+ color: #888;
535
+ }
465
536
  .empty-state-icon {
466
537
  font-size: 48px;
467
538
  margin-bottom: 16px;
@@ -2527,31 +2527,85 @@ async function fetchTask(taskId) {
2527
2527
  return response.json();
2528
2528
  }
2529
2529
 
2530
+ // viewer/components/breadcrumb.ts
2531
+ var Breadcrumb = class extends HTMLElement {
2532
+ currentEpicId = null;
2533
+ tasks = [];
2534
+ setData(currentEpicId, tasks) {
2535
+ this.currentEpicId = currentEpicId;
2536
+ this.tasks = tasks;
2537
+ this.render();
2538
+ }
2539
+ buildPath() {
2540
+ if (!this.currentEpicId) return [];
2541
+ const path = [];
2542
+ let currentId = this.currentEpicId;
2543
+ while (currentId) {
2544
+ const epic = this.tasks.find((t) => t.id === currentId);
2545
+ if (!epic) break;
2546
+ path.unshift(epic);
2547
+ currentId = epic.epic_id || null;
2548
+ }
2549
+ return path;
2550
+ }
2551
+ render() {
2552
+ const path = this.buildPath();
2553
+ this.innerHTML = `
2554
+ <div class="breadcrumb">
2555
+ <button class="breadcrumb-segment" data-epic-id="" title="All Tasks">All Tasks</button>
2556
+ ${path.map((epic) => `
2557
+ <span class="breadcrumb-separator">\u203A</span>
2558
+ <button class="breadcrumb-segment" data-epic-id="${epic.id}" title="${epic.title}">${epic.title}</button>
2559
+ `).join("")}
2560
+ </div>
2561
+ `;
2562
+ this.querySelectorAll(".breadcrumb-segment").forEach((btn) => {
2563
+ btn.addEventListener("click", () => {
2564
+ const epicId = btn.dataset.epicId || null;
2565
+ document.dispatchEvent(new CustomEvent("epic-navigate", { detail: { epicId } }));
2566
+ });
2567
+ });
2568
+ }
2569
+ };
2570
+ customElements.define("epic-breadcrumb", Breadcrumb);
2571
+
2572
+ // viewer/icons/copy.svg
2573
+ var copy_default = "./copy-3LO2VTYZ.svg";
2574
+
2575
+ // viewer/icons/epic.svg
2576
+ var epic_default = "./epic-OFTO5ZU3.svg";
2577
+
2578
+ // viewer/icons/task.svg
2579
+ var task_default = "./task-QBLM24U4.svg";
2580
+
2581
+ // viewer/icons/settings.svg
2582
+ var settings_default = "./settings-3ELIWNF4.svg";
2583
+
2584
+ // viewer/icons/ring.svg
2585
+ var ring_default = "./ring-IUCBBGZ6.svg";
2586
+
2587
+ // viewer/icons/index.ts
2588
+ var copyIcon = copy_default;
2589
+ var epicIcon = epic_default;
2590
+ var taskIcon = task_default;
2591
+ var settingsIcon = settings_default;
2592
+ var ringIcon = ring_default;
2593
+
2530
2594
  // viewer/components/task-list.ts
2531
2595
  function escapeAttr(text2) {
2532
2596
  if (!text2) return "";
2533
2597
  return text2.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
2534
2598
  }
2535
- function getCollapsedEpics() {
2536
- try {
2537
- return new Set(JSON.parse(localStorage.getItem("collapsed-epics") || "[]"));
2538
- } catch {
2539
- return /* @__PURE__ */ new Set();
2540
- }
2541
- }
2542
- function setCollapsedEpics(ids) {
2543
- localStorage.setItem("collapsed-epics", JSON.stringify([...ids]));
2544
- }
2545
2599
  var TaskList = class extends HTMLElement {
2546
2600
  currentFilter = "active";
2547
2601
  currentType = "all";
2548
- pinnedEpicId = null;
2602
+ currentEpicId = null;
2549
2603
  selectedTaskId = null;
2550
- collapsedEpics = getCollapsedEpics();
2604
+ allTasks = [];
2551
2605
  connectedCallback() {
2552
2606
  const params = new URLSearchParams(window.location.search);
2553
2607
  this.selectedTaskId = params.get("task");
2554
- this.pinnedEpicId = params.get("epic");
2608
+ this.currentEpicId = params.get("epic");
2555
2609
  this.loadTasks();
2556
2610
  setInterval(() => this.loadTasks(), 5e3);
2557
2611
  document.addEventListener("filter-change", ((e) => {
@@ -2562,87 +2616,102 @@ var TaskList = class extends HTMLElement {
2562
2616
  document.addEventListener("task-selected", ((e) => {
2563
2617
  this.setSelected(e.detail.taskId);
2564
2618
  }));
2565
- document.addEventListener("epic-pin", ((e) => {
2566
- this.pinnedEpicId = e.detail.epicId;
2567
- this.loadTasks();
2568
- }));
2569
- document.addEventListener("epic-toggle", ((e) => {
2570
- const { epicId } = e.detail;
2571
- if (this.collapsedEpics.has(epicId)) {
2572
- this.collapsedEpics.delete(epicId);
2573
- } else {
2574
- this.collapsedEpics.add(epicId);
2619
+ document.addEventListener("epic-navigate", ((e) => {
2620
+ this.currentEpicId = e.detail.epicId;
2621
+ if (e.detail.epicId) {
2622
+ this.selectedTaskId = e.detail.epicId;
2623
+ document.dispatchEvent(new CustomEvent("task-selected", { detail: { taskId: e.detail.epicId } }));
2575
2624
  }
2576
- setCollapsedEpics(this.collapsedEpics);
2577
2625
  this.loadTasks();
2578
2626
  }));
2627
+ document.addEventListener("keydown", (e) => {
2628
+ if (e.key === "Escape" && this.currentEpicId) {
2629
+ const currentEpic = this.allTasks.find((t) => t.id === this.currentEpicId);
2630
+ const parentEpicId = currentEpic?.epic_id || null;
2631
+ document.dispatchEvent(new CustomEvent("epic-navigate", { detail: { epicId: parentEpicId } }));
2632
+ }
2633
+ });
2579
2634
  }
2580
2635
  setState(filter, type, epicId, taskId) {
2581
2636
  this.currentFilter = filter;
2582
2637
  this.currentType = type;
2583
- this.pinnedEpicId = epicId;
2638
+ this.currentEpicId = epicId;
2584
2639
  this.selectedTaskId = taskId;
2585
2640
  this.loadTasks();
2586
2641
  }
2587
2642
  async loadTasks() {
2588
2643
  try {
2589
2644
  let tasks = await fetchTasks(this.currentFilter);
2645
+ this.allTasks = tasks;
2590
2646
  if (this.currentType !== "all") {
2591
2647
  tasks = tasks.filter((t) => (t.type ?? "task") === this.currentType);
2592
2648
  }
2593
- if (this.pinnedEpicId) {
2594
- const pinnedEpic = tasks.find((t) => t.id === this.pinnedEpicId);
2595
- const children = tasks.filter((t) => t.epic_id === this.pinnedEpicId);
2596
- tasks = pinnedEpic ? [pinnedEpic, ...children] : children;
2649
+ if (this.currentEpicId) {
2650
+ const currentEpic = tasks.find((t) => t.id === this.currentEpicId);
2651
+ const children = tasks.filter((t) => t.epic_id === this.currentEpicId);
2652
+ tasks = currentEpic ? [currentEpic, ...children] : children;
2653
+ } else {
2654
+ const rootEpics = tasks.filter((t) => (t.type ?? "task") === "epic" && !t.epic_id);
2655
+ const orphanTasks = tasks.filter((t) => (t.type ?? "task") === "task" && !t.epic_id);
2656
+ tasks = [...rootEpics, ...orphanTasks];
2597
2657
  }
2598
2658
  this.render(tasks);
2659
+ const breadcrumb = this.querySelector("epic-breadcrumb");
2660
+ if (breadcrumb) {
2661
+ breadcrumb.setData(this.currentEpicId, this.allTasks);
2662
+ }
2599
2663
  } catch (error) {
2600
2664
  this.innerHTML = `<div class="error">Failed to load tasks: ${error.message}</div>`;
2601
2665
  }
2602
2666
  }
2603
2667
  render(tasks) {
2604
- if (tasks.length === 0) {
2668
+ const isEmpty = tasks.length === 0;
2669
+ const isInsideEpic = !!this.currentEpicId;
2670
+ const currentEpic = isInsideEpic ? tasks.find((t) => t.id === this.currentEpicId) : null;
2671
+ const hasOnlyEpic = isInsideEpic && tasks.length === 1 && currentEpic;
2672
+ if (isEmpty) {
2605
2673
  this.innerHTML = `
2674
+ <epic-breadcrumb></epic-breadcrumb>
2606
2675
  <div class="empty-state">
2607
2676
  <div class="empty-state-icon">\u2014</div>
2608
2677
  <div>No tasks found</div>
2609
2678
  </div>
2610
2679
  `;
2680
+ const breadcrumb2 = this.querySelector("epic-breadcrumb");
2681
+ if (breadcrumb2) {
2682
+ breadcrumb2.setData(this.currentEpicId, this.allTasks);
2683
+ }
2611
2684
  return;
2612
2685
  }
2613
2686
  const epics = tasks.filter((t) => (t.type ?? "task") === "epic");
2614
- const rootEpics = epics.filter((e) => !e.epic_id);
2615
- const childTasks = tasks.filter((t) => t.epic_id && epics.some((e) => e.id === t.epic_id));
2616
- const orphanTasks = tasks.filter((t) => (t.type ?? "task") === "task" && !childTasks.includes(t));
2617
- const grouped = [];
2618
- for (const epic of rootEpics) {
2619
- const children = childTasks.filter((t) => t.epic_id === epic.id);
2620
- const isCollapsed = this.collapsedEpics.has(epic.id);
2621
- grouped.push({ ...epic, childCount: children.length });
2622
- if (!isCollapsed) {
2623
- for (const child of children) {
2624
- grouped.push({ ...child, isChild: true });
2625
- }
2626
- }
2627
- }
2628
- grouped.push(...orphanTasks);
2687
+ const regularTasks = tasks.filter((t) => (t.type ?? "task") === "task");
2688
+ const grouped = [...epics, ...regularTasks];
2629
2689
  this.innerHTML = `
2690
+ <epic-breadcrumb></epic-breadcrumb>
2630
2691
  <div class="task-list">
2631
- ${grouped.map((task) => `
2632
- <task-item
2633
- data-id="${task.id}"
2634
- data-title="${escapeAttr(task.title)}"
2635
- data-status="${task.status}"
2636
- data-type="${task.type ?? "task"}"
2637
- ${task.isChild ? 'data-child="true"' : ""}
2638
- ${task.childCount !== void 0 ? `data-child-count="${task.childCount}"` : ""}
2639
- ${this.collapsedEpics.has(task.id) ? 'data-collapsed="true"' : ""}
2640
- ${this.selectedTaskId === task.id ? "selected" : ""}
2641
- ${this.pinnedEpicId === task.id ? "pinned" : ""}
2642
- ></task-item>
2643
- `).join("")}
2692
+ ${grouped.map((task, index) => {
2693
+ const childCount = (task.type ?? "task") === "epic" ? this.allTasks.filter((t) => t.epic_id === task.id).length : 0;
2694
+ const isCurrentEpic = this.currentEpicId === task.id;
2695
+ return `
2696
+ <task-item
2697
+ data-id="${task.id}"
2698
+ data-title="${escapeAttr(task.title)}"
2699
+ data-status="${task.status}"
2700
+ data-type="${task.type ?? "task"}"
2701
+ data-child-count="${childCount}"
2702
+ ${this.selectedTaskId === task.id ? "selected" : ""}
2703
+ ${isCurrentEpic ? 'data-current-epic="true"' : ""}
2704
+ ></task-item>
2705
+ ${isCurrentEpic ? `<div class="epic-separator"><svg-icon class="separator-icon" src="${ringIcon}"></svg-icon></div>` : ""}
2706
+ `;
2707
+ }).join("")}
2708
+ ${hasOnlyEpic ? '<div class="empty-state-inline"><div class="empty-state-icon">\u2014</div><div>No tasks in this epic</div></div>' : ""}
2644
2709
  </div>
2645
2710
  `;
2711
+ const breadcrumb = this.querySelector("epic-breadcrumb");
2712
+ if (breadcrumb) {
2713
+ breadcrumb.setData(this.currentEpicId, this.allTasks);
2714
+ }
2646
2715
  }
2647
2716
  setSelected(taskId) {
2648
2717
  this.selectedTaskId = taskId;
@@ -2650,32 +2719,6 @@ var TaskList = class extends HTMLElement {
2650
2719
  };
2651
2720
  customElements.define("task-list", TaskList);
2652
2721
 
2653
- // viewer/icons/copy.svg
2654
- var copy_default = "./copy-3LO2VTYZ.svg";
2655
-
2656
- // viewer/icons/pin.svg
2657
- var pin_default = "./pin-CTBSQJY3.svg";
2658
-
2659
- // viewer/icons/epic.svg
2660
- var epic_default = "./epic-OFTO5ZU3.svg";
2661
-
2662
- // viewer/icons/task.svg
2663
- var task_default = "./task-QBLM24U4.svg";
2664
-
2665
- // viewer/icons/chevron.svg
2666
- var chevron_default = "./chevron-CBYYYF2L.svg";
2667
-
2668
- // viewer/icons/settings.svg
2669
- var settings_default = "./settings-3ELIWNF4.svg";
2670
-
2671
- // viewer/icons/index.ts
2672
- var copyIcon = copy_default;
2673
- var pinIcon = pin_default;
2674
- var epicIcon = epic_default;
2675
- var taskIcon = task_default;
2676
- var chevronIcon = chevron_default;
2677
- var settingsIcon = settings_default;
2678
-
2679
2722
  // viewer/components/task-item.ts
2680
2723
  var TaskItem = class extends HTMLElement {
2681
2724
  connectedCallback() {
@@ -2687,29 +2730,31 @@ var TaskItem = class extends HTMLElement {
2687
2730
  const title = this.dataset.title || "";
2688
2731
  const status = this.dataset.status || "open";
2689
2732
  const type = this.dataset.type || "task";
2690
- const isChild = this.dataset.child === "true";
2691
- const isPinned = this.hasAttribute("pinned");
2733
+ const isCurrentEpic = this.dataset.currentEpic === "true";
2692
2734
  const isSelected = this.hasAttribute("selected");
2693
- const isCollapsed = this.dataset.collapsed === "true";
2694
- const childCount = this.dataset.childCount;
2695
- this.className = `task-item-wrapper ${isPinned ? "pinned" : ""} ${isChild ? "child" : ""}`;
2735
+ const childCount = this.dataset.childCount || "0";
2736
+ this.className = "task-item-wrapper";
2696
2737
  this.innerHTML = `
2697
- <div class="task-item ${isSelected ? "selected" : ""} type-${type}">
2698
- ${type === "epic" ? `<button class="collapse-btn ${isCollapsed ? "collapsed" : ""}" title="${isCollapsed ? "Expand" : "Collapse"}"><svg-icon src="${chevronIcon}"></svg-icon></button>` : ""}
2738
+ <div class="task-item ${isSelected ? "selected" : ""} ${isCurrentEpic ? "current-epic" : ""} type-${type}">
2699
2739
  <task-badge task-id="${id}" type="${type}"></task-badge>
2700
2740
  <span class="task-title">${title}</span>
2701
- ${isCollapsed && childCount ? `<span class="child-count">${childCount}</span>` : ""}
2741
+ ${type === "epic" ? `<span class="child-count">${childCount}</span>` : ""}
2742
+ ${type === "epic" && !isCurrentEpic ? '<span class="enter-icon">\u2192</span>' : ""}
2702
2743
  <span class="status-badge status-${status}">${status.replace("_", " ")}</span>
2703
2744
  </div>
2704
- ${type === "epic" ? `<button class="pin-btn ${isPinned ? "pinned" : ""}" title="${isPinned ? "Unpin" : "Pin to filter"}"><svg-icon src="${pinIcon}"></svg-icon></button>` : ""}
2705
2745
  `;
2706
2746
  }
2707
2747
  attachListeners() {
2708
2748
  const taskItem = this.querySelector(".task-item");
2749
+ const type = this.dataset.type || "task";
2750
+ const isCurrentEpic = this.dataset.currentEpic === "true";
2709
2751
  taskItem?.addEventListener("click", (e) => {
2710
- if (e.target.closest(".collapse-btn")) return;
2711
2752
  const taskId = this.dataset.id;
2712
2753
  if (!taskId) return;
2754
+ if (type === "epic" && !isCurrentEpic) {
2755
+ document.dispatchEvent(new CustomEvent("epic-navigate", { detail: { epicId: taskId } }));
2756
+ return;
2757
+ }
2713
2758
  document.querySelectorAll("task-item .task-item").forEach((item) => {
2714
2759
  item.classList.toggle("selected", item.closest("task-item")?.dataset.id === taskId);
2715
2760
  });
@@ -2723,26 +2768,6 @@ var TaskItem = class extends HTMLElement {
2723
2768
  taskList.setSelected(taskId);
2724
2769
  }
2725
2770
  });
2726
- const collapseBtn = this.querySelector(".collapse-btn");
2727
- if (collapseBtn) {
2728
- collapseBtn.addEventListener("click", (e) => {
2729
- e.stopPropagation();
2730
- const epicId = this.dataset.id;
2731
- if (epicId) {
2732
- document.dispatchEvent(new CustomEvent("epic-toggle", { detail: { epicId } }));
2733
- }
2734
- });
2735
- }
2736
- const pinBtn = this.querySelector(".pin-btn");
2737
- if (pinBtn) {
2738
- pinBtn.addEventListener("click", (e) => {
2739
- e.stopPropagation();
2740
- const epicId = this.dataset.id;
2741
- if (!epicId) return;
2742
- const isPinned = this.hasAttribute("pinned");
2743
- document.dispatchEvent(new CustomEvent("epic-pin", { detail: { epicId: isPinned ? null : epicId } }));
2744
- });
2745
- }
2746
2771
  }
2747
2772
  };
2748
2773
  customElements.define("task-item", TaskItem);
@@ -3534,6 +3559,9 @@ document.addEventListener("DOMContentLoaded", () => {
3534
3559
  }
3535
3560
  const modal = document.querySelector("system-info-modal");
3536
3561
  systemInfoBtn?.addEventListener("click", () => modal?.open());
3562
+ document.getElementById("home-button")?.addEventListener("click", () => {
3563
+ urlState.set({ epic: null, task: null });
3564
+ });
3537
3565
  const savedResource = localStorage.getItem("openResource");
3538
3566
  if (savedResource) {
3539
3567
  if (savedResource.startsWith("mcp://")) {
@@ -3549,6 +3577,9 @@ document.addEventListener("filter-change", ((e) => {
3549
3577
  document.addEventListener("task-selected", ((e) => {
3550
3578
  urlState.set({ task: e.detail.taskId });
3551
3579
  }));
3580
+ document.addEventListener("epic-navigate", ((e) => {
3581
+ urlState.set({ epic: e.detail.epicId });
3582
+ }));
3552
3583
  document.addEventListener("epic-pin", ((e) => {
3553
3584
  urlState.set({ epic: e.detail.epicId });
3554
3585
  }));
@@ -0,0 +1,3 @@
1
+ <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'>
2
+ <circle cx='12' cy='12' r='9' fill='none' stroke='black' stroke-width='3'/>
3
+ </svg>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backlog-mcp",
3
- "version": "0.26.1",
3
+ "version": "0.27.0",
4
4
  "description": "Minimal task backlog MCP server for Claude and AI agents",
5
5
  "keywords": [
6
6
  "mcp",
@@ -0,0 +1,52 @@
1
+ import type { Task } from '../utils/api.js';
2
+
3
+ export class Breadcrumb extends HTMLElement {
4
+ private currentEpicId: string | null = null;
5
+ private tasks: Task[] = [];
6
+
7
+ setData(currentEpicId: string | null, tasks: Task[]) {
8
+ this.currentEpicId = currentEpicId;
9
+ this.tasks = tasks;
10
+ this.render();
11
+ }
12
+
13
+ private buildPath(): Task[] {
14
+ if (!this.currentEpicId) return [];
15
+
16
+ const path: Task[] = [];
17
+ let currentId: string | null = this.currentEpicId;
18
+
19
+ while (currentId) {
20
+ const epic = this.tasks.find(t => t.id === currentId);
21
+ if (!epic) break;
22
+ path.unshift(epic);
23
+ currentId = epic.epic_id || null;
24
+ }
25
+
26
+ return path;
27
+ }
28
+
29
+ private render() {
30
+ const path = this.buildPath();
31
+
32
+ // Always render breadcrumb, even at root
33
+ this.innerHTML = `
34
+ <div class="breadcrumb">
35
+ <button class="breadcrumb-segment" data-epic-id="" title="All Tasks">All Tasks</button>
36
+ ${path.map(epic => `
37
+ <span class="breadcrumb-separator">›</span>
38
+ <button class="breadcrumb-segment" data-epic-id="${epic.id}" title="${epic.title}">${epic.title}</button>
39
+ `).join('')}
40
+ </div>
41
+ `;
42
+
43
+ this.querySelectorAll('.breadcrumb-segment').forEach(btn => {
44
+ btn.addEventListener('click', () => {
45
+ const epicId = (btn as HTMLElement).dataset.epicId || null;
46
+ document.dispatchEvent(new CustomEvent('epic-navigate', { detail: { epicId } }));
47
+ });
48
+ });
49
+ }
50
+ }
51
+
52
+ customElements.define('epic-breadcrumb', Breadcrumb);
@@ -1,5 +1,3 @@
1
- import { pinIcon, chevronIcon } from '../icons/index.js';
2
-
3
1
  export class TaskItem extends HTMLElement {
4
2
  connectedCallback() {
5
3
  this.render();
@@ -11,32 +9,38 @@ export class TaskItem extends HTMLElement {
11
9
  const title = this.dataset.title || '';
12
10
  const status = this.dataset.status || 'open';
13
11
  const type = this.dataset.type || 'task';
14
- const isChild = this.dataset.child === 'true';
15
- const isPinned = this.hasAttribute('pinned');
12
+ const isCurrentEpic = this.dataset.currentEpic === 'true';
16
13
  const isSelected = this.hasAttribute('selected');
17
- const isCollapsed = this.dataset.collapsed === 'true';
18
- const childCount = this.dataset.childCount;
14
+ const childCount = this.dataset.childCount || '0';
19
15
 
20
- this.className = `task-item-wrapper ${isPinned ? 'pinned' : ''} ${isChild ? 'child' : ''}`;
16
+ this.className = 'task-item-wrapper';
21
17
  this.innerHTML = `
22
- <div class="task-item ${isSelected ? 'selected' : ''} type-${type}">
23
- ${type === 'epic' ? `<button class="collapse-btn ${isCollapsed ? 'collapsed' : ''}" title="${isCollapsed ? 'Expand' : 'Collapse'}"><svg-icon src="${chevronIcon}"></svg-icon></button>` : ''}
18
+ <div class="task-item ${isSelected ? 'selected' : ''} ${isCurrentEpic ? 'current-epic' : ''} type-${type}">
24
19
  <task-badge task-id="${id}" type="${type}"></task-badge>
25
20
  <span class="task-title">${title}</span>
26
- ${isCollapsed && childCount ? `<span class="child-count">${childCount}</span>` : ''}
21
+ ${type === 'epic' ? `<span class="child-count">${childCount}</span>` : ''}
22
+ ${type === 'epic' && !isCurrentEpic ? '<span class="enter-icon">→</span>' : ''}
27
23
  <span class="status-badge status-${status}">${status.replace('_', ' ')}</span>
28
24
  </div>
29
- ${type === 'epic' ? `<button class="pin-btn ${isPinned ? 'pinned' : ''}" title="${isPinned ? 'Unpin' : 'Pin to filter'}"><svg-icon src="${pinIcon}"></svg-icon></button>` : ''}
30
25
  `;
31
26
  }
32
27
 
33
28
  attachListeners() {
34
29
  const taskItem = this.querySelector('.task-item');
30
+ const type = this.dataset.type || 'task';
31
+ const isCurrentEpic = this.dataset.currentEpic === 'true';
32
+
35
33
  taskItem?.addEventListener('click', (e) => {
36
- if ((e.target as HTMLElement).closest('.collapse-btn')) return;
37
34
  const taskId = this.dataset.id;
38
35
  if (!taskId) return;
39
36
 
37
+ // If epic and not current epic, navigate into it
38
+ if (type === 'epic' && !isCurrentEpic) {
39
+ document.dispatchEvent(new CustomEvent('epic-navigate', { detail: { epicId: taskId } }));
40
+ return;
41
+ }
42
+
43
+ // Otherwise, select and show detail
40
44
  document.querySelectorAll('task-item .task-item').forEach(item => {
41
45
  item.classList.toggle('selected', (item.closest('task-item') as HTMLElement)?.dataset.id === taskId);
42
46
  });
@@ -53,28 +57,6 @@ export class TaskItem extends HTMLElement {
53
57
  (taskList as any).setSelected(taskId);
54
58
  }
55
59
  });
56
-
57
- const collapseBtn = this.querySelector('.collapse-btn');
58
- if (collapseBtn) {
59
- collapseBtn.addEventListener('click', (e) => {
60
- e.stopPropagation();
61
- const epicId = this.dataset.id;
62
- if (epicId) {
63
- document.dispatchEvent(new CustomEvent('epic-toggle', { detail: { epicId } }));
64
- }
65
- });
66
- }
67
-
68
- const pinBtn = this.querySelector('.pin-btn');
69
- if (pinBtn) {
70
- pinBtn.addEventListener('click', (e) => {
71
- e.stopPropagation();
72
- const epicId = this.dataset.id;
73
- if (!epicId) return;
74
- const isPinned = this.hasAttribute('pinned');
75
- document.dispatchEvent(new CustomEvent('epic-pin', { detail: { epicId: isPinned ? null : epicId } }));
76
- });
77
- }
78
60
  }
79
61
  }
80
62
 
@@ -1,31 +1,23 @@
1
1
  import { fetchTasks, type Task } from '../utils/api.js';
2
+ import './breadcrumb.js';
3
+ import { ringIcon } from '../icons/index.js';
2
4
 
3
5
  function escapeAttr(text: string | undefined): string {
4
6
  if (!text) return '';
5
7
  return text.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
6
8
  }
7
9
 
8
- function getCollapsedEpics(): Set<string> {
9
- try {
10
- return new Set(JSON.parse(localStorage.getItem('collapsed-epics') || '[]'));
11
- } catch { return new Set(); }
12
- }
13
-
14
- function setCollapsedEpics(ids: Set<string>) {
15
- localStorage.setItem('collapsed-epics', JSON.stringify([...ids]));
16
- }
17
-
18
10
  export class TaskList extends HTMLElement {
19
11
  private currentFilter: string = 'active';
20
12
  private currentType: string = 'all';
21
- private pinnedEpicId: string | null = null;
13
+ private currentEpicId: string | null = null;
22
14
  private selectedTaskId: string | null = null;
23
- private collapsedEpics: Set<string> = getCollapsedEpics();
15
+ private allTasks: Task[] = [];
24
16
 
25
17
  connectedCallback() {
26
18
  const params = new URLSearchParams(window.location.search);
27
19
  this.selectedTaskId = params.get('task');
28
- this.pinnedEpicId = params.get('epic');
20
+ this.currentEpicId = params.get('epic');
29
21
 
30
22
  this.loadTasks();
31
23
  setInterval(() => this.loadTasks(), 5000);
@@ -40,27 +32,29 @@ export class TaskList extends HTMLElement {
40
32
  this.setSelected(e.detail.taskId);
41
33
  }) as EventListener);
42
34
 
43
- document.addEventListener('epic-pin', ((e: CustomEvent) => {
44
- this.pinnedEpicId = e.detail.epicId;
45
- this.loadTasks();
46
- }) as EventListener);
47
-
48
- document.addEventListener('epic-toggle', ((e: CustomEvent) => {
49
- const { epicId } = e.detail;
50
- if (this.collapsedEpics.has(epicId)) {
51
- this.collapsedEpics.delete(epicId);
52
- } else {
53
- this.collapsedEpics.add(epicId);
35
+ document.addEventListener('epic-navigate', ((e: CustomEvent) => {
36
+ this.currentEpicId = e.detail.epicId;
37
+ // Auto-select epic when navigating into it
38
+ if (e.detail.epicId) {
39
+ this.selectedTaskId = e.detail.epicId;
40
+ document.dispatchEvent(new CustomEvent('task-selected', { detail: { taskId: e.detail.epicId } }));
54
41
  }
55
- setCollapsedEpics(this.collapsedEpics);
56
42
  this.loadTasks();
57
43
  }) as EventListener);
44
+
45
+ document.addEventListener('keydown', (e: KeyboardEvent) => {
46
+ if (e.key === 'Escape' && this.currentEpicId) {
47
+ const currentEpic = this.allTasks.find(t => t.id === this.currentEpicId);
48
+ const parentEpicId = currentEpic?.epic_id || null;
49
+ document.dispatchEvent(new CustomEvent('epic-navigate', { detail: { epicId: parentEpicId } }));
50
+ }
51
+ });
58
52
  }
59
53
 
60
54
  setState(filter: string, type: string, epicId: string | null, taskId: string | null) {
61
55
  this.currentFilter = filter;
62
56
  this.currentType = type;
63
- this.pinnedEpicId = epicId;
57
+ this.currentEpicId = epicId;
64
58
  this.selectedTaskId = taskId;
65
59
  this.loadTasks();
66
60
  }
@@ -68,72 +62,91 @@ export class TaskList extends HTMLElement {
68
62
  async loadTasks() {
69
63
  try {
70
64
  let tasks = await fetchTasks(this.currentFilter as any);
65
+ this.allTasks = tasks;
71
66
 
72
67
  // Type filter
73
68
  if (this.currentType !== 'all') {
74
69
  tasks = tasks.filter(t => (t.type ?? 'task') === this.currentType);
75
70
  }
76
71
 
77
- // Epic pin filter
78
- if (this.pinnedEpicId) {
79
- const pinnedEpic = tasks.find(t => t.id === this.pinnedEpicId);
80
- const children = tasks.filter(t => t.epic_id === this.pinnedEpicId);
81
- tasks = pinnedEpic ? [pinnedEpic, ...children] : children;
72
+ // Epic navigation filter
73
+ if (this.currentEpicId) {
74
+ const currentEpic = tasks.find(t => t.id === this.currentEpicId);
75
+ const children = tasks.filter(t => t.epic_id === this.currentEpicId);
76
+ tasks = currentEpic ? [currentEpic, ...children] : children;
77
+ } else {
78
+ // Home page: only root epics and orphan tasks
79
+ const rootEpics = tasks.filter(t => (t.type ?? 'task') === 'epic' && !t.epic_id);
80
+ const orphanTasks = tasks.filter(t => (t.type ?? 'task') === 'task' && !t.epic_id);
81
+ tasks = [...rootEpics, ...orphanTasks];
82
82
  }
83
83
 
84
84
  this.render(tasks);
85
+
86
+ const breadcrumb = this.querySelector('epic-breadcrumb');
87
+ if (breadcrumb) {
88
+ (breadcrumb as any).setData(this.currentEpicId, this.allTasks);
89
+ }
85
90
  } catch (error) {
86
91
  this.innerHTML = `<div class="error">Failed to load tasks: ${(error as Error).message}</div>`;
87
92
  }
88
93
  }
89
94
 
90
95
  render(tasks: Task[]) {
91
- if (tasks.length === 0) {
96
+ const isEmpty = tasks.length === 0;
97
+ const isInsideEpic = !!this.currentEpicId;
98
+ const currentEpic = isInsideEpic ? tasks.find(t => t.id === this.currentEpicId) : null;
99
+ const hasOnlyEpic = isInsideEpic && tasks.length === 1 && currentEpic;
100
+
101
+ if (isEmpty) {
92
102
  this.innerHTML = `
103
+ <epic-breadcrumb></epic-breadcrumb>
93
104
  <div class="empty-state">
94
105
  <div class="empty-state-icon">—</div>
95
106
  <div>No tasks found</div>
96
107
  </div>
97
108
  `;
109
+ const breadcrumb = this.querySelector('epic-breadcrumb');
110
+ if (breadcrumb) {
111
+ (breadcrumb as any).setData(this.currentEpicId, this.allTasks);
112
+ }
98
113
  return;
99
114
  }
100
115
 
101
- // Group: epics first with their children, then orphan tasks
116
+ // Group: epics first, then tasks
102
117
  const epics = tasks.filter(t => (t.type ?? 'task') === 'epic');
103
- const rootEpics = epics.filter(e => !e.epic_id); // Only root epics for iteration
104
- const childTasks = tasks.filter(t => t.epic_id && epics.some(e => e.id === t.epic_id));
105
- const orphanTasks = tasks.filter(t => (t.type ?? 'task') === 'task' && !childTasks.includes(t));
106
-
107
- const grouped: Array<Task & { isChild?: boolean; childCount?: number }> = [];
108
- for (const epic of rootEpics) {
109
- const children = childTasks.filter(t => t.epic_id === epic.id);
110
- const isCollapsed = this.collapsedEpics.has(epic.id);
111
- grouped.push({ ...epic, childCount: children.length });
112
- if (!isCollapsed) {
113
- for (const child of children) {
114
- grouped.push({ ...child, isChild: true });
115
- }
116
- }
117
- }
118
- grouped.push(...orphanTasks);
118
+ const regularTasks = tasks.filter(t => (t.type ?? 'task') === 'task');
119
+ const grouped = [...epics, ...regularTasks];
119
120
 
120
121
  this.innerHTML = `
122
+ <epic-breadcrumb></epic-breadcrumb>
121
123
  <div class="task-list">
122
- ${grouped.map(task => `
123
- <task-item
124
- data-id="${task.id}"
125
- data-title="${escapeAttr(task.title)}"
126
- data-status="${task.status}"
127
- data-type="${task.type ?? 'task'}"
128
- ${task.isChild ? 'data-child="true"' : ''}
129
- ${task.childCount !== undefined ? `data-child-count="${task.childCount}"` : ''}
130
- ${this.collapsedEpics.has(task.id) ? 'data-collapsed="true"' : ''}
131
- ${this.selectedTaskId === task.id ? 'selected' : ''}
132
- ${this.pinnedEpicId === task.id ? 'pinned' : ''}
133
- ></task-item>
134
- `).join('')}
124
+ ${grouped.map((task, index) => {
125
+ const childCount = (task.type ?? 'task') === 'epic'
126
+ ? this.allTasks.filter(t => t.epic_id === task.id).length
127
+ : 0;
128
+ const isCurrentEpic = this.currentEpicId === task.id;
129
+ return `
130
+ <task-item
131
+ data-id="${task.id}"
132
+ data-title="${escapeAttr(task.title)}"
133
+ data-status="${task.status}"
134
+ data-type="${task.type ?? 'task'}"
135
+ data-child-count="${childCount}"
136
+ ${this.selectedTaskId === task.id ? 'selected' : ''}
137
+ ${isCurrentEpic ? 'data-current-epic="true"' : ''}
138
+ ></task-item>
139
+ ${isCurrentEpic ? `<div class="epic-separator"><svg-icon class="separator-icon" src="${ringIcon}"></svg-icon></div>` : ''}
140
+ `;
141
+ }).join('')}
142
+ ${hasOnlyEpic ? '<div class="empty-state-inline"><div class="empty-state-icon">—</div><div>No tasks in this epic</div></div>' : ''}
135
143
  </div>
136
144
  `;
145
+
146
+ const breadcrumb = this.querySelector('epic-breadcrumb');
147
+ if (breadcrumb) {
148
+ (breadcrumb as any).setData(this.currentEpicId, this.allTasks);
149
+ }
137
150
  }
138
151
 
139
152
  setSelected(taskId: string) {
@@ -5,6 +5,7 @@ import epicIconSvg from './epic.svg';
5
5
  import taskIconSvg from './task.svg';
6
6
  import chevronIconSvg from './chevron.svg';
7
7
  import settingsIconSvg from './settings.svg';
8
+ import ringIconSvg from './ring.svg';
8
9
 
9
10
  export const copyIcon = copyIconSvg;
10
11
  export const pinIcon = pinIconSvg;
@@ -12,3 +13,4 @@ export const epicIcon = epicIconSvg;
12
13
  export const taskIcon = taskIconSvg;
13
14
  export const chevronIcon = chevronIconSvg;
14
15
  export const settingsIcon = settingsIconSvg;
16
+ export const ringIcon = ringIconSvg;
package/viewer/index.html CHANGED
@@ -20,7 +20,7 @@
20
20
  <!-- Left Pane: Task List -->
21
21
  <div class="left-pane" id="left-pane">
22
22
  <div class="pane-header">
23
- <div class="pane-title">
23
+ <div class="pane-title" id="home-button" style="cursor: pointer;" title="Go to All Tasks">
24
24
  <img src="./logo.svg" class="logo" alt="">
25
25
  Backlog
26
26
  </div>
package/viewer/main.ts CHANGED
@@ -47,6 +47,11 @@ document.addEventListener('DOMContentLoaded', () => {
47
47
  const modal = document.querySelector('system-info-modal') as any;
48
48
  systemInfoBtn?.addEventListener('click', () => modal?.open());
49
49
 
50
+ // Wire up home button
51
+ document.getElementById('home-button')?.addEventListener('click', () => {
52
+ urlState.set({ epic: null, task: null });
53
+ });
54
+
50
55
  // Restore resource from localStorage
51
56
  const savedResource = localStorage.getItem('openResource');
52
57
  if (savedResource) {
@@ -67,6 +72,10 @@ document.addEventListener('task-selected', ((e: CustomEvent) => {
67
72
  urlState.set({ task: e.detail.taskId });
68
73
  }) as EventListener);
69
74
 
75
+ document.addEventListener('epic-navigate', ((e: CustomEvent) => {
76
+ urlState.set({ epic: e.detail.epicId });
77
+ }) as EventListener);
78
+
70
79
  document.addEventListener('epic-pin', ((e: CustomEvent) => {
71
80
  urlState.set({ epic: e.detail.epicId });
72
81
  }) as EventListener);
package/viewer/styles.css CHANGED
@@ -151,7 +151,7 @@
151
151
 
152
152
  task-filter-bar {
153
153
  display: block;
154
- margin-bottom: 16px;
154
+ margin-bottom: 0;
155
155
  padding: 12px;
156
156
  background: #252526;
157
157
  border-radius: 8px;
@@ -200,6 +200,47 @@
200
200
  color: white;
201
201
  }
202
202
 
203
+ /* Breadcrumb */
204
+ .breadcrumb {
205
+ display: flex;
206
+ align-items: center;
207
+ gap: 8px;
208
+ padding: 12px 0;
209
+ margin-bottom: 8px;
210
+ border-bottom: 1px solid #3e3e42;
211
+ font-size: 13px;
212
+ }
213
+
214
+ .breadcrumb-segment {
215
+ background: none;
216
+ border: none;
217
+ color: #888;
218
+ cursor: pointer;
219
+ padding: 4px 8px;
220
+ border-radius: 4px;
221
+ transition: all 0.2s;
222
+ font-size: 13px;
223
+ max-width: 200px;
224
+ overflow: hidden;
225
+ text-overflow: ellipsis;
226
+ white-space: nowrap;
227
+ }
228
+
229
+ .breadcrumb-segment:hover {
230
+ background: #2d2d30;
231
+ color: #d4d4d4;
232
+ }
233
+
234
+ .breadcrumb-segment:last-child {
235
+ color: #d4d4d4;
236
+ font-weight: 500;
237
+ }
238
+
239
+ .breadcrumb-separator {
240
+ color: #555;
241
+ user-select: none;
242
+ }
243
+
203
244
  /* Task List */
204
245
  .task-list {
205
246
  display: flex;
@@ -240,11 +281,6 @@
240
281
  background: #3e3e42;
241
282
  }
242
283
 
243
- .task-item-wrapper.pinned .task-item {
244
- border-color: #f0b429;
245
- background: #3d3522;
246
- }
247
-
248
284
  .task-item {
249
285
  flex: 1;
250
286
  display: flex;
@@ -288,6 +324,19 @@
288
324
  border-radius: 10px;
289
325
  }
290
326
 
327
+ .enter-icon {
328
+ font-size: 14px;
329
+ color: #888;
330
+ margin-left: auto;
331
+ opacity: 0.6;
332
+ transition: opacity 0.2s;
333
+ }
334
+
335
+ .task-item.type-epic:hover .enter-icon {
336
+ opacity: 1;
337
+ color: #007acc;
338
+ }
339
+
291
340
  .task-item:hover {
292
341
  background: #2d2d30;
293
342
  border-color: #007acc;
@@ -298,28 +347,38 @@
298
347
  border-color: #007acc;
299
348
  }
300
349
 
301
- .task-item-wrapper > .pin-btn {
302
- background: #252526;
303
- border: 1px solid #3e3e42;
304
- border-radius: 6px;
305
- padding: 0 8px;
306
- cursor: pointer;
307
- color: #888;
350
+ .task-item.current-epic {
351
+ /* Separator below provides visual distinction */
352
+ }
353
+
354
+ .epic-separator {
308
355
  display: flex;
309
356
  align-items: center;
310
- transition: all 0.2s;
357
+ margin: 12px 0;
358
+ gap: 12px;
311
359
  }
312
360
 
313
- .task-item-wrapper > .pin-btn:hover {
314
- background: #2d2d30;
315
- border-color: #f0b429;
316
- color: #f0b429;
361
+ .epic-separator::before,
362
+ .epic-separator::after {
363
+ content: '';
364
+ flex: 1;
365
+ height: 1px;
366
+ background: linear-gradient(90deg, transparent, rgba(9, 105, 218, 0.3), transparent);
317
367
  }
318
368
 
319
- .task-item-wrapper > .pin-btn.pinned {
320
- background: #3d3522;
321
- border-color: #f0b429;
322
- color: #f0b429;
369
+ .epic-separator::before {
370
+ background: linear-gradient(90deg, transparent, rgba(9, 105, 218, 0.3));
371
+ }
372
+
373
+ .epic-separator::after {
374
+ background: linear-gradient(90deg, rgba(9, 105, 218, 0.3), transparent);
375
+ }
376
+
377
+ .separator-icon {
378
+ width: 10px !important;
379
+ height: 10px !important;
380
+ flex-shrink: 0;
381
+ background: linear-gradient(135deg, #00d4ff, #7b2dff, #ff2d7b) !important;
323
382
  }
324
383
 
325
384
  .task-id {
@@ -491,6 +550,12 @@
491
550
  color: #888;
492
551
  }
493
552
 
553
+ .empty-state-inline {
554
+ text-align: center;
555
+ padding: 40px 20px;
556
+ color: #888;
557
+ }
558
+
494
559
  .empty-state-icon {
495
560
  font-size: 48px;
496
561
  margin-bottom: 16px;
@@ -1,3 +0,0 @@
1
- <svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
2
- <path d="M6.22 3.22a.75.75 0 011.06 0l4.25 4.25a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 01-1.06-1.06L9.94 8 6.22 4.28a.75.75 0 010-1.06z"/>
3
- </svg>
@@ -1,3 +0,0 @@
1
- <svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
2
- <path d="M4.456.734a1.75 1.75 0 012.826.504l.613 1.327a3.08 3.08 0 002.084 1.707l2.454.584c1.332.317 1.8 1.972.832 2.94L11.06 10l3.72 3.72a.75.75 0 11-1.06 1.06L10 11.06l-2.204 2.205c-.968.968-2.623.5-2.94-.832l-.584-2.454a3.08 3.08 0 00-1.707-2.084l-1.327-.613a1.75 1.75 0 01-.504-2.826L4.456.734z"/>
3
- </svg>