claude-code-kanban 7.5.0 → 7.6.1

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.
Files changed (40) hide show
  1. package/hub-app.json +5 -0
  2. package/lib/panes.js +16 -2
  3. package/lib/parsers.js +27 -2
  4. package/lib/retention.js +29 -6
  5. package/lib/session-events.js +154 -42
  6. package/lib/show.js +385 -0
  7. package/lib/terminal-client.js +14 -1
  8. package/lib/terminal-host.js +2 -1
  9. package/lib/terminal.js +27 -7
  10. package/package.json +1 -1
  11. package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
  12. package/plugin/plugins/claude-code-kanban/hooks/doorbell.ts +66 -10
  13. package/plugin/plugins/claude-code-kanban/hooks/index.ts +2 -0
  14. package/plugin/plugins/claude-code-kanban/hooks/show.ts +120 -0
  15. package/plugin/plugins/claude-code-kanban/skills/dispatch/SKILL.md +2 -0
  16. package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +2 -0
  17. package/plugin/plugins/claude-code-kanban/skills/show/SKILL.md +45 -0
  18. package/plugin/plugins/claude-code-kanban/skills/show/references/NOTICE.md +27 -0
  19. package/plugin/plugins/claude-code-kanban/skills/show/references/chart.md +58 -0
  20. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/architecture.md +18 -0
  21. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/er.md +10 -0
  22. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/flow.md +13 -0
  23. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/layers.md +10 -0
  24. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/sequence.md +19 -0
  25. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/state.md +12 -0
  26. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/swimlane.md +10 -0
  27. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/timeline.md +9 -0
  28. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/tree.md +10 -0
  29. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram.md +87 -0
  30. package/plugin/plugins/claude-code-kanban/skills/show/references/explain.md +98 -0
  31. package/plugin/plugins/claude-code-kanban/skills/show/references/mockup.md +30 -0
  32. package/plugin/plugins/claude-code-kanban/tests/doorbell.test.ts +69 -10
  33. package/plugin/plugins/claude-code-kanban/tests/show.test.ts +137 -0
  34. package/public/app.js +1149 -108
  35. package/public/index.html +7 -6
  36. package/public/style.css +770 -9
  37. package/public/terminal-frame.js +0 -2
  38. package/public/tree-highlight.js +137 -15
  39. package/server.js +71 -24
  40. package/lib/dispatch.js +0 -32
