claude-code-kanban 3.5.3 → 3.6.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/lib/parsers.js CHANGED
@@ -714,6 +714,27 @@ function buildAgentProgressMap(jsonlPath) {
714
714
 
715
715
  function readCompactSummaries(jsonlPath) {
716
716
  const results = [];
717
+ // Inline format: newer Claude Code stores the summary directly in the parent
718
+ // session JSONL as a user message with isCompactSummary: true (no subagent file).
719
+ try {
720
+ const content = readFileSync(jsonlPath, 'utf8');
721
+ for (const line of content.split('\n')) {
722
+ if (!line.trim() || !line.includes('isCompactSummary')) continue;
723
+ try {
724
+ const obj = JSON.parse(line);
725
+ if (!obj.isCompactSummary) continue;
726
+ const c = obj.message?.content;
727
+ let text = typeof c === 'string'
728
+ ? c
729
+ : Array.isArray(c) ? c.filter(b => b?.type === 'text' && b.text).map(b => b.text).join('\n') : '';
730
+ if (!text) continue;
731
+ // Strip the "This session is being continued..." preamble if present.
732
+ text = text.replace(/^This session is being continued[^\n]*\n+(The summary below[^\n]*\n+)?/i, '').trim();
733
+ if (text) results.push({ timestamp: obj.timestamp, summary: text });
734
+ } catch (_) {}
735
+ }
736
+ } catch (_) {}
737
+ // Legacy format: summary lives in subagents/agent-acompact-*.jsonl.
717
738
  try {
718
739
  const subagentsDir = path.join(path.dirname(jsonlPath), path.basename(jsonlPath, '.jsonl'), 'subagents');
719
740
  const files = readdirSync(subagentsDir).filter(f => f.startsWith('agent-acompact-') && f.endsWith('.jsonl'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "3.5.3",
3
+ "version": "3.6.0",
4
4
  "description": "A web-based Kanban board for viewing Claude Code tasks with agent teams support",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -51,6 +51,7 @@
51
51
  "public/**/*"
52
52
  ],
53
53
  "devDependencies": {
54
+ "@biomejs/biome": "2.4.14",
54
55
  "ajv": "^8.18.0",
55
56
  "ajv-formats": "^3.0.1",
56
57
  "husky": "^9.1.7"
package/public/app.js CHANGED
@@ -1623,9 +1623,9 @@ function getToolDetail(tool, params, detail) {
1623
1623
  const parts = [];
1624
1624
  if (params.offset) parts.push(`L${params.offset}`);
1625
1625
  if (params.limit) parts.push(`+${params.limit}`);
1626
- if (parts.length) extra = ` <span style="color:var(--text-muted);opacity:.7">${parts.join(' ')}</span>`;
1626
+ if (parts.length) extra = ` <span style="color:var(--text-muted)">${parts.join(' ')}</span>`;
1627
1627
  }
1628
- return ` <span style="color:var(--text-muted)">${escapeHtml(detail)}</span>${extra}`;
1628
+ return ` <span style="color:var(--text-secondary)">${escapeHtml(detail)}</span>${extra}`;
1629
1629
  }
1630
1630
  function renderTaskResult(toolResult) {
1631
1631
  if (!toolResult) return '';
@@ -2277,7 +2277,10 @@ function renderSessions() {
2277
2277
  const hasInProgress = session.inProgress > 0;
2278
2278
  const isLive =
2279
2279
  hasInProgress || (session.modifiedAt && Date.now() - new Date(session.modifiedAt).getTime() <= LIVE_INDICATOR_MS);
2280
- const sessionName = session.name || session.id;
2280
+ const rawName = session.name || session.id;
2281
+ const sessionName = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(rawName)
2282
+ ? rawName.slice(0, 8)
2283
+ : rawName;
2281
2284
  const useGrouped = sessionFilter === 'active' && session.project;
2282
2285
  const primaryName = useGrouped ? sessionName : session.project ? session.project.split('/').pop() : sessionName;
2283
2286
  const secondaryName = useGrouped ? null : session.project ? sessionName : null;
@@ -2299,8 +2302,9 @@ function renderSessions() {
2299
2302
  const showCtx = !!session.contextStatus;
2300
2303
  const linkedDocsCount = getSessionPreviewPaths(session.id).length;
2301
2304
  const bookmarksCount = loadPins(session.id).length;
2305
+ const tempClass = session.hasRecentLog || session.inProgress || session.hasWaitingForUser ? 'warm' : 'stale';
2302
2306
  return `
2303
- <button onclick="fetchTasks('${session.id}')" data-session-id="${session.id}" class="session-item ${isActive ? 'active' : ''} ${session.hasWaitingForUser ? 'permission-pending' : ''} ${!session.hasRecentLog && !session.inProgress && !session.hasWaitingForUser ? 'stale' : ''} ${showCtx ? 'has-context' : ''}" title="${tooltip}">
2307
+ <button onclick="fetchTasks('${session.id}')" data-session-id="${session.id}" class="session-item ${isActive ? 'active' : ''} ${session.hasWaitingForUser ? 'permission-pending' : ''} ${tempClass} ${showCtx ? 'has-context' : ''}" title="${tooltip}">
2304
2308
  <span class="session-pin-btn${pinClass}" onclick="event.stopPropagation();toggleSessionPin('${escapeHtml(session.id)}')" title="${pinTitle} session">${SESSION_PIN_SVG}</span>
2305
2309
  <div class="session-name">${escapeHtml(primaryName)}</div>
2306
2310
  ${secondaryName ? `<div class="session-secondary">${escapeHtml(secondaryName)}</div>` : ''}
@@ -3957,6 +3961,19 @@ function matchKey(e, ...keys) {
3957
3961
  return keys.some((k) => e.key === k || e.code === k);
3958
3962
  }
3959
3963
 
3964
+ const MODAL_ESC_PRIORITY = ['preview-modal', 'msg-detail-modal', 'plan-modal'];
3965
+ const MODAL_CLOSERS = {
3966
+ 'preview-modal': () => closePreviewModal(),
3967
+ 'msg-detail-modal': () => {
3968
+ closeMsgDetailModal();
3969
+ msgDetailFollowLatest = false;
3970
+ },
3971
+ 'plan-modal': () => closePlanModal(),
3972
+ 'team-modal': () => closeTeamModal(),
3973
+ 'agent-modal': () => closeAgentModal(),
3974
+ 'help-modal': () => closeHelpModal(),
3975
+ };
3976
+
3960
3977
  document.addEventListener('keydown', (e) => {
3961
3978
  if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') {
3962
3979
  return;
@@ -3969,9 +3986,13 @@ document.addEventListener('keydown', (e) => {
3969
3986
  closeScratchpad();
3970
3987
  return;
3971
3988
  }
3972
- // biome-ignore lint/suspicious/useIterableCallbackReturn: forEach side-effect
3973
- document.querySelectorAll('.modal-overlay.visible').forEach((m) => m.classList.remove('visible'));
3974
- msgDetailFollowLatest = false;
3989
+ // Close only the topmost so a child Esc doesn't also dismiss its parent.
3990
+ const visible = [...document.querySelectorAll('.modal-overlay.visible')];
3991
+ const topId = MODAL_ESC_PRIORITY.find((id) => visible.some((m) => m.id === id)) || visible[visible.length - 1].id;
3992
+ const close = MODAL_CLOSERS[topId];
3993
+ if (close) close();
3994
+ else document.getElementById(topId).classList.remove('visible');
3995
+ e.stopImmediatePropagation();
3975
3996
  } else if (
3976
3997
  e.code === 'KeyM' &&
3977
3998
  e.shiftKey &&
@@ -4241,8 +4262,24 @@ function openPreviewModal(filePath, content) {
4241
4262
  currentPreviewPath = filePath;
4242
4263
  document.getElementById('preview-modal-title').textContent = filePath.split(/[\\/]/).pop();
4243
4264
  const { fm, body } = /\.(md|markdown)$/i.test(filePath) ? splitFrontmatter(content) : { fm: null, body: content };
4244
- document.getElementById('preview-modal-body').innerHTML =
4245
- (fm ? renderFrontmatterBlock(fm) : '') + renderMarkdown(body);
4265
+ const bodyEl = document.getElementById('preview-modal-body');
4266
+ bodyEl.innerHTML = (fm ? renderFrontmatterBlock(fm) : '') + renderMarkdown(body);
4267
+ if (!bodyEl.dataset.relLinkBound) {
4268
+ bodyEl.addEventListener('click', (e) => {
4269
+ const a = e.target.closest('a[href]');
4270
+ if (!a) return;
4271
+ const href = a.getAttribute('href');
4272
+ if (!href || href.startsWith('#')) return;
4273
+ const isAbsoluteUrl = /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith('//');
4274
+ const isAbsolutePath = href.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(href);
4275
+ if (isAbsoluteUrl) return;
4276
+ if (!/\.(md|markdown)(#.*)?$/i.test(href)) return;
4277
+ e.preventDefault();
4278
+ const cleanHref = href.replace(/#.*$/, '');
4279
+ openPreviewByPath(cleanHref, isAbsolutePath ? undefined : currentPreviewPath);
4280
+ });
4281
+ bodyEl.dataset.relLinkBound = '1';
4282
+ }
4246
4283
  document.getElementById('preview-modal-meta').textContent = filePath;
4247
4284
  document.getElementById('preview-modal').classList.add('visible');
4248
4285
  updatePreviewLinkBtn();
@@ -4313,7 +4350,6 @@ function refreshInfoModalLinkedDocs() {
4313
4350
  bindLinkedDocsHandlers(node, _infoModalSessionId);
4314
4351
  }
4315
4352
 
4316
- // biome-ignore lint/correctness/noUnusedVariables: used in HTML
4317
4353
  function closePreviewModal() {
4318
4354
  resetModalFullscreen('preview-modal');
4319
4355
  currentPreviewPath = null;
@@ -4325,10 +4361,12 @@ function openPreviewInEditor() {
4325
4361
  postAndToast('/api/open-in-editor', { file: currentPreviewPath }, 'in editor');
4326
4362
  }
4327
4363
 
4328
- async function openPreviewByPath(filePath) {
4364
+ async function openPreviewByPath(filePath, base) {
4329
4365
  if (!filePath) return;
4330
4366
  try {
4331
- const r = await fetch(`/api/preview?path=${encodeURIComponent(filePath)}`);
4367
+ const qs = new URLSearchParams({ path: filePath });
4368
+ if (base) qs.set('base', base);
4369
+ const r = await fetch(`/api/preview?${qs}`);
4332
4370
  if (!r.ok) {
4333
4371
  showToast('Preview file unavailable');
4334
4372
  return;
@@ -4372,22 +4410,33 @@ async function handlePreviewOpenEvent(data) {
4372
4410
  openPreviewModal(filePath, content);
4373
4411
  }
4374
4412
 
4413
+ function getSessionBaseDir(sessionId) {
4414
+ const s = sessions.find((x) => x.id === sessionId);
4415
+ return s?.cwd || s?.project || '';
4416
+ }
4417
+
4375
4418
  function renderLinkedDocsHtml(sessionId) {
4376
4419
  const paths = getSessionPreviewPaths(sessionId);
4377
4420
  if (!paths.length) return '';
4421
+ const baseDir = getSessionBaseDir(sessionId);
4378
4422
  const items = paths
4379
4423
  .map((p, i) => {
4380
4424
  const name = p.split(/[\\/]/).pop();
4381
- return `<a href="#" class="linked-doc-link" data-idx="${i}" title="${escapeHtml(p)}" style="color:var(--accent-text);text-decoration:underline;text-decoration-style:dotted;text-underline-offset:3px;">${escapeHtml(name)}</a>`;
4425
+ const rel = baseDir ? toRelativeIfUnder(p, baseDir) : null;
4426
+ const pathSpan = rel ? `<span class="linked-doc-path" title="${escapeHtml(p)}">${escapeHtml(rel)}</span>` : '';
4427
+ return `<li class="linked-doc-item">
4428
+ <a href="#" class="linked-doc-link" data-idx="${i}" title="${escapeHtml(p)}">${escapeHtml(name)}</a>
4429
+ ${pathSpan}
4430
+ </li>`;
4382
4431
  })
4383
- .join(', ');
4432
+ .join('');
4384
4433
  return `<div class="linked-docs-section" style="margin-bottom:16px;font-size:12px;">
4385
4434
  <div style="font-size:11px;font-weight:500;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px;display:flex;align-items:center;gap:6px;">
4386
4435
  ${linkSvg(12)}
4387
4436
  <span>Linked documents</span>
4388
4437
  <span style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:10px;padding:0 6px;font-size:10px;color:var(--text-secondary);">${paths.length}</span>
4389
4438
  </div>
4390
- <div>${items}</div>
4439
+ <ul class="linked-doc-list">${items}</ul>
4391
4440
  </div>`;
4392
4441
  }
4393
4442
 
@@ -4396,10 +4445,11 @@ function bindLinkedDocsHandlers(container, sessionId) {
4396
4445
  const links = container.querySelectorAll('.linked-doc-link');
4397
4446
  if (!links.length) return;
4398
4447
  const paths = getSessionPreviewPaths(sessionId);
4448
+ const base = getSessionBaseDir(sessionId);
4399
4449
  for (const link of links) {
4400
4450
  link.addEventListener('click', (e) => {
4401
4451
  e.preventDefault();
4402
- openPreviewByPath(paths[+link.dataset.idx]);
4452
+ openPreviewByPath(paths[+link.dataset.idx], base);
4403
4453
  });
4404
4454
  }
4405
4455
  }
@@ -4748,6 +4798,18 @@ function escapeHtml(text) {
4748
4798
  return div.innerHTML;
4749
4799
  }
4750
4800
 
4801
+ function toRelativeIfUnder(filePath, baseDir) {
4802
+ if (!filePath || !baseDir) return null;
4803
+ const fp = filePath.replace(/\\/g, '/').replace(/\/+$/, '');
4804
+ const bd = baseDir.replace(/\\/g, '/').replace(/\/+$/, '');
4805
+ const isWin = /^[a-zA-Z]:\//.test(fp) || /^[a-zA-Z]:\//.test(bd);
4806
+ const a = isWin ? fp.toLowerCase() : fp;
4807
+ const b = isWin ? bd.toLowerCase() : bd;
4808
+ if (a === b) return '.';
4809
+ if (!a.startsWith(`${b}/`)) return null;
4810
+ return fp.slice(bd.length + 1);
4811
+ }
4812
+
4751
4813
  function renderMarkdown(text) {
4752
4814
  if (typeof DOMPurify !== 'undefined' && typeof marked !== 'undefined') {
4753
4815
  return DOMPurify.sanitize(marked.parse(text));
@@ -5867,6 +5929,25 @@ document.addEventListener('keydown', (e) => {
5867
5929
  }
5868
5930
  });
5869
5931
 
5932
+ document.addEventListener('click', (e) => {
5933
+ if (!window.__HUB__?.enabled) return;
5934
+ const a = e.target.closest?.('a[href]');
5935
+ if (!a) return;
5936
+ const href = a.getAttribute('href');
5937
+ if (!href) return;
5938
+ let url;
5939
+ try {
5940
+ url = new URL(href, window.location.href);
5941
+ } catch (_) {
5942
+ return;
5943
+ }
5944
+ if (url.origin === window.location.origin) return;
5945
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return;
5946
+ e.preventDefault();
5947
+ e.stopPropagation();
5948
+ window.parent?.postMessage({ type: 'hub:openExternal', url: url.href }, '*');
5949
+ });
5950
+
5870
5951
  window.hubNavigate = function hubNavigate(app, url) {
5871
5952
  if (!window.__HUB__?.enabled) return;
5872
5953
  window.parent?.postMessage({ type: 'hub:navigate', app, url }, '*');
package/public/style.css CHANGED
@@ -549,14 +549,13 @@ body::before {
549
549
 
550
550
  .session-branch {
551
551
  font-size: 10px;
552
- color: var(--text-muted);
552
+ color: var(--text-secondary);
553
553
  margin-top: 2px;
554
554
  display: block;
555
555
  font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
556
556
  white-space: nowrap;
557
557
  overflow: hidden;
558
558
  text-overflow: ellipsis;
559
- opacity: 0.7;
560
559
  }
561
560
 
562
561
  .session-plan {
@@ -1877,7 +1876,7 @@ body::before {
1877
1876
  width: 16px;
1878
1877
  height: 16px;
1879
1878
  margin-top: 2px;
1880
- opacity: 0.6;
1879
+ opacity: 0.9;
1881
1880
  }
1882
1881
  .msg-body {
1883
1882
  flex: 1;
@@ -2575,6 +2574,40 @@ body::before {
2575
2574
  color: var(--text-primary);
2576
2575
  }
2577
2576
 
2577
+ .linked-doc-list {
2578
+ list-style: none;
2579
+ padding: 0;
2580
+ margin: 0;
2581
+ display: flex;
2582
+ flex-direction: column;
2583
+ gap: 4px;
2584
+ }
2585
+
2586
+ .linked-doc-item {
2587
+ display: flex;
2588
+ align-items: baseline;
2589
+ gap: 8px;
2590
+ min-width: 0;
2591
+ }
2592
+
2593
+ .linked-doc-link {
2594
+ color: var(--accent-text);
2595
+ text-decoration: underline;
2596
+ text-decoration-style: dotted;
2597
+ text-underline-offset: 3px;
2598
+ flex-shrink: 0;
2599
+ }
2600
+
2601
+ .linked-doc-path {
2602
+ color: var(--text-muted);
2603
+ font-size: 11px;
2604
+ opacity: 0.7;
2605
+ overflow: hidden;
2606
+ text-overflow: ellipsis;
2607
+ white-space: nowrap;
2608
+ min-width: 0;
2609
+ }
2610
+
2578
2611
  /* #endregion */
2579
2612
 
2580
2613
  /* #region PERMISSION_PENDING */
@@ -3678,7 +3711,39 @@ pre.mermaid svg {
3678
3711
  .session-item.kb-selected::before {
3679
3712
  width: 0;
3680
3713
  }
3714
+ .session-item.stale .session-time,
3715
+ .session-item.warm .session-time {
3716
+ display: inline-flex;
3717
+ align-items: center;
3718
+ gap: 6px;
3719
+ }
3720
+ .session-item.stale .session-time::before,
3721
+ .session-item.warm .session-time::before {
3722
+ content: '';
3723
+ width: 6px;
3724
+ height: 6px;
3725
+ border-radius: 50%;
3726
+ flex-shrink: 0;
3727
+ }
3728
+ .session-item.warm .session-time {
3729
+ color: var(--success);
3730
+ }
3731
+ .session-item.warm .session-time::before {
3732
+ background: var(--success);
3733
+ }
3681
3734
  .session-item.stale {
3682
- opacity: 0.6;
3735
+ opacity: 0.85;
3736
+ }
3737
+ .session-item.stale:hover,
3738
+ .session-item.stale.active,
3739
+ .session-item.stale.kb-selected {
3740
+ opacity: 1;
3741
+ }
3742
+ .session-item.stale .session-time {
3743
+ color: var(--text-muted);
3744
+ }
3745
+ .session-item.stale .session-time::before {
3746
+ background: transparent;
3747
+ border: 1px solid var(--text-muted);
3683
3748
  }
3684
3749
  /* #endregion */
package/server.js CHANGED
@@ -119,11 +119,18 @@ function isAgentFresh(agent) {
119
119
  return (Date.now() - new Date(ts).getTime()) < AGENT_TTL_MS;
120
120
  }
121
121
 
122
+ const sessionLogStatCache = new Map();
122
123
  function getSessionLogStat(meta) {
123
124
  if (!meta.jsonlPath) return { mtime: null, hasMessages: false };
124
125
  try {
125
126
  const st = statSync(meta.jsonlPath);
126
- return { mtime: st.mtimeMs, hasMessages: st.size > 1000 };
127
+ const cached = sessionLogStatCache.get(meta.jsonlPath);
128
+ if (cached && cached.mtime === st.mtimeMs) return cached;
129
+ const content = readFileSync(meta.jsonlPath, 'utf8');
130
+ const hasMessages = content.includes('"type":"assistant"');
131
+ const data = { mtime: st.mtimeMs, hasMessages };
132
+ sessionLogStatCache.set(meta.jsonlPath, data);
133
+ return data;
127
134
  } catch (e) { return { mtime: null, hasMessages: false }; }
128
135
  }
129
136
 
@@ -1475,15 +1482,26 @@ async function readMarkdownFile(absPath) {
1475
1482
  }
1476
1483
  }
1477
1484
 
1478
- function resolvePreviewPath(filePath) {
1485
+ function resolvePreviewPath(filePath, base) {
1479
1486
  if (!filePath || typeof filePath !== 'string') return null;
1480
- return path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
1487
+ if (path.isAbsolute(filePath)) return filePath;
1488
+ if (base && typeof base === 'string' && path.isAbsolute(base)) {
1489
+ let baseDir = base;
1490
+ try {
1491
+ if (statSync(base).isFile()) baseDir = path.dirname(base);
1492
+ } catch {
1493
+ // base doesn't exist — fall back to dirname if it looks like a file
1494
+ if (path.extname(base)) baseDir = path.dirname(base);
1495
+ }
1496
+ return path.resolve(baseDir, filePath);
1497
+ }
1498
+ return path.resolve(filePath);
1481
1499
  }
1482
1500
 
1483
1501
  app.post('/api/preview', async (req, res) => {
1484
1502
  try {
1485
- const { path: filePath, sessionId } = req.body || {};
1486
- const abs = resolvePreviewPath(filePath);
1503
+ const { path: filePath, sessionId, base } = req.body || {};
1504
+ const abs = resolvePreviewPath(filePath, base);
1487
1505
  if (!abs) return res.status(400).json({ error: 'path is required' });
1488
1506
  const content = await readMarkdownFile(abs);
1489
1507
  broadcast({ type: 'preview:open', path: abs, content, sessionId: sessionId || null });
@@ -1548,7 +1566,7 @@ app.post('/api/session/pin', async (req, res) => {
1548
1566
 
1549
1567
  app.get('/api/preview', async (req, res) => {
1550
1568
  try {
1551
- const abs = resolvePreviewPath(req.query.path);
1569
+ const abs = resolvePreviewPath(req.query.path, req.query.base);
1552
1570
  if (!abs) return res.status(400).json({ error: 'path is required' });
1553
1571
  const content = await readMarkdownFile(abs);
1554
1572
  res.json({ path: abs, content });