claude-code-kanban 7.5.0 → 7.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hub-app.json +5 -0
- package/lib/panes.js +16 -2
- package/lib/parsers.js +27 -2
- package/lib/retention.js +29 -6
- package/lib/session-events.js +154 -42
- package/lib/show.js +385 -0
- package/lib/terminal-client.js +14 -1
- package/lib/terminal-host.js +2 -1
- package/lib/terminal.js +27 -7
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/hooks/doorbell.ts +66 -10
- package/plugin/plugins/claude-code-kanban/hooks/index.ts +2 -0
- package/plugin/plugins/claude-code-kanban/hooks/show.ts +120 -0
- package/plugin/plugins/claude-code-kanban/skills/dispatch/SKILL.md +2 -0
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +2 -0
- package/plugin/plugins/claude-code-kanban/skills/show/SKILL.md +45 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/NOTICE.md +27 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/chart.md +58 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/architecture.md +18 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/er.md +10 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/flow.md +13 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/layers.md +10 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/sequence.md +19 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/state.md +12 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/swimlane.md +10 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/timeline.md +9 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/tree.md +10 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram.md +87 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/explain.md +98 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/mockup.md +30 -0
- package/plugin/plugins/claude-code-kanban/tests/doorbell.test.ts +69 -10
- package/plugin/plugins/claude-code-kanban/tests/show.test.ts +137 -0
- package/public/app.js +1133 -108
- package/public/index.html +7 -6
- package/public/style.css +770 -9
- package/public/terminal-frame.js +0 -2
- package/public/tree-highlight.js +137 -15
- package/server.js +71 -24
- 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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
${
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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('
|
|
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',
|
|
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', () =>
|
|
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
|
-
|
|
8197
|
-
|
|
8198
|
-
|
|
8199
|
-
|
|
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
|
-
|
|
9921
|
-
|
|
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
|
-
|
|
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
|
|
9953
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
9967
|
-
|
|
9968
|
-
|
|
9969
|
-
|
|
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))
|
|
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
|
-
|
|
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'))
|
|
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
|
-
|
|
12941
|
+
await reloadPaneView(sid, pane, view);
|
|
12942
|
+
} else {
|
|
12943
|
+
unmountPane(sid, pane.id);
|
|
12944
|
+
paneRefused.delete(pane.target);
|
|
12945
|
+
syncPanes();
|
|
12690
12946
|
}
|
|
12691
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,685 @@ 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
|
+
function focusShowBody() {
|
|
14699
|
+
showCardEl()?.querySelector('.show-body')?.focus();
|
|
14700
|
+
}
|
|
14701
|
+
|
|
14702
|
+
const SHOW_KEY_COMMANDS = { 'alt+Enter': 'expand', 'alt+w': 'close' };
|
|
14703
|
+
|
|
14704
|
+
// The body keeps focus, because a render replaces an HTML card's frame.
|
|
14705
|
+
function showCardKey(e) {
|
|
14706
|
+
if (matchKey(e, '[', ']')) {
|
|
14707
|
+
showCommand(e.key === ']' ? 'next' : 'prev');
|
|
14708
|
+
focusShowBody();
|
|
14709
|
+
return true;
|
|
14710
|
+
}
|
|
14711
|
+
const combo = ClaudeHub.comboOf(e);
|
|
14712
|
+
const cmd = combo === 'Escape' ? (showState.expanded ? 'expand' : 'collapse') : SHOW_KEY_COMMANDS[combo];
|
|
14713
|
+
if (cmd) {
|
|
14714
|
+
showCommand(cmd);
|
|
14715
|
+
if (cmd === 'expand') focusShowBody();
|
|
14716
|
+
else focusTerminalPane();
|
|
14717
|
+
return true;
|
|
14718
|
+
}
|
|
14719
|
+
const p = showState.posts[showState.idx];
|
|
14720
|
+
if (combo !== 'ctrl+d' || !p) return false;
|
|
14721
|
+
const message = `Remove "${p.title}" from the card?`;
|
|
14722
|
+
confirmModal({ title: 'Remove post', message, okLabel: 'Remove' }).then((ok) => {
|
|
14723
|
+
if (!ok || showState.posts[showState.idx]?.id !== p.id) return;
|
|
14724
|
+
showCommand('remove');
|
|
14725
|
+
focusAfterShowRemove();
|
|
14726
|
+
});
|
|
14727
|
+
return true;
|
|
14728
|
+
}
|
|
14729
|
+
|
|
14730
|
+
// The removed post took the focused body or button with it.
|
|
14731
|
+
function focusAfterShowRemove() {
|
|
14732
|
+
if (showState.posts.length) focusShowBody();
|
|
14733
|
+
else focusTerminalPane();
|
|
14734
|
+
}
|
|
14735
|
+
|
|
14736
|
+
function showIcon(name) {
|
|
14737
|
+
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${SHOW_ICONS[name]}</svg>`;
|
|
14738
|
+
}
|
|
14739
|
+
|
|
14740
|
+
// An empty list can be a host that has not restored its terminals yet, so it prunes nothing.
|
|
14741
|
+
function rememberTerminalIds(list) {
|
|
14742
|
+
terminalIds.clear();
|
|
14743
|
+
for (const t of list) if (t.terminalId) terminalIds.set(t.id, t.terminalId);
|
|
14744
|
+
if (!terminalIds.size) return;
|
|
14745
|
+
const live = new Set(terminalIds.values());
|
|
14746
|
+
const gone = Object.keys(showViews).filter((tid) => !live.has(tid));
|
|
14747
|
+
if (!gone.length) return;
|
|
14748
|
+
for (const tid of gone) delete showViews[tid];
|
|
14749
|
+
store.writeJson(SHOW_VIEW_KEY, showViews);
|
|
14750
|
+
}
|
|
14751
|
+
|
|
14752
|
+
function setShowView(tid, view) {
|
|
14753
|
+
const set = SHOW_VIEW_FLAGS.filter((f) => view[f]);
|
|
14754
|
+
const old = showViews[tid] || {};
|
|
14755
|
+
if (SHOW_VIEW_FLAGS.every((f) => !!old[f] === set.includes(f))) return;
|
|
14756
|
+
if (set.length) showViews[tid] = Object.fromEntries(set.map((f) => [f, true]));
|
|
14757
|
+
else delete showViews[tid];
|
|
14758
|
+
store.writeJson(SHOW_VIEW_KEY, showViews);
|
|
14759
|
+
}
|
|
14760
|
+
|
|
14761
|
+
function saveShowView() {
|
|
14762
|
+
if (showState.terminalId) setShowView(showState.terminalId, showState);
|
|
14763
|
+
}
|
|
14764
|
+
|
|
14765
|
+
function resetShowPosts() {
|
|
14766
|
+
showState.posts = [];
|
|
14767
|
+
showState.marked.clear();
|
|
14768
|
+
showState.rendered = null;
|
|
14769
|
+
}
|
|
14770
|
+
|
|
14771
|
+
// Runs on every view change, so it returns at once unless the shown terminal changed.
|
|
14772
|
+
function syncShowCard() {
|
|
14773
|
+
const pty = wantsTerminal() ? currentSessionId : null;
|
|
14774
|
+
if (pty && runningTerminals.has(pty) && !terminalIds.has(pty) && showState.lookup !== pty) {
|
|
14775
|
+
showState.lookup = pty;
|
|
14776
|
+
loadTerminals().catch(() => {});
|
|
14777
|
+
}
|
|
14778
|
+
const tid = pty ? (terminalIds.get(pty) ?? null) : null;
|
|
14779
|
+
if (tid === showState.terminalId) return;
|
|
14780
|
+
const view = (tid && showViews[tid]) || {};
|
|
14781
|
+
Object.assign(showState, { terminalId: tid, sessionId: null, idx: 0 });
|
|
14782
|
+
for (const f of SHOW_VIEW_FLAGS) showState[f] = !!view[f];
|
|
14783
|
+
resetShowPosts();
|
|
14784
|
+
renderShow();
|
|
14785
|
+
if (tid) loadShowPosts(tid, { newest: true }).then((ok) => ok && renderShow());
|
|
14786
|
+
}
|
|
14787
|
+
|
|
14788
|
+
async function loadShowPosts(tid, { newest = false } = {}) {
|
|
14789
|
+
const seq = ++showState.listSeq;
|
|
14790
|
+
const data = await getJson(apiPath`/api/terminals/${tid}/show`, null, { cache: 'no-store' });
|
|
14791
|
+
if (seq !== showState.listSeq || tid !== showState.terminalId) return false;
|
|
14792
|
+
const shownId = showState.posts[showState.idx]?.id;
|
|
14793
|
+
showState.sessionId = data?.sessionId ?? null;
|
|
14794
|
+
showState.posts = Array.isArray(data?.posts) ? data.posts : [];
|
|
14795
|
+
const at = showState.posts.findIndex((p) => p.id === shownId);
|
|
14796
|
+
showState.idx = newest || at < 0 ? Math.max(0, showState.posts.length - 1) : at;
|
|
14797
|
+
return true;
|
|
14798
|
+
}
|
|
14799
|
+
|
|
14800
|
+
async function onShowPosted(d) {
|
|
14801
|
+
if (typeof d.terminalId !== 'string') return;
|
|
14802
|
+
const view = showViews[d.terminalId];
|
|
14803
|
+
if (!d.replaced && view?.closed && d.terminalId !== showState.terminalId)
|
|
14804
|
+
setShowView(d.terminalId, { ...view, closed: false });
|
|
14805
|
+
if (!showState.terminalId) {
|
|
14806
|
+
if (!wantsTerminal()) return;
|
|
14807
|
+
if (!terminalIds.has(currentSessionId)) showState.lookup = null;
|
|
14808
|
+
syncShowCard();
|
|
14809
|
+
return;
|
|
14810
|
+
}
|
|
14811
|
+
if (d.terminalId !== showState.terminalId) return;
|
|
14812
|
+
if (d.sessionId !== showState.sessionId) resetShowPosts();
|
|
14813
|
+
if (!(await loadShowPosts(d.terminalId))) return;
|
|
14814
|
+
const i = showState.posts.findIndex((p) => p.id === d.id);
|
|
14815
|
+
if (i < 0) return renderShow();
|
|
14816
|
+
if (d.replaced) {
|
|
14817
|
+
if (i !== showState.idx) showState.marked.add(d.id);
|
|
14818
|
+
else if (showState.rendered) showState.rendered.stale = true;
|
|
14819
|
+
return renderShow();
|
|
14820
|
+
}
|
|
14821
|
+
showState.idx = i;
|
|
14822
|
+
showState.closed = false;
|
|
14823
|
+
renderShow({ flash: true });
|
|
14824
|
+
}
|
|
14825
|
+
|
|
14826
|
+
function onShowCleared(d) {
|
|
14827
|
+
if (d.terminalId !== showState.terminalId) return;
|
|
14828
|
+
resetShowPosts();
|
|
14829
|
+
renderShow();
|
|
14830
|
+
}
|
|
14831
|
+
|
|
14832
|
+
function dropShowPost(id) {
|
|
14833
|
+
const i = showState.posts.findIndex((p) => p.id === id);
|
|
14834
|
+
if (i < 0) return false;
|
|
14835
|
+
showState.posts.splice(i, 1);
|
|
14836
|
+
showState.marked.delete(id);
|
|
14837
|
+
if (i < showState.idx || showState.idx >= showState.posts.length) showState.idx--;
|
|
14838
|
+
return true;
|
|
14839
|
+
}
|
|
14840
|
+
|
|
14841
|
+
function onShowRemoved(d) {
|
|
14842
|
+
if (d.terminalId !== showState.terminalId || d.sessionId !== showState.sessionId) return;
|
|
14843
|
+
if (dropShowPost(d.id)) renderShow();
|
|
14844
|
+
}
|
|
14845
|
+
|
|
14846
|
+
function showHeaderHtml(p) {
|
|
14847
|
+
const n = showState.posts.length;
|
|
14848
|
+
const i = showState.idx;
|
|
14849
|
+
const key = p.key ? `<span class="show-sep">·</span><span class="show-key">${escapeHtml(p.key)}</span>` : '';
|
|
14850
|
+
const btn = (cmd, icon, title, disabled = false) =>
|
|
14851
|
+
`<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>`;
|
|
14852
|
+
const { collapsed, expanded } = showState;
|
|
14853
|
+
return `<span class="show-nav"><span class="show-title" title="${escapeHtml(p.title)}">${escapeHtml(p.title)}</span>${key}</span>
|
|
14854
|
+
<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>
|
|
14855
|
+
<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>`;
|
|
14856
|
+
}
|
|
14857
|
+
|
|
14858
|
+
function showTicksHtml() {
|
|
14859
|
+
return showState.posts
|
|
14860
|
+
.map((p, i) => {
|
|
14861
|
+
const marked = showState.marked.has(p.id);
|
|
14862
|
+
const title = marked ? `${p.title} · updated` : p.title;
|
|
14863
|
+
return `<button type="button" class="show-tick${i === showState.idx ? ' on' : ''}${marked ? ' dot' : ''}" data-go="${i}" title="${escapeHtml(title)}" aria-label="${escapeHtml(title)}"></button>`;
|
|
14864
|
+
})
|
|
14865
|
+
.join('');
|
|
14866
|
+
}
|
|
14867
|
+
|
|
14868
|
+
function renderShow({ flash = false } = {}) {
|
|
14869
|
+
saveShowView();
|
|
14870
|
+
const card = showCardEl();
|
|
14871
|
+
const chip = document.getElementById('show-reopen');
|
|
14872
|
+
const n = showState.posts.length;
|
|
14873
|
+
const open = n > 0 && !showState.closed;
|
|
14874
|
+
chip.hidden = !n || open;
|
|
14875
|
+
chip.querySelector('.show-reopen-n').textContent = `show · ${n}`;
|
|
14876
|
+
card.classList.toggle('open', open);
|
|
14877
|
+
if (!open) {
|
|
14878
|
+
showState.rendered = null;
|
|
14879
|
+
showState.frame = null;
|
|
14880
|
+
setShowReview(null);
|
|
14881
|
+
showHostObserver?.disconnect();
|
|
14882
|
+
showHostObserver = null;
|
|
14883
|
+
card.replaceChildren();
|
|
14884
|
+
return;
|
|
14885
|
+
}
|
|
14886
|
+
showState.idx = Math.min(Math.max(0, showState.idx), n - 1);
|
|
14887
|
+
const p = showState.posts[showState.idx];
|
|
14888
|
+
showState.marked.delete(p.id);
|
|
14889
|
+
card.classList.toggle('expanded', showState.expanded && !showState.collapsed);
|
|
14890
|
+
card.classList.toggle('collapsed', showState.collapsed);
|
|
14891
|
+
if (!card.firstChild) {
|
|
14892
|
+
card.innerHTML = `<header class="show-head"></header><div class="show-ticks"></div><div class="show-body" tabindex="-1"></div>
|
|
14893
|
+
<aside class="review-panel show-review" hidden></aside>
|
|
14894
|
+
<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>`;
|
|
14895
|
+
}
|
|
14896
|
+
card.querySelector('.show-head').innerHTML = showHeaderHtml(p);
|
|
14897
|
+
card.querySelector('.show-ticks').innerHTML = showTicksHtml();
|
|
14898
|
+
const r = showState.rendered;
|
|
14899
|
+
if (!showState.collapsed && (!r || r.stale || r.id !== p.id || r.updatedAt !== p.updatedAt)) {
|
|
14900
|
+
renderShowBody(p, r?.id === p.id);
|
|
14901
|
+
}
|
|
14902
|
+
if (flash) {
|
|
14903
|
+
card.classList.remove('flash');
|
|
14904
|
+
void card.offsetWidth;
|
|
14905
|
+
card.classList.add('flash');
|
|
14906
|
+
}
|
|
14907
|
+
if (!showHostObserver) {
|
|
14908
|
+
showHostObserver = new ResizeObserver(() => layoutShow());
|
|
14909
|
+
showHostObserver.observe(document.getElementById('terminal-host'));
|
|
14910
|
+
showHostObserver.observe(card.querySelector('.show-review'));
|
|
14911
|
+
}
|
|
14912
|
+
layoutShow();
|
|
14913
|
+
}
|
|
14914
|
+
|
|
14915
|
+
async function renderShowBody(p, samePost) {
|
|
14916
|
+
const seq = ++showState.bodySeq;
|
|
14917
|
+
const tid = showState.terminalId;
|
|
14918
|
+
showState.rendered = { id: p.id, updatedAt: p.updatedAt };
|
|
14919
|
+
const data = await getJson(apiPath`/api/terminals/${tid}/show/${p.id}`, null, { cache: 'no-store' });
|
|
14920
|
+
const card = showCardEl();
|
|
14921
|
+
const body = card.querySelector('.show-body');
|
|
14922
|
+
if (seq !== showState.bodySeq || !body) return;
|
|
14923
|
+
showState.path = data && !data.waiting ? data.path : null;
|
|
14924
|
+
const html = data?.kind === 'html' && !data.waiting;
|
|
14925
|
+
// A new frame or document drops focus to the page, and Ctrl +/- then zooms the terminal.
|
|
14926
|
+
const refocus = isShowCardFocused() ? () => body.focus() : () => {};
|
|
14927
|
+
card.classList.toggle('html', html);
|
|
14928
|
+
if (html) {
|
|
14929
|
+
const restoreY = samePost && showState.frame ? showState.frameY : 0;
|
|
14930
|
+
showState.frameY = restoreY;
|
|
14931
|
+
if (samePost && showState.frame?.isConnected) {
|
|
14932
|
+
showState.frame.srcdoc = showSrcdoc(data.content, restoreY);
|
|
14933
|
+
} else {
|
|
14934
|
+
showState.frameH = 0;
|
|
14935
|
+
body.replaceChildren(createShowFrame(p.title, showSrcdoc(data.content, 0)));
|
|
14936
|
+
}
|
|
14937
|
+
refocus();
|
|
14938
|
+
setShowReview(showReviewOpts(p, data, body, showState.frame));
|
|
14939
|
+
layoutShow();
|
|
14940
|
+
return;
|
|
14941
|
+
}
|
|
14942
|
+
showState.frame = null;
|
|
14943
|
+
const keepScroll = samePost ? body.scrollTop : 0;
|
|
14944
|
+
const md = document.createElement('div');
|
|
14945
|
+
md.className = 'show-md modal-zoomable';
|
|
14946
|
+
if (!data) md.innerHTML = '<p class="show-gone">This post is gone.</p>';
|
|
14947
|
+
else if (data.waiting) md.innerHTML = '<p class="show-gone">Waiting for content.</p>';
|
|
14948
|
+
else renderPreviewContent(md, data.file || p.file || '', data.content ?? '', data.kind);
|
|
14949
|
+
for (const pre of md.querySelectorAll('pre.mermaid')) {
|
|
14950
|
+
if (/^\s*(---|%%\{)/.test(pre.textContent)) continue;
|
|
14951
|
+
pre.textContent = SHOW_MERMAID_INIT + pre.textContent;
|
|
14952
|
+
}
|
|
14953
|
+
body.replaceChildren(md);
|
|
14954
|
+
refocus();
|
|
14955
|
+
body.scrollTop = keepScroll;
|
|
14956
|
+
setShowReview(data && !data.waiting ? showReviewOpts(p, data, md, null) : null);
|
|
14957
|
+
layoutShow();
|
|
14958
|
+
}
|
|
14959
|
+
|
|
14960
|
+
function showReviewOpts(p, data, contentEl, frame) {
|
|
14961
|
+
const opts = fileReviewOpts(data.path ?? '', data.kind, contentEl, frame);
|
|
14962
|
+
if (!opts) return null;
|
|
14963
|
+
const card = showCardEl();
|
|
14964
|
+
return {
|
|
14965
|
+
...opts,
|
|
14966
|
+
panelEl: card.querySelector('.show-review'),
|
|
14967
|
+
hostEl: card,
|
|
14968
|
+
sessionId: showState.sessionId,
|
|
14969
|
+
source: {
|
|
14970
|
+
kind: 'show',
|
|
14971
|
+
id: p.id,
|
|
14972
|
+
label: `the show card "${p.title}"`,
|
|
14973
|
+
locate: data.path ? `The card shows ${data.path}.` : null,
|
|
14974
|
+
},
|
|
14975
|
+
};
|
|
14976
|
+
}
|
|
14977
|
+
|
|
14978
|
+
// The card's review is the base review in the terminal view, like a pane's elsewhere.
|
|
14979
|
+
function setShowReview(opts) {
|
|
14980
|
+
if (showState.review === opts) return;
|
|
14981
|
+
showState.review = opts;
|
|
14982
|
+
syncPaneReview();
|
|
14983
|
+
}
|
|
14984
|
+
|
|
14985
|
+
// No allow-popups: it opens window.open as a way out. The CSP blocks fetch and remote sub-resources,
|
|
14986
|
+
// but not navigation or WebRTC; the real guard is that only the terminal's own agent can post.
|
|
14987
|
+
function createShowFrame(title, srcdoc) {
|
|
14988
|
+
const frame = createPreviewFrame('show-frame', srcdoc, 'allow-scripts');
|
|
14989
|
+
frame.title = title;
|
|
14990
|
+
showState.frame = frame;
|
|
14991
|
+
showState.frameFocused = false;
|
|
14992
|
+
return frame;
|
|
14993
|
+
}
|
|
14994
|
+
|
|
14995
|
+
function showTokens() {
|
|
14996
|
+
const css = getComputedStyle(document.body);
|
|
14997
|
+
const mode = isLightTheme() ? 'light' : 'dark';
|
|
14998
|
+
const out = { ...SHOW_FONTS, '--color-scheme': mode };
|
|
14999
|
+
for (const [token, v] of Object.entries(SHOW_TOKENS)) out[token] = css.getPropertyValue(v).trim();
|
|
15000
|
+
for (const [kind, colors] of Object.entries(SHOW_PALETTE[mode])) {
|
|
15001
|
+
for (const [i, c] of colors.entries()) out[`--color-${kind}-${i + 1}`] = c;
|
|
15002
|
+
}
|
|
15003
|
+
return out;
|
|
15004
|
+
}
|
|
15005
|
+
|
|
15006
|
+
// A whole document gets the tokens and base styles first in its head, so its own styles win.
|
|
15007
|
+
function showSrcdoc(content, restoreY) {
|
|
15008
|
+
const tokens = Object.entries(showTokens())
|
|
15009
|
+
.map(([k, v]) => `${k}:${v}`)
|
|
15010
|
+
.join(';');
|
|
15011
|
+
const head = `<meta http-equiv="Content-Security-Policy" content="${SHOW_CSP}"><style>:root{${tokens}}${SHOW_FRAME_BASE}</style>`;
|
|
15012
|
+
const bridge = `<script>(${showBridge})(${Math.max(0, Math.round(restoreY) || 0)}, ${modalZoom});</script>${REVIEW_BRIDGE_TAG}`;
|
|
15013
|
+
if (!/^\s*(<!doctype|<html)/i.test(content)) {
|
|
15014
|
+
return `<!doctype html><html><head><meta charset="utf-8">${head}</head><body>${content}${bridge}</body></html>`;
|
|
15015
|
+
}
|
|
15016
|
+
const at = /<head[^>]*>/i.exec(content) || /<html[^>]*>/i.exec(content);
|
|
15017
|
+
const inject = at?.[0].toLowerCase().startsWith('<head') ? head : `<head>${head}</head>`;
|
|
15018
|
+
const cut = at ? at.index + at[0].length : 0;
|
|
15019
|
+
return content.slice(0, cut) + inject + content.slice(cut) + bridge;
|
|
15020
|
+
}
|
|
15021
|
+
|
|
15022
|
+
// Runs inside the card as source text, so it must not close over anything outside its own body.
|
|
15023
|
+
// Keys and links go through reviewBridge, which the card loads next to this.
|
|
15024
|
+
function showBridge(restoreY, textZoom) {
|
|
15025
|
+
const P = 'cck-show:';
|
|
15026
|
+
const send = (msg) => parent.postMessage({ ...msg, type: P + msg.type }, '*');
|
|
15027
|
+
const restore = () => {
|
|
15028
|
+
if (restoreY) scrollTo(0, restoreY);
|
|
15029
|
+
};
|
|
15030
|
+
restore();
|
|
15031
|
+
addEventListener('load', restore);
|
|
15032
|
+
// 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.
|
|
15033
|
+
// The board's text zoom (Ctrl +/-) multiplies it; the zoom shrinks innerWidth, so the fit reads the unzoomed width.
|
|
15034
|
+
const fit = () => {
|
|
15035
|
+
const width = innerWidth * (Number.parseFloat(document.documentElement.style.zoom) || 1);
|
|
15036
|
+
const z = Math.min(Math.max((10.2 + width * 0.0058) / 13, 1), 18 / 13) * textZoom;
|
|
15037
|
+
document.documentElement.style.zoom = z !== 1 ? z.toFixed(3) : '';
|
|
15038
|
+
};
|
|
15039
|
+
fit();
|
|
15040
|
+
addEventListener('resize', fit);
|
|
15041
|
+
let lastH = -1;
|
|
15042
|
+
const measure = () => {
|
|
15043
|
+
const b = document.body;
|
|
15044
|
+
const cs = getComputedStyle(b);
|
|
15045
|
+
const h = Math.ceil(b.getBoundingClientRect().height + parseFloat(cs.marginTop) + parseFloat(cs.marginBottom));
|
|
15046
|
+
if (h === lastH) return;
|
|
15047
|
+
lastH = h;
|
|
15048
|
+
send({ type: 'height', h });
|
|
15049
|
+
};
|
|
15050
|
+
new ResizeObserver(measure).observe(document.body);
|
|
15051
|
+
addEventListener('focus', () => send({ type: 'focus', on: true }));
|
|
15052
|
+
addEventListener('blur', () => send({ type: 'focus', on: false }));
|
|
15053
|
+
let raf = 0;
|
|
15054
|
+
addEventListener('scroll', () => {
|
|
15055
|
+
if (raf) return;
|
|
15056
|
+
raf = requestAnimationFrame(() => {
|
|
15057
|
+
raf = 0;
|
|
15058
|
+
send({ type: 'scroll', y: scrollY });
|
|
15059
|
+
});
|
|
15060
|
+
});
|
|
15061
|
+
addEventListener('message', (e) => {
|
|
15062
|
+
if (e.source !== parent) return;
|
|
15063
|
+
if (e.data?.type === `${P}zoom`) {
|
|
15064
|
+
textZoom = Number(e.data.z) || 1;
|
|
15065
|
+
fit();
|
|
15066
|
+
} else if (e.data?.type === `${P}tokens`) {
|
|
15067
|
+
for (const [k, v] of Object.entries(e.data.tokens)) document.documentElement.style.setProperty(k, v);
|
|
15068
|
+
}
|
|
15069
|
+
});
|
|
15070
|
+
}
|
|
15071
|
+
|
|
15072
|
+
window.addEventListener('message', (e) => {
|
|
15073
|
+
const frame = showState.frame;
|
|
15074
|
+
if (!frame || e.source !== frame.contentWindow || typeof e.data?.type !== 'string') return;
|
|
15075
|
+
if (e.data.type === `${SHOW_MSG}scroll`) showState.frameY = Number(e.data.y) || 0;
|
|
15076
|
+
else if (e.data.type === `${SHOW_MSG}focus`) {
|
|
15077
|
+
showState.frameFocused = e.data.on === true;
|
|
15078
|
+
syncShowFocus();
|
|
15079
|
+
} else if (e.data.type === `${SHOW_MSG}height`) {
|
|
15080
|
+
showState.frameH = Number(e.data.h) || 0;
|
|
15081
|
+
layoutShow();
|
|
15082
|
+
}
|
|
15083
|
+
});
|
|
15084
|
+
|
|
15085
|
+
// The same body attributes the terminal theme watches; a markdown card follows the board's CSS.
|
|
15086
|
+
new MutationObserver(() => {
|
|
15087
|
+
postToShowFrames(() => ({ type: `${SHOW_MSG}tokens`, tokens: showTokens() }));
|
|
15088
|
+
}).observe(document.body, { attributes: true, attributeFilter: ['class', 'data-color-theme', 'style'] });
|
|
15089
|
+
|
|
15090
|
+
// Builds the message only when a frame is there, because showTokens forces a style recalc.
|
|
15091
|
+
function postToShowFrames(makeMsg) {
|
|
15092
|
+
const frames = [showState.frame, ...document.querySelectorAll('.show-pane-frame')].filter((f) => f?.contentWindow);
|
|
15093
|
+
if (!frames.length) return;
|
|
15094
|
+
const msg = makeMsg();
|
|
15095
|
+
for (const f of frames) f.contentWindow.postMessage(msg, '*');
|
|
15096
|
+
}
|
|
15097
|
+
|
|
15098
|
+
function showFitSize(w, h) {
|
|
15099
|
+
return { w: Math.min(Math.max(360, Math.round(w * 0.4)), 640, w - 2 * SHOW_INSET), h: Math.round(h * 0.6) };
|
|
15100
|
+
}
|
|
15101
|
+
|
|
15102
|
+
function showClampSize(size, w, h) {
|
|
15103
|
+
const clamp = (v, lo, hi) => Math.max(lo, Math.min(v, hi));
|
|
15104
|
+
return { w: clamp(size.w, SHOW_MIN_W, w - 2 * SHOW_INSET), h: clamp(size.h, SHOW_MIN_H, h - 2 * SHOW_INSET) };
|
|
15105
|
+
}
|
|
15106
|
+
|
|
15107
|
+
function layoutShow() {
|
|
15108
|
+
const card = showCardEl();
|
|
15109
|
+
if (!card.classList.contains('open')) return;
|
|
15110
|
+
const host = document.getElementById('terminal-host');
|
|
15111
|
+
const w = host.clientWidth;
|
|
15112
|
+
const h = host.clientHeight;
|
|
15113
|
+
if (!w || !h) return;
|
|
15114
|
+
const fit = showFitSize(w, h);
|
|
15115
|
+
card.style.setProperty('--show-fit-w', `${fit.w}px`);
|
|
15116
|
+
card.style.setProperty('--show-fit-h', `${fit.h}px`);
|
|
15117
|
+
card.style.setProperty('--show-term-font', `${currentTerminalFontSize()}px`);
|
|
15118
|
+
const sized = !!showSize && !showState.expanded && !showState.collapsed;
|
|
15119
|
+
card.classList.toggle('sized', sized);
|
|
15120
|
+
if (sized) {
|
|
15121
|
+
const s = showClampSize(showSize, w, h);
|
|
15122
|
+
card.style.setProperty('--show-w', `${s.w}px`);
|
|
15123
|
+
card.style.setProperty('--show-h', `${s.h}px`);
|
|
15124
|
+
}
|
|
15125
|
+
const frame = showState.frame;
|
|
15126
|
+
if (!frame) return;
|
|
15127
|
+
const peek = !showState.expanded && !sized;
|
|
15128
|
+
const chrome =
|
|
15129
|
+
card.querySelector('.show-head').offsetHeight +
|
|
15130
|
+
card.querySelector('.show-ticks').offsetHeight +
|
|
15131
|
+
card.querySelector('.show-review').offsetHeight +
|
|
15132
|
+
2;
|
|
15133
|
+
frame.style.flex = peek ? 'none' : '1';
|
|
15134
|
+
frame.style.height = peek ? `${Math.min(showState.frameH || SHOW_MIN_H, fit.h - chrome)}px` : '';
|
|
15135
|
+
}
|
|
15136
|
+
|
|
15137
|
+
async function openShowInPane() {
|
|
15138
|
+
const sid = showState.sessionId;
|
|
15139
|
+
const target = showState.path;
|
|
15140
|
+
if (!sid || !target) return showToast('The post has no file yet');
|
|
15141
|
+
const title = showState.posts[showState.idx]?.title;
|
|
15142
|
+
const onScreen = await postPane(sid, { target, title, show: true, base: getSessionBaseDir(sid) || undefined });
|
|
15143
|
+
toastPaneAdded(sid, onScreen, 'Card');
|
|
15144
|
+
}
|
|
15145
|
+
|
|
15146
|
+
// `onScreen` is postPane's result: null failed, true is already in view unless the terminal covers it.
|
|
15147
|
+
function toastPaneAdded(sid, onScreen, label) {
|
|
15148
|
+
if (onScreen === null || (onScreen && !wantsTerminal())) return;
|
|
15149
|
+
const open = () => (sid === currentSessionId && wantsTerminal() ? toggleTerminal() : openSession(sid));
|
|
15150
|
+
showToast(`${label} added as a pane`, 'success', { label: 'Open', onClick: open });
|
|
15151
|
+
}
|
|
15152
|
+
|
|
15153
|
+
function showCommand(cmd) {
|
|
15154
|
+
const tid = showState.terminalId;
|
|
15155
|
+
if (cmd === 'pane') {
|
|
15156
|
+
openShowInPane();
|
|
15157
|
+
return;
|
|
15158
|
+
}
|
|
15159
|
+
if (cmd === 'prev' || cmd === 'next') {
|
|
15160
|
+
const i = showState.idx + (cmd === 'prev' ? -1 : 1);
|
|
15161
|
+
if (i < 0 || i >= showState.posts.length) return;
|
|
15162
|
+
showState.idx = i;
|
|
15163
|
+
showState.collapsed = false;
|
|
15164
|
+
} else if (cmd === 'collapse') showState.collapsed = !showState.collapsed;
|
|
15165
|
+
else if (cmd === 'expand') {
|
|
15166
|
+
showState.expanded = !showState.expanded;
|
|
15167
|
+
showState.collapsed = false;
|
|
15168
|
+
} else if (cmd === 'close') showState.closed = true;
|
|
15169
|
+
else if (cmd === 'remove') {
|
|
15170
|
+
const id = showState.posts[showState.idx]?.id;
|
|
15171
|
+
if (!id) return;
|
|
15172
|
+
dropShowPost(id);
|
|
15173
|
+
terminalFetch(apiPath`/api/terminals/${tid}/show/${id}`, 'DELETE')
|
|
15174
|
+
.then((res) => {
|
|
15175
|
+
if (res.ok || res.status === 404) return;
|
|
15176
|
+
showToast(`Could not remove the post (HTTP ${res.status})`, 'error');
|
|
15177
|
+
loadShowPosts(tid).then((ok) => ok && renderShow());
|
|
15178
|
+
})
|
|
15179
|
+
.catch((e) => showToast(e.message, 'error'));
|
|
15180
|
+
} else if (cmd === 'clear') {
|
|
15181
|
+
resetShowPosts();
|
|
15182
|
+
terminalFetch(apiPath`/api/terminals/${tid}/show`, 'DELETE')
|
|
15183
|
+
.then((res) => {
|
|
15184
|
+
if (!res.ok && res.status !== 404) showToast(`Could not clear the posts (HTTP ${res.status})`, 'error');
|
|
15185
|
+
})
|
|
15186
|
+
.catch((e) => showToast(e.message, 'error'));
|
|
15187
|
+
}
|
|
15188
|
+
renderShow();
|
|
15189
|
+
}
|
|
15190
|
+
|
|
15191
|
+
function initShowCard() {
|
|
15192
|
+
const card = showCardEl();
|
|
15193
|
+
card.addEventListener('focusin', syncShowFocus);
|
|
15194
|
+
card.addEventListener('focusout', syncShowFocus);
|
|
15195
|
+
// A click on the header gives the card focus, so its keys and zoom apply next. Close and
|
|
15196
|
+
// collapse hide the body and leave focus where it was.
|
|
15197
|
+
card.addEventListener('mousedown', (e) => {
|
|
15198
|
+
if (!e.target.closest('.show-head, .show-ticks')) return;
|
|
15199
|
+
e.preventDefault();
|
|
15200
|
+
if (showState.collapsed || e.target.closest('[data-show="close"], [data-show="collapse"]')) return;
|
|
15201
|
+
focusShowBody();
|
|
15202
|
+
});
|
|
15203
|
+
card.addEventListener('click', (e) => {
|
|
15204
|
+
const b = e.target.closest('[data-show]');
|
|
15205
|
+
if (b) {
|
|
15206
|
+
if (!b.disabled) showCommand(b.dataset.show === 'remove' && e.shiftKey ? 'clear' : b.dataset.show);
|
|
15207
|
+
if (b.dataset.show === 'collapse' && !showState.collapsed) focusShowBody();
|
|
15208
|
+
if (b.dataset.show === 'remove') focusAfterShowRemove();
|
|
15209
|
+
return;
|
|
15210
|
+
}
|
|
15211
|
+
const t = e.target.closest('[data-go]');
|
|
15212
|
+
if (!t) return;
|
|
15213
|
+
showState.idx = Number(t.dataset.go);
|
|
15214
|
+
showState.collapsed = false;
|
|
15215
|
+
renderShow();
|
|
15216
|
+
});
|
|
15217
|
+
// Pointer capture keeps the drag when the pointer crosses the card's frame or the terminal's.
|
|
15218
|
+
card.addEventListener('pointerdown', (e) => {
|
|
15219
|
+
const grip = e.target.closest('.show-grip');
|
|
15220
|
+
if (!grip || e.button !== 0) return;
|
|
15221
|
+
e.preventDefault();
|
|
15222
|
+
grip.setPointerCapture(e.pointerId);
|
|
15223
|
+
const r = card.getBoundingClientRect();
|
|
15224
|
+
const start = { x: e.clientX, y: e.clientY, w: r.width, h: r.height };
|
|
15225
|
+
let last = e;
|
|
15226
|
+
let raf = 0;
|
|
15227
|
+
document.body.classList.add('show-resizing');
|
|
15228
|
+
const move = (ev) => {
|
|
15229
|
+
last = ev;
|
|
15230
|
+
raf ||= requestAnimationFrame(() => {
|
|
15231
|
+
raf = 0;
|
|
15232
|
+
showSize = { w: Math.round(start.w + start.x - last.clientX), h: Math.round(start.h + last.clientY - start.y) };
|
|
15233
|
+
layoutShow();
|
|
15234
|
+
});
|
|
15235
|
+
};
|
|
15236
|
+
const up = () => {
|
|
15237
|
+
cancelAnimationFrame(raf);
|
|
15238
|
+
grip.removeEventListener('pointermove', move);
|
|
15239
|
+
grip.removeEventListener('pointerup', up);
|
|
15240
|
+
grip.removeEventListener('pointercancel', up);
|
|
15241
|
+
document.body.classList.remove('show-resizing');
|
|
15242
|
+
const host = document.getElementById('terminal-host');
|
|
15243
|
+
if (showSize) showSize = showClampSize(showSize, host.clientWidth, host.clientHeight);
|
|
15244
|
+
store.writeJson(SHOW_SIZE_KEY, showSize);
|
|
15245
|
+
layoutShow();
|
|
15246
|
+
};
|
|
15247
|
+
grip.addEventListener('pointermove', move);
|
|
15248
|
+
grip.addEventListener('pointerup', up);
|
|
15249
|
+
grip.addEventListener('pointercancel', up);
|
|
15250
|
+
});
|
|
15251
|
+
card.addEventListener('dblclick', (e) => {
|
|
15252
|
+
if (!e.target.closest('.show-grip')) return;
|
|
15253
|
+
showSize = null;
|
|
15254
|
+
store.removeItem(SHOW_SIZE_KEY);
|
|
15255
|
+
layoutShow();
|
|
15256
|
+
});
|
|
15257
|
+
document.getElementById('show-reopen').addEventListener('click', () => {
|
|
15258
|
+
showState.closed = false;
|
|
15259
|
+
renderShow({ flash: true });
|
|
15260
|
+
});
|
|
15261
|
+
}
|
|
15262
|
+
|
|
15263
|
+
//#endregion
|
|
15264
|
+
|
|
14274
15265
|
//#region NEW_SESSION
|
|
14275
15266
|
// claude writes no transcript until the first message, so until then the sidebar shows a
|
|
14276
15267
|
// placeholder built from the form. newSpecs holds one entry per such session; the entry
|
|
@@ -14417,6 +15408,8 @@ async function loadTerminals() {
|
|
|
14417
15408
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
14418
15409
|
const list = data.sessions || [];
|
|
14419
15410
|
runningTerminals = new Set(list.map((t) => t.id));
|
|
15411
|
+
rememberTerminalIds(list);
|
|
15412
|
+
syncShowCard();
|
|
14420
15413
|
pushTerminalClaims();
|
|
14421
15414
|
renderActivityChip();
|
|
14422
15415
|
return list;
|
|
@@ -14809,15 +15802,46 @@ if (store.getItem(sectionCollapsedKey('sessions-filters')) === 'true') {
|
|
|
14809
15802
|
document.getElementById('sessions-chevron').classList.add('rotated');
|
|
14810
15803
|
}
|
|
14811
15804
|
|
|
15805
|
+
// Capture phase: a message preview opens its detail on any click, so a fold stops the click first.
|
|
15806
|
+
function onCallstackFold(e) {
|
|
15807
|
+
if (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return;
|
|
15808
|
+
const fold = e.target.closest?.('code.ct .ct-has');
|
|
15809
|
+
if (!fold) return;
|
|
15810
|
+
// A drag that selects text ends in a click, and a review comment needs the selection.
|
|
15811
|
+
if (e.type === 'click' && (e.target.closest('.ct-loc') || !window.getSelection().isCollapsed)) return;
|
|
15812
|
+
e.preventDefault();
|
|
15813
|
+
e.stopPropagation();
|
|
15814
|
+
treeHighlight.toggleFold(fold);
|
|
15815
|
+
}
|
|
15816
|
+
document.addEventListener('click', onCallstackFold, true);
|
|
15817
|
+
document.addEventListener('keydown', onCallstackFold, true);
|
|
15818
|
+
|
|
15819
|
+
// A path:line note opens its file at that line: as a pane of the session, or in the preview with
|
|
15820
|
+
// Shift, from a modal, or with no session.
|
|
15821
|
+
function onCallstackLoc(e) {
|
|
15822
|
+
const el = e.target.closest?.('code.ct .ct-loc');
|
|
15823
|
+
const loc = el && treeHighlight.parseLoc(el.textContent);
|
|
15824
|
+
// Shift+click extends the selection to the link, so a selection there is the click's own.
|
|
15825
|
+
if (!loc || (!e.shiftKey && !window.getSelection().isCollapsed)) return;
|
|
15826
|
+
e.preventDefault();
|
|
15827
|
+
e.stopPropagation();
|
|
15828
|
+
if (e.shiftKey) window.getSelection().collapseToEnd();
|
|
15829
|
+
const sid = el.closest('#show-card') ? showState.sessionId : currentSessionId;
|
|
15830
|
+
const base = (sid && getSessionBaseDir(sid)) || undefined;
|
|
15831
|
+
if (e.shiftKey || !sid || el.closest('.modal-overlay')) openPreviewAtLine(loc, base);
|
|
15832
|
+
else openPaneAtLine(sid, loc, base);
|
|
15833
|
+
}
|
|
15834
|
+
document.addEventListener('click', onCallstackLoc, true);
|
|
15835
|
+
|
|
14812
15836
|
document.addEventListener('DOMContentLoaded', () => {
|
|
14813
15837
|
if (typeof marked !== 'undefined' && typeof hljs !== 'undefined') {
|
|
14814
15838
|
const renderer = new marked.Renderer();
|
|
14815
15839
|
renderer.code = ({ text, lang }) => {
|
|
14816
15840
|
if (lang === 'mermaid') {
|
|
14817
|
-
return `<pre class="mermaid"
|
|
15841
|
+
return `<pre class="mermaid">${escapeHtml(text)}</pre>`;
|
|
14818
15842
|
}
|
|
14819
15843
|
if (treeHighlight.wantsTree(lang, text)) {
|
|
14820
|
-
return
|
|
15844
|
+
return treeHighlight.renderTree(text);
|
|
14821
15845
|
}
|
|
14822
15846
|
let highlighted;
|
|
14823
15847
|
if (lang && hljs.getLanguage(lang)) {
|
|
@@ -14848,6 +15872,7 @@ loadSessionGroups();
|
|
|
14848
15872
|
initSessionGroupsDnd();
|
|
14849
15873
|
initSessionPicker();
|
|
14850
15874
|
initProjectPicker();
|
|
15875
|
+
initShowCard();
|
|
14851
15876
|
for (const k of readStoredList(ACTIVITY_FILTER_KEY)) activityFilter.add(k);
|
|
14852
15877
|
initSidebarResize();
|
|
14853
15878
|
applyModalZoom();
|