claude-code-kanban 3.7.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.7.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
@@ -496,6 +496,7 @@ async function fetchTasks(sessionId) {
496
496
  if (revealedStorageSessionId && sessionId !== revealedStorageSessionId) {
497
497
  revealedStorageSessionId = null;
498
498
  }
499
+ if (currentSessionId && currentSessionId !== sessionId) deferredPinPlacement.delete(currentSessionId);
499
500
  currentSessionId = sessionId;
500
501
  currentPins = loadPins(sessionId);
501
502
  ownerFilter = '';
@@ -1206,6 +1207,9 @@ function togglePin(msgIndex) {
1206
1207
  text: m.text || null,
1207
1208
  fullText: m.fullText || null,
1208
1209
  tool: m.tool || null,
1210
+ toolUseId: m.toolUseId || null,
1211
+ toolResult: m.toolResult || null,
1212
+ toolResultTruncated: m.toolResultTruncated || false,
1209
1213
  detail: m.detail || null,
1210
1214
  fullDetail: m.fullDetail || null,
1211
1215
  description: m.description || null,
@@ -1294,6 +1298,8 @@ function togglePinnedCollapse() {
1294
1298
  //#region PINNING
1295
1299
  let pinnedSessionIds = new Set();
1296
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();
1297
1303
 
1298
1304
  function loadPinnedSessions() {
1299
1305
  try {
@@ -1316,35 +1322,45 @@ function savePinnedSessions() {
1316
1322
  localStorage.setItem('sticky-sessions', JSON.stringify([...stickySessionIds]));
1317
1323
  }
1318
1324
 
1319
- // biome-ignore lint/correctness/noUnusedVariables: used in HTML
1320
1325
  function toggleSessionPin(sessionId) {
1321
1326
  if (pinnedSessionIds.has(sessionId)) {
1322
1327
  pinnedSessionIds.delete(sessionId);
1323
1328
  stickySessionIds.delete(sessionId);
1329
+ deferredPinPlacement.delete(sessionId);
1324
1330
  } else {
1325
1331
  pinnedSessionIds.add(sessionId);
1332
+ if (sessionId === currentSessionId) deferredPinPlacement.add(sessionId);
1326
1333
  }
1327
1334
  savePinnedSessions();
1328
1335
  renderSessions();
1329
1336
  }
1330
1337
 
1331
- // biome-ignore lint/correctness/noUnusedVariables: used in HTML
1332
1338
  function toggleSessionSticky(sessionId) {
1333
1339
  if (stickySessionIds.has(sessionId)) {
1334
1340
  stickySessionIds.delete(sessionId);
1335
1341
  pinnedSessionIds.delete(sessionId);
1342
+ deferredPinPlacement.delete(sessionId);
1336
1343
  } else {
1337
1344
  pinnedSessionIds.add(sessionId);
1338
1345
  stickySessionIds.add(sessionId);
1346
+ if (sessionId === currentSessionId) deferredPinPlacement.add(sessionId);
1339
1347
  }
1340
1348
  savePinnedSessions();
1341
1349
  renderSessions();
1342
1350
  }
1343
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
+
1344
1359
  function handleSessionPinEvent({ id, state }) {
1345
1360
  if (!id) return;
1346
1361
  pinnedSessionIds.delete(id);
1347
1362
  stickySessionIds.delete(id);
1363
+ deferredPinPlacement.delete(id);
1348
1364
  if (state === 'pinned') pinnedSessionIds.add(id);
1349
1365
  if (state === 'sticky') {
1350
1366
  pinnedSessionIds.add(id);
@@ -1372,11 +1388,16 @@ function _renderPinToDetail(pin) {
1372
1388
  document.getElementById('msg-detail-title').textContent = pin.tool || 'Tool';
1373
1389
  const fullText = pin.fullDetail || pin.detail || '';
1374
1390
  const pinParamsHtml = renderToolParamsHtml(pin.params);
1375
- 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
+ );
1376
1397
  const pinDetailEscaped = escapeHtml(fullText);
1377
1398
  const pinDetailRendered = pin.tool === 'Bash' ? highlightBash(pinDetailEscaped) : pinDetailEscaped;
1378
1399
  body.innerHTML =
1379
- (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>') +
1380
1401
  pinParamsHtml +
1381
1402
  pinResultHtml;
1382
1403
  } else if (pin.type === 'agent') {
@@ -1437,7 +1458,7 @@ function showMsgDetail(idx) {
1437
1458
  const taskResultHtml = TASK_TOOLS.has(m.tool) ? renderTaskResult(m.toolResult) : '';
1438
1459
  const toolResultHtml = hideResult
1439
1460
  ? ''
1440
- : renderToolResultHtml(m.toolResult, m.toolResultTruncated, m.toolResultFull);
1461
+ : renderToolResultHtml(m.toolResult, m.toolResultTruncated, m.toolResultFull, m.toolUseId);
1441
1462
  const hasAgentTabs = m.tool === 'Agent' && m.agentId && (m.agentLastMessage || m.agentPrompt);
1442
1463
  let mainHtml;
1443
1464
  if (sendProto) {
@@ -1451,7 +1472,7 @@ function showMsgDetail(idx) {
1451
1472
  } else if (fullText) {
1452
1473
  const detailEscaped = escapeHtml(fullText);
1453
1474
  const detailRendered = m.tool === 'Bash' ? highlightBash(detailEscaped) : detailEscaped;
1454
- mainHtml = `${descHtml}<pre class="msg-detail-pre">${detailRendered}</pre>`;
1475
+ mainHtml = `${descHtml}<pre class="${TINTED_PRE_CLASS}">${detailRendered}</pre>`;
1455
1476
  } else {
1456
1477
  mainHtml = TASK_TOOLS.has(m.tool) ? '' : '<em>No details</em>';
1457
1478
  }
@@ -1698,11 +1719,11 @@ function renderToolParamsHtml(params) {
1698
1719
  html += `<div style="margin-top:8px;padding-top:6px;border-top:1px solid var(--border)">`;
1699
1720
  if (params.old_string) {
1700
1721
  html += `<div style="font-size:0.75rem;color:var(--text-muted);margin-bottom:2px">old_string</div>
1701
- <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>`;
1702
1723
  }
1703
1724
  if (params.new_string) {
1704
1725
  html += `<div style="font-size:0.75rem;color:var(--text-muted);margin-bottom:2px;margin-top:6px">new_string</div>
1705
- <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>`;
1706
1727
  }
1707
1728
  html += `</div>`;
1708
1729
  }
@@ -1717,13 +1738,14 @@ function renderToolParamsHtml(params) {
1717
1738
  const toggle = makeExpandToggle(escapeHtml(truncContent), escapeHtml(params.content), {
1718
1739
  fontSize: '0.75rem',
1719
1740
  maxHeight: '500px',
1741
+ tinted: true,
1720
1742
  });
1721
1743
  writeMoreBtn = ` ${toggle.btn}`;
1722
1744
  fullBlock = toggle.full;
1723
1745
  }
1724
1746
  html += `<div style="margin-top:8px;padding-top:6px;border-top:1px solid var(--border)">
1725
1747
  <div style="font-size:0.75rem;color:var(--text-muted);margin-bottom:2px">content${writeMoreBtn}</div>
1726
- <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>
1727
1749
  ${fullBlock}
1728
1750
  </div>`;
1729
1751
  }
@@ -1757,26 +1779,31 @@ function highlightBash(escaped) {
1757
1779
  .replace(/((?:^|\s)(?:&amp;&amp;|\|\||[|;])(?:\s|$))/g, '<span style="color:#d4d4d4;font-weight:bold">$1</span>');
1758
1780
  }
1759
1781
 
1782
+ const TINTED_PRE_CLASS = 'msg-detail-pre msg-detail-pre-tinted';
1760
1783
  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';
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';
1767
1789
  btn.textContent = expand ? 'Show less' : 'Show more';
1768
1790
  const panel = btn.closest('.message-panel');
1769
1791
  if (panel) panel.classList.toggle('msg-expanded-wide', expand);
1770
1792
  const modal = btn.closest('.modal');
1771
1793
  if (modal) _setModalWidth(modal, 'Expand', expand, '60vw', '60vw');
1772
1794
  }
1795
+ function _toggleExpand(btn) {
1796
+ const f = document.getElementById(btn.dataset.expandId);
1797
+ if (f) _applyExpandToggle(btn, f);
1798
+ }
1773
1799
  function makeExpandToggle(_truncatedHtml, fullHtml, opts = {}) {
1774
1800
  const id = `expand-${++_expandIdCounter}`;
1775
1801
  const fontSize = opts.fontSize || '0.8rem';
1776
1802
  const maxHeight = opts.maxHeight || '';
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>`;
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>`;
1778
1805
  const mhStyle = maxHeight ? `max-height:${maxHeight};` : '';
1779
- 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>`;
1780
1807
  return { btn, full };
1781
1808
  }
1782
1809
 
@@ -1796,7 +1823,7 @@ function autoSizeModal(modal, body) {
1796
1823
  if (desired > current) modal.style.maxWidth = `${desired}px`;
1797
1824
  }
1798
1825
 
1799
- function renderToolResultHtml(toolResult, isTruncated, fullResult) {
1826
+ function renderToolResultHtml(toolResult, isTruncated, fullResult, toolUseId) {
1800
1827
  if (!toolResult) return '';
1801
1828
  const stripped = stripLineNumbers(toolResult);
1802
1829
  const escaped = escapeHtml(stripped);
@@ -1806,6 +1833,10 @@ function renderToolResultHtml(toolResult, isTruncated, fullResult) {
1806
1833
  const toggle = makeExpandToggle(escaped, escapeHtml(stripLineNumbers(fullResult)));
1807
1834
  truncLabel = toggle.btn;
1808
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>`;
1809
1840
  } else if (isTruncated) {
1810
1841
  truncLabel = '<span style="color:var(--text-muted);font-size:0.8rem;margin-left:6px">(truncated)</span>';
1811
1842
  }
@@ -1816,12 +1847,42 @@ function renderToolResultHtml(toolResult, isTruncated, fullResult) {
1816
1847
  </div>`;
1817
1848
  }
1818
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
+
1819
1876
  function buildToolContent(m) {
1820
1877
  let content = m.fullDetail || m.detail || '';
1821
1878
  if (m.toolResult) content += `\n\n--- Output ---\n\n${m.toolResultFull || m.toolResult}`;
1822
1879
  return content;
1823
1880
  }
1824
1881
 
1882
+ function getMessageDisplayContent(m) {
1883
+ return m.type === 'tool_use' ? buildToolContent(m) : m.compactSummary || stripAnsi(m.fullText || m.text);
1884
+ }
1885
+
1825
1886
  function getDetailMsg() {
1826
1887
  if (currentMsgDetailIdx != null) return currentMessages[currentMsgDetailIdx];
1827
1888
  if (currentPinDetailId) return currentPins.find((p) => p.id === currentPinDetailId);
@@ -1832,8 +1893,7 @@ function getDetailMsg() {
1832
1893
  async function copyMsgToClipboard(btn) {
1833
1894
  const m = getDetailMsg();
1834
1895
  if (!m) return;
1835
- const content = m.type === 'tool_use' ? buildToolContent(m) : stripAnsi(m.fullText || m.text);
1836
- copyWithFeedback(content, btn);
1896
+ copyWithFeedback(getMessageDisplayContent(m), btn);
1837
1897
  }
1838
1898
 
1839
1899
  async function postAndToast(url, body, label) {
@@ -1853,9 +1913,8 @@ async function postAndToast(url, body, label) {
1853
1913
  async function openMsgInEditor() {
1854
1914
  const m = getDetailMsg();
1855
1915
  if (!m) return;
1856
- const content = m.type === 'tool_use' ? buildToolContent(m) : stripAnsi(m.fullText || m.text);
1857
- const title = m.type === 'tool_use' ? m.tool : m.type;
1858
- 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');
1859
1918
  }
1860
1919
 
1861
1920
  function formatDuration(ms) {
@@ -2332,7 +2391,8 @@ function renderSessions() {
2332
2391
 
2333
2392
  const pinState = getSessionPinState(session.id);
2334
2393
  const pinClass = pinState === 'sticky' ? ' sticky' : pinState === 'pinned' ? ' pinned' : '';
2335
- const pinTitle = pinState === 'pinned' || pinState === 'sticky' ? 'Unpin' : 'Pin';
2394
+ const pinTitle =
2395
+ pinState === 'pinned' || pinState === 'sticky' ? 'Unpin session (.)' : 'Pin session (. · > sticky)';
2336
2396
  const showCtx = !!session.contextStatus;
2337
2397
  const linkedDocsCount = getSessionPreviewPaths(session.id).length;
2338
2398
  const bookmarksCount = loadPins(session.id).length;
@@ -2383,12 +2443,10 @@ function renderSessions() {
2383
2443
  const groupPinned = localStorage.getItem('groupPinnedSessions') !== 'false';
2384
2444
  const renderGroupSessions = (sessions, pinKey) => {
2385
2445
  if (!groupPinned || pinnedSessionIds.size === 0) return sessions.map(renderSessionCard).join('');
2386
- 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));
2387
2447
  if (gPinned.length === 0) return sessions.map(renderSessionCard).join('');
2388
2448
  const gIdlePinned = gPinned.filter((s) => !isSessionActive(s));
2389
- const gUnpinned = sessions.filter(
2390
- (s) => !pinnedSessionIds.has(s.id) || isSessionActive(s) || stickySessionIds.has(s.id),
2391
- );
2449
+ const gUnpinned = sessions.filter((s) => !isPlacedPinned(s.id) || isSessionActive(s) || isPlacedSticky(s.id));
2392
2450
  const pinCollapsed = collapsedProjectGroups.has(pinKey);
2393
2451
  if (gIdlePinned.length === 0 && !pinCollapsed) return gUnpinned.map(renderSessionCard).join('');
2394
2452
  return (
@@ -2415,8 +2473,7 @@ function renderSessions() {
2415
2473
  );
2416
2474
  };
2417
2475
  if (!groupPinned && (pinnedSessionIds.size > 0 || stickySessionIds.size > 0)) {
2418
- const pinWeight = (s) =>
2419
- 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);
2420
2477
  const pinSort = (a, b) => pinWeight(b) - pinWeight(a);
2421
2478
  for (const [, arr] of groups) arr.sort(pinSort);
2422
2479
  ungrouped.sort(pinSort);
@@ -2492,12 +2549,10 @@ function renderSessions() {
2492
2549
 
2493
2550
  sessionsList.innerHTML = html;
2494
2551
  } else {
2495
- const sticky = filteredSessions.filter((s) => stickySessionIds.has(s.id));
2496
- 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));
2497
2554
  const rest = filteredSessions.filter(
2498
- (s) =>
2499
- (!pinnedSessionIds.has(s.id) && !stickySessionIds.has(s.id)) ||
2500
- (pinnedSessionIds.has(s.id) && isSessionActive(s)),
2555
+ (s) => (!isPlacedPinned(s.id) && !isPlacedSticky(s.id)) || (isPlacedPinned(s.id) && isSessionActive(s)),
2501
2556
  );
2502
2557
  let html = '';
2503
2558
  if (sticky.length > 0) {
@@ -2860,17 +2915,26 @@ function getGroupSessionsContainer(header) {
2860
2915
 
2861
2916
  function getNavigableItems() {
2862
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
+ };
2863
2933
  for (const el of sessionsList.children) {
2864
2934
  if (el.classList.contains('project-group-header')) {
2865
2935
  items.push(el);
2866
2936
  if (!collapsedProjectGroups.has(el.dataset.groupPath)) {
2867
- const container = getGroupSessionsContainer(el);
2868
- if (container) {
2869
- for (const s of container.querySelectorAll('.session-item')) {
2870
- if (s.closest('.pinned-sub-items.collapsed')) continue;
2871
- items.push(s);
2872
- }
2873
- }
2937
+ walkGroupContainer(getGroupSessionsContainer(el));
2874
2938
  }
2875
2939
  } else if (el.classList.contains('session-item')) {
2876
2940
  items.push(el);
@@ -2931,49 +2995,57 @@ function setGroupCollapsed(header, collapsed) {
2931
2995
  } catch (_) {}
2932
2996
  }
2933
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
+
2934
3014
  function handleSidebarHorizontal(direction) {
2935
3015
  const items = getNavigableItems();
2936
3016
  if (selectedSessionIdx < 0 || selectedSessionIdx >= items.length) return;
2937
3017
  const el = items[selectedSessionIdx];
2938
- const isHeader = el.classList.contains('project-group-header');
2939
3018
  const collapse = direction < 0;
2940
3019
 
2941
- if (isHeader) {
2942
- const groupPath = el.dataset.groupPath;
2943
- const isCollapsed = collapsedProjectGroups.has(groupPath);
3020
+ if (isGroupHeader(el)) {
3021
+ const isCollapsed = collapsedProjectGroups.has(el.dataset.groupPath);
2944
3022
  if (collapse) {
2945
3023
  if (!isCollapsed) setGroupCollapsed(el, true);
3024
+ } else if (isCollapsed) {
3025
+ setGroupCollapsed(el, false);
2946
3026
  } else {
2947
- if (isCollapsed) {
2948
- setGroupCollapsed(el, false);
2949
- } else {
2950
- navigateSession(1);
2951
- }
2952
- }
2953
- } else {
2954
- if (collapse) {
2955
- const container = el.closest('.project-group-sessions');
2956
- if (container) {
2957
- let header = container.previousElementSibling;
2958
- while (header && !header.classList.contains('project-group-header')) header = header.previousElementSibling;
2959
- if (header) {
2960
- const headerIdx = items.indexOf(header);
2961
- if (headerIdx >= 0) selectSessionByIndex(headerIdx, items);
2962
- }
2963
- }
2964
- } else {
2965
- activateSelectedSession(items);
3027
+ navigateSession(1);
2966
3028
  }
3029
+ return;
2967
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);
2968
3041
  }
2969
3042
 
2970
3043
  function activateSelectedSession(items) {
2971
3044
  items = items || getNavigableItems();
2972
3045
  if (selectedSessionIdx < 0 || selectedSessionIdx >= items.length) return;
2973
3046
  const el = items[selectedSessionIdx];
2974
- if (el.classList.contains('project-group-header')) {
2975
- const groupPath = el.dataset.groupPath;
2976
- setGroupCollapsed(el, !collapsedProjectGroups.has(groupPath));
3047
+ if (isGroupHeader(el)) {
3048
+ setGroupCollapsed(el, !collapsedProjectGroups.has(el.dataset.groupPath));
2977
3049
  } else {
2978
3050
  el.click();
2979
3051
  }
@@ -4082,6 +4154,14 @@ document.addEventListener('keydown', (e) => {
4082
4154
  showStorageManager();
4083
4155
  return;
4084
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
+ }
4085
4165
 
4086
4166
  // Tab toggles focus zone
4087
4167
  if (e.key === 'Tab') {
@@ -4213,6 +4293,18 @@ document.addEventListener('keydown', (e) => {
4213
4293
  hubNavigate('memory', mSession?.project ? `?project=${encodeURIComponent(mSession.project)}` : undefined);
4214
4294
  return;
4215
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
+ }
4216
4308
  if (matchKey(e, 'KeyR')) {
4217
4309
  e.preventDefault();
4218
4310
  if (_manualRefreshing) return;
@@ -5986,4 +6078,24 @@ window.hubNavigate = function hubNavigate(app, url) {
5986
6078
  if (!window.__HUB__?.enabled) return;
5987
6079
  window.parent?.postMessage({ type: 'hub:navigate', app, url }, '*');
5988
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
+ })();
5989
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
@@ -2236,6 +2236,19 @@ body::before {
2236
2236
  font-family: var(--font-mono);
2237
2237
  font-size: 0.85rem;
2238
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
+ }
2239
2252
  .msg-cmd .msg-text code {
2240
2253
  background: var(--bg-hover);
2241
2254
  padding: 2px 6px;
@@ -3488,7 +3501,8 @@ pre.mermaid svg {
3488
3501
  color: var(--text-primary);
3489
3502
  }
3490
3503
 
3491
- .project-group-header.kb-selected {
3504
+ .project-group-header.kb-selected,
3505
+ .pinned-sub-header.kb-selected {
3492
3506
  color: var(--text-primary);
3493
3507
  background: var(--bg-hover);
3494
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")) {
@@ -1293,12 +1294,23 @@ app.get('/api/sessions/:sessionId/messages', (req, res) => {
1293
1294
  }
1294
1295
  }
1295
1296
  for (const msg of messages) {
1296
- 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;
1297
1299
  delete msg.promptId;
1298
1300
  }
1299
1301
  res.json({ messages, hasMore, sessionId: req.params.sessionId });
1300
1302
  });
1301
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
+
1302
1314
  app.get('/api/version', (req, res) => {
1303
1315
  const pkg = require('./package.json');
1304
1316
  res.json({ version: pkg.version });