claude-code-kanban 3.5.3 → 3.7.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.7.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
@@ -59,7 +59,9 @@ function getUrlState() {
59
59
  project: params.get('project'),
60
60
  owner: params.get('owner'),
61
61
  search: params.get('search'),
62
- messages: params.get('messages') === '1',
62
+ messages: params.has('messages')
63
+ ? params.get('messages') === '1'
64
+ : localStorage.getItem('message-panel-open') === 'true',
63
65
  projectView: params.get('projectView'),
64
66
  };
65
67
  }
@@ -653,6 +655,7 @@ async function refreshProjectAgents() {
653
655
  function toggleMessagePanel() {
654
656
  const panel = document.getElementById('message-panel');
655
657
  messagePanelOpen = !messagePanelOpen;
658
+ localStorage.setItem('message-panel-open', messagePanelOpen);
656
659
  panel.classList.toggle('visible', messagePanelOpen);
657
660
  document.getElementById('message-toggle')?.classList.toggle('active', messagePanelOpen);
658
661
  if (messagePanelOpen && currentSessionId) {
@@ -667,10 +670,13 @@ async function openSessionWithBookmarks(sessionId) {
667
670
  if (!messagePanelOpen) {
668
671
  const panel = document.getElementById('message-panel');
669
672
  messagePanelOpen = true;
673
+ localStorage.setItem('message-panel-open', 'true');
670
674
  panel.classList.add('visible');
671
675
  document.getElementById('message-toggle')?.classList.add('active');
672
676
  }
673
677
  await fetchTasks(sessionId);
678
+ if (currentMessages.length) renderMessages(currentMessages);
679
+ fetchMessages(sessionId);
674
680
  }
675
681
 
676
682
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
@@ -1503,10 +1509,25 @@ function closeMsgDetailModal() {
1503
1509
  msgDetailFollowLatest = false;
1504
1510
  }
1505
1511
 
1512
+ function _setModalWidth(modal, slot, on, maxWidth, width) {
1513
+ const mwKey = `prev${slot}MaxWidth`;
1514
+ const wKey = `prev${slot}Width`;
1515
+ if (on) {
1516
+ modal.dataset[mwKey] = modal.style.maxWidth || '';
1517
+ modal.dataset[wKey] = modal.style.width || '';
1518
+ modal.style.maxWidth = maxWidth;
1519
+ modal.style.width = width;
1520
+ } else {
1521
+ modal.style.maxWidth = modal.dataset[mwKey] || '';
1522
+ modal.style.width = modal.dataset[wKey] || '';
1523
+ }
1524
+ }
1525
+
1506
1526
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
1507
1527
  function toggleModalFullscreen(modalId) {
1508
1528
  const modal = document.querySelector(`#${modalId} .modal`);
1509
1529
  const isFs = modal.classList.toggle('fullscreen');
1530
+ _setModalWidth(modal, 'Fs', isFs, '', '');
1510
1531
  updateFullscreenBtnIcon(`${modalId}-fullscreen-btn`, isFs);
1511
1532
  }
1512
1533
 
@@ -1623,9 +1644,9 @@ function getToolDetail(tool, params, detail) {
1623
1644
  const parts = [];
1624
1645
  if (params.offset) parts.push(`L${params.offset}`);
1625
1646
  if (params.limit) parts.push(`+${params.limit}`);
1626
- if (parts.length) extra = ` <span style="color:var(--text-muted);opacity:.7">${parts.join(' ')}</span>`;
1647
+ if (parts.length) extra = ` <span style="color:var(--text-muted)">${parts.join(' ')}</span>`;
1627
1648
  }
1628
- return ` <span style="color:var(--text-muted)">${escapeHtml(detail)}</span>${extra}`;
1649
+ return ` <span style="color:var(--text-secondary)">${escapeHtml(detail)}</span>${extra}`;
1629
1650
  }
1630
1651
  function renderTaskResult(toolResult) {
1631
1652
  if (!toolResult) return '';
@@ -1737,17 +1758,30 @@ function highlightBash(escaped) {
1737
1758
  }
1738
1759
 
1739
1760
  let _expandIdCounter = 0;
1761
+ function _toggleExpand(btn) {
1762
+ const f = document.getElementById(btn.dataset.expandId);
1763
+ const t = btn.parentElement.nextElementSibling;
1764
+ const expand = f.style.display === 'none';
1765
+ f.style.display = expand ? 'block' : 'none';
1766
+ t.style.display = expand ? 'none' : 'block';
1767
+ btn.textContent = expand ? 'Show less' : 'Show more';
1768
+ const panel = btn.closest('.message-panel');
1769
+ if (panel) panel.classList.toggle('msg-expanded-wide', expand);
1770
+ const modal = btn.closest('.modal');
1771
+ if (modal) _setModalWidth(modal, 'Expand', expand, '60vw', '60vw');
1772
+ }
1740
1773
  function makeExpandToggle(_truncatedHtml, fullHtml, opts = {}) {
1741
1774
  const id = `expand-${++_expandIdCounter}`;
1742
1775
  const fontSize = opts.fontSize || '0.8rem';
1743
1776
  const maxHeight = opts.maxHeight || '';
1744
- const btn = `<button onclick="var f=document.getElementById('${id}'),t=this.parentElement.nextElementSibling,expand=f.style.display==='none';f.style.display=expand?'block':'none';t.style.display=expand?'none':'block';this.textContent=expand?'Show less':'Show more'" style="background:none;border:none;color:var(--accent);cursor:pointer;font-size:${fontSize};text-decoration:underline;margin-left:6px">Show more</button>`;
1777
+ const btn = `<button data-expand-id="${id}" onclick="_toggleExpand(this)" style="background:none;border:none;color:var(--accent);cursor:pointer;font-size:${fontSize};text-decoration:underline;margin-left:6px">Show more</button>`;
1745
1778
  const mhStyle = maxHeight ? `max-height:${maxHeight};` : '';
1746
1779
  const full = `<pre id="${id}" class="msg-detail-pre" style="${mhStyle}overflow:auto;display:none">${fullHtml}</pre>`;
1747
1780
  return { btn, full };
1748
1781
  }
1749
1782
 
1750
1783
  function autoSizeModal(modal, body) {
1784
+ if (modal.classList.contains('fullscreen')) return;
1751
1785
  modal.style.maxWidth = '';
1752
1786
  modal.classList.remove('has-mermaid');
1753
1787
  const hasMermaid = body.querySelector('pre.mermaid') !== null;
@@ -2277,7 +2311,10 @@ function renderSessions() {
2277
2311
  const hasInProgress = session.inProgress > 0;
2278
2312
  const isLive =
2279
2313
  hasInProgress || (session.modifiedAt && Date.now() - new Date(session.modifiedAt).getTime() <= LIVE_INDICATOR_MS);
2280
- const sessionName = session.name || session.id;
2314
+ const rawName = session.name || session.id;
2315
+ 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)
2316
+ ? rawName.slice(0, 8)
2317
+ : rawName;
2281
2318
  const useGrouped = sessionFilter === 'active' && session.project;
2282
2319
  const primaryName = useGrouped ? sessionName : session.project ? session.project.split('/').pop() : sessionName;
2283
2320
  const secondaryName = useGrouped ? null : session.project ? sessionName : null;
@@ -2299,8 +2336,9 @@ function renderSessions() {
2299
2336
  const showCtx = !!session.contextStatus;
2300
2337
  const linkedDocsCount = getSessionPreviewPaths(session.id).length;
2301
2338
  const bookmarksCount = loadPins(session.id).length;
2339
+ const tempClass = session.hasRecentLog || session.inProgress || session.hasWaitingForUser ? 'warm' : 'stale';
2302
2340
  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}">
2341
+ <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
2342
  <span class="session-pin-btn${pinClass}" onclick="event.stopPropagation();toggleSessionPin('${escapeHtml(session.id)}')" title="${pinTitle} session">${SESSION_PIN_SVG}</span>
2305
2343
  <div class="session-name">${escapeHtml(primaryName)}</div>
2306
2344
  ${secondaryName ? `<div class="session-secondary">${escapeHtml(secondaryName)}</div>` : ''}
@@ -3957,6 +3995,19 @@ function matchKey(e, ...keys) {
3957
3995
  return keys.some((k) => e.key === k || e.code === k);
3958
3996
  }
3959
3997
 
3998
+ const MODAL_ESC_PRIORITY = ['preview-modal', 'msg-detail-modal', 'plan-modal'];
3999
+ const MODAL_CLOSERS = {
4000
+ 'preview-modal': () => closePreviewModal(),
4001
+ 'msg-detail-modal': () => {
4002
+ closeMsgDetailModal();
4003
+ msgDetailFollowLatest = false;
4004
+ },
4005
+ 'plan-modal': () => closePlanModal(),
4006
+ 'team-modal': () => closeTeamModal(),
4007
+ 'agent-modal': () => closeAgentModal(),
4008
+ 'help-modal': () => closeHelpModal(),
4009
+ };
4010
+
3960
4011
  document.addEventListener('keydown', (e) => {
3961
4012
  if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') {
3962
4013
  return;
@@ -3969,9 +4020,13 @@ document.addEventListener('keydown', (e) => {
3969
4020
  closeScratchpad();
3970
4021
  return;
3971
4022
  }
3972
- // biome-ignore lint/suspicious/useIterableCallbackReturn: forEach side-effect
3973
- document.querySelectorAll('.modal-overlay.visible').forEach((m) => m.classList.remove('visible'));
3974
- msgDetailFollowLatest = false;
4023
+ // Close only the topmost so a child Esc doesn't also dismiss its parent.
4024
+ const visible = [...document.querySelectorAll('.modal-overlay.visible')];
4025
+ const topId = MODAL_ESC_PRIORITY.find((id) => visible.some((m) => m.id === id)) || visible[visible.length - 1].id;
4026
+ const close = MODAL_CLOSERS[topId];
4027
+ if (close) close();
4028
+ else document.getElementById(topId).classList.remove('visible');
4029
+ e.stopImmediatePropagation();
3975
4030
  } else if (
3976
4031
  e.code === 'KeyM' &&
3977
4032
  e.shiftKey &&
@@ -4241,8 +4296,24 @@ function openPreviewModal(filePath, content) {
4241
4296
  currentPreviewPath = filePath;
4242
4297
  document.getElementById('preview-modal-title').textContent = filePath.split(/[\\/]/).pop();
4243
4298
  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);
4299
+ const bodyEl = document.getElementById('preview-modal-body');
4300
+ bodyEl.innerHTML = (fm ? renderFrontmatterBlock(fm) : '') + renderMarkdown(body);
4301
+ if (!bodyEl.dataset.relLinkBound) {
4302
+ bodyEl.addEventListener('click', (e) => {
4303
+ const a = e.target.closest('a[href]');
4304
+ if (!a) return;
4305
+ const href = a.getAttribute('href');
4306
+ if (!href || href.startsWith('#')) return;
4307
+ const isAbsoluteUrl = /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith('//');
4308
+ const isAbsolutePath = href.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(href);
4309
+ if (isAbsoluteUrl) return;
4310
+ if (!/\.(md|markdown)(#.*)?$/i.test(href)) return;
4311
+ e.preventDefault();
4312
+ const cleanHref = href.replace(/#.*$/, '');
4313
+ openPreviewByPath(cleanHref, isAbsolutePath ? undefined : currentPreviewPath);
4314
+ });
4315
+ bodyEl.dataset.relLinkBound = '1';
4316
+ }
4246
4317
  document.getElementById('preview-modal-meta').textContent = filePath;
4247
4318
  document.getElementById('preview-modal').classList.add('visible');
4248
4319
  updatePreviewLinkBtn();
@@ -4313,7 +4384,6 @@ function refreshInfoModalLinkedDocs() {
4313
4384
  bindLinkedDocsHandlers(node, _infoModalSessionId);
4314
4385
  }
4315
4386
 
4316
- // biome-ignore lint/correctness/noUnusedVariables: used in HTML
4317
4387
  function closePreviewModal() {
4318
4388
  resetModalFullscreen('preview-modal');
4319
4389
  currentPreviewPath = null;
@@ -4325,10 +4395,12 @@ function openPreviewInEditor() {
4325
4395
  postAndToast('/api/open-in-editor', { file: currentPreviewPath }, 'in editor');
4326
4396
  }
4327
4397
 
4328
- async function openPreviewByPath(filePath) {
4398
+ async function openPreviewByPath(filePath, base) {
4329
4399
  if (!filePath) return;
4330
4400
  try {
4331
- const r = await fetch(`/api/preview?path=${encodeURIComponent(filePath)}`);
4401
+ const qs = new URLSearchParams({ path: filePath });
4402
+ if (base) qs.set('base', base);
4403
+ const r = await fetch(`/api/preview?${qs}`);
4332
4404
  if (!r.ok) {
4333
4405
  showToast('Preview file unavailable');
4334
4406
  return;
@@ -4372,22 +4444,33 @@ async function handlePreviewOpenEvent(data) {
4372
4444
  openPreviewModal(filePath, content);
4373
4445
  }
4374
4446
 
4447
+ function getSessionBaseDir(sessionId) {
4448
+ const s = sessions.find((x) => x.id === sessionId);
4449
+ return s?.cwd || s?.project || '';
4450
+ }
4451
+
4375
4452
  function renderLinkedDocsHtml(sessionId) {
4376
4453
  const paths = getSessionPreviewPaths(sessionId);
4377
4454
  if (!paths.length) return '';
4455
+ const baseDir = getSessionBaseDir(sessionId);
4378
4456
  const items = paths
4379
4457
  .map((p, i) => {
4380
4458
  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>`;
4459
+ const rel = baseDir ? toRelativeIfUnder(p, baseDir) : null;
4460
+ const pathSpan = rel ? `<span class="linked-doc-path" title="${escapeHtml(p)}">${escapeHtml(rel)}</span>` : '';
4461
+ return `<li class="linked-doc-item">
4462
+ <a href="#" class="linked-doc-link" data-idx="${i}" title="${escapeHtml(p)}">${escapeHtml(name)}</a>
4463
+ ${pathSpan}
4464
+ </li>`;
4382
4465
  })
4383
- .join(', ');
4466
+ .join('');
4384
4467
  return `<div class="linked-docs-section" style="margin-bottom:16px;font-size:12px;">
4385
4468
  <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
4469
  ${linkSvg(12)}
4387
4470
  <span>Linked documents</span>
4388
4471
  <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
4472
  </div>
4390
- <div>${items}</div>
4473
+ <ul class="linked-doc-list">${items}</ul>
4391
4474
  </div>`;
4392
4475
  }
4393
4476
 
@@ -4396,10 +4479,11 @@ function bindLinkedDocsHandlers(container, sessionId) {
4396
4479
  const links = container.querySelectorAll('.linked-doc-link');
4397
4480
  if (!links.length) return;
4398
4481
  const paths = getSessionPreviewPaths(sessionId);
4482
+ const base = getSessionBaseDir(sessionId);
4399
4483
  for (const link of links) {
4400
4484
  link.addEventListener('click', (e) => {
4401
4485
  e.preventDefault();
4402
- openPreviewByPath(paths[+link.dataset.idx]);
4486
+ openPreviewByPath(paths[+link.dataset.idx], base);
4403
4487
  });
4404
4488
  }
4405
4489
  }
@@ -4748,6 +4832,18 @@ function escapeHtml(text) {
4748
4832
  return div.innerHTML;
4749
4833
  }
4750
4834
 
4835
+ function toRelativeIfUnder(filePath, baseDir) {
4836
+ if (!filePath || !baseDir) return null;
4837
+ const fp = filePath.replace(/\\/g, '/').replace(/\/+$/, '');
4838
+ const bd = baseDir.replace(/\\/g, '/').replace(/\/+$/, '');
4839
+ const isWin = /^[a-zA-Z]:\//.test(fp) || /^[a-zA-Z]:\//.test(bd);
4840
+ const a = isWin ? fp.toLowerCase() : fp;
4841
+ const b = isWin ? bd.toLowerCase() : bd;
4842
+ if (a === b) return '.';
4843
+ if (!a.startsWith(`${b}/`)) return null;
4844
+ return fp.slice(bd.length + 1);
4845
+ }
4846
+
4751
4847
  function renderMarkdown(text) {
4752
4848
  if (typeof DOMPurify !== 'undefined' && typeof marked !== 'undefined') {
4753
4849
  return DOMPurify.sanitize(marked.parse(text));
@@ -5867,6 +5963,25 @@ document.addEventListener('keydown', (e) => {
5867
5963
  }
5868
5964
  });
5869
5965
 
5966
+ document.addEventListener('click', (e) => {
5967
+ if (!window.__HUB__?.enabled) return;
5968
+ const a = e.target.closest?.('a[href]');
5969
+ if (!a) return;
5970
+ const href = a.getAttribute('href');
5971
+ if (!href) return;
5972
+ let url;
5973
+ try {
5974
+ url = new URL(href, window.location.href);
5975
+ } catch (_) {
5976
+ return;
5977
+ }
5978
+ if (url.origin === window.location.origin) return;
5979
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return;
5980
+ e.preventDefault();
5981
+ e.stopPropagation();
5982
+ window.parent?.postMessage({ type: 'hub:openExternal', url: url.href }, '*');
5983
+ });
5984
+
5870
5985
  window.hubNavigate = function hubNavigate(app, url) {
5871
5986
  if (!window.__HUB__?.enabled) return;
5872
5987
  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 {
@@ -1825,6 +1824,9 @@ body::before {
1825
1824
  .message-panel.visible {
1826
1825
  display: flex;
1827
1826
  }
1827
+ .message-panel.msg-expanded-wide {
1828
+ width: 60vw;
1829
+ }
1828
1830
  .message-panel-header {
1829
1831
  padding: 16px 20px;
1830
1832
  display: flex;
@@ -1877,7 +1879,7 @@ body::before {
1877
1879
  width: 16px;
1878
1880
  height: 16px;
1879
1881
  margin-top: 2px;
1880
- opacity: 0.6;
1882
+ opacity: 0.9;
1881
1883
  }
1882
1884
  .msg-body {
1883
1885
  flex: 1;
@@ -2575,6 +2577,40 @@ body::before {
2575
2577
  color: var(--text-primary);
2576
2578
  }
2577
2579
 
2580
+ .linked-doc-list {
2581
+ list-style: none;
2582
+ padding: 0;
2583
+ margin: 0;
2584
+ display: flex;
2585
+ flex-direction: column;
2586
+ gap: 4px;
2587
+ }
2588
+
2589
+ .linked-doc-item {
2590
+ display: flex;
2591
+ align-items: baseline;
2592
+ gap: 8px;
2593
+ min-width: 0;
2594
+ }
2595
+
2596
+ .linked-doc-link {
2597
+ color: var(--accent-text);
2598
+ text-decoration: underline;
2599
+ text-decoration-style: dotted;
2600
+ text-underline-offset: 3px;
2601
+ flex-shrink: 0;
2602
+ }
2603
+
2604
+ .linked-doc-path {
2605
+ color: var(--text-muted);
2606
+ font-size: 11px;
2607
+ opacity: 0.7;
2608
+ overflow: hidden;
2609
+ text-overflow: ellipsis;
2610
+ white-space: nowrap;
2611
+ min-width: 0;
2612
+ }
2613
+
2578
2614
  /* #endregion */
2579
2615
 
2580
2616
  /* #region PERMISSION_PENDING */
@@ -2754,8 +2790,8 @@ body.light .msg-assistant .msg-text {
2754
2790
  }
2755
2791
 
2756
2792
  .modal.fullscreen {
2757
- width: 90vw;
2758
- max-width: 90vw;
2793
+ width: 80vw;
2794
+ max-width: 80vw;
2759
2795
  height: 92vh;
2760
2796
  max-height: 92vh;
2761
2797
  }
@@ -2769,8 +2805,8 @@ body.light .msg-assistant .msg-text {
2769
2805
  }
2770
2806
 
2771
2807
  .modal.plan-modal.fullscreen {
2772
- width: 90vw;
2773
- max-width: 90vw;
2808
+ width: 80vw;
2809
+ max-width: 80vw;
2774
2810
  height: 92vh;
2775
2811
  max-height: 92vh;
2776
2812
  }
@@ -3678,7 +3714,39 @@ pre.mermaid svg {
3678
3714
  .session-item.kb-selected::before {
3679
3715
  width: 0;
3680
3716
  }
3717
+ .session-item.stale .session-time,
3718
+ .session-item.warm .session-time {
3719
+ display: inline-flex;
3720
+ align-items: center;
3721
+ gap: 6px;
3722
+ }
3723
+ .session-item.stale .session-time::before,
3724
+ .session-item.warm .session-time::before {
3725
+ content: '';
3726
+ width: 6px;
3727
+ height: 6px;
3728
+ border-radius: 50%;
3729
+ flex-shrink: 0;
3730
+ }
3731
+ .session-item.warm .session-time {
3732
+ color: var(--success);
3733
+ }
3734
+ .session-item.warm .session-time::before {
3735
+ background: var(--success);
3736
+ }
3681
3737
  .session-item.stale {
3682
- opacity: 0.6;
3738
+ opacity: 0.85;
3739
+ }
3740
+ .session-item.stale:hover,
3741
+ .session-item.stale.active,
3742
+ .session-item.stale.kb-selected {
3743
+ opacity: 1;
3744
+ }
3745
+ .session-item.stale .session-time {
3746
+ color: var(--text-muted);
3747
+ }
3748
+ .session-item.stale .session-time::before {
3749
+ background: transparent;
3750
+ border: 1px solid var(--text-muted);
3683
3751
  }
3684
3752
  /* #endregion */
package/server.js CHANGED
@@ -1475,15 +1475,26 @@ async function readMarkdownFile(absPath) {
1475
1475
  }
1476
1476
  }
1477
1477
 
1478
- function resolvePreviewPath(filePath) {
1478
+ function resolvePreviewPath(filePath, base) {
1479
1479
  if (!filePath || typeof filePath !== 'string') return null;
1480
- return path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
1480
+ if (path.isAbsolute(filePath)) return filePath;
1481
+ if (base && typeof base === 'string' && path.isAbsolute(base)) {
1482
+ let baseDir = base;
1483
+ try {
1484
+ if (statSync(base).isFile()) baseDir = path.dirname(base);
1485
+ } catch {
1486
+ // base doesn't exist — fall back to dirname if it looks like a file
1487
+ if (path.extname(base)) baseDir = path.dirname(base);
1488
+ }
1489
+ return path.resolve(baseDir, filePath);
1490
+ }
1491
+ return path.resolve(filePath);
1481
1492
  }
1482
1493
 
1483
1494
  app.post('/api/preview', async (req, res) => {
1484
1495
  try {
1485
- const { path: filePath, sessionId } = req.body || {};
1486
- const abs = resolvePreviewPath(filePath);
1496
+ const { path: filePath, sessionId, base } = req.body || {};
1497
+ const abs = resolvePreviewPath(filePath, base);
1487
1498
  if (!abs) return res.status(400).json({ error: 'path is required' });
1488
1499
  const content = await readMarkdownFile(abs);
1489
1500
  broadcast({ type: 'preview:open', path: abs, content, sessionId: sessionId || null });
@@ -1548,7 +1559,7 @@ app.post('/api/session/pin', async (req, res) => {
1548
1559
 
1549
1560
  app.get('/api/preview', async (req, res) => {
1550
1561
  try {
1551
- const abs = resolvePreviewPath(req.query.path);
1562
+ const abs = resolvePreviewPath(req.query.path, req.query.base);
1552
1563
  if (!abs) return res.status(400).json({ error: 'path is required' });
1553
1564
  const content = await readMarkdownFile(abs);
1554
1565
  res.json({ path: abs, content });