package/public/app.js CHANGED
@@ -546,6 +546,10 @@ function toggleSection(containerId, chevronId) {
546
546
  function isWaitingSession(s) {
547
547
  return !!s.hasWaitingForUser;
548
548
  }
549
+ function sessionBarClass(s) {
550
+ if (isWaitingSession(s)) return 'permission-pending';
551
+ return s.unread ? 'unread' : '';
552
+ }
549
553
  function isActiveSession(s) {
550
554
  return !s.hasWaitingForUser && (s.inProgress > 0 || s.hasRecentLog || s.hasRunningAgents);
551
555
  }
@@ -691,66 +695,103 @@ function setActivityFilter(kind) {
691
695
  }
692
696
 
693
697
  let lastCurrentTasksHash = '';
698
+ let taskFetchSeq = 0;
699
+ let switchSeq = 0;
700
+ const taskRetry = { timer: null, delay: 0 };
701
+
702
+ // The board refetches only on an SSE update that names the open session, so a failed refresh
703
+ // must retry by itself: when the session's tasks stop changing, no later event repairs it.
704
+ function retryTaskFetch(sessionId) {
705
+ clearTimeout(taskRetry.timer);
706
+ taskRetry.delay = Math.min((taskRetry.delay || 1000) * 2, 30000);
707
+ taskRetry.timer = setTimeout(() => {
708
+ if (sessionId === currentSessionId && viewMode === 'session') return fetchTasks(sessionId);
709
+ }, taskRetry.delay);
710
+ }
694
711
 
695
712
  async function fetchTasks(sessionId) {
713
+ const refresh = viewMode === 'session' && sessionId === currentSessionId;
714
+ const seq = ++taskFetchSeq;
715
+ const logSeq = refresh ? switchSeq : ++switchSeq;
716
+ let msgPage = null;
717
+ if (!refresh) {
718
+ if (!agentLogMode) msgPage = fetchSessionMessagesPage(sessionId).catch(() => null);
719
+ if (messagePanelOpen) scheduleLogLoading(logSeq);
720
+ } else if (logLoading) {
721
+ endLogLoading();
722
+ if (messagePanelOpen && !agentLogMode) renderMessages(currentMessages);
723
+ }
724
+ // The PTY already runs, so the attach need not wait for the session fetch and the render after it.
725
+ if (!refresh && termState.sessionId !== sessionId && runningTerminals.has(sessionId) && wantsTerminalFor(sessionId))
726
+ openTerminal(sessionId, terminalOpenMode(sessionId));
727
+ viewMode = 'session';
728
+ document.getElementById('message-toggle')?.style.removeProperty('display');
729
+ let newTasks;
696
730
  try {
697
- const refresh = viewMode === 'session' && sessionId === currentSessionId;
698
- // The PTY already runs, so the attach need not wait for the session fetch and the render after it.
699
- if (!refresh && termState.sessionId !== sessionId && runningTerminals.has(sessionId) && wantsTerminalFor(sessionId))
700
- openTerminal(sessionId, terminalOpenMode(sessionId));
701
- viewMode = 'session';
702
- document.getElementById('message-toggle')?.style.removeProperty('display');
703
731
  const res = await api(apiPath`/api/sessions/${sessionId}`);
704
-
705
732
  if (!res.ok) throw new Error(`Failed to fetch tasks: ${res.status}`);
706
- const newTasks = await res.json();
707
-
708
- const hash = JSON.stringify(newTasks);
709
- if (sessionId === currentSessionId && hash === lastCurrentTasksHash) return;
710
- lastCurrentTasksHash = hash;
711
-
712
- currentTasks = newTasks;
713
- if (agentLogMode && sessionId !== currentSessionId) exitAgentLogMode();
714
- if (sessionId !== currentSessionId && document.getElementById('scratchpad-modal').classList.contains('visible'))
715
- closeScratchpad();
716
- if (sessionId !== currentSessionId && detailPanel.classList.contains('visible')) closeDetailPanel();
717
- if (revealedPlanSessionId && sessionId !== revealedPlanSessionId) {
718
- revealedPlanSessionId = null;
719
- }
720
- if (revealedStorageSessionId && sessionId !== revealedStorageSessionId) {
721
- revealedStorageSessionId = null;
722
- }
723
- if (currentSessionId && currentSessionId !== sessionId && deferredPinPlacement.delete(currentSessionId))
724
- expandPinnedFor(currentSessionId);
725
- if (lastSessionId !== sessionId) setSwapPair(sessionId, lastSessionId);
726
- const switched = sessionId !== currentSessionId;
727
- currentSessionId = sessionId;
728
- markOpenSessionRead();
729
- if (switched) autoRevealLog(sessionId);
730
- // A task change in the open session must not reset the messages, the agents or the owner
731
- // filter: that reset rebuilds the panels and reads as a reload after each card move.
732
- if (!refresh) {
733
- currentPins = loadPins(sessionId);
734
- ownerFilter = '';
735
- resetMessageScrollState();
736
- for (const k of Object.keys(ownerColorCache)) delete ownerColorCache[k];
737
- for (const k of Object.keys(teamColorMap)) delete teamColorMap[k];
738
- sessionJustSelected = true;
739
- resetAgentState();
740
- }
741
- updateUrl();
742
- renderSession();
743
- renderSessions();
744
- fetchAgents(sessionId);
745
- if (!agentLogMode) fetchMessages(sessionId);
733
+ newTasks = await res.json();
746
734
  } catch (error) {
747
735
  console.error('Failed to fetch tasks:', error);
736
+ if (seq !== taskFetchSeq) return;
737
+ if (sessionId === currentSessionId) return retryTaskFetch(sessionId);
748
738
  currentTasks = [];
749
739
  currentSessionId = sessionId;
750
740
  lastCurrentTasksHash = '';
751
741
  updateUrl();
752
742
  renderSession();
743
+ if (!agentLogMode) {
744
+ resetMessageScrollState();
745
+ fetchMessages(sessionId, msgPage);
746
+ }
747
+ return;
748
+ }
749
+ if (seq !== taskFetchSeq) return;
750
+ clearTimeout(taskRetry.timer);
751
+ taskRetry.delay = 0;
752
+
753
+ const hash = JSON.stringify(newTasks);
754
+ if (sessionId === currentSessionId && hash === lastCurrentTasksHash) return;
755
+ lastCurrentTasksHash = hash;
756
+
757
+ currentTasks = newTasks;
758
+ if (agentLogMode && sessionId !== currentSessionId) exitAgentLogMode();
759
+ if (sessionId !== currentSessionId && document.getElementById('scratchpad-modal').classList.contains('visible'))
760
+ closeScratchpad();
761
+ if (sessionId !== currentSessionId && detailPanel.classList.contains('visible')) closeDetailPanel();
762
+ if (revealedPlanSessionId && sessionId !== revealedPlanSessionId) {
763
+ revealedPlanSessionId = null;
753
764
  }
765
+ if (revealedStorageSessionId && sessionId !== revealedStorageSessionId) {
766
+ revealedStorageSessionId = null;
767
+ }
768
+ if (currentSessionId && currentSessionId !== sessionId && deferredPinPlacement.delete(currentSessionId))
769
+ expandPinnedFor(currentSessionId);
770
+ if (lastSessionId !== sessionId) setSwapPair(sessionId, lastSessionId);
771
+ const switched = sessionId !== currentSessionId;
772
+ currentSessionId = sessionId;
773
+ markOpenSessionRead();
774
+ if (switched) autoRevealLog(sessionId);
775
+ // A task change in the open session must not reset the messages, the agents or the owner
776
+ // filter: that reset rebuilds the panels and reads as a reload after each card move.
777
+ if (!refresh) {
778
+ currentPins = loadPins(sessionId);
779
+ ownerFilter = '';
780
+ resetMessageScrollState();
781
+ for (const k of Object.keys(ownerColorCache)) delete ownerColorCache[k];
782
+ for (const k of Object.keys(teamColorMap)) delete teamColorMap[k];
783
+ sessionJustSelected = true;
784
+ resetAgentState();
785
+ if (messagePanelOpen && !agentLogMode) {
786
+ if (logLoading?.seq !== logSeq) scheduleLogLoading(logSeq);
787
+ showLogLoading(logSeq);
788
+ }
789
+ }
790
+ updateUrl();
791
+ renderSession();
792
+ renderSessions();
793
+ fetchAgents(sessionId);
794
+ if (!agentLogMode) fetchMessages(sessionId, msgPage);
754
795
  }
755
796
  //#endregion
756
797
 
@@ -804,6 +845,8 @@ async function fetchProjectView(projectPath) {
804
845
  viewMode = 'project';
805
846
  currentProjectPath = projectPath;
806
847
  currentSessionId = null;
848
+ switchSeq++;
849
+ endLogLoading();
807
850
  setCurrentMessages([]);
808
851
  lastMessagesHash = '';
809
852
  if (messagePanelOpen) toggleMessagePanel();
@@ -913,7 +956,7 @@ function toggleMessagePanel() {
913
956
  panel.classList.toggle('visible', messagePanelOpen);
914
957
  document.getElementById('message-toggle')?.classList.toggle('active', messagePanelOpen);
915
958
  if (messagePanelOpen && currentSessionId) {
916
- if (currentMessages.length) renderMessages(currentMessages);
959
+ if (currentMessages.length || lastMessagesHash) renderMessages(currentMessages);
917
960
  fetchMessages(currentSessionId);
918
961
  }
919
962
  updateUrl();
@@ -1055,10 +1098,46 @@ async function fetchSessionMessagesPage(sessionId, { before, limit = MSG_PAGE_LI
1055
1098
  return res.ok ? res.json() : null;
1056
1099
  }
1057
1100
 
1058
- async function fetchMessages(sessionId) {
1101
+ const LOG_LOADING_DELAY_MS = 150;
1102
+ let logLoading = null;
1103
+
1104
+ function scheduleLogLoading(seq) {
1105
+ clearTimeout(logLoading?.timer);
1106
+ logLoading = { seq, startedAt: performance.now(), shown: false };
1107
+ logLoading.timer = setTimeout(() => showLogLoading(seq), LOG_LOADING_DELAY_MS);
1108
+ }
1109
+
1110
+ // Called again when the new session's header lands, so the old log never sits under it;
1111
+ // the delay keeps a fast switch from flashing the skeleton.
1112
+ function showLogLoading(seq) {
1113
+ if (!logLoading || logLoading.seq !== seq || logLoading.shown) return;
1114
+ logLoading.shown = true;
1115
+ const delay = Math.max(0, Math.round(LOG_LOADING_DELAY_MS - (performance.now() - logLoading.startedAt)));
1116
+ const rows = '<div class="msg-log-loading-row"><span></span><span></span></div>'.repeat(4);
1117
+ document.getElementById('message-panel-pinned').innerHTML = '';
1118
+ document.getElementById('message-panel-content').innerHTML =
1119
+ `<div class="msg-log-loading" style="--log-loading-delay:${delay}ms" role="status" aria-live="polite">${rows}<div class="msg-log-loading-label">Loading session log…</div></div>`;
1120
+ }
1121
+
1122
+ function endLogLoading() {
1123
+ if (!logLoading) return;
1124
+ clearTimeout(logLoading.timer);
1125
+ logLoading = null;
1126
+ }
1127
+
1128
+ async function fetchMessages(sessionId, page) {
1129
+ const seq = switchSeq;
1059
1130
  try {
1060
- const data = await fetchSessionMessagesPage(sessionId);
1061
- if (!data) return;
1131
+ const data = await (page || fetchSessionMessagesPage(sessionId));
1132
+ if (seq !== switchSeq || sessionId !== currentSessionId) return;
1133
+ if (!data) {
1134
+ if (logLoading?.seq === seq && messagePanelOpen && !agentLogMode) {
1135
+ endLogLoading();
1136
+ document.getElementById('message-panel-content').innerHTML =
1137
+ '<div class="msg-empty">Could not load the session log</div>';
1138
+ }
1139
+ return;
1140
+ }
1062
1141
  let agentEnriched = false;
1063
1142
  for (const m of data.messages) {
1064
1143
  if (m.agentId && m.agentPrompt) {
@@ -1755,6 +1834,7 @@ async function discardWaiting() {
1755
1834
  }
1756
1835
 
1757
1836
  function renderMessages(messages) {
1837
+ endLogLoading();
1758
1838
  const container = document.getElementById('message-panel-content');
1759
1839
  const pinnedContainer = document.getElementById('message-panel-pinned');
1760
1840
  pinnedContainer.innerHTML = agentLogMode ? '' : renderPinnedSection();
@@ -2421,6 +2501,7 @@ function mountReplyReview(m, body, modal) {
2421
2501
  locate: replyLocateHint(m.timestamp),
2422
2502
  },
2423
2503
  onComment: () => setMsgDetailFollow(false),
2504
+ onSent: closeMsgDetailModal,
2424
2505
  });
2425
2506
  }
2426
2507
 
@@ -2602,6 +2683,7 @@ function modalZoomLabel() {
2602
2683
  function applyModalZoom() {
2603
2684
  document.documentElement.style.setProperty('--modal-zoom', String(modalZoom));
2604
2685
  for (const el of document.querySelectorAll('.pane-zoom-val')) el.textContent = modalZoomLabel();
2686
+ postToShowFrames(() => ({ type: `${SHOW_MSG}zoom`, z: modalZoom }));
2605
2687
  }
2606
2688
 
2607
2689
  // delta 0 resets to 100%
@@ -2627,7 +2709,7 @@ function isAnyModalOpen() {
2627
2709
 
2628
2710
  // An open modal owns the zoom keys even when it has nothing to scale.
2629
2711
  function isZoomableOnScreen() {
2630
- return isAnyModalOpen() ? isZoomableModalOpen() : isPaneDocOnScreen();
2712
+ return isAnyModalOpen() ? isZoomableModalOpen() : isPaneDocOnScreen() || isShowCardFocused();
2631
2713
  }
2632
2714
 
2633
2715
  const ZOOM_KEYS = { '+': 0.1, '=': 0.1, NumpadAdd: 0.1, '-': -0.1, _: -0.1, NumpadSubtract: -0.1, 0: 0, Numpad0: 0 };
@@ -3544,6 +3626,8 @@ function showNoSession() {
3544
3626
  viewMode = 'none';
3545
3627
  if (agentLogMode) exitAgentLogMode();
3546
3628
  currentSessionId = null;
3629
+ switchSeq++;
3630
+ endLogLoading();
3547
3631
  ownerFilter = '';
3548
3632
  resetAgentState();
3549
3633
  currentTasks = [];
@@ -3746,7 +3830,7 @@ function renderSessions() {
3746
3830
  : '';
3747
3831
  const metricsHtml = progressHtml + (showCtx ? renderContextBar(session) : '');
3748
3832
  return `
3749
- <button onclick="openSession('${sid}')" draggable="true" data-session-id="${escapeHtml(session.id)}" class="session-item ${isActive ? 'active' : ''} ${session.hasWaitingForUser ? 'permission-pending' : ''} ${session.unread ? 'unread' : ''} ${tempClass} ${idleClass} ${justFinishedClass}">
3833
+ <button onclick="openSession('${sid}')" draggable="true" data-session-id="${escapeHtml(session.id)}" class="session-item ${isActive ? 'active' : ''} ${sessionBarClass(session)} ${tempClass} ${idleClass} ${justFinishedClass}">
3750
3834
  <span class="session-pin-btn${pinClass}" onclick="event.stopPropagation();toggleSessionPin('${sid}')" title="${pinTitle} session">${pinState === 'sticky' ? SESSION_STAR_SVG : SESSION_PIN_SVG}</span>
3751
3835
  <div class="session-name">${escapeHtml(sessionName)}</div>
3752
3836
  ${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
@@ -4578,7 +4662,7 @@ const transientGroups = new Map();
4578
4662
  function sgTransientGroup(name) {
4579
4663
  let group = transientGroups.get(name);
4580
4664
  if (!group) {
4581
- group = { id: `t_${name}`, name, transient: true };
4665
+ group = { id: `t_${name}`, name, transient: true, members: [] };
4582
4666
  transientGroups.set(name, group);
4583
4667
  }
4584
4668
  return group;
@@ -5142,11 +5226,19 @@ function sgOpenMenu(x, y, kind, ref) {
5142
5226
  menu.className = 'sg-menu';
5143
5227
  menu.setAttribute('role', 'menu');
5144
5228
  const canFork = kind === 'session' && !!forkableSession(ref);
5145
- menu.setAttribute('aria-label', canFork ? 'Session actions' : `Move ${label} to group`);
5229
+ const canEnd = kind === 'session' && runningTerminals.has(ref);
5230
+ const actions =
5231
+ (canFork
5232
+ ? '<button class="sg-menu-item" role="menuitem" data-sg-action="fork" title="claude --resume --fork-session">Fork</button>'
5233
+ : '') +
5234
+ (canEnd
5235
+ ? '<button class="sg-menu-item" role="menuitem" data-sg-action="end" title="Alt+Shift+`">End terminal</button>'
5236
+ : '');
5237
+ menu.setAttribute('aria-label', actions ? 'Session actions' : `Move ${label} to group`);
5146
5238
  menu.dataset.sgKind = kind;
5147
5239
  menu.dataset.sgRef = ref;
5148
5240
  menu.innerHTML = `
5149
- ${canFork ? '<div class="sg-menu-label" aria-hidden="true">Session</div><button class="sg-menu-item" role="menuitem" data-sg-action="fork" title="claude --resume --fork-session">Fork</button>' : ''}
5241
+ ${actions ? `<div class="sg-menu-label" aria-hidden="true">Session</div>${actions}` : ''}
5150
5242
  <div class="sg-menu-label" aria-hidden="true">Move ${label} to group</div>
5151
5243
  ${rows}
5152
5244
  <button class="sg-menu-item" role="menuitem" data-sg-move="__new__">+ New group…</button>
@@ -5195,6 +5287,7 @@ document.addEventListener('click', (e) => {
5195
5287
  const move = item.dataset.sgMove;
5196
5288
  sgCloseMenu();
5197
5289
  if (item.dataset.sgAction === 'fork') return forkSession(sgRef);
5290
+ if (item.dataset.sgAction === 'end') return closeTerminalSession(sgRef);
5198
5291
  if (move === '__none__') {
5199
5292
  sgDetach(sgKind, sgRef);
5200
5293
  persistSessionGroups();
@@ -5428,7 +5521,11 @@ function expandActiveGroups({ onlyNew = false } = {}) {
5428
5521
  if (!isSessionActive(s)) continue;
5429
5522
  activeIds.add(s.id);
5430
5523
  if (onlyNew && (!primed || prevActiveSessionIds.has(s.id))) continue;
5431
- if (uncollapseFor(s)) changed = true;
5524
+ try {
5525
+ if (uncollapseFor(s)) changed = true;
5526
+ } catch (err) {
5527
+ console.error('uncollapseFor failed for session', s.id, err);
5528
+ }
5432
5529
  }
5433
5530
  prevActiveSessionIds = activeIds;
5434
5531
  if (changed) persistCollapsedGroups();
@@ -5946,7 +6043,7 @@ const SHORTCUT_TABS = [
5946
6043
  { keys: ['Shift', 'L'], combo: true, label: 'Toggle session log' },
5947
6044
  { keys: ['Shift', 'M'], combo: true, label: 'Open last message' },
5948
6045
  { keys: ['↑', '↓'], label: 'Previous / next message in detail' },
5949
- { keys: ['Ctrl', 'Enter'], combo: true, label: 'Allow / approve the waiting prompt' },
6046
+ { keys: ['Ctrl', 'Enter'], combo: true, label: 'Allow / approve the waiting prompt; send review comments' },
5950
6047
  ],
5951
6048
  },
5952
6049
  {
@@ -5969,11 +6066,23 @@ const SHORTCUT_TABS = [
5969
6066
  rows: [
5970
6067
  { keys: ['Ctrl', '`'], combo: true, label: 'Show / hide terminal' },
5971
6068
  { keys: ['Alt', '`'], combo: true, label: 'Focus terminal / page' },
6069
+ { keys: ['Ctrl', 'Alt', '`'], combo: true, hubMod: true, label: 'Focus terminal / show card' },
5972
6070
  { keys: ['Alt', 'Shift', '`'], combo: true, label: 'End terminal' },
5973
6071
  { keys: ['Ctrl', 'Shift', '`'], combo: true, label: 'All terminals' },
5974
6072
  { keys: ['Ctrl', '+/−/0'], combo: true, label: 'Terminal text size' },
5975
6073
  ],
5976
6074
  },
6075
+ {
6076
+ title: 'Show card (focused)',
6077
+ rows: [
6078
+ { keys: ['[', ']'], label: 'Previous / next post' },
6079
+ { keys: ['Alt', 'Enter'], combo: true, label: 'Fill the terminal / back to the card' },
6080
+ { keys: ['Esc'], label: 'Back to the card, else collapse to the header' },
6081
+ { keys: ['Alt', 'W'], combo: true, label: 'Close until the next post' },
6082
+ { keys: ['Ctrl', 'D'], combo: true, label: 'Remove the post' },
6083
+ { keys: ['Ctrl', '+/−/0'], combo: true, label: 'Card text size' },
6084
+ ],
6085
+ },
5977
6086
  ],
5978
6087
  },
5979
6088
  {
@@ -7030,6 +7139,10 @@ document.addEventListener('keydown', (e) => {
7030
7139
  adjustModalZoom(zoom);
7031
7140
  return;
7032
7141
  }
7142
+ if (isShowCardFocused() && !isAnyModalOpen() && showCardKey(e)) {
7143
+ e.preventDefault();
7144
+ return;
7145
+ }
7033
7146
 
7034
7147
  // Above the text-field guard: xterm's input is a textarea, and the toggle must work from it.
7035
7148
  const terminalPrompt = document.getElementById('terminal-prompt');
@@ -7510,6 +7623,7 @@ function stopReview() {
7510
7623
  activeReview = null;
7511
7624
  reviewHighlight?.clear();
7512
7625
  reviewHotHighlight?.clear();
7626
+ syncReviewSendClaims();
7513
7627
  }
7514
7628
 
7515
7629
  // The three helpers below are also handed to `reviewBridge` as source text, so each must use
@@ -7716,7 +7830,9 @@ function reviewBridge(textBefore, headingBefore, rangeAt, comboOf, fieldSelector
7716
7830
  let forward = new Set();
7717
7831
  document.addEventListener('keydown', (e) => {
7718
7832
  if (e.defaultPrevented || e.target.closest?.(fieldSelector)) return;
7719
- if (!forward.has(comboOf(e)) && !keySigs(e).some((s) => claims.has(s))) return;
7833
+ const sigs = keySigs(e);
7834
+ const offerKey = pending && sigs.includes('|k:c');
7835
+ if (!offerKey && !forward.has(comboOf(e)) && !sigs.some((s) => claims.has(s))) return;
7720
7836
  e.preventDefault();
7721
7837
  send({ type: 'key', key: e.key, code: e.code, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey, meta: e.metaKey });
7722
7838
  });
@@ -7754,7 +7870,8 @@ function reviewBridge(textBefore, headingBefore, rangeAt, comboOf, fieldSelector
7754
7870
  });
7755
7871
  }
7756
7872
 
7757
- const TEXT_FIELD_SELECTOR = 'input, textarea, select, [contenteditable]';
7873
+ const TEXT_FIELD_SELECTOR =
7874
+ 'input:not([type=checkbox], [type=radio], [type=button], [type=submit], [type=reset], [type=range], [type=color], [type=file], [type=image]), textarea, select, [contenteditable]';
7758
7875
 
7759
7876
  const REVIEW_BRIDGE_TAG = `<script>(${reviewBridge})(${reviewTextBefore}, ${reviewHeadingBefore}, ${reviewRangeAt}, ${ClaudeHub.comboOf}, ${JSON.stringify(TEXT_FIELD_SELECTOR)});</script>`;
7760
7877
 
@@ -7788,6 +7905,16 @@ const PANE_CLAIMS = [
7788
7905
  ...['BracketLeft', 'BracketRight', 'KeyW'].map((code) => ({ altKey: true, key: 'Unidentified', code })),
7789
7906
  ];
7790
7907
 
7908
+ // Ctrl+D, Alt+Enter and Esc act on the show card (showCardKey); other pages keep them. Alt+W is in PANE_CLAIMS.
7909
+ const SHOW_CLAIMS = [
7910
+ ...['ctrlKey', 'metaKey'].map((mod) => ({ [mod]: true, key: 'd', code: '' })),
7911
+ { altKey: true, key: 'Enter', code: '' },
7912
+ { key: 'Escape', code: '' },
7913
+ ];
7914
+
7915
+ // Claimed only while comments wait, so a previewed page keeps Ctrl+Enter otherwise.
7916
+ const REVIEW_SEND_CLAIMS = ['ctrlKey', 'metaKey'].map((mod) => ({ [mod]: true, key: 'Enter', code: '' }));
7917
+
7791
7918
  function sendBridgeClaims(frames, claims) {
7792
7919
  for (const f of frames) f.contentWindow?.postMessage({ type: `${REVIEW_MSG}claims`, ...claims }, '*');
7793
7920
  }
@@ -7856,12 +7983,26 @@ function showReviewOffer(picked) {
7856
7983
  }
7857
7984
 
7858
7985
  function onReviewOfferKey(e) {
7986
+ if (isReviewSendKey(e)) {
7987
+ e.preventDefault();
7988
+ e.stopImmediatePropagation();
7989
+ sendReview();
7990
+ return;
7991
+ }
7859
7992
  if (reviewPopMode !== 'offer' || !matchKey(e, 'c', 'C')) return;
7860
7993
  e.preventDefault();
7861
7994
  e.stopImmediatePropagation();
7862
7995
  reviewPopEl().querySelector('.review-offer-btn').click();
7863
7996
  }
7864
7997
 
7998
+ function isReviewSendKey(e) {
7999
+ if (e.key !== 'Enter' || !(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey) return false;
8000
+ if (reviewPopMode === 'editor' || !reviewItems().length) return false;
8001
+ // A pane's review stays live under a modal, where Ctrl+Enter belongs to the modal.
8002
+ if (activeReview.opts === reviewBase && document.querySelector('.modal-overlay.visible')) return false;
8003
+ return !e.target.closest?.(TEXT_FIELD_SELECTOR);
8004
+ }
8005
+
7865
8006
  function openReviewEditor(picked) {
7866
8007
  const el = reviewPopEl();
7867
8008
  reviewPopMode = 'editor';
@@ -7965,7 +8106,17 @@ function onReviewPanelHover(e) {
7965
8106
  setReviewHot((card && reviewItems()[Number(card.dataset.i)]) || null);
7966
8107
  }
7967
8108
 
8109
+ let reviewSendClaimed = false;
8110
+
8111
+ function syncReviewSendClaims() {
8112
+ const want = reviewItems().length > 0;
8113
+ if (want === reviewSendClaimed) return;
8114
+ reviewSendClaimed = want;
8115
+ pushTerminalClaims();
8116
+ }
8117
+
7968
8118
  function renderReviewPanel() {
8119
+ syncReviewSendClaims();
7969
8120
  if (!activeReview) return;
7970
8121
  const { panelEl, hostEl } = activeReview;
7971
8122
  const items = reviewItems();
@@ -7999,7 +8150,7 @@ async function sendReview() {
7999
8150
  });
8000
8151
  const data = await res.json();
8001
8152
  if (!res.ok) return showToast(data.error || 'Review failed', 'error');
8002
- if (data.delivered === 'doorbell') showToast('Sent to session', 'success');
8153
+ if (data.delivered === 'doorbell') showToast('Queued for the session', 'success');
8003
8154
  else if (data.delivered === 'terminal') showToast('Pasted into the terminal: press Enter there to send', 'info');
8004
8155
  else {
8005
8156
  const { markdown } = data;
@@ -8028,6 +8179,7 @@ async function sendReview() {
8028
8179
  //#region MARKDOWN_PREVIEW
8029
8180
  const PREVIEW_STORAGE_PREFIX = 'preview-paths-';
8030
8181
  let currentPreviewPath = null;
8182
+ let previewFromTerminal = false;
8031
8183
 
8032
8184
  function readStoredList(key) {
8033
8185
  const arr = store.readJson(key);
@@ -8132,14 +8284,14 @@ function renderFrontmatterBlock(fm) {
8132
8284
  // allow-same-origin puts it on an opaque origin, so it cannot touch this app's
8133
8285
  // storage, DOM or API. srcdoc has no base URL, so the server has already embedded
8134
8286
  // the document's local assets (lib/inline-assets.js); remote refs load normally.
8135
- function createPreviewFrame(className, srcdoc) {
8287
+ function createPreviewFrame(className, srcdoc, sandbox = 'allow-scripts allow-popups') {
8136
8288
  const frame = document.createElement('iframe');
8137
8289
  frame.className = className;
8138
- frame.setAttribute('sandbox', 'allow-scripts allow-popups');
8290
+ frame.setAttribute('sandbox', sandbox);
8139
8291
  frame.setAttribute('referrerpolicy', 'no-referrer');
8140
8292
  frame.srcdoc = srcdoc;
8141
8293
  bridgedFrames.add(frame);
8142
- frame.addEventListener('load', () => sendBridgeClaims([frame], bridgeClaims(keyClaims())));
8294
+ frame.addEventListener('load', () => sendBridgeClaimsAll([frame], keyClaims()));
8143
8295
  return frame;
8144
8296
  }
8145
8297
 
@@ -8193,10 +8345,13 @@ function highlightCode(text, lang) {
8193
8345
  function renderSourcePreview(filePath, content) {
8194
8346
  const ext = langOfPath(filePath);
8195
8347
  const value = highlightCode(content, ext);
8196
- if (value != null) {
8197
- return `<pre class="preview-source"><code class="hljs language-${escapeHtml(ext)}">${value}</code></pre>`;
8198
- }
8199
- return `<pre class="preview-source"><code class="hljs">${escapeHtml(content)}</code></pre>`;
8348
+ const n = treeHighlight.lineCount(content);
8349
+ const gutter = `<span class="src-gutter" aria-hidden="true">${Array.from({ length: n }, (_, i) => i + 1).join('\n')}</span>`;
8350
+ const code =
8351
+ value != null
8352
+ ? `<code class="hljs language-${escapeHtml(ext)}">${value}</code>`
8353
+ : `<code class="hljs">${escapeHtml(content)}</code>`;
8354
+ return `<pre class="preview-source" data-lines="${n}">${gutter}${code}</pre>`;
8200
8355
  }
8201
8356
 
8202
8357
  // Every kind but html, which needs a frame.
@@ -8227,6 +8382,9 @@ function openPreviewModal(filePath, content, kind) {
8227
8382
  else renderPreviewContent(bodyEl, filePath, content, kind);
8228
8383
  document.getElementById('preview-modal-meta').textContent = filePath;
8229
8384
  document.getElementById('preview-modal').classList.add('visible');
8385
+ // A preview the CLI opens finds focus in the terminal frame, whose keys never reach the board's Esc.
8386
+ previewFromTerminal ||= terminalPaneFocused();
8387
+ document.querySelector('#preview-modal .modal').focus();
8230
8388
  updatePreviewLinkBtn();
8231
8389
  const panelEl = document.getElementById('preview-review-panel');
8232
8390
  const opts = fileReviewOpts(filePath, kind, bodyEl, isHtml ? bodyEl.querySelector('iframe') : null);
@@ -8306,6 +8464,8 @@ function closePreviewModal() {
8306
8464
  // Empty the body so an iframe preview is destroyed and its scripts/timers stop.
8307
8465
  document.getElementById('preview-modal-body').innerHTML = '';
8308
8466
  currentPreviewPath = null;
8467
+ if (previewFromTerminal) focusTerminalPane();
8468
+ previewFromTerminal = false;
8309
8469
  }
8310
8470
 
8311
8471
  function openFileInEditor(filePath) {
@@ -9091,6 +9251,11 @@ function setupEventSource() {
9091
9251
 
9092
9252
  if (data.type === 'dispatch-update') onDispatchUpdate();
9093
9253
 
9254
+ if (data.type === 'show:posted') onShowPosted(data);
9255
+
9256
+ if (data.type === 'show:cleared') onShowCleared(data);
9257
+ if (data.type === 'show:removed') onShowRemoved(data);
9258
+
9094
9259
  if (data.type === 'agent-update') {
9095
9260
  if (!data.unreadOnly) pendingAgentSessionIds.add(data.sessionId);
9096
9261
  clearTimeout(agentRefreshTimer);
@@ -9917,8 +10082,53 @@ function isLightTheme() {
9917
10082
  return document.body.classList.contains('light');
9918
10083
  }
9919
10084
 
9920
- function getMermaidTheme() {
9921
- return isLightTheme() ? 'default' : 'dark';
10085
+ const MERMAID_VARS = {
10086
+ background: '--bg-surface',
10087
+ primaryTextColor: '--text-primary',
10088
+ primaryBorderColor: '--accent',
10089
+ secondaryColor: '--bg-hover',
10090
+ tertiaryColor: '--bg-surface',
10091
+ lineColor: '--text-tertiary',
10092
+ textColor: '--text-primary',
10093
+ titleColor: '--text-primary',
10094
+ clusterBkg: '--bg-surface',
10095
+ clusterBorder: '--border',
10096
+ edgeLabelBackground: '--bg-surface',
10097
+ actorLineColor: '--text-tertiary',
10098
+ noteBkgColor: '--bg-deep',
10099
+ noteBorderColor: '--border',
10100
+ noteTextColor: '--text-primary',
10101
+ pieStrokeColor: '--bg-surface',
10102
+ pieOuterStrokeColor: '--border',
10103
+ pieTitleTextColor: '--text-primary',
10104
+ pieLegendTextColor: '--text-primary',
10105
+ fontFamily: '--mono',
10106
+ };
10107
+ // --bg-elevated sits too close to the dark page for a node, and the base theme derives near-black ER
10108
+ // rows from a dark primaryColor.
10109
+ const MERMAID_MODE_VARS = {
10110
+ dark: { primaryColor: '--bg-hover', rowOdd: '--bg-hover', rowEven: '--bg-elevated' },
10111
+ light: { primaryColor: '--bg-elevated', rowOdd: '--bg-elevated', rowEven: '--bg-elevated' },
10112
+ };
10113
+ let mermaidThemeKey = '';
10114
+
10115
+ // The base theme derives its other colors from these, and it parses colors itself, so it takes
10116
+ // resolved values rather than var() references.
10117
+ function mermaidConfig() {
10118
+ const css = getComputedStyle(document.body);
10119
+ const mode = isLightTheme() ? 'light' : 'dark';
10120
+ const themeVariables = { darkMode: mode === 'dark', pieOpacity: '1', pieSectionTextColor: '#fff' };
10121
+ for (const [k, name] of Object.entries({ ...MERMAID_VARS, ...MERMAID_MODE_VARS[mode] })) {
10122
+ themeVariables[k] = css.getPropertyValue(name).trim();
10123
+ }
10124
+ SHOW_PALETTE[mode].series.forEach((c, i) => {
10125
+ themeVariables[`pie${i + 1}`] = c;
10126
+ themeVariables[`cScale${i}`] = c;
10127
+ });
10128
+ // ER labels are half see-through, so lines cross their text, and ER circles have a fixed white fill.
10129
+ const themeCSS = `.relationshipLabelBox, .relationshipLabelBox rect { opacity: 1; } marker circle { fill: ${themeVariables.background}; }`;
10130
+ // The default neo look draws borders as an accent-to-gray gradient with a drop shadow.
10131
+ return { startOnLoad: false, theme: 'base', look: 'classic', themeVariables, themeCSS };
9922
10132
  }
9923
10133
 
9924
10134
  // Mermaid is about 3 MB, so it loads on the first diagram instead of on the page's critical path.
@@ -9935,7 +10145,9 @@ function loadMermaid() {
9935
10145
  script.integrity = MERMAID_SCRIPT.integrity;
9936
10146
  script.crossOrigin = 'anonymous';
9937
10147
  script.onload = () => {
9938
- mermaid.initialize({ startOnLoad: false, theme: getMermaidTheme() });
10148
+ const config = mermaidConfig();
10149
+ mermaidThemeKey = JSON.stringify(config);
10150
+ mermaid.initialize(config);
9939
10151
  resolve();
9940
10152
  };
9941
10153
  script.onerror = () => {
@@ -9948,9 +10160,16 @@ function loadMermaid() {
9948
10160
  return mermaidLoading;
9949
10161
  }
9950
10162
 
10163
+ let mermaidQueue = Promise.resolve();
10164
+
10165
+ // mermaid.run is async, and two runs that overlap draw diagrams over each other.
10166
+ function queueMermaid(work) {
10167
+ mermaidQueue = mermaidQueue.then(work).catch((err) => console.warn(err.message));
10168
+ }
10169
+
9951
10170
  function initMermaidBlocks(container) {
9952
- const blocks = (container || document).querySelectorAll('pre.mermaid:not([data-processed])');
9953
- if (!blocks.length) return;
10171
+ const root = container || document;
10172
+ if (!root.querySelector('pre.mermaid:not([data-processed])')) return;
9954
10173
  if (typeof mermaid === 'undefined') {
9955
10174
  loadMermaid().then(
9956
10175
  () => initMermaidBlocks(container),
@@ -9958,19 +10177,37 @@ function initMermaidBlocks(container) {
9958
10177
  );
9959
10178
  return;
9960
10179
  }
9961
- mermaid.run({ nodes: [...blocks] });
10180
+ queueMermaid(() => {
10181
+ const blocks = [...root.querySelectorAll('pre.mermaid:not([data-processed])')];
10182
+ // Set here, after DOMPurify: it drops an attribute whose value holds `-->`, as most diagrams do.
10183
+ for (const b of blocks) if (!b.hasAttribute('data-original')) b.setAttribute('data-original', b.textContent);
10184
+ return blocks.length ? mermaid.run({ nodes: blocks }) : undefined;
10185
+ });
9962
10186
  }
9963
10187
 
9964
10188
  function reinitMermaidTheme() {
9965
10189
  if (typeof mermaid === 'undefined') return;
9966
- mermaid.initialize({ startOnLoad: false, theme: getMermaidTheme() });
9967
- document.querySelectorAll('pre.mermaid[data-processed]').forEach((el) => {
9968
- el.removeAttribute('data-processed');
9969
- el.innerHTML = escapeHtml(el.getAttribute('data-original') || '');
10190
+ queueMermaid(() => {
10191
+ const config = mermaidConfig();
10192
+ const key = JSON.stringify(config);
10193
+ if (key === mermaidThemeKey) return;
10194
+ mermaidThemeKey = key;
10195
+ mermaid.initialize(config);
10196
+ document.querySelectorAll('pre.mermaid[data-processed]').forEach((el) => {
10197
+ el.removeAttribute('data-processed');
10198
+ el.innerHTML = escapeHtml(el.getAttribute('data-original') || '');
10199
+ });
10200
+ initMermaidBlocks();
9970
10201
  });
9971
- initMermaidBlocks();
9972
10202
  }
9973
10203
 
10204
+ // Keyed on the colors for the same reason as the terminal theme: under the hub a theme change
10205
+ // lands in two steps.
10206
+ new MutationObserver(reinitMermaidTheme).observe(document.body, {
10207
+ attributes: true,
10208
+ attributeFilter: ['class', 'data-color-theme', 'style'],
10209
+ });
10210
+
9974
10211
  const _agentTabTexts = {};
9975
10212
 
9976
10213
  function renderAgentTabs(promptHtml, responseHtml, promptText, responseText) {
@@ -10504,7 +10741,6 @@ function applyTheme(light) {
10504
10741
  const light$ = document.getElementById('hljs-theme-light');
10505
10742
  if (dark$) dark$.disabled = light;
10506
10743
  if (light$) light$.disabled = !light;
10507
- reinitMermaidTheme();
10508
10744
  }
10509
10745
 
10510
10746
  function toggleTheme() {
@@ -11171,7 +11407,7 @@ function renderSessionPicker() {
11171
11407
  .map((s, i) => {
11172
11408
  const outside = i >= local.length;
11173
11409
  const project = s.project ? (outside ? s.project : pathBasename(s.project)) : '';
11174
- return `<button class="sp-row${s.id === currentSessionId ? ' current' : ''}${outside ? ' outside' : ''}" data-idx="${i}" title="${escapeHtml(s.project ? `${s.id}\n${s.project}` : s.id)}">
11410
+ return `<button class="sp-row${s.id === currentSessionId ? ' current' : ''}${outside ? ' outside' : ''} ${sessionBarClass(s)}" data-idx="${i}" title="${escapeHtml(s.project ? `${s.id}\n${s.project}` : s.id)}">
11175
11411
  ${spDotHtml(s)}
11176
11412
  <span class="sp-name">${escapeHtml(sessionDisplayName(s))}</span>
11177
11413
  <span class="sp-project">${escapeHtml(project)}</span>
@@ -12228,6 +12464,7 @@ function syncPanes() {
12228
12464
  const side = sides.get(el.dataset.key);
12229
12465
  el.classList.toggle('on', side !== undefined);
12230
12466
  markPaneSide(el, side, el.dataset.key === focusKey);
12467
+ if (paneLineWanted.size && side !== undefined) applyPaneLine(el);
12231
12468
  }
12232
12469
  const boardWasSplit = boardInSplit();
12233
12470
  markPaneSide(paneBoard, boardSide, !!boardSide && !pane);
@@ -12342,7 +12579,7 @@ function mountPane(sid, pane) {
12342
12579
  if (existing) {
12343
12580
  paneFrames.delete(key);
12344
12581
  paneFrames.set(key, existing);
12345
- if (stalePaneViews.delete(existing)) reloadPaneView(sid, pane, existing);
12582
+ if (stalePaneViews.delete(existing)) autoReloadPaneView(sid, pane, existing);
12346
12583
  return key;
12347
12584
  }
12348
12585
  const view = document.createElement('div');
@@ -12405,7 +12642,11 @@ async function loadPaneView(sid, pane, view, scroll) {
12405
12642
  const restore = scroll
12406
12643
  ? `<script>addEventListener('load',()=>requestAnimationFrame(()=>scrollTo(${Number(scroll.x) || 0},${Number(scroll.y) || 0})))</script>`
12407
12644
  : '';
12408
- const frame = createPreviewFrame('pane-frame modal-zoomable', data.content + REVIEW_BRIDGE_TAG + restore);
12645
+ // A show card's own zoom follows the text zoom (showBridge), so its frame is not .modal-zoomable.
12646
+ const [cls, doc] = pane.show
12647
+ ? ['pane-frame show-pane-frame', showSrcdoc(data.content, scroll?.y)]
12648
+ : ['pane-frame modal-zoomable', data.content + REVIEW_BRIDGE_TAG + restore];
12649
+ const frame = createPreviewFrame(cls, doc);
12409
12650
  frame.title = pane.title;
12410
12651
  view.appendChild(frame);
12411
12652
  attachPaneReview(view, sid, fileReviewOpts(data.path, data.kind, frame, frame));
@@ -12416,6 +12657,7 @@ async function loadPaneView(sid, pane, view, scroll) {
12416
12657
  body.parentElement.dataset.path = data.path;
12417
12658
  renderPreviewContent(body, data.path, data.content, data.kind);
12418
12659
  attachPaneReview(view, sid, fileReviewOpts(data.path, data.kind, body, null));
12660
+ applyPaneLine(view);
12419
12661
  return;
12420
12662
  }
12421
12663
  const [title, text] = !data
@@ -12475,13 +12717,23 @@ function onPaneFileChanged(filePath) {
12475
12717
  const [sid, paneId] = key.split('/');
12476
12718
  const pane = paneLayout(sid).panes.find((p) => p.id === paneId);
12477
12719
  if (pane?.target !== filePath) continue;
12478
- if (view.classList.contains('on')) reloadPaneView(sid, pane, view);
12720
+ if (view.classList.contains('on')) autoReloadPaneView(sid, pane, view);
12479
12721
  else stalePaneViews.add(view);
12480
12722
  }
12481
12723
  }
12482
12724
 
12725
+ let paneUpdatedToastTimer = 0;
12726
+
12727
+ // An editor save can fire several change events, so one toast follows the last reload.
12728
+ async function autoReloadPaneView(sid, pane, view) {
12729
+ if (!(await reloadPaneView(sid, pane, view))) return;
12730
+ clearTimeout(paneUpdatedToastTimer);
12731
+ paneUpdatedToastTimer = setTimeout(() => showToast('Pane updated', 'info'), 1000);
12732
+ }
12733
+
12483
12734
  // Builds the new view hidden over the old one and swaps them once it has loaded and scrolled,
12484
- // so a reload shows no blank frame and no jump. The newest reload of a view wins.
12735
+ // so a reload shows no blank frame and no jump. The newest reload of a view wins; the others
12736
+ // resolve false.
12485
12737
  async function reloadPaneView(sid, pane, view) {
12486
12738
  const key = view.dataset.key;
12487
12739
  const token = {};
@@ -12495,7 +12747,7 @@ async function reloadPaneView(sid, pane, view) {
12495
12747
  const fresh = document.createElement('div');
12496
12748
  fresh.dataset.key = key;
12497
12749
  await loadPaneView(sid, pane, fresh, frame && paneFrameScroll.get(frame));
12498
- if (!live()) return;
12750
+ if (!live()) return false;
12499
12751
  fresh.className = view.className;
12500
12752
  fresh.style.visibility = 'hidden';
12501
12753
  view.after(fresh);
@@ -12512,7 +12764,7 @@ async function reloadPaneView(sid, pane, view) {
12512
12764
  }
12513
12765
  if (!live()) {
12514
12766
  fresh.remove();
12515
- return;
12767
+ return false;
12516
12768
  }
12517
12769
  paneReloads.delete(key);
12518
12770
  fresh.className = view.className;
@@ -12520,6 +12772,7 @@ async function reloadPaneView(sid, pane, view) {
12520
12772
  view.remove();
12521
12773
  paneFrames.set(key, fresh);
12522
12774
  syncPaneReview();
12775
+ return true;
12523
12776
  }
12524
12777
 
12525
12778
  // One reading width for every pane, kept apart from the modals': a modal's saved size
@@ -12578,7 +12831,7 @@ function attachPaneReview(view, sessionId, opts) {
12578
12831
 
12579
12832
  function syncPaneReview() {
12580
12833
  const view = activePaneView();
12581
- setBaseReview((view && paneReviews.get(view)) || null);
12834
+ setBaseReview((view && paneReviews.get(view)) || (wantsTerminal() && showState.review) || null);
12582
12835
  }
12583
12836
 
12584
12837
  function unmountPane(sid, paneId) {
@@ -12679,18 +12932,19 @@ function paneById(id) {
12679
12932
  return id == null ? activePane(sid) : paneLayout(sid).panes.find((p) => p.id === id) || null;
12680
12933
  }
12681
12934
 
12682
- function reloadPane(id) {
12935
+ async function reloadPane(id) {
12683
12936
  const pane = paneById(id);
12684
12937
  if (!pane) return;
12685
12938
  const sid = paneSessionId();
12686
12939
  const view = paneFrames.get(`${sid}/${pane.id}`);
12687
12940
  if (pane.kind !== 'url' && view) {
12688
- reloadPaneView(sid, pane, view);
12689
- return;
12941
+ await reloadPaneView(sid, pane, view);
12942
+ } else {
12943
+ unmountPane(sid, pane.id);
12944
+ paneRefused.delete(pane.target);
12945
+ syncPanes();
12690
12946
  }
12691
- unmountPane(sid, pane.id);
12692
- paneRefused.delete(pane.target);
12693
- syncPanes();
12947
+ showToast('Pane reloaded', 'success');
12694
12948
  }
12695
12949
 
12696
12950
  function openPaneExternally(id) {
@@ -12896,6 +13150,45 @@ async function postPane(sid, body) {
12896
13150
  return sid === paneSessionId();
12897
13151
  }
12898
13152
 
13153
+ // Pane key → line to show once the pane's source is rendered and on screen. Not stored with the pane.
13154
+ const paneLineWanted = new Map();
13155
+
13156
+ function applyPaneLine(view) {
13157
+ const line = paneLineWanted.get(view.dataset.key);
13158
+ const body = view.querySelector('.pane-doc-body');
13159
+ if (!line || !body || !view.offsetParent) return;
13160
+ paneLineWanted.delete(view.dataset.key);
13161
+ markSourceLine(body, line);
13162
+ }
13163
+
13164
+ async function openPaneAtLine(sid, { path, line }, base) {
13165
+ const onScreen = await postPane(sid, { target: path, base });
13166
+ if (onScreen === null) return;
13167
+ paneLineWanted.set(`${sid}/${getActivePaneId(sid)}`, line);
13168
+ if (onScreen) syncPanes();
13169
+ toastPaneAdded(sid, onScreen, pathBasename(path));
13170
+ }
13171
+
13172
+ async function openPreviewAtLine({ path, line }, base) {
13173
+ await openPreviewByPath(path, base, openFileInEditor);
13174
+ if (currentPreviewPath) markSourceLine(document.getElementById('preview-modal-body'), line);
13175
+ }
13176
+
13177
+ // A band under the line, so the text stays as it is; past the end it marks the last line.
13178
+ function markSourceLine(bodyEl, line) {
13179
+ const pre = bodyEl.querySelector('.preview-source');
13180
+ if (!pre) return;
13181
+ let band = pre.querySelector('.src-line');
13182
+ if (!band) {
13183
+ band = document.createElement('div');
13184
+ band.className = 'src-line';
13185
+ band.setAttribute('aria-hidden', 'true');
13186
+ pre.prepend(band);
13187
+ }
13188
+ band.style.setProperty('--line', Math.min(line, Number(pre.dataset.lines) || 1) - 1);
13189
+ band.scrollIntoView({ block: 'center', inline: 'nearest' });
13190
+ }
13191
+
12899
13192
  function openFileInPane(filePath, closeModal) {
12900
13193
  if (!filePath || !paneSessionId()) return;
12901
13194
  closeModal();
@@ -13395,8 +13688,10 @@ function toggleTerminal() {
13395
13688
  syncTerminal();
13396
13689
  }
13397
13690
 
13691
+ // The show card sits in the pane but is not the terminal, so Alt+` from it goes to the terminal.
13398
13692
  function terminalPaneFocused() {
13399
- return document.getElementById('terminal-pane').contains(document.activeElement);
13693
+ const active = document.activeElement;
13694
+ return document.getElementById('terminal-pane').contains(active) && !showCardEl().contains(active);
13400
13695
  }
13401
13696
 
13402
13697
  // Kanban's own session keys (new, resume, swap) use the hub's modifier, so the hub leaves them alone.
@@ -13429,6 +13724,7 @@ function terminalShortcut(e, probe = false) {
13429
13724
  return () => openNewSession(null, e.code === 'KeyR');
13430
13725
  }
13431
13726
  if (e.code !== 'Backquote' || e.metaKey) return null;
13727
+ if (ctrlAlt) return toggleShowFocus;
13432
13728
  if (e.ctrlKey && !e.altKey) return e.shiftKey ? openTerminalManager : toggleTerminal;
13433
13729
  if (e.altKey && !e.ctrlKey && !e.shiftKey) return toggleTerminalFocus;
13434
13730
  if (!e.altKey || !e.shiftKey || e.ctrlKey) return null;
@@ -13467,6 +13763,20 @@ function toggleTerminalFocus() {
13467
13763
  else focusTerminalPane();
13468
13764
  }
13469
13765
 
13766
+ function toggleShowFocus() {
13767
+ if (isShowCardFocused()) {
13768
+ focusTerminalPane();
13769
+ return;
13770
+ }
13771
+ if (!wantsTerminal() || !showState.posts.length) return;
13772
+ if (showState.closed || showState.collapsed) {
13773
+ showState.closed = false;
13774
+ showState.collapsed = false;
13775
+ renderShow();
13776
+ }
13777
+ focusShowBody();
13778
+ }
13779
+
13470
13780
  // Idempotent: runs on every view change, so whichever path changed the view lands here.
13471
13781
  function syncTerminal() {
13472
13782
  const btn = document.getElementById('terminal-toggle');
@@ -13491,6 +13801,7 @@ function syncTerminal() {
13491
13801
  }
13492
13802
  sessionView.classList.toggle('terminal-mode', on);
13493
13803
  syncPanes();
13804
+ syncShowCard();
13494
13805
  pushTerminalClaims();
13495
13806
  if (!on) {
13496
13807
  // A hidden xterm textarea drops focus, but a display:none iframe keeps it and would take keys unseen.
@@ -13748,7 +14059,16 @@ function keyClaims() {
13748
14059
  }
13749
14060
 
13750
14061
  function bridgeClaims(claims) {
13751
- return { ...claims, keys: [...claims.keys, ...ZOOM_CLAIMS, ...PANE_CLAIMS] };
14062
+ const review = reviewItems().length ? REVIEW_SEND_CLAIMS : [];
14063
+ return { ...claims, keys: [...claims.keys, ...ZOOM_CLAIMS, ...PANE_CLAIMS, ...review] };
14064
+ }
14065
+
14066
+ function sendBridgeClaimsAll(frames, claims) {
14067
+ const base = bridgeClaims(claims);
14068
+ const rest = frames.filter((f) => f !== showState.frame);
14069
+ sendBridgeClaims(rest, base);
14070
+ if (rest.length < frames.length)
14071
+ sendBridgeClaims([showState.frame], { ...base, keys: [...base.keys, ...SHOW_CLAIMS] });
13752
14072
  }
13753
14073
 
13754
14074
  function pushTerminalClaims() {
@@ -13759,7 +14079,7 @@ function pushTerminalClaims() {
13759
14079
  const bridged = bridgedFrameEls();
13760
14080
  if (!termFrame.inited && !bridged.length) return;
13761
14081
  const claims = keyClaims();
13762
- sendBridgeClaims(bridged, bridgeClaims(claims));
14082
+ sendBridgeClaimsAll(bridged, claims);
13763
14083
  if (!termFrame.inited) return;
13764
14084
  const sig = JSON.stringify(claims);
13765
14085
  if (sig === termFrame.claims) return;
@@ -13816,14 +14136,11 @@ function adjustTerminalFontSize(step) {
13816
14136
  else store.setItem(TERMINAL_FONT_KEY, String(size));
13817
14137
  termState.fontSize = size;
13818
14138
  terminalFrameSend('font', { size });
14139
+ layoutShow();
13819
14140
  }
13820
14141
  showToast(`Terminal ${size}px`);
13821
14142
  }
13822
14143
 
13823
- function terminalSend(msg) {
13824
- if (termState.socket) terminalFrameSend('send', { msg });
13825
- }
13826
-
13827
14144
  function setTerminalStatus(text) {
13828
14145
  document.getElementById('terminal-status').textContent = text;
13829
14146
  }
@@ -14139,11 +14456,6 @@ function onTerminalMessage(sessionId, msg, tail = '') {
14139
14456
  }
14140
14457
  }
14141
14458
 
14142
- // biome-ignore lint/correctness/noUnusedVariables: used in HTML
14143
- function endTerminalSession() {
14144
- terminalSend({ t: 'kill' });
14145
- }
14146
-
14147
14459
  // Detaching first means no exit message reaches the pane, so no Resume prompt shows before the board.
14148
14460
  // Terminal mode must go too: left on, opening the session again reconnects in 'auto' and resumes claude.
14149
14461
  async function closeTerminalSession(id = termState.sessionId) {
@@ -14271,6 +14583,699 @@ async function endAllTerminals() {
14271
14583
 
14272
14584
  //#endregion
14273
14585
 
14586
+ //#region SHOW
14587
+ // The card the session in the embedded terminal posts to with the show tool. It belongs to the
14588
+ // terminal, not the session: /clear and the --resume picker change the session id, the terminal id
14589
+ // stays. Nothing here runs until the terminal view shows a terminal that has posts.
14590
+ const SHOW_MSG = 'cck-show:';
14591
+ const SHOW_SIZE_KEY = 'cck-show-size-v2';
14592
+ const SHOW_VIEW_KEY = 'cck-show-view-v1';
14593
+ const SHOW_MIN_W = 280;
14594
+ const SHOW_MIN_H = 120;
14595
+ const SHOW_INSET = 8;
14596
+ const SHOW_CSP =
14597
+ "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src data:; media-src data:; frame-src 'none'; connect-src 'none'; form-action 'none'";
14598
+ // The card's token contract (the show skill lists these names) mapped to the board's theme vars.
14599
+ const SHOW_TOKENS = {
14600
+ '--color-bg': '--bg-surface',
14601
+ '--color-bg-subtle': '--bg-deep',
14602
+ '--color-text': '--text-primary',
14603
+ // --text-muted is too faint for body text on the card.
14604
+ '--color-text-muted': '--text-tertiary',
14605
+ '--color-border': '--border',
14606
+ '--color-accent': '--accent',
14607
+ '--color-info': '--info',
14608
+ '--color-success': '--success',
14609
+ '--color-warning': '--warning',
14610
+ '--color-danger': '--danger',
14611
+ };
14612
+ // One chart palette for every color theme, checked against each theme in show-card.test.js.
14613
+ const SHOW_PALETTE = {
14614
+ light: {
14615
+ series: ['#2a78d6', '#c4511c', '#008a72', '#ad6f00', '#c4467a', '#008300', '#4a3aa7', '#d03a3a'],
14616
+ ramp: ['#5598e7', '#2a78d6', '#1c5cab', '#104281', '#0a2b57'],
14617
+ },
14618
+ dark: {
14619
+ series: ['#3987e5', '#d95926', '#199e70', '#c98500', '#d55181', '#2a9a2a', '#9085e9', '#e66767'],
14620
+ ramp: ['#256abf', '#3987e5', '#6da7ec', '#9ec5f4', '#cde2fb'],
14621
+ },
14622
+ };
14623
+ // Bundled fonts do not load in the opaque-origin srcdoc.
14624
+ const SHOW_FONTS = {
14625
+ '--font-sans': "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif",
14626
+ '--font-mono': "ui-monospace, 'Cascadia Mono', Consolas, 'SF Mono', monospace",
14627
+ };
14628
+ const SHOW_FRAME_BASE =
14629
+ `html{color-scheme:var(--color-scheme);font-size:13px}body{margin:0;padding:.9rem 1.1rem;background:var(--color-bg);color:var(--color-text);font:1rem/1.5 var(--font-sans)}` +
14630
+ 'h1,h2,h3,h4{margin:0 0 .5em;font-weight:600;line-height:1.3}h1{font-size:1.25rem}h2{font-size:1.1rem}h3,h4{font-size:1rem}p{margin:0 0 .6em}a{color:var(--color-accent)}' +
14631
+ 'code,pre,kbd{font-family:var(--font-mono);font-size:.92rem}pre{padding:.6rem .8rem;border-radius:6px;background:var(--color-bg-subtle);border:1px solid var(--color-border);overflow:auto}' +
14632
+ 'table{border-collapse:collapse;font-size:.92rem}th,td{border:1px solid var(--color-border);padding:.3rem .6rem;text-align:left}th{background:var(--color-bg-subtle)}hr{border:0;border-top:1px solid var(--color-border)}' +
14633
+ 'button{font:inherit;font-size:.92rem;padding:.4rem .9rem;border-radius:6px;border:1px solid var(--color-border);background:transparent;color:var(--color-text);cursor:pointer}' +
14634
+ 'button.primary{background:var(--color-accent);border-color:var(--color-accent);color:#fff}input,select,textarea{font:inherit;color:var(--color-text)}';
14635
+ const SHOW_MERMAID_INIT =
14636
+ '%%{init: {"themeVariables": {"fontSize": "12px"}, "flowchart": {"nodeSpacing": 28, "rankSpacing": 28, "padding": 6}}}%%\n';
14637
+ const SHOW_ICONS = {
14638
+ prev: '<path d="M15 18l-6-6 6-6"/>',
14639
+ next: '<path d="M9 18l6-6-6-6"/>',
14640
+ collapse: '<path d="M18 15l-6-6-6 6"/>',
14641
+ uncollapse: '<path d="M6 9l6 6 6-6"/>',
14642
+ expand: '<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/>',
14643
+ restore: '<path d="M4 14h6v6M20 10h-6V4M14 10l7-7M3 21l7-7"/>',
14644
+ clear: '<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>',
14645
+ close: '<path d="M18 6L6 18M6 6l12 12"/>',
14646
+ pane: '<use href="#icon-pane"/>',
14647
+ };
14648
+
14649
+ // PTY id → terminal id, from the terminal list. A terminal started after the last list read is
14650
+ // missing until a post asks for it.
14651
+ const terminalIds = new Map();
14652
+ const showState = {
14653
+ terminalId: null,
14654
+ sessionId: null,
14655
+ posts: [],
14656
+ idx: 0,
14657
+ closed: false,
14658
+ expanded: false,
14659
+ collapsed: false,
14660
+ marked: new Set(),
14661
+ rendered: null,
14662
+ frame: null,
14663
+ frameH: 0,
14664
+ frameY: 0,
14665
+ frameFocused: false,
14666
+ listSeq: 0,
14667
+ bodySeq: 0,
14668
+ lookup: null,
14669
+ review: null,
14670
+ path: null,
14671
+ };
14672
+ let showSize = store.readJson(SHOW_SIZE_KEY);
14673
+ // Terminal id → { closed?, collapsed?, expanded? }; no entry when no flag is set. Ids gone from the terminal list are pruned.
14674
+ const showViews = store.readJson(SHOW_VIEW_KEY, {});
14675
+ // Older boards stored the string 'collapsed' or 'expanded'.
14676
+ for (const [tid, v] of Object.entries(showViews)) if (typeof v === 'string') showViews[tid] = { [v]: true };
14677
+ const SHOW_VIEW_FLAGS = ['closed', 'collapsed', 'expanded'];
14678
+ let showHostObserver = null;
14679
+
14680
+ function showCardEl() {
14681
+ return document.getElementById('show-card');
14682
+ }
14683
+
14684
+ // Focus that moves from the terminal's frame (another process) into the card's frame leaves the
14685
+ // board's activeElement on the terminal frame and fires no event here, so the card's frame
14686
+ // reports its own focus and blur (showBridge).
14687
+ function isShowCardFocused() {
14688
+ if (showState.frameFocused && showState.frame?.isConnected) return true;
14689
+ const el = document.activeElement;
14690
+ return el?.tagName !== 'IFRAME' && !!el?.closest('#show-card .show-body');
14691
+ }
14692
+
14693
+ // Deferred, because a blur in the board comes before activeElement changes.
14694
+ function syncShowFocus() {
14695
+ setTimeout(() => showCardEl()?.classList.toggle('focused', isShowCardFocused()));
14696
+ }
14697
+
14698
+ // An HTML card's own keys and scroll keys reach it only inside its frame.
14699
+ function focusShowBody() {
14700
+ const frame = showState.frame;
14701
+ if (frame?.isConnected) frame.focus();
14702
+ else showCardEl()?.querySelector('.show-body')?.focus();
14703
+ }
14704
+
14705
+ const SHOW_KEY_COMMANDS = { 'alt+Enter': 'expand', 'alt+w': 'close' };
14706
+
14707
+ // The body keeps focus, because a render replaces an HTML card's frame.
14708
+ function showCardKey(e) {
14709
+ if (matchKey(e, '[', ']')) {
14710
+ showCommand(e.key === ']' ? 'next' : 'prev');
14711
+ focusShowBody();
14712
+ return true;
14713
+ }
14714
+ const combo = ClaudeHub.comboOf(e);
14715
+ const cmd = combo === 'Escape' ? (showState.expanded ? 'expand' : 'collapse') : SHOW_KEY_COMMANDS[combo];
14716
+ if (cmd) {
14717
+ showCommand(cmd);
14718
+ if (cmd === 'expand') focusShowBody();
14719
+ else focusTerminalPane();
14720
+ return true;
14721
+ }
14722
+ const p = showState.posts[showState.idx];
14723
+ if (combo !== 'ctrl+d' || !p) return false;
14724
+ const message = `Remove "${p.title}" from the card?`;
14725
+ confirmModal({ title: 'Remove post', message, okLabel: 'Remove' }).then((ok) => {
14726
+ if (!ok || showState.posts[showState.idx]?.id !== p.id) return;
14727
+ showCommand('remove');
14728
+ focusAfterShowRemove();
14729
+ });
14730
+ return true;
14731
+ }
14732
+
14733
+ // The removed post took the focused body or button with it.
14734
+ function focusAfterShowRemove() {
14735
+ if (showState.posts.length) focusShowBody();
14736
+ else focusTerminalPane();
14737
+ }
14738
+
14739
+ function showIcon(name) {
14740
+ return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${SHOW_ICONS[name]}</svg>`;
14741
+ }
14742
+
14743
+ // An empty list can be a host that has not restored its terminals yet, so it prunes nothing.
14744
+ function rememberTerminalIds(list) {
14745
+ terminalIds.clear();
14746
+ for (const t of list) if (t.terminalId) terminalIds.set(t.id, t.terminalId);
14747
+ if (!terminalIds.size) return;
14748
+ const live = new Set(terminalIds.values());
14749
+ const gone = Object.keys(showViews).filter((tid) => !live.has(tid));
14750
+ if (!gone.length) return;
14751
+ for (const tid of gone) delete showViews[tid];
14752
+ store.writeJson(SHOW_VIEW_KEY, showViews);
14753
+ }
14754
+
14755
+ // A session resumed after its terminal ended runs under the same PTY id in a new terminal, with a
14756
+ // new terminal id, so the next view change looks it up again.
14757
+ function forgetEndedTerminals() {
14758
+ for (const pty of terminalIds.keys()) if (!runningTerminals.has(pty)) terminalIds.delete(pty);
14759
+ if (!runningTerminals.has(showState.lookup)) showState.lookup = null;
14760
+ }
14761
+
14762
+ function setShowView(tid, view) {
14763
+ const set = SHOW_VIEW_FLAGS.filter((f) => view[f]);
14764
+ const old = showViews[tid] || {};
14765
+ if (SHOW_VIEW_FLAGS.every((f) => !!old[f] === set.includes(f))) return;
14766
+ if (set.length) showViews[tid] = Object.fromEntries(set.map((f) => [f, true]));
14767
+ else delete showViews[tid];
14768
+ store.writeJson(SHOW_VIEW_KEY, showViews);
14769
+ }
14770
+
14771
+ function saveShowView() {
14772
+ if (showState.terminalId) setShowView(showState.terminalId, showState);
14773
+ }
14774
+
14775
+ function resetShowPosts() {
14776
+ showState.posts = [];
14777
+ showState.marked.clear();
14778
+ showState.rendered = null;
14779
+ }
14780
+
14781
+ // Runs on every view change, so it returns at once unless the shown terminal changed.
14782
+ function syncShowCard() {
14783
+ const pty = wantsTerminal() ? currentSessionId : null;
14784
+ if (pty && runningTerminals.has(pty) && !terminalIds.has(pty) && showState.lookup !== pty) {
14785
+ showState.lookup = pty;
14786
+ loadTerminals().catch(() => {});
14787
+ }
14788
+ const tid = pty ? (terminalIds.get(pty) ?? null) : null;
14789
+ if (tid === showState.terminalId) return;
14790
+ const view = (tid && showViews[tid]) || {};
14791
+ Object.assign(showState, { terminalId: tid, sessionId: null, idx: 0 });
14792
+ for (const f of SHOW_VIEW_FLAGS) showState[f] = !!view[f];
14793
+ resetShowPosts();
14794
+ renderShow();
14795
+ if (tid) loadShowPosts(tid, { newest: true }).then((ok) => ok && renderShow());
14796
+ }
14797
+
14798
+ async function loadShowPosts(tid, { newest = false } = {}) {
14799
+ const seq = ++showState.listSeq;
14800
+ const data = await getJson(apiPath`/api/terminals/${tid}/show`, null, { cache: 'no-store' });
14801
+ if (seq !== showState.listSeq || tid !== showState.terminalId) return false;
14802
+ const shownId = showState.posts[showState.idx]?.id;
14803
+ showState.sessionId = data?.sessionId ?? null;
14804
+ showState.posts = Array.isArray(data?.posts) ? data.posts : [];
14805
+ const at = showState.posts.findIndex((p) => p.id === shownId);
14806
+ showState.idx = newest || at < 0 ? Math.max(0, showState.posts.length - 1) : at;
14807
+ return true;
14808
+ }
14809
+
14810
+ async function onShowPosted(d) {
14811
+ if (typeof d.terminalId !== 'string') return;
14812
+ const view = showViews[d.terminalId];
14813
+ if (!d.replaced && view?.closed && d.terminalId !== showState.terminalId)
14814
+ setShowView(d.terminalId, { ...view, closed: false });
14815
+ if (!showState.terminalId) {
14816
+ if (!wantsTerminal()) return;
14817
+ if (!terminalIds.has(currentSessionId)) showState.lookup = null;
14818
+ syncShowCard();
14819
+ return;
14820
+ }
14821
+ if (d.terminalId !== showState.terminalId) return;
14822
+ if (d.sessionId !== showState.sessionId) resetShowPosts();
14823
+ if (!(await loadShowPosts(d.terminalId))) return;
14824
+ const i = showState.posts.findIndex((p) => p.id === d.id);
14825
+ if (i < 0) return renderShow();
14826
+ if (d.replaced) {
14827
+ if (i !== showState.idx) showState.marked.add(d.id);
14828
+ else if (showState.rendered) showState.rendered.stale = true;
14829
+ return renderShow();
14830
+ }
14831
+ showState.idx = i;
14832
+ showState.closed = false;
14833
+ renderShow({ flash: true });
14834
+ }
14835
+
14836
+ function onShowCleared(d) {
14837
+ if (d.terminalId !== showState.terminalId) return;
14838
+ resetShowPosts();
14839
+ renderShow();
14840
+ }
14841
+
14842
+ function dropShowPost(id) {
14843
+ const i = showState.posts.findIndex((p) => p.id === id);
14844
+ if (i < 0) return false;
14845
+ showState.posts.splice(i, 1);
14846
+ showState.marked.delete(id);
14847
+ if (i < showState.idx || showState.idx >= showState.posts.length) showState.idx--;
14848
+ return true;
14849
+ }
14850
+
14851
+ function onShowRemoved(d) {
14852
+ if (d.terminalId !== showState.terminalId || d.sessionId !== showState.sessionId) return;
14853
+ if (dropShowPost(d.id)) renderShow();
14854
+ }
14855
+
14856
+ function showHeaderHtml(p) {
14857
+ const n = showState.posts.length;
14858
+ const i = showState.idx;
14859
+ const key = p.key ? `<span class="show-sep">·</span><span class="show-key">${escapeHtml(p.key)}</span>` : '';
14860
+ const btn = (cmd, icon, title, disabled = false) =>
14861
+ `<button type="button" class="show-btn${cmd === 'remove' ? ' remove' : ''}" data-show="${escapeHtml(cmd)}" title="${escapeHtml(title)}" aria-label="${escapeHtml(title)}"${disabled ? ' disabled' : ''}>${showIcon(icon)}</button>`;
14862
+ const { collapsed, expanded } = showState;
14863
+ return `<span class="show-nav"><span class="show-title" title="${escapeHtml(p.title)}">${escapeHtml(p.title)}</span>${key}</span>
14864
+ <span class="show-pager">${btn('prev', 'prev', 'Previous post', i === 0)}<span class="show-pos">${i + 1}/${n}</span>${btn('next', 'next', 'Next post', i === n - 1)}</span>
14865
+ <span class="show-actions">${btn('pane', 'pane', 'Open in a pane of the session')}${btn('collapse', collapsed ? 'uncollapse' : 'collapse', collapsed ? 'Show the post' : 'Collapse to the header · Esc')}${btn('expand', expanded ? 'restore' : 'expand', expanded ? 'Back to the card · Alt+Enter or Esc' : 'Fill the terminal · Alt+Enter')}${btn('remove', 'clear', 'Remove this post · Ctrl+D · Shift+click clears every post')}${btn('close', 'close', 'Close until the next post · Alt+W')}</span>`;
14866
+ }
14867
+
14868
+ function showTicksHtml() {
14869
+ return showState.posts
14870
+ .map((p, i) => {
14871
+ const marked = showState.marked.has(p.id);
14872
+ const title = marked ? `${p.title} · updated` : p.title;
14873
+ return `<button type="button" class="show-tick${i === showState.idx ? ' on' : ''}${marked ? ' dot' : ''}" data-go="${i}" title="${escapeHtml(title)}" aria-label="${escapeHtml(title)}"></button>`;
14874
+ })
14875
+ .join('');
14876
+ }
14877
+
14878
+ function renderShow({ flash = false } = {}) {
14879
+ saveShowView();
14880
+ const card = showCardEl();
14881
+ const chip = document.getElementById('show-reopen');
14882
+ const n = showState.posts.length;
14883
+ const open = n > 0 && !showState.closed;
14884
+ chip.hidden = !n || open;
14885
+ chip.querySelector('.show-reopen-n').textContent = `show · ${n}`;
14886
+ card.classList.toggle('open', open);
14887
+ if (!open) {
14888
+ showState.rendered = null;
14889
+ showState.frame = null;
14890
+ setShowReview(null);
14891
+ showHostObserver?.disconnect();
14892
+ showHostObserver = null;
14893
+ card.replaceChildren();
14894
+ return;
14895
+ }
14896
+ showState.idx = Math.min(Math.max(0, showState.idx), n - 1);
14897
+ const p = showState.posts[showState.idx];
14898
+ showState.marked.delete(p.id);
14899
+ card.classList.toggle('expanded', showState.expanded && !showState.collapsed);
14900
+ card.classList.toggle('collapsed', showState.collapsed);
14901
+ if (!card.firstChild) {
14902
+ card.innerHTML = `<header class="show-head"></header><div class="show-ticks"></div><div class="show-body" tabindex="-1"></div>
14903
+ <aside class="review-panel show-review" hidden></aside>
14904
+ <div class="show-grip" title="Resize · double-click to fit"><svg viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M1 1v8h8M1 5l4 4"/></svg></div>`;
14905
+ }
14906
+ card.querySelector('.show-head').innerHTML = showHeaderHtml(p);
14907
+ card.querySelector('.show-ticks').innerHTML = showTicksHtml();
14908
+ const r = showState.rendered;
14909
+ if (!showState.collapsed && (!r || r.stale || r.id !== p.id || r.updatedAt !== p.updatedAt)) {
14910
+ renderShowBody(p, r?.id === p.id);
14911
+ }
14912
+ if (flash) {
14913
+ card.classList.remove('flash');
14914
+ void card.offsetWidth;
14915
+ card.classList.add('flash');
14916
+ }
14917
+ if (!showHostObserver) {
14918
+ showHostObserver = new ResizeObserver(() => layoutShow());
14919
+ showHostObserver.observe(document.getElementById('terminal-host'));
14920
+ showHostObserver.observe(card.querySelector('.show-review'));
14921
+ }
14922
+ layoutShow();
14923
+ }
14924
+
14925
+ async function renderShowBody(p, samePost) {
14926
+ const seq = ++showState.bodySeq;
14927
+ const tid = showState.terminalId;
14928
+ showState.rendered = { id: p.id, updatedAt: p.updatedAt };
14929
+ const data = await getJson(apiPath`/api/terminals/${tid}/show/${p.id}`, null, { cache: 'no-store' });
14930
+ const card = showCardEl();
14931
+ const body = card.querySelector('.show-body');
14932
+ if (seq !== showState.bodySeq || !body) return;
14933
+ showState.path = data && !data.waiting ? data.path : null;
14934
+ const html = data?.kind === 'html' && !data.waiting;
14935
+ // A new frame or document drops focus to the page, and Ctrl +/- then zooms the terminal.
14936
+ const refocus = isShowCardFocused() ? focusShowBody : () => {};
14937
+ card.classList.toggle('html', html);
14938
+ if (html) {
14939
+ const restoreY = samePost && showState.frame ? showState.frameY : 0;
14940
+ showState.frameY = restoreY;
14941
+ if (samePost && showState.frame?.isConnected) {
14942
+ showState.frame.srcdoc = showSrcdoc(data.content, restoreY);
14943
+ } else {
14944
+ showState.frameH = 0;
14945
+ body.replaceChildren(createShowFrame(p.title, showSrcdoc(data.content, 0)));
14946
+ }
14947
+ refocus();
14948
+ setShowReview(showReviewOpts(p, data, body, showState.frame));
14949
+ layoutShow();
14950
+ return;
14951
+ }
14952
+ showState.frame = null;
14953
+ const keepScroll = samePost ? body.scrollTop : 0;
14954
+ const md = document.createElement('div');
14955
+ md.className = 'show-md modal-zoomable';
14956
+ if (!data) md.innerHTML = '<p class="show-gone">This post is gone.</p>';
14957
+ else if (data.waiting) md.innerHTML = '<p class="show-gone">Waiting for content.</p>';
14958
+ else renderPreviewContent(md, data.file || p.file || '', data.content ?? '', data.kind);
14959
+ for (const pre of md.querySelectorAll('pre.mermaid')) {
14960
+ if (/^\s*(---|%%\{)/.test(pre.textContent)) continue;
14961
+ pre.textContent = SHOW_MERMAID_INIT + pre.textContent;
14962
+ }
14963
+ body.replaceChildren(md);
14964
+ refocus();
14965
+ body.scrollTop = keepScroll;
14966
+ setShowReview(data && !data.waiting ? showReviewOpts(p, data, md, null) : null);
14967
+ layoutShow();
14968
+ }
14969
+
14970
+ function showReviewOpts(p, data, contentEl, frame) {
14971
+ const opts = fileReviewOpts(data.path ?? '', data.kind, contentEl, frame);
14972
+ if (!opts) return null;
14973
+ const card = showCardEl();
14974
+ return {
14975
+ ...opts,
14976
+ panelEl: card.querySelector('.show-review'),
14977
+ hostEl: card,
14978
+ sessionId: showState.sessionId,
14979
+ source: {
14980
+ kind: 'show',
14981
+ id: p.id,
14982
+ label: `the show card "${p.title}"`,
14983
+ locate: data.path ? `The card shows ${data.path}.` : null,
14984
+ },
14985
+ };
14986
+ }
14987
+
14988
+ // The card's review is the base review in the terminal view, like a pane's elsewhere.
14989
+ function setShowReview(opts) {
14990
+ if (showState.review === opts) return;
14991
+ showState.review = opts;
14992
+ syncPaneReview();
14993
+ }
14994
+
14995
+ // No allow-popups: it opens window.open as a way out. The CSP blocks fetch and remote sub-resources,
14996
+ // but not navigation or WebRTC; the real guard is that only the terminal's own agent can post.
14997
+ function createShowFrame(title, srcdoc) {
14998
+ const frame = createPreviewFrame('show-frame', srcdoc, 'allow-scripts');
14999
+ frame.title = title;
15000
+ // Focus given before a document loads stays on the frame element, out of the document's keys.
15001
+ frame.addEventListener('load', () => {
15002
+ if (frame === document.activeElement) frame.contentWindow?.focus();
15003
+ });
15004
+ showState.frame = frame;
15005
+ showState.frameFocused = false;
15006
+ return frame;
15007
+ }
15008
+
15009
+ function showTokens() {
15010
+ const css = getComputedStyle(document.body);
15011
+ const mode = isLightTheme() ? 'light' : 'dark';
15012
+ const out = { ...SHOW_FONTS, '--color-scheme': mode };
15013
+ for (const [token, v] of Object.entries(SHOW_TOKENS)) out[token] = css.getPropertyValue(v).trim();
15014
+ for (const [kind, colors] of Object.entries(SHOW_PALETTE[mode])) {
15015
+ for (const [i, c] of colors.entries()) out[`--color-${kind}-${i + 1}`] = c;
15016
+ }
15017
+ return out;
15018
+ }
15019
+
15020
+ // A whole document gets the tokens and base styles first in its head, so its own styles win.
15021
+ function showSrcdoc(content, restoreY) {
15022
+ const tokens = Object.entries(showTokens())
15023
+ .map(([k, v]) => `${k}:${v}`)
15024
+ .join(';');
15025
+ const head = `<meta http-equiv="Content-Security-Policy" content="${SHOW_CSP}"><style>:root{${tokens}}${SHOW_FRAME_BASE}</style>`;
15026
+ const bridge = `<script>(${showBridge})(${Math.max(0, Math.round(restoreY) || 0)}, ${modalZoom});</script>${REVIEW_BRIDGE_TAG}`;
15027
+ if (!/^\s*(<!doctype|<html)/i.test(content)) {
15028
+ return `<!doctype html><html><head><meta charset="utf-8">${head}</head><body>${content}${bridge}</body></html>`;
15029
+ }
15030
+ const at = /<head[^>]*>/i.exec(content) || /<html[^>]*>/i.exec(content);
15031
+ const inject = at?.[0].toLowerCase().startsWith('<head') ? head : `<head>${head}</head>`;
15032
+ const cut = at ? at.index + at[0].length : 0;
15033
+ return content.slice(0, cut) + inject + content.slice(cut) + bridge;
15034
+ }
15035
+
15036
+ // Runs inside the card as source text, so it must not close over anything outside its own body.
15037
+ // Keys and links go through reviewBridge, which the card loads next to this.
15038
+ function showBridge(restoreY, textZoom) {
15039
+ const P = 'cck-show:';
15040
+ const send = (msg) => parent.postMessage({ ...msg, type: P + msg.type }, '*');
15041
+ const restore = () => {
15042
+ if (restoreY) scrollTo(0, restoreY);
15043
+ };
15044
+ restore();
15045
+ addEventListener('load', restore);
15046
+ // Zoom, not a root font size, because most cards are sized in px: 1 up to a 480px card, 16/13 at 1000px, at most 18/13.
15047
+ // The board's text zoom (Ctrl +/-) multiplies it; the zoom shrinks innerWidth, so the fit reads the unzoomed width.
15048
+ const fit = () => {
15049
+ const width = innerWidth * (Number.parseFloat(document.documentElement.style.zoom) || 1);
15050
+ const z = Math.min(Math.max((10.2 + width * 0.0058) / 13, 1), 18 / 13) * textZoom;
15051
+ document.documentElement.style.zoom = z !== 1 ? z.toFixed(3) : '';
15052
+ };
15053
+ fit();
15054
+ addEventListener('resize', fit);
15055
+ let lastH = -1;
15056
+ const measure = () => {
15057
+ const b = document.body;
15058
+ const cs = getComputedStyle(b);
15059
+ const h = Math.ceil(b.getBoundingClientRect().height + parseFloat(cs.marginTop) + parseFloat(cs.marginBottom));
15060
+ if (h === lastH) return;
15061
+ lastH = h;
15062
+ send({ type: 'height', h });
15063
+ };
15064
+ new ResizeObserver(measure).observe(document.body);
15065
+ addEventListener('focus', () => send({ type: 'focus', on: true }));
15066
+ addEventListener('blur', () => send({ type: 'focus', on: false }));
15067
+ let raf = 0;
15068
+ addEventListener('scroll', () => {
15069
+ if (raf) return;
15070
+ raf = requestAnimationFrame(() => {
15071
+ raf = 0;
15072
+ send({ type: 'scroll', y: scrollY });
15073
+ });
15074
+ });
15075
+ addEventListener('message', (e) => {
15076
+ if (e.source !== parent) return;
15077
+ if (e.data?.type === `${P}zoom`) {
15078
+ textZoom = Number(e.data.z) || 1;
15079
+ fit();
15080
+ } else if (e.data?.type === `${P}tokens`) {
15081
+ for (const [k, v] of Object.entries(e.data.tokens)) document.documentElement.style.setProperty(k, v);
15082
+ }
15083
+ });
15084
+ }
15085
+
15086
+ window.addEventListener('message', (e) => {
15087
+ const frame = showState.frame;
15088
+ if (!frame || e.source !== frame.contentWindow || typeof e.data?.type !== 'string') return;
15089
+ if (e.data.type === `${SHOW_MSG}scroll`) showState.frameY = Number(e.data.y) || 0;
15090
+ else if (e.data.type === `${SHOW_MSG}focus`) {
15091
+ showState.frameFocused = e.data.on === true;
15092
+ syncShowFocus();
15093
+ } else if (e.data.type === `${SHOW_MSG}height`) {
15094
+ showState.frameH = Number(e.data.h) || 0;
15095
+ layoutShow();
15096
+ }
15097
+ });
15098
+
15099
+ // The same body attributes the terminal theme watches; a markdown card follows the board's CSS.
15100
+ new MutationObserver(() => {
15101
+ postToShowFrames(() => ({ type: `${SHOW_MSG}tokens`, tokens: showTokens() }));
15102
+ }).observe(document.body, { attributes: true, attributeFilter: ['class', 'data-color-theme', 'style'] });
15103
+
15104
+ // Builds the message only when a frame is there, because showTokens forces a style recalc.
15105
+ function postToShowFrames(makeMsg) {
15106
+ const frames = [showState.frame, ...document.querySelectorAll('.show-pane-frame')].filter((f) => f?.contentWindow);
15107
+ if (!frames.length) return;
15108
+ const msg = makeMsg();
15109
+ for (const f of frames) f.contentWindow.postMessage(msg, '*');
15110
+ }
15111
+
15112
+ function showFitSize(w, h) {
15113
+ return { w: Math.min(Math.max(360, Math.round(w * 0.4)), 640, w - 2 * SHOW_INSET), h: Math.round(h * 0.6) };
15114
+ }
15115
+
15116
+ function showClampSize(size, w, h) {
15117
+ const clamp = (v, lo, hi) => Math.max(lo, Math.min(v, hi));
15118
+ return { w: clamp(size.w, SHOW_MIN_W, w - 2 * SHOW_INSET), h: clamp(size.h, SHOW_MIN_H, h - 2 * SHOW_INSET) };
15119
+ }
15120
+
15121
+ function layoutShow() {
15122
+ const card = showCardEl();
15123
+ if (!card.classList.contains('open')) return;
15124
+ const host = document.getElementById('terminal-host');
15125
+ const w = host.clientWidth;
15126
+ const h = host.clientHeight;
15127
+ if (!w || !h) return;
15128
+ const fit = showFitSize(w, h);
15129
+ card.style.setProperty('--show-fit-w', `${fit.w}px`);
15130
+ card.style.setProperty('--show-fit-h', `${fit.h}px`);
15131
+ card.style.setProperty('--show-term-font', `${currentTerminalFontSize()}px`);
15132
+ const sized = !!showSize && !showState.expanded && !showState.collapsed;
15133
+ card.classList.toggle('sized', sized);
15134
+ if (sized) {
15135
+ const s = showClampSize(showSize, w, h);
15136
+ card.style.setProperty('--show-w', `${s.w}px`);
15137
+ card.style.setProperty('--show-h', `${s.h}px`);
15138
+ }
15139
+ const frame = showState.frame;
15140
+ if (!frame) return;
15141
+ const peek = !showState.expanded && !sized;
15142
+ const chrome =
15143
+ card.querySelector('.show-head').offsetHeight +
15144
+ card.querySelector('.show-ticks').offsetHeight +
15145
+ card.querySelector('.show-review').offsetHeight +
15146
+ 2;
15147
+ frame.style.flex = peek ? 'none' : '1';
15148
+ frame.style.height = peek ? `${Math.min(showState.frameH || SHOW_MIN_H, fit.h - chrome)}px` : '';
15149
+ }
15150
+
15151
+ async function openShowInPane() {
15152
+ const sid = showState.sessionId;
15153
+ const target = showState.path;
15154
+ if (!sid || !target) return showToast('The post has no file yet');
15155
+ const title = showState.posts[showState.idx]?.title;
15156
+ const onScreen = await postPane(sid, { target, title, show: true, base: getSessionBaseDir(sid) || undefined });
15157
+ toastPaneAdded(sid, onScreen, 'Card');
15158
+ }
15159
+
15160
+ // `onScreen` is postPane's result: null failed, true is already in view unless the terminal covers it.
15161
+ function toastPaneAdded(sid, onScreen, label) {
15162
+ if (onScreen === null || (onScreen && !wantsTerminal())) return;
15163
+ const open = () => (sid === currentSessionId && wantsTerminal() ? toggleTerminal() : openSession(sid));
15164
+ showToast(`${label} added as a pane`, 'success', { label: 'Open', onClick: open });
15165
+ }
15166
+
15167
+ function showCommand(cmd) {
15168
+ const tid = showState.terminalId;
15169
+ if (cmd === 'pane') {
15170
+ openShowInPane();
15171
+ return;
15172
+ }
15173
+ if (cmd === 'prev' || cmd === 'next') {
15174
+ const i = showState.idx + (cmd === 'prev' ? -1 : 1);
15175
+ if (i < 0 || i >= showState.posts.length) return;
15176
+ showState.idx = i;
15177
+ showState.collapsed = false;
15178
+ } else if (cmd === 'collapse') showState.collapsed = !showState.collapsed;
15179
+ else if (cmd === 'expand') {
15180
+ showState.expanded = !showState.expanded;
15181
+ showState.collapsed = false;
15182
+ } else if (cmd === 'close') showState.closed = true;
15183
+ else if (cmd === 'remove') {
15184
+ const id = showState.posts[showState.idx]?.id;
15185
+ if (!id) return;
15186
+ dropShowPost(id);
15187
+ terminalFetch(apiPath`/api/terminals/${tid}/show/${id}`, 'DELETE')
15188
+ .then((res) => {
15189
+ if (res.ok || res.status === 404) return;
15190
+ showToast(`Could not remove the post (HTTP ${res.status})`, 'error');
15191
+ loadShowPosts(tid).then((ok) => ok && renderShow());
15192
+ })
15193
+ .catch((e) => showToast(e.message, 'error'));
15194
+ } else if (cmd === 'clear') {
15195
+ resetShowPosts();
15196
+ terminalFetch(apiPath`/api/terminals/${tid}/show`, 'DELETE')
15197
+ .then((res) => {
15198
+ if (!res.ok && res.status !== 404) showToast(`Could not clear the posts (HTTP ${res.status})`, 'error');
15199
+ })
15200
+ .catch((e) => showToast(e.message, 'error'));
15201
+ }
15202
+ renderShow();
15203
+ }
15204
+
15205
+ function initShowCard() {
15206
+ const card = showCardEl();
15207
+ card.addEventListener('focusin', syncShowFocus);
15208
+ card.addEventListener('focusout', syncShowFocus);
15209
+ // A click on the header gives the card focus, so its keys and zoom apply next. Close and
15210
+ // collapse hide the body and leave focus where it was.
15211
+ card.addEventListener('mousedown', (e) => {
15212
+ if (!e.target.closest('.show-head, .show-ticks')) return;
15213
+ e.preventDefault();
15214
+ if (showState.collapsed || e.target.closest('[data-show="close"], [data-show="collapse"]')) return;
15215
+ focusShowBody();
15216
+ });
15217
+ card.addEventListener('click', (e) => {
15218
+ const b = e.target.closest('[data-show]');
15219
+ if (b) {
15220
+ if (!b.disabled) showCommand(b.dataset.show === 'remove' && e.shiftKey ? 'clear' : b.dataset.show);
15221
+ if (b.dataset.show === 'collapse' && !showState.collapsed) focusShowBody();
15222
+ if (b.dataset.show === 'remove') focusAfterShowRemove();
15223
+ return;
15224
+ }
15225
+ const t = e.target.closest('[data-go]');
15226
+ if (!t) return;
15227
+ showState.idx = Number(t.dataset.go);
15228
+ showState.collapsed = false;
15229
+ renderShow();
15230
+ });
15231
+ // Pointer capture keeps the drag when the pointer crosses the card's frame or the terminal's.
15232
+ card.addEventListener('pointerdown', (e) => {
15233
+ const grip = e.target.closest('.show-grip');
15234
+ if (!grip || e.button !== 0) return;
15235
+ e.preventDefault();
15236
+ grip.setPointerCapture(e.pointerId);
15237
+ const r = card.getBoundingClientRect();
15238
+ const start = { x: e.clientX, y: e.clientY, w: r.width, h: r.height };
15239
+ let last = e;
15240
+ let raf = 0;
15241
+ document.body.classList.add('show-resizing');
15242
+ const move = (ev) => {
15243
+ last = ev;
15244
+ raf ||= requestAnimationFrame(() => {
15245
+ raf = 0;
15246
+ showSize = { w: Math.round(start.w + start.x - last.clientX), h: Math.round(start.h + last.clientY - start.y) };
15247
+ layoutShow();
15248
+ });
15249
+ };
15250
+ const up = () => {
15251
+ cancelAnimationFrame(raf);
15252
+ grip.removeEventListener('pointermove', move);
15253
+ grip.removeEventListener('pointerup', up);
15254
+ grip.removeEventListener('pointercancel', up);
15255
+ document.body.classList.remove('show-resizing');
15256
+ const host = document.getElementById('terminal-host');
15257
+ if (showSize) showSize = showClampSize(showSize, host.clientWidth, host.clientHeight);
15258
+ store.writeJson(SHOW_SIZE_KEY, showSize);
15259
+ layoutShow();
15260
+ };
15261
+ grip.addEventListener('pointermove', move);
15262
+ grip.addEventListener('pointerup', up);
15263
+ grip.addEventListener('pointercancel', up);
15264
+ });
15265
+ card.addEventListener('dblclick', (e) => {
15266
+ if (!e.target.closest('.show-grip')) return;
15267
+ showSize = null;
15268
+ store.removeItem(SHOW_SIZE_KEY);
15269
+ layoutShow();
15270
+ });
15271
+ document.getElementById('show-reopen').addEventListener('click', () => {
15272
+ showState.closed = false;
15273
+ renderShow({ flash: true });
15274
+ });
15275
+ }
15276
+
15277
+ //#endregion
15278
+
14274
15279
  //#region NEW_SESSION
14275
15280
  // claude writes no transcript until the first message, so until then the sidebar shows a
14276
15281
  // placeholder built from the form. newSpecs holds one entry per such session; the entry
@@ -14417,6 +15422,8 @@ async function loadTerminals() {
14417
15422
  if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
14418
15423
  const list = data.sessions || [];
14419
15424
  runningTerminals = new Set(list.map((t) => t.id));
15425
+ rememberTerminalIds(list);
15426
+ syncShowCard();
14420
15427
  pushTerminalClaims();
14421
15428
  renderActivityChip();
14422
15429
  return list;
@@ -14424,6 +15431,8 @@ async function loadTerminals() {
14424
15431
 
14425
15432
  function setRunningTerminals(ids) {
14426
15433
  runningTerminals = new Set(ids);
15434
+ forgetEndedTerminals();
15435
+ syncShowCard();
14427
15436
  pushTerminalClaims();
14428
15437
  if (ids.some((id) => !sessions.some((s) => s.id === id))) fetchSessions(false).catch(() => {});
14429
15438
  else renderSessionViews();
@@ -14809,15 +15818,46 @@ if (store.getItem(sectionCollapsedKey('sessions-filters')) === 'true') {
14809
15818
  document.getElementById('sessions-chevron').classList.add('rotated');
14810
15819
  }
14811
15820
 
15821
+ // Capture phase: a message preview opens its detail on any click, so a fold stops the click first.
15822
+ function onCallstackFold(e) {
15823
+ if (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return;
15824
+ const fold = e.target.closest?.('code.ct .ct-has');
15825
+ if (!fold) return;
15826
+ // A drag that selects text ends in a click, and a review comment needs the selection.
15827
+ if (e.type === 'click' && (e.target.closest('.ct-loc') || !window.getSelection().isCollapsed)) return;
15828
+ e.preventDefault();
15829
+ e.stopPropagation();
15830
+ treeHighlight.toggleFold(fold);
15831
+ }
15832
+ document.addEventListener('click', onCallstackFold, true);
15833
+ document.addEventListener('keydown', onCallstackFold, true);
15834
+
15835
+ // A path:line note opens its file at that line: as a pane of the session, or in the preview with
15836
+ // Shift, from a modal, or with no session.
15837
+ function onCallstackLoc(e) {
15838
+ const el = e.target.closest?.('code.ct .ct-loc');
15839
+ const loc = el && treeHighlight.parseLoc(el.textContent);
15840
+ // Shift+click extends the selection to the link, so a selection there is the click's own.
15841
+ if (!loc || (!e.shiftKey && !window.getSelection().isCollapsed)) return;
15842
+ e.preventDefault();
15843
+ e.stopPropagation();
15844
+ if (e.shiftKey) window.getSelection().collapseToEnd();
15845
+ const sid = el.closest('#show-card') ? showState.sessionId : currentSessionId;
15846
+ const base = (sid && getSessionBaseDir(sid)) || undefined;
15847
+ if (e.shiftKey || !sid || el.closest('.modal-overlay')) openPreviewAtLine(loc, base);
15848
+ else openPaneAtLine(sid, loc, base);
15849
+ }
15850
+ document.addEventListener('click', onCallstackLoc, true);
15851
+
14812
15852
  document.addEventListener('DOMContentLoaded', () => {
14813
15853
  if (typeof marked !== 'undefined' && typeof hljs !== 'undefined') {
14814
15854
  const renderer = new marked.Renderer();
14815
15855
  renderer.code = ({ text, lang }) => {
14816
15856
  if (lang === 'mermaid') {
14817
- return `<pre class="mermaid" data-original="${escapeHtml(text)}">${escapeHtml(text)}</pre>`;
15857
+ return `<pre class="mermaid">${escapeHtml(text)}</pre>`;
14818
15858
  }
14819
15859
  if (treeHighlight.wantsTree(lang, text)) {
14820
- return `<pre><code class="hljs language-tree">${treeHighlight.highlightTree(text)}</code></pre>`;
15860
+ return treeHighlight.renderTree(text);
14821
15861
  }
14822
15862
  let highlighted;
14823
15863
  if (lang && hljs.getLanguage(lang)) {
@@ -14848,6 +15888,7 @@ loadSessionGroups();
14848
15888
  initSessionGroupsDnd();
14849
15889
  initSessionPicker();
14850
15890
  initProjectPicker();
15891
+ initShowCard();
14851
15892
  for (const k of readStoredList(ACTIVITY_FILTER_KEY)) activityFilter.add(k);
14852
15893
  initSidebarResize();
14853
15894
  applyModalZoom();