claude-code-kanban 3.6.0 → 3.8.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
@@ -552,14 +552,15 @@ function readRecentMessages(jsonlPath, limit = 10) {
552
552
  readSize *= 4;
553
553
  }
554
554
 
555
- // Attach tool results to their corresponding tool_use messages
555
+ // Attach tool results to their corresponding tool_use messages.
556
+ // For perf, we never ship the full text in the messages payload — when
557
+ // truncated, the client lazy-fetches via /api/sessions/:id/tool-result/:toolUseId.
556
558
  for (const msg of messages) {
557
559
  if (msg.type === 'tool_use' && msg.toolUseId && toolResults.has(msg.toolUseId)) {
558
560
  const full = toolResults.get(msg.toolUseId);
559
561
  const truncated = full.length > TOOL_RESULT_MAX;
560
562
  msg.toolResult = truncated ? full.slice(0, TOOL_RESULT_MAX) + '\n... (truncated)' : full;
561
563
  msg.toolResultTruncated = truncated;
562
- if (truncated) msg.toolResultFull = full;
563
564
  }
564
565
  }
565
566
 
@@ -578,6 +579,34 @@ function readRecentMessages(jsonlPath, limit = 10) {
578
579
  }
579
580
  }
580
581
 
582
+ function readFullToolResult(jsonlPath, toolUseId) {
583
+ if (!toolUseId || !jsonlPath || !existsSync(jsonlPath)) return null;
584
+ try {
585
+ const content = readFileSync(jsonlPath, 'utf8');
586
+ const lines = content.split('\n');
587
+ for (const line of lines) {
588
+ if (!line || line.indexOf(toolUseId) === -1) continue;
589
+ try {
590
+ const obj = JSON.parse(line);
591
+ if (obj?.message?.content && Array.isArray(obj.message.content)) {
592
+ for (const block of obj.message.content) {
593
+ if (block.type === 'tool_result' && block.tool_use_id === toolUseId) {
594
+ if (typeof block.content === 'string') return block.content;
595
+ if (Array.isArray(block.content)) {
596
+ return block.content
597
+ .filter((c) => c.type === 'text' && c.text)
598
+ .map((c) => c.text)
599
+ .join('\n');
600
+ }
601
+ }
602
+ }
603
+ }
604
+ } catch (_) {}
605
+ }
606
+ } catch (_) {}
607
+ return null;
608
+ }
609
+
581
610
  function readMessagesPage(jsonlPath, limit = 10, beforeTimestamp = null) {
582
611
  const fetchLimit = limit + 1;
583
612
  const applyFilter = beforeTimestamp
@@ -850,6 +879,7 @@ module.exports = {
850
879
  readSessionInfoFromJsonl,
851
880
  readRecentMessages,
852
881
  readMessagesPage,
882
+ readFullToolResult,
853
883
  buildAgentProgressMap,
854
884
  buildSessionDigest,
855
885
  readCompactSummaries,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "3.6.0",
3
+ "version": "3.8.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": {
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
  }
@@ -494,6 +496,7 @@ async function fetchTasks(sessionId) {
494
496
  if (revealedStorageSessionId && sessionId !== revealedStorageSessionId) {
495
497
  revealedStorageSessionId = null;
496
498
  }
499
+ if (currentSessionId && currentSessionId !== sessionId) deferredPinPlacement.delete(currentSessionId);
497
500
  currentSessionId = sessionId;
498
501
  currentPins = loadPins(sessionId);
499
502
  ownerFilter = '';
@@ -653,6 +656,7 @@ async function refreshProjectAgents() {
653
656
  function toggleMessagePanel() {
654
657
  const panel = document.getElementById('message-panel');
655
658
  messagePanelOpen = !messagePanelOpen;
659
+ localStorage.setItem('message-panel-open', messagePanelOpen);
656
660
  panel.classList.toggle('visible', messagePanelOpen);
657
661
  document.getElementById('message-toggle')?.classList.toggle('active', messagePanelOpen);
658
662
  if (messagePanelOpen && currentSessionId) {
@@ -667,10 +671,13 @@ async function openSessionWithBookmarks(sessionId) {
667
671
  if (!messagePanelOpen) {
668
672
  const panel = document.getElementById('message-panel');
669
673
  messagePanelOpen = true;
674
+ localStorage.setItem('message-panel-open', 'true');
670
675
  panel.classList.add('visible');
671
676
  document.getElementById('message-toggle')?.classList.add('active');
672
677
  }
673
678
  await fetchTasks(sessionId);
679
+ if (currentMessages.length) renderMessages(currentMessages);
680
+ fetchMessages(sessionId);
674
681
  }
675
682
 
676
683
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
@@ -1200,6 +1207,9 @@ function togglePin(msgIndex) {
1200
1207
  text: m.text || null,
1201
1208
  fullText: m.fullText || null,
1202
1209
  tool: m.tool || null,
1210
+ toolUseId: m.toolUseId || null,
1211
+ toolResult: m.toolResult || null,
1212
+ toolResultTruncated: m.toolResultTruncated || false,
1203
1213
  detail: m.detail || null,
1204
1214
  fullDetail: m.fullDetail || null,
1205
1215
  description: m.description || null,
@@ -1288,6 +1298,8 @@ function togglePinnedCollapse() {
1288
1298
  //#region PINNING
1289
1299
  let pinnedSessionIds = new Set();
1290
1300
  let stickySessionIds = new Set();
1301
+ // Pinning the currently-selected session keeps it in place until deselected (less UI movement).
1302
+ const deferredPinPlacement = new Set();
1291
1303
 
1292
1304
  function loadPinnedSessions() {
1293
1305
  try {
@@ -1310,35 +1322,45 @@ function savePinnedSessions() {
1310
1322
  localStorage.setItem('sticky-sessions', JSON.stringify([...stickySessionIds]));
1311
1323
  }
1312
1324
 
1313
- // biome-ignore lint/correctness/noUnusedVariables: used in HTML
1314
1325
  function toggleSessionPin(sessionId) {
1315
1326
  if (pinnedSessionIds.has(sessionId)) {
1316
1327
  pinnedSessionIds.delete(sessionId);
1317
1328
  stickySessionIds.delete(sessionId);
1329
+ deferredPinPlacement.delete(sessionId);
1318
1330
  } else {
1319
1331
  pinnedSessionIds.add(sessionId);
1332
+ if (sessionId === currentSessionId) deferredPinPlacement.add(sessionId);
1320
1333
  }
1321
1334
  savePinnedSessions();
1322
1335
  renderSessions();
1323
1336
  }
1324
1337
 
1325
- // biome-ignore lint/correctness/noUnusedVariables: used in HTML
1326
1338
  function toggleSessionSticky(sessionId) {
1327
1339
  if (stickySessionIds.has(sessionId)) {
1328
1340
  stickySessionIds.delete(sessionId);
1329
1341
  pinnedSessionIds.delete(sessionId);
1342
+ deferredPinPlacement.delete(sessionId);
1330
1343
  } else {
1331
1344
  pinnedSessionIds.add(sessionId);
1332
1345
  stickySessionIds.add(sessionId);
1346
+ if (sessionId === currentSessionId) deferredPinPlacement.add(sessionId);
1333
1347
  }
1334
1348
  savePinnedSessions();
1335
1349
  renderSessions();
1336
1350
  }
1337
1351
 
1352
+ function isPlacedPinned(id) {
1353
+ return pinnedSessionIds.has(id) && !deferredPinPlacement.has(id);
1354
+ }
1355
+ function isPlacedSticky(id) {
1356
+ return stickySessionIds.has(id) && !deferredPinPlacement.has(id);
1357
+ }
1358
+
1338
1359
  function handleSessionPinEvent({ id, state }) {
1339
1360
  if (!id) return;
1340
1361
  pinnedSessionIds.delete(id);
1341
1362
  stickySessionIds.delete(id);
1363
+ deferredPinPlacement.delete(id);
1342
1364
  if (state === 'pinned') pinnedSessionIds.add(id);
1343
1365
  if (state === 'sticky') {
1344
1366
  pinnedSessionIds.add(id);
@@ -1366,11 +1388,16 @@ function _renderPinToDetail(pin) {
1366
1388
  document.getElementById('msg-detail-title').textContent = pin.tool || 'Tool';
1367
1389
  const fullText = pin.fullDetail || pin.detail || '';
1368
1390
  const pinParamsHtml = renderToolParamsHtml(pin.params);
1369
- const pinResultHtml = renderToolResultHtml(pin.toolResult, pin.toolResultTruncated, pin.toolResultFull);
1391
+ const pinResultHtml = renderToolResultHtml(
1392
+ pin.toolResult,
1393
+ pin.toolResultTruncated,
1394
+ pin.toolResultFull,
1395
+ pin.toolUseId,
1396
+ );
1370
1397
  const pinDetailEscaped = escapeHtml(fullText);
1371
1398
  const pinDetailRendered = pin.tool === 'Bash' ? highlightBash(pinDetailEscaped) : pinDetailEscaped;
1372
1399
  body.innerHTML =
1373
- (fullText ? `<pre class="msg-detail-pre">${pinDetailRendered}</pre>` : '<em>No details</em>') +
1400
+ (fullText ? `<pre class="${TINTED_PRE_CLASS}">${pinDetailRendered}</pre>` : '<em>No details</em>') +
1374
1401
  pinParamsHtml +
1375
1402
  pinResultHtml;
1376
1403
  } else if (pin.type === 'agent') {
@@ -1431,7 +1458,7 @@ function showMsgDetail(idx) {
1431
1458
  const taskResultHtml = TASK_TOOLS.has(m.tool) ? renderTaskResult(m.toolResult) : '';
1432
1459
  const toolResultHtml = hideResult
1433
1460
  ? ''
1434
- : renderToolResultHtml(m.toolResult, m.toolResultTruncated, m.toolResultFull);
1461
+ : renderToolResultHtml(m.toolResult, m.toolResultTruncated, m.toolResultFull, m.toolUseId);
1435
1462
  const hasAgentTabs = m.tool === 'Agent' && m.agentId && (m.agentLastMessage || m.agentPrompt);
1436
1463
  let mainHtml;
1437
1464
  if (sendProto) {
@@ -1445,7 +1472,7 @@ function showMsgDetail(idx) {
1445
1472
  } else if (fullText) {
1446
1473
  const detailEscaped = escapeHtml(fullText);
1447
1474
  const detailRendered = m.tool === 'Bash' ? highlightBash(detailEscaped) : detailEscaped;
1448
- mainHtml = `${descHtml}<pre class="msg-detail-pre">${detailRendered}</pre>`;
1475
+ mainHtml = `${descHtml}<pre class="${TINTED_PRE_CLASS}">${detailRendered}</pre>`;
1449
1476
  } else {
1450
1477
  mainHtml = TASK_TOOLS.has(m.tool) ? '' : '<em>No details</em>';
1451
1478
  }
@@ -1503,10 +1530,25 @@ function closeMsgDetailModal() {
1503
1530
  msgDetailFollowLatest = false;
1504
1531
  }
1505
1532
 
1533
+ function _setModalWidth(modal, slot, on, maxWidth, width) {
1534
+ const mwKey = `prev${slot}MaxWidth`;
1535
+ const wKey = `prev${slot}Width`;
1536
+ if (on) {
1537
+ modal.dataset[mwKey] = modal.style.maxWidth || '';
1538
+ modal.dataset[wKey] = modal.style.width || '';
1539
+ modal.style.maxWidth = maxWidth;
1540
+ modal.style.width = width;
1541
+ } else {
1542
+ modal.style.maxWidth = modal.dataset[mwKey] || '';
1543
+ modal.style.width = modal.dataset[wKey] || '';
1544
+ }
1545
+ }
1546
+
1506
1547
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
1507
1548
  function toggleModalFullscreen(modalId) {
1508
1549
  const modal = document.querySelector(`#${modalId} .modal`);
1509
1550
  const isFs = modal.classList.toggle('fullscreen');
1551
+ _setModalWidth(modal, 'Fs', isFs, '', '');
1510
1552
  updateFullscreenBtnIcon(`${modalId}-fullscreen-btn`, isFs);
1511
1553
  }
1512
1554
 
@@ -1677,11 +1719,11 @@ function renderToolParamsHtml(params) {
1677
1719
  html += `<div style="margin-top:8px;padding-top:6px;border-top:1px solid var(--border)">`;
1678
1720
  if (params.old_string) {
1679
1721
  html += `<div style="font-size:0.75rem;color:var(--text-muted);margin-bottom:2px">old_string</div>
1680
- <pre class="msg-detail-pre" style="max-height:200px;overflow:auto;border-left:3px solid #e55;padding-left:8px">${escapeHtml(params.old_string)}</pre>`;
1722
+ <pre class="${TINTED_PRE_CLASS}" style="max-height:200px;overflow:auto;border-left:3px solid #e55;padding-left:8px">${escapeHtml(params.old_string)}</pre>`;
1681
1723
  }
1682
1724
  if (params.new_string) {
1683
1725
  html += `<div style="font-size:0.75rem;color:var(--text-muted);margin-bottom:2px;margin-top:6px">new_string</div>
1684
- <pre class="msg-detail-pre" style="max-height:200px;overflow:auto;border-left:3px solid #5b5;padding-left:8px">${escapeHtml(params.new_string)}</pre>`;
1726
+ <pre class="${TINTED_PRE_CLASS}" style="max-height:200px;overflow:auto;border-left:3px solid #5b5;padding-left:8px">${escapeHtml(params.new_string)}</pre>`;
1685
1727
  }
1686
1728
  html += `</div>`;
1687
1729
  }
@@ -1696,13 +1738,14 @@ function renderToolParamsHtml(params) {
1696
1738
  const toggle = makeExpandToggle(escapeHtml(truncContent), escapeHtml(params.content), {
1697
1739
  fontSize: '0.75rem',
1698
1740
  maxHeight: '500px',
1741
+ tinted: true,
1699
1742
  });
1700
1743
  writeMoreBtn = ` ${toggle.btn}`;
1701
1744
  fullBlock = toggle.full;
1702
1745
  }
1703
1746
  html += `<div style="margin-top:8px;padding-top:6px;border-top:1px solid var(--border)">
1704
1747
  <div style="font-size:0.75rem;color:var(--text-muted);margin-bottom:2px">content${writeMoreBtn}</div>
1705
- <pre class="msg-detail-pre" style="max-height:300px;overflow:auto">${escapeHtml(truncContent)}</pre>
1748
+ <pre class="${TINTED_PRE_CLASS}" style="max-height:300px;overflow:auto">${escapeHtml(truncContent)}</pre>
1706
1749
  ${fullBlock}
1707
1750
  </div>`;
1708
1751
  }
@@ -1736,18 +1779,36 @@ function highlightBash(escaped) {
1736
1779
  .replace(/((?:^|\s)(?:&amp;&amp;|\|\||[|;])(?:\s|$))/g, '<span style="color:#d4d4d4;font-weight:bold">$1</span>');
1737
1780
  }
1738
1781
 
1782
+ const TINTED_PRE_CLASS = 'msg-detail-pre msg-detail-pre-tinted';
1739
1783
  let _expandIdCounter = 0;
1784
+ function _applyExpandToggle(btn, fullEl) {
1785
+ const truncEl = btn.parentElement.nextElementSibling;
1786
+ const expand = fullEl.style.display === 'none';
1787
+ fullEl.style.display = expand ? 'block' : 'none';
1788
+ if (truncEl) truncEl.style.display = expand ? 'none' : 'block';
1789
+ btn.textContent = expand ? 'Show less' : 'Show more';
1790
+ const panel = btn.closest('.message-panel');
1791
+ if (panel) panel.classList.toggle('msg-expanded-wide', expand);
1792
+ const modal = btn.closest('.modal');
1793
+ if (modal) _setModalWidth(modal, 'Expand', expand, '60vw', '60vw');
1794
+ }
1795
+ function _toggleExpand(btn) {
1796
+ const f = document.getElementById(btn.dataset.expandId);
1797
+ if (f) _applyExpandToggle(btn, f);
1798
+ }
1740
1799
  function makeExpandToggle(_truncatedHtml, fullHtml, opts = {}) {
1741
1800
  const id = `expand-${++_expandIdCounter}`;
1742
1801
  const fontSize = opts.fontSize || '0.8rem';
1743
1802
  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>`;
1803
+ const cls = opts.tinted ? TINTED_PRE_CLASS : 'msg-detail-pre';
1804
+ const btn = `<button data-expand-id="${id}" onclick="_toggleExpand(this)" class="expand-toggle-btn" style="font-size:${fontSize}">Show more</button>`;
1745
1805
  const mhStyle = maxHeight ? `max-height:${maxHeight};` : '';
1746
- const full = `<pre id="${id}" class="msg-detail-pre" style="${mhStyle}overflow:auto;display:none">${fullHtml}</pre>`;
1806
+ const full = `<pre id="${id}" class="${cls}" style="${mhStyle}overflow:auto;display:none">${fullHtml}</pre>`;
1747
1807
  return { btn, full };
1748
1808
  }
1749
1809
 
1750
1810
  function autoSizeModal(modal, body) {
1811
+ if (modal.classList.contains('fullscreen')) return;
1751
1812
  modal.style.maxWidth = '';
1752
1813
  modal.classList.remove('has-mermaid');
1753
1814
  const hasMermaid = body.querySelector('pre.mermaid') !== null;
@@ -1762,7 +1823,7 @@ function autoSizeModal(modal, body) {
1762
1823
  if (desired > current) modal.style.maxWidth = `${desired}px`;
1763
1824
  }
1764
1825
 
1765
- function renderToolResultHtml(toolResult, isTruncated, fullResult) {
1826
+ function renderToolResultHtml(toolResult, isTruncated, fullResult, toolUseId) {
1766
1827
  if (!toolResult) return '';
1767
1828
  const stripped = stripLineNumbers(toolResult);
1768
1829
  const escaped = escapeHtml(stripped);
@@ -1772,6 +1833,10 @@ function renderToolResultHtml(toolResult, isTruncated, fullResult) {
1772
1833
  const toggle = makeExpandToggle(escaped, escapeHtml(stripLineNumbers(fullResult)));
1773
1834
  truncLabel = toggle.btn;
1774
1835
  fullBlock = toggle.full;
1836
+ } else if (isTruncated && toolUseId) {
1837
+ const id = `expand-${++_expandIdCounter}`;
1838
+ truncLabel = `<button data-expand-id="${id}" data-tool-use-id="${escapeHtml(toolUseId)}" onclick="_toggleToolResultExpand(this)" class="expand-toggle-btn" style="font-size:0.8rem">Show more</button>`;
1839
+ fullBlock = `<pre id="${id}" class="msg-detail-pre" style="overflow:auto;display:none"></pre>`;
1775
1840
  } else if (isTruncated) {
1776
1841
  truncLabel = '<span style="color:var(--text-muted);font-size:0.8rem;margin-left:6px">(truncated)</span>';
1777
1842
  }
@@ -1782,12 +1847,42 @@ function renderToolResultHtml(toolResult, isTruncated, fullResult) {
1782
1847
  </div>`;
1783
1848
  }
1784
1849
 
1850
+ async function _toggleToolResultExpand(btn) {
1851
+ const f = document.getElementById(btn.dataset.expandId);
1852
+ if (!f) return;
1853
+ if (!btn.dataset.loaded) {
1854
+ if (!currentSessionId || !btn.dataset.toolUseId) return;
1855
+ btn.disabled = true;
1856
+ btn.textContent = 'Loading…';
1857
+ try {
1858
+ const r = await fetch(
1859
+ `/api/sessions/${encodeURIComponent(currentSessionId)}/tool-result/${encodeURIComponent(btn.dataset.toolUseId)}`,
1860
+ );
1861
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
1862
+ const { content } = await r.json();
1863
+ f.textContent = stripLineNumbers(content);
1864
+ btn.dataset.loaded = '1';
1865
+ } catch (_e) {
1866
+ btn.textContent = 'Show more';
1867
+ btn.disabled = false;
1868
+ showToast('Failed to load full output');
1869
+ return;
1870
+ }
1871
+ btn.disabled = false;
1872
+ }
1873
+ _applyExpandToggle(btn, f);
1874
+ }
1875
+
1785
1876
  function buildToolContent(m) {
1786
1877
  let content = m.fullDetail || m.detail || '';
1787
1878
  if (m.toolResult) content += `\n\n--- Output ---\n\n${m.toolResultFull || m.toolResult}`;
1788
1879
  return content;
1789
1880
  }
1790
1881
 
1882
+ function getMessageDisplayContent(m) {
1883
+ return m.type === 'tool_use' ? buildToolContent(m) : m.compactSummary || stripAnsi(m.fullText || m.text);
1884
+ }
1885
+
1791
1886
  function getDetailMsg() {
1792
1887
  if (currentMsgDetailIdx != null) return currentMessages[currentMsgDetailIdx];
1793
1888
  if (currentPinDetailId) return currentPins.find((p) => p.id === currentPinDetailId);
@@ -1798,8 +1893,7 @@ function getDetailMsg() {
1798
1893
  async function copyMsgToClipboard(btn) {
1799
1894
  const m = getDetailMsg();
1800
1895
  if (!m) return;
1801
- const content = m.type === 'tool_use' ? buildToolContent(m) : stripAnsi(m.fullText || m.text);
1802
- copyWithFeedback(content, btn);
1896
+ copyWithFeedback(getMessageDisplayContent(m), btn);
1803
1897
  }
1804
1898
 
1805
1899
  async function postAndToast(url, body, label) {
@@ -1819,9 +1913,8 @@ async function postAndToast(url, body, label) {
1819
1913
  async function openMsgInEditor() {
1820
1914
  const m = getDetailMsg();
1821
1915
  if (!m) return;
1822
- const content = m.type === 'tool_use' ? buildToolContent(m) : stripAnsi(m.fullText || m.text);
1823
- const title = m.type === 'tool_use' ? m.tool : m.type;
1824
- postAndToast('/api/open-in-editor', { content, title }, 'in editor');
1916
+ const title = m.type === 'tool_use' ? m.tool : m.compactSummary ? 'compact-summary' : m.type;
1917
+ postAndToast('/api/open-in-editor', { content: getMessageDisplayContent(m), title }, 'in editor');
1825
1918
  }
1826
1919
 
1827
1920
  function formatDuration(ms) {
@@ -2298,7 +2391,8 @@ function renderSessions() {
2298
2391
 
2299
2392
  const pinState = getSessionPinState(session.id);
2300
2393
  const pinClass = pinState === 'sticky' ? ' sticky' : pinState === 'pinned' ? ' pinned' : '';
2301
- const pinTitle = pinState === 'pinned' || pinState === 'sticky' ? 'Unpin' : 'Pin';
2394
+ const pinTitle =
2395
+ pinState === 'pinned' || pinState === 'sticky' ? 'Unpin session (.)' : 'Pin session (. · > sticky)';
2302
2396
  const showCtx = !!session.contextStatus;
2303
2397
  const linkedDocsCount = getSessionPreviewPaths(session.id).length;
2304
2398
  const bookmarksCount = loadPins(session.id).length;
@@ -2349,12 +2443,10 @@ function renderSessions() {
2349
2443
  const groupPinned = localStorage.getItem('groupPinnedSessions') !== 'false';
2350
2444
  const renderGroupSessions = (sessions, pinKey) => {
2351
2445
  if (!groupPinned || pinnedSessionIds.size === 0) return sessions.map(renderSessionCard).join('');
2352
- const gPinned = sessions.filter((s) => pinnedSessionIds.has(s.id) && !stickySessionIds.has(s.id));
2446
+ const gPinned = sessions.filter((s) => isPlacedPinned(s.id) && !isPlacedSticky(s.id));
2353
2447
  if (gPinned.length === 0) return sessions.map(renderSessionCard).join('');
2354
2448
  const gIdlePinned = gPinned.filter((s) => !isSessionActive(s));
2355
- const gUnpinned = sessions.filter(
2356
- (s) => !pinnedSessionIds.has(s.id) || isSessionActive(s) || stickySessionIds.has(s.id),
2357
- );
2449
+ const gUnpinned = sessions.filter((s) => !isPlacedPinned(s.id) || isSessionActive(s) || isPlacedSticky(s.id));
2358
2450
  const pinCollapsed = collapsedProjectGroups.has(pinKey);
2359
2451
  if (gIdlePinned.length === 0 && !pinCollapsed) return gUnpinned.map(renderSessionCard).join('');
2360
2452
  return (
@@ -2381,8 +2473,7 @@ function renderSessions() {
2381
2473
  );
2382
2474
  };
2383
2475
  if (!groupPinned && (pinnedSessionIds.size > 0 || stickySessionIds.size > 0)) {
2384
- const pinWeight = (s) =>
2385
- stickySessionIds.has(s.id) ? 2 : pinnedSessionIds.has(s.id) && !isSessionActive(s) ? 1 : 0;
2476
+ const pinWeight = (s) => (isPlacedSticky(s.id) ? 2 : isPlacedPinned(s.id) && !isSessionActive(s) ? 1 : 0);
2386
2477
  const pinSort = (a, b) => pinWeight(b) - pinWeight(a);
2387
2478
  for (const [, arr] of groups) arr.sort(pinSort);
2388
2479
  ungrouped.sort(pinSort);
@@ -2458,12 +2549,10 @@ function renderSessions() {
2458
2549
 
2459
2550
  sessionsList.innerHTML = html;
2460
2551
  } else {
2461
- const sticky = filteredSessions.filter((s) => stickySessionIds.has(s.id));
2462
- const idlePinned = filteredSessions.filter((s) => pinnedSessionIds.has(s.id) && !isSessionActive(s));
2552
+ const sticky = filteredSessions.filter((s) => isPlacedSticky(s.id));
2553
+ const idlePinned = filteredSessions.filter((s) => isPlacedPinned(s.id) && !isSessionActive(s));
2463
2554
  const rest = filteredSessions.filter(
2464
- (s) =>
2465
- (!pinnedSessionIds.has(s.id) && !stickySessionIds.has(s.id)) ||
2466
- (pinnedSessionIds.has(s.id) && isSessionActive(s)),
2555
+ (s) => (!isPlacedPinned(s.id) && !isPlacedSticky(s.id)) || (isPlacedPinned(s.id) && isSessionActive(s)),
2467
2556
  );
2468
2557
  let html = '';
2469
2558
  if (sticky.length > 0) {
@@ -2826,17 +2915,26 @@ function getGroupSessionsContainer(header) {
2826
2915
 
2827
2916
  function getNavigableItems() {
2828
2917
  const items = [];
2918
+ const walkGroupContainer = (container) => {
2919
+ if (!container) return;
2920
+ for (const child of container.children) {
2921
+ if (child.classList.contains('pinned-sub-section')) {
2922
+ const subHeader = child.querySelector('.pinned-sub-header');
2923
+ if (subHeader) items.push(subHeader);
2924
+ const subItems = child.querySelector('.pinned-sub-items');
2925
+ if (subItems && !subItems.classList.contains('collapsed')) {
2926
+ for (const s of subItems.querySelectorAll(':scope > .session-item')) items.push(s);
2927
+ }
2928
+ } else if (child.classList.contains('session-item')) {
2929
+ items.push(child);
2930
+ }
2931
+ }
2932
+ };
2829
2933
  for (const el of sessionsList.children) {
2830
2934
  if (el.classList.contains('project-group-header')) {
2831
2935
  items.push(el);
2832
2936
  if (!collapsedProjectGroups.has(el.dataset.groupPath)) {
2833
- const container = getGroupSessionsContainer(el);
2834
- if (container) {
2835
- for (const s of container.querySelectorAll('.session-item')) {
2836
- if (s.closest('.pinned-sub-items.collapsed')) continue;
2837
- items.push(s);
2838
- }
2839
- }
2937
+ walkGroupContainer(getGroupSessionsContainer(el));
2840
2938
  }
2841
2939
  } else if (el.classList.contains('session-item')) {
2842
2940
  items.push(el);
@@ -2897,49 +2995,57 @@ function setGroupCollapsed(header, collapsed) {
2897
2995
  } catch (_) {}
2898
2996
  }
2899
2997
 
2998
+ function isGroupHeader(el) {
2999
+ return el.classList.contains('project-group-header') || el.classList.contains('pinned-sub-header');
3000
+ }
3001
+
3002
+ function findParentHeader(el) {
3003
+ const subContainer = el.closest('.pinned-sub-items');
3004
+ if (subContainer?.previousElementSibling?.classList.contains('pinned-sub-header')) {
3005
+ return subContainer.previousElementSibling;
3006
+ }
3007
+ const container = el.closest('.project-group-sessions');
3008
+ if (!container) return null;
3009
+ let header = container.previousElementSibling;
3010
+ while (header && !header.classList.contains('project-group-header')) header = header.previousElementSibling;
3011
+ return header;
3012
+ }
3013
+
2900
3014
  function handleSidebarHorizontal(direction) {
2901
3015
  const items = getNavigableItems();
2902
3016
  if (selectedSessionIdx < 0 || selectedSessionIdx >= items.length) return;
2903
3017
  const el = items[selectedSessionIdx];
2904
- const isHeader = el.classList.contains('project-group-header');
2905
3018
  const collapse = direction < 0;
2906
3019
 
2907
- if (isHeader) {
2908
- const groupPath = el.dataset.groupPath;
2909
- const isCollapsed = collapsedProjectGroups.has(groupPath);
3020
+ if (isGroupHeader(el)) {
3021
+ const isCollapsed = collapsedProjectGroups.has(el.dataset.groupPath);
2910
3022
  if (collapse) {
2911
3023
  if (!isCollapsed) setGroupCollapsed(el, true);
3024
+ } else if (isCollapsed) {
3025
+ setGroupCollapsed(el, false);
2912
3026
  } else {
2913
- if (isCollapsed) {
2914
- setGroupCollapsed(el, false);
2915
- } else {
2916
- navigateSession(1);
2917
- }
2918
- }
2919
- } else {
2920
- if (collapse) {
2921
- const container = el.closest('.project-group-sessions');
2922
- if (container) {
2923
- let header = container.previousElementSibling;
2924
- while (header && !header.classList.contains('project-group-header')) header = header.previousElementSibling;
2925
- if (header) {
2926
- const headerIdx = items.indexOf(header);
2927
- if (headerIdx >= 0) selectSessionByIndex(headerIdx, items);
2928
- }
2929
- }
2930
- } else {
2931
- activateSelectedSession(items);
3027
+ navigateSession(1);
2932
3028
  }
3029
+ return;
2933
3030
  }
3031
+
3032
+ if (!collapse) {
3033
+ activateSelectedSession(items);
3034
+ return;
3035
+ }
3036
+
3037
+ const header = findParentHeader(el);
3038
+ if (!header) return;
3039
+ const headerIdx = items.indexOf(header);
3040
+ if (headerIdx >= 0) selectSessionByIndex(headerIdx, items);
2934
3041
  }
2935
3042
 
2936
3043
  function activateSelectedSession(items) {
2937
3044
  items = items || getNavigableItems();
2938
3045
  if (selectedSessionIdx < 0 || selectedSessionIdx >= items.length) return;
2939
3046
  const el = items[selectedSessionIdx];
2940
- if (el.classList.contains('project-group-header')) {
2941
- const groupPath = el.dataset.groupPath;
2942
- setGroupCollapsed(el, !collapsedProjectGroups.has(groupPath));
3047
+ if (isGroupHeader(el)) {
3048
+ setGroupCollapsed(el, !collapsedProjectGroups.has(el.dataset.groupPath));
2943
3049
  } else {
2944
3050
  el.click();
2945
3051
  }
@@ -4048,6 +4154,14 @@ document.addEventListener('keydown', (e) => {
4048
4154
  showStorageManager();
4049
4155
  return;
4050
4156
  }
4157
+ if (e.key === '.' || e.key === '>') {
4158
+ const sid = sessionsList.querySelector('.kb-selected')?.dataset.sessionId || currentSessionId;
4159
+ if (sid) {
4160
+ e.preventDefault();
4161
+ (e.shiftKey ? toggleSessionSticky : toggleSessionPin)(sid);
4162
+ return;
4163
+ }
4164
+ }
4051
4165
 
4052
4166
  // Tab toggles focus zone
4053
4167
  if (e.key === 'Tab') {
@@ -4179,6 +4293,18 @@ document.addEventListener('keydown', (e) => {
4179
4293
  hubNavigate('memory', mSession?.project ? `?project=${encodeURIComponent(mSession.project)}` : undefined);
4180
4294
  return;
4181
4295
  }
4296
+ if (e.code === 'KeyC' && e.shiftKey) {
4297
+ e.preventDefault();
4298
+ if (!contextSid) {
4299
+ showToast('No session selected');
4300
+ return;
4301
+ }
4302
+ navigator.clipboard
4303
+ .writeText(contextSid)
4304
+ .then(() => showToast(`Copied session id: ${contextSid.slice(0, 8)}`, 'success'))
4305
+ .catch(() => showToast('Failed to copy session id'));
4306
+ return;
4307
+ }
4182
4308
  if (matchKey(e, 'KeyR')) {
4183
4309
  e.preventDefault();
4184
4310
  if (_manualRefreshing) return;
@@ -5952,4 +6078,24 @@ window.hubNavigate = function hubNavigate(app, url) {
5952
6078
  if (!window.__HUB__?.enabled) return;
5953
6079
  window.parent?.postMessage({ type: 'hub:navigate', app, url }, '*');
5954
6080
  };
6081
+
6082
+ (function initHubTheme() {
6083
+ const getTheme = () => (document.body.classList.contains('light') ? 'light' : 'dark');
6084
+ const hubOrigin = () => (window.__HUB__?.url ? new URL(window.__HUB__.url).origin : null);
6085
+ let lastTheme = getTheme();
6086
+ window.addEventListener('message', (e) => {
6087
+ if (e.source !== window.parent || e.origin !== hubOrigin()) return;
6088
+ if (e.data?.type !== 'hub:theme') return;
6089
+ if (getTheme() === e.data.theme) return;
6090
+ window.toggleTheme();
6091
+ lastTheme = getTheme();
6092
+ });
6093
+ new MutationObserver(() => {
6094
+ const t = getTheme();
6095
+ if (t === lastTheme) return;
6096
+ lastTheme = t;
6097
+ const origin = hubOrigin();
6098
+ if (origin) window.parent.postMessage({ type: 'hub:theme', theme: t }, origin);
6099
+ }).observe(document.body, { attributes: true, attributeFilter: ['class'] });
6100
+ })();
5955
6101
  // #endregion HUB_INTEGRATION
package/public/index.html CHANGED
@@ -421,6 +421,14 @@
421
421
  <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">N</kbd></td>
422
422
  <td style="padding: 4px 0; color: var(--text-primary);">Toggle scratchpad</td>
423
423
  </tr>
424
+ <tr>
425
+ <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">.</kbd></td>
426
+ <td style="padding: 4px 0; color: var(--text-primary);">Pin/unpin selected session</td>
427
+ </tr>
428
+ <tr>
429
+ <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">&gt;</kbd></td>
430
+ <td style="padding: 4px 0; color: var(--text-primary);">Toggle sticky on selected session</td>
431
+ </tr>
424
432
  <tr>
425
433
  <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">T</kbd></td>
426
434
  <td style="padding: 4px 0; color: var(--text-primary);">Toggle theme</td>
@@ -445,6 +453,10 @@
445
453
  <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">Shift+S</kbd></td>
446
454
  <td style="padding: 4px 0; color: var(--text-primary);">Storage manager</td>
447
455
  </tr>
456
+ <tr>
457
+ <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">Shift+C</kbd></td>
458
+ <td style="padding: 4px 0; color: var(--text-primary);">Copy session id</td>
459
+ </tr>
448
460
  <tr>
449
461
  <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">J/K</kbd></td>
450
462
  <td style="padding: 4px 0; color: var(--text-primary);">Navigate messages in detail modal</td>
package/public/style.css CHANGED
@@ -1824,6 +1824,9 @@ body::before {
1824
1824
  .message-panel.visible {
1825
1825
  display: flex;
1826
1826
  }
1827
+ .message-panel.msg-expanded-wide {
1828
+ width: 60vw;
1829
+ }
1827
1830
  .message-panel-header {
1828
1831
  padding: 16px 20px;
1829
1832
  display: flex;
@@ -2233,6 +2236,19 @@ body::before {
2233
2236
  font-family: var(--font-mono);
2234
2237
  font-size: 0.85rem;
2235
2238
  }
2239
+ .msg-detail-pre-tinted {
2240
+ background: rgba(127, 127, 127, 0.15);
2241
+ border-radius: 4px;
2242
+ padding: 8px 10px;
2243
+ }
2244
+ .expand-toggle-btn {
2245
+ background: none;
2246
+ border: none;
2247
+ color: var(--accent);
2248
+ cursor: pointer;
2249
+ text-decoration: underline;
2250
+ margin-left: 6px;
2251
+ }
2236
2252
  .msg-cmd .msg-text code {
2237
2253
  background: var(--bg-hover);
2238
2254
  padding: 2px 6px;
@@ -2787,8 +2803,8 @@ body.light .msg-assistant .msg-text {
2787
2803
  }
2788
2804
 
2789
2805
  .modal.fullscreen {
2790
- width: 90vw;
2791
- max-width: 90vw;
2806
+ width: 80vw;
2807
+ max-width: 80vw;
2792
2808
  height: 92vh;
2793
2809
  max-height: 92vh;
2794
2810
  }
@@ -2802,8 +2818,8 @@ body.light .msg-assistant .msg-text {
2802
2818
  }
2803
2819
 
2804
2820
  .modal.plan-modal.fullscreen {
2805
- width: 90vw;
2806
- max-width: 90vw;
2821
+ width: 80vw;
2822
+ max-width: 80vw;
2807
2823
  height: 92vh;
2808
2824
  max-height: 92vh;
2809
2825
  }
@@ -3485,7 +3501,8 @@ pre.mermaid svg {
3485
3501
  color: var(--text-primary);
3486
3502
  }
3487
3503
 
3488
- .project-group-header.kb-selected {
3504
+ .project-group-header.kb-selected,
3505
+ .pinned-sub-header.kb-selected {
3489
3506
  color: var(--text-primary);
3490
3507
  background: var(--bg-hover);
3491
3508
  border-radius: 4px;
package/server.js CHANGED
@@ -19,7 +19,8 @@ const {
19
19
  readCompactSummaries,
20
20
  findTerminatedTeammates,
21
21
  extractPromptFromTranscript,
22
- extractModelFromTranscript
22
+ extractModelFromTranscript,
23
+ readFullToolResult
23
24
  } = require('./lib/parsers');
24
25
 
25
26
  if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
@@ -119,18 +120,11 @@ function isAgentFresh(agent) {
119
120
  return (Date.now() - new Date(ts).getTime()) < AGENT_TTL_MS;
120
121
  }
121
122
 
122
- const sessionLogStatCache = new Map();
123
123
  function getSessionLogStat(meta) {
124
124
  if (!meta.jsonlPath) return { mtime: null, hasMessages: false };
125
125
  try {
126
126
  const st = statSync(meta.jsonlPath);
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
+ return { mtime: st.mtimeMs, hasMessages: st.size > 1000 };
134
128
  } catch (e) { return { mtime: null, hasMessages: false }; }
135
129
  }
136
130
 
@@ -1300,12 +1294,23 @@ app.get('/api/sessions/:sessionId/messages', (req, res) => {
1300
1294
  }
1301
1295
  }
1302
1296
  for (const msg of messages) {
1303
- if (msg.toolUseId) delete msg.toolUseId;
1297
+ // Keep toolUseId on truncated tool results so the client can lazy-fetch the full text
1298
+ if (msg.toolUseId && !msg.toolResultTruncated) delete msg.toolUseId;
1304
1299
  delete msg.promptId;
1305
1300
  }
1306
1301
  res.json({ messages, hasMore, sessionId: req.params.sessionId });
1307
1302
  });
1308
1303
 
1304
+ app.get('/api/sessions/:sessionId/tool-result/:toolUseId', (req, res) => {
1305
+ const metadata = loadSessionMetadata();
1306
+ const meta = metadata[req.params.sessionId];
1307
+ const jsonlPath = meta?.jsonlPath;
1308
+ if (!jsonlPath) return res.status(404).json({ error: 'session not found' });
1309
+ const content = readFullToolResult(jsonlPath, req.params.toolUseId);
1310
+ if (content == null) return res.status(404).json({ error: 'tool result not found' });
1311
+ res.json({ toolUseId: req.params.toolUseId, content });
1312
+ });
1313
+
1309
1314
  app.get('/api/version', (req, res) => {
1310
1315
  const pkg = require('./package.json');
1311
1316
  res.json({ version: pkg.version });