claude-code-kanban 7.1.2 → 7.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/panes.js +9 -1
- package/package.json +1 -1
- package/public/app.js +620 -67
- package/public/index.html +17 -8
- package/public/style.css +239 -5
- package/server.js +41 -2
package/public/app.js
CHANGED
|
@@ -1932,6 +1932,7 @@ function showPinnedMsgDetail(pinIdx) {
|
|
|
1932
1932
|
const pinBtn = document.getElementById('msg-detail-pin-btn');
|
|
1933
1933
|
if (pinBtn) pinBtn.classList.add('active');
|
|
1934
1934
|
document.getElementById('msg-detail-modal').classList.add('visible');
|
|
1935
|
+
mountReplyReview(pin, body, pinModal);
|
|
1935
1936
|
}
|
|
1936
1937
|
|
|
1937
1938
|
function updateMsgDetailPinState() {
|
|
@@ -2102,6 +2103,7 @@ function _renderPinToDetail(pin) {
|
|
|
2102
2103
|
}
|
|
2103
2104
|
|
|
2104
2105
|
const SESSION_PIN_SVG = PIN_SVG.replace('width="14" height="14"', 'width="12" height="12"');
|
|
2106
|
+
const BOOKMARK_SVG = PIN_SVG.replace('width="14" height="14"', 'width="10" height="10"');
|
|
2105
2107
|
const SESSION_STAR_SVG =
|
|
2106
2108
|
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12"><path d="M12 2 L15 9 L22 9 L17 14 L19 22 L12 18 L5 22 L7 14 L2 9 L9 9 Z"/></svg>';
|
|
2107
2109
|
const LINK_SVG_PATHS =
|
|
@@ -2311,11 +2313,31 @@ function mountReplyReview(m, body, modal) {
|
|
|
2311
2313
|
contentEl: body,
|
|
2312
2314
|
panelEl: document.getElementById('msg-review-panel'),
|
|
2313
2315
|
hostEl: modal,
|
|
2314
|
-
source: {
|
|
2316
|
+
source: {
|
|
2317
|
+
kind: 'reply',
|
|
2318
|
+
id: m.timestamp,
|
|
2319
|
+
label: replyReviewLabel(m.timestamp),
|
|
2320
|
+
locate: replyLocateHint(m.timestamp),
|
|
2321
|
+
},
|
|
2315
2322
|
onComment: () => setMsgDetailFollow(false),
|
|
2316
2323
|
});
|
|
2317
2324
|
}
|
|
2318
2325
|
|
|
2326
|
+
function replyReviewLabel(timestamp) {
|
|
2327
|
+
const d = new Date(timestamp);
|
|
2328
|
+
const day =
|
|
2329
|
+
d.toDateString() === new Date().toDateString()
|
|
2330
|
+
? ''
|
|
2331
|
+
: ` on ${d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}`;
|
|
2332
|
+
return `your reply${day} at ${d.toLocaleTimeString()}`;
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
function replyLocateHint(timestamp) {
|
|
2336
|
+
const transcript = sessions.find((s) => s.id === currentSessionId)?.jsonlPath;
|
|
2337
|
+
if (!transcript) return undefined;
|
|
2338
|
+
return `If that reply is not in your context, it is in ${transcript}, the assistant entry with timestamp ${timestamp}.`;
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2319
2341
|
function closeMsgDetailModal() {
|
|
2320
2342
|
unmountMsgReview();
|
|
2321
2343
|
hideModalOverlay('msg-detail-modal');
|
|
@@ -3131,10 +3153,6 @@ function renderAgentFooter() {
|
|
|
3131
3153
|
footer.classList.add('visible');
|
|
3132
3154
|
label.textContent = `Agents Log (${visible.length})`;
|
|
3133
3155
|
|
|
3134
|
-
const collapsed = store.getItem('agentFooterCollapsed') === 'true';
|
|
3135
|
-
footer.classList.toggle('collapsed', collapsed);
|
|
3136
|
-
document.getElementById('agent-footer-toggle').innerHTML = collapsed ? '▴' : '▾';
|
|
3137
|
-
|
|
3138
3156
|
const permHtml = permFresh
|
|
3139
3157
|
? `<button type="button" class="permission-badge" onclick="showWaitingDetail()">${currentWaiting.kind === 'question' ? '❓ Question pending' : currentWaiting.kind === 'plan' ? '📋 Plan pending' : `⏳ Awaiting: ${escapeHtml(currentWaiting.toolName || 'unknown')}`}</button>`
|
|
3140
3158
|
: '';
|
|
@@ -3206,9 +3224,24 @@ function renderAgentFooter() {
|
|
|
3206
3224
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
3207
3225
|
function toggleAgentFooter() {
|
|
3208
3226
|
const footer = document.getElementById('agent-footer');
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3227
|
+
store.setItem(agentFooterCollapseKey(), !footer.classList.contains('collapsed'));
|
|
3228
|
+
applyAgentFooterCollapsed();
|
|
3229
|
+
}
|
|
3230
|
+
|
|
3231
|
+
function boardInSplit() {
|
|
3232
|
+
const board = document.getElementById('board-side');
|
|
3233
|
+
return board.classList.contains('side-l') || board.classList.contains('side-r');
|
|
3234
|
+
}
|
|
3235
|
+
|
|
3236
|
+
// With the board in a split, the agents log keeps its own collapse preference and starts collapsed.
|
|
3237
|
+
function agentFooterCollapseKey() {
|
|
3238
|
+
return boardInSplit() ? 'agentFooterCollapsedSplit' : 'agentFooterCollapsed';
|
|
3239
|
+
}
|
|
3240
|
+
|
|
3241
|
+
function applyAgentFooterCollapsed() {
|
|
3242
|
+
const saved = store.getItem(agentFooterCollapseKey());
|
|
3243
|
+
const collapsed = saved === null ? boardInSplit() : saved === 'true';
|
|
3244
|
+
document.getElementById('agent-footer').classList.toggle('collapsed', collapsed);
|
|
3212
3245
|
document.getElementById('agent-footer-toggle').innerHTML = collapsed ? '▴' : '▾';
|
|
3213
3246
|
}
|
|
3214
3247
|
|
|
@@ -3649,7 +3682,7 @@ function renderSessions() {
|
|
|
3649
3682
|
${renderWorkflowBadge(session)}
|
|
3650
3683
|
${renderLoopBadge(session)}
|
|
3651
3684
|
${hasScratchpad ? `<span class="scratchpad-badge${padEmoji ? ' has-emoji' : ''}" onclick="event.stopPropagation(); openSessionScratchpad('${sid}')" title="Open scratchpad">${padEmoji ? escapeHtml(padEmoji) : `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`}</span>` : ''}
|
|
3652
|
-
${bookmarksCount > 0 ? `<span class="bookmarks-badge" onclick="event.stopPropagation(); openSessionWithBookmarks('${sid}')" title="${bookmarksCount} bookmarked message${bookmarksCount > 1 ? 's' : ''}"
|
|
3685
|
+
${bookmarksCount > 0 ? `<span class="bookmarks-badge" onclick="event.stopPropagation(); openSessionWithBookmarks('${sid}')" title="${bookmarksCount} bookmarked message${bookmarksCount > 1 ? 's' : ''}">${BOOKMARK_SVG}${bookmarksCount}</span>` : ''}
|
|
3653
3686
|
${linkedDocsCount > 0 ? `<span class="linked-docs-badge" onclick="event.stopPropagation(); showSessionInfoModal('${sid}')" title="${linkedDocsCount} linked document${linkedDocsCount > 1 ? 's' : ''}">${linkSvg(10)}${linkedDocsCount}</span>` : ''}
|
|
3654
3687
|
${session.hasPlan && !session.planSourceSessionId ? `<span class="plan-indicator" onclick="event.stopPropagation(); openPlanForSession('${sid}')" title="View plan">${ICON_PLAN}</span>` : ''}
|
|
3655
3688
|
${session.planSourceSessionId ? `<span class="plan-indicator" title="Implements plan — click to reveal plan session" onclick="event.stopPropagation(); revealPlanSession('${escAttrJs(session.planSourceSessionId)}')">${ICON_PLAN}</span>` : ''}
|
|
@@ -3674,6 +3707,7 @@ function renderSessions() {
|
|
|
3674
3707
|
${renderScratchpadRow(zenSession)}
|
|
3675
3708
|
${renderLinkedDocsHtml(zenSession.id)}
|
|
3676
3709
|
${renderArtifactsHtml(zenSession.id)}
|
|
3710
|
+
${renderZenBookmarksHtml(zenSession.id)}
|
|
3677
3711
|
</div>
|
|
3678
3712
|
${zenSession.hasWorkflow ? renderWorkflowLiveHtml(zenSession.id) : ''}`;
|
|
3679
3713
|
bindLinkedDocsHandlers(sessionsList.querySelector('.linked-docs-section'), zenSession.id);
|
|
@@ -5387,6 +5421,8 @@ function setFocusZone(zone, target) {
|
|
|
5387
5421
|
|
|
5388
5422
|
focusZone = zone;
|
|
5389
5423
|
if (zone === 'sidebar') {
|
|
5424
|
+
const active = document.activeElement;
|
|
5425
|
+
if (active && document.getElementById('main-content').contains(active)) active.blur();
|
|
5390
5426
|
expandSidebar();
|
|
5391
5427
|
const items = getNavigableItems();
|
|
5392
5428
|
if (items.length > 0) {
|
|
@@ -5418,6 +5454,7 @@ function setFocusZone(zone, target) {
|
|
|
5418
5454
|
if (card) card.classList.add('selected');
|
|
5419
5455
|
} else {
|
|
5420
5456
|
navigateVertical(1);
|
|
5457
|
+
if (!selectedTaskId) document.querySelector('.column-add')?.focus();
|
|
5421
5458
|
}
|
|
5422
5459
|
if (selectedTaskId && detailPanel.classList.contains('visible')) {
|
|
5423
5460
|
showTaskDetail(selectedTaskId, selectedSessionId);
|
|
@@ -5834,7 +5871,11 @@ const SHORTCUT_TABS = [
|
|
|
5834
5871
|
title: 'View',
|
|
5835
5872
|
rows: [
|
|
5836
5873
|
{ keys: ['\\'], label: 'Toggle sidebar' },
|
|
5837
|
-
{ keys: ['[', ']'], label: 'Previous / next tab (
|
|
5874
|
+
{ keys: ['[', ']'], label: 'Previous / next tab (a split counts as one)' },
|
|
5875
|
+
{ keys: ['Alt', '[ / ]'], combo: true, label: 'Change the tab in the focused side of a split' },
|
|
5876
|
+
{ keys: ['|'], label: 'Split with the tab before it / unsplit (or drag a tab onto a side)' },
|
|
5877
|
+
{ keys: ['{', '}'], label: 'Focus left / right side of a split' },
|
|
5878
|
+
{ keys: ['Alt', 'W'], combo: true, label: 'Close the focused tab' },
|
|
5838
5879
|
{ keys: ['T'], label: 'Toggle theme' },
|
|
5839
5880
|
{ keys: ['Shift', 'S'], combo: true, label: 'Storage manager' },
|
|
5840
5881
|
{ keys: ['Ctrl', 'Shift', 'Z'], combo: true, label: 'Zen mode (current session only)' },
|
|
@@ -7005,7 +7046,25 @@ document.addEventListener('keydown', (e) => {
|
|
|
7005
7046
|
toggleSidebar();
|
|
7006
7047
|
return;
|
|
7007
7048
|
}
|
|
7008
|
-
if (matchKey(e, '[', ']') &&
|
|
7049
|
+
if (matchKey(e, '[', ']') && cyclePaneStrip(e.key === ']' ? 1 : -1)) {
|
|
7050
|
+
e.preventDefault();
|
|
7051
|
+
return;
|
|
7052
|
+
}
|
|
7053
|
+
const altOnly = e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey;
|
|
7054
|
+
if (altOnly && (e.code === 'BracketLeft' || e.code === 'BracketRight')) {
|
|
7055
|
+
if (cyclePane(e.code === 'BracketRight' ? 1 : -1)) e.preventDefault();
|
|
7056
|
+
return;
|
|
7057
|
+
}
|
|
7058
|
+
if (altOnly && e.code === 'KeyW' && closeFocusedPane()) {
|
|
7059
|
+
e.preventDefault();
|
|
7060
|
+
return;
|
|
7061
|
+
}
|
|
7062
|
+
const plainShift = !e.ctrlKey && !e.altKey && !e.metaKey;
|
|
7063
|
+
if (plainShift && e.key === '|' && togglePaneSplit()) {
|
|
7064
|
+
e.preventDefault();
|
|
7065
|
+
return;
|
|
7066
|
+
}
|
|
7067
|
+
if (plainShift && (e.key === '{' || e.key === '}') && focusPaneSide(e.key === '{' ? 'l' : 'r')) {
|
|
7009
7068
|
e.preventDefault();
|
|
7010
7069
|
return;
|
|
7011
7070
|
}
|
|
@@ -7062,10 +7121,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
7062
7121
|
else leaveTerminalPane();
|
|
7063
7122
|
return;
|
|
7064
7123
|
}
|
|
7065
|
-
if (focusZone === 'sidebar')
|
|
7066
|
-
const hasCards = document.querySelector('.task-card');
|
|
7067
|
-
if (!hasCards) return;
|
|
7068
|
-
}
|
|
7124
|
+
if (focusZone === 'sidebar' && !document.querySelector('.task-card, .column-add')) return;
|
|
7069
7125
|
setFocusZone(focusZone === 'board' ? 'sidebar' : 'board');
|
|
7070
7126
|
return;
|
|
7071
7127
|
}
|
|
@@ -7102,12 +7158,6 @@ document.addEventListener('keydown', (e) => {
|
|
|
7102
7158
|
e.preventDefault();
|
|
7103
7159
|
return;
|
|
7104
7160
|
}
|
|
7105
|
-
if (e.key === 'Escape') {
|
|
7106
|
-
// Plain unfocus — drop the keyboard cursor without jumping into the board
|
|
7107
|
-
clearKbSelection();
|
|
7108
|
-
focusZone = 'board';
|
|
7109
|
-
return;
|
|
7110
|
-
}
|
|
7111
7161
|
}
|
|
7112
7162
|
|
|
7113
7163
|
// Board navigation
|
|
@@ -7156,7 +7206,11 @@ document.addEventListener('keydown', (e) => {
|
|
|
7156
7206
|
if (detailPanel.classList.contains('visible')) closeDetailPanel();
|
|
7157
7207
|
else if (agentLogMode) exitAgentLogMode();
|
|
7158
7208
|
else if (messagePanelOpen) toggleMessagePanel();
|
|
7159
|
-
else {
|
|
7209
|
+
else if (focusZone === 'sidebar') {
|
|
7210
|
+
// Plain unfocus — drop the keyboard cursor without jumping into the board
|
|
7211
|
+
clearKbSelection();
|
|
7212
|
+
focusZone = 'board';
|
|
7213
|
+
} else {
|
|
7160
7214
|
// Nothing open — plain unfocus: drop the task-card selection highlight
|
|
7161
7215
|
clearTaskSelection();
|
|
7162
7216
|
selectedTaskId = null;
|
|
@@ -7588,6 +7642,11 @@ function reviewBridge(textBefore, headingBefore, rangeAt, comboOf, fieldSelector
|
|
|
7588
7642
|
e.preventDefault();
|
|
7589
7643
|
send({ type: 'key', key: e.key, code: e.code, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey, meta: e.metaKey });
|
|
7590
7644
|
});
|
|
7645
|
+
let scrollTimer = 0;
|
|
7646
|
+
addEventListener('scroll', () => {
|
|
7647
|
+
clearTimeout(scrollTimer);
|
|
7648
|
+
scrollTimer = setTimeout(() => send({ type: 'scroll', x: scrollX, y: scrollY }), 150);
|
|
7649
|
+
});
|
|
7591
7650
|
addEventListener('message', (e) => {
|
|
7592
7651
|
if (e.source !== parent || typeof e.data?.type !== 'string' || !e.data.type.startsWith(P)) return;
|
|
7593
7652
|
const type = e.data.type.slice(P.length);
|
|
@@ -7624,6 +7683,7 @@ const REVIEW_BRIDGE_TAG = `<script>(${reviewBridge})(${reviewTextBefore}, ${revi
|
|
|
7624
7683
|
// Frames from createPreviewFrame, which carry the bridge. A URL pane is another site and must
|
|
7625
7684
|
// not drive the board.
|
|
7626
7685
|
const bridgedFrames = new WeakSet();
|
|
7686
|
+
const paneFrameScroll = new WeakMap();
|
|
7627
7687
|
|
|
7628
7688
|
function bridgedFrameEls() {
|
|
7629
7689
|
return [...document.querySelectorAll('iframe[srcdoc]')].filter((f) => bridgedFrames.has(f));
|
|
@@ -7641,6 +7701,15 @@ const ZOOM_CLAIMS = ['ctrlKey', 'metaKey'].flatMap((mod) =>
|
|
|
7641
7701
|
),
|
|
7642
7702
|
);
|
|
7643
7703
|
|
|
7704
|
+
// The sidebar and tab keys, so they work while a previewed page has focus. Matched by key where
|
|
7705
|
+
// the board matches by key, so they follow the keyboard layout. A page that handles one first
|
|
7706
|
+
// keeps it, and text fields keep every key.
|
|
7707
|
+
const PANE_CLAIMS = [
|
|
7708
|
+
...['\\', '[', ']'].map((key) => ({ key, code: '' })),
|
|
7709
|
+
...['|', '{', '}'].map((key) => ({ shiftKey: true, key, code: '' })),
|
|
7710
|
+
...['BracketLeft', 'BracketRight', 'KeyW'].map((code) => ({ altKey: true, key: 'Unidentified', code })),
|
|
7711
|
+
];
|
|
7712
|
+
|
|
7644
7713
|
function sendBridgeClaims(frames, claims) {
|
|
7645
7714
|
for (const f of frames) f.contentWindow?.postMessage({ type: `${REVIEW_MSG}claims`, ...claims }, '*');
|
|
7646
7715
|
}
|
|
@@ -7648,10 +7717,11 @@ function sendBridgeClaims(frames, claims) {
|
|
|
7648
7717
|
window.addEventListener('message', (e) => {
|
|
7649
7718
|
const type =
|
|
7650
7719
|
typeof e.data?.type === 'string' && e.data.type.startsWith(REVIEW_MSG) && e.data.type.slice(REVIEW_MSG.length);
|
|
7651
|
-
if (type !== 'link' && type !== 'key') return;
|
|
7720
|
+
if (type !== 'link' && type !== 'key' && type !== 'scroll') return;
|
|
7652
7721
|
const frame = bridgedFrameEls().find((f) => f.contentWindow === e.source);
|
|
7653
7722
|
if (!frame) return;
|
|
7654
|
-
if (type === '
|
|
7723
|
+
if (type === 'scroll') paneFrameScroll.set(frame, { x: e.data.x, y: e.data.y });
|
|
7724
|
+
else if (type === 'key') replayKey(e.data, frame);
|
|
7655
7725
|
else {
|
|
7656
7726
|
const url = linkUrl(e.data.href);
|
|
7657
7727
|
if (url) hub.openExternal(url);
|
|
@@ -8507,6 +8577,54 @@ function renderArtifactsHtml(sessionId) {
|
|
|
8507
8577
|
return `<div class="artifacts-section panel-section" data-artifacts-for="${escapeHtml(sessionId)}">${artifactsInnerHtml(sessionId)}</div>`;
|
|
8508
8578
|
}
|
|
8509
8579
|
|
|
8580
|
+
const ZEN_BOOKMARKS_COLLAPSED = 3;
|
|
8581
|
+
const zenBookmarksExpanded = new Set();
|
|
8582
|
+
|
|
8583
|
+
function zenBookmarkParts(p) {
|
|
8584
|
+
if (p.type === 'tool_use') return { who: p.tool || 'Tool', text: p.description || p.detail || '' };
|
|
8585
|
+
if (p.type === 'agent') return { who: p.agentType || 'Agent', text: stripTeammateWrapper(p.lastMessage || '') };
|
|
8586
|
+
return { who: p.type === 'assistant' ? 'Claude' : 'You', text: p.text || '' };
|
|
8587
|
+
}
|
|
8588
|
+
|
|
8589
|
+
// Zen shows the open session only, so its pins are currentPins and a row opens through
|
|
8590
|
+
// showPinnedMsgDetail by index.
|
|
8591
|
+
function renderZenBookmarksHtml(sessionId) {
|
|
8592
|
+
if (sessionId !== currentSessionId || !currentPins.length) return '';
|
|
8593
|
+
const { shown, moreHtml } = showAllToggle(
|
|
8594
|
+
zenBookmarksExpanded,
|
|
8595
|
+
sessionId,
|
|
8596
|
+
currentPins,
|
|
8597
|
+
ZEN_BOOKMARKS_COLLAPSED,
|
|
8598
|
+
`onclick="toggleZenBookmarks('${escAttrJs(sessionId)}')"`,
|
|
8599
|
+
);
|
|
8600
|
+
const items = shown
|
|
8601
|
+
.map((p, i) => {
|
|
8602
|
+
const { who, text } = zenBookmarkParts(p);
|
|
8603
|
+
const label = truncateAtWord(cleanMessageText(text), 200);
|
|
8604
|
+
return `<li class="zen-bookmark-item">
|
|
8605
|
+
<span class="zen-bookmark-who">${escapeHtml(who)}</span>
|
|
8606
|
+
<button type="button" class="zen-bookmark-link" onclick="showPinnedMsgDetail(${i})" title="${escapeHtml(label)}">${escapeHtml(label || '(empty)')}</button>
|
|
8607
|
+
<span class="scratch-file-time">${formatDate(p.timestamp)}</span>
|
|
8608
|
+
</li>`;
|
|
8609
|
+
})
|
|
8610
|
+
.join('');
|
|
8611
|
+
return `<div class="zen-bookmarks-section panel-section">
|
|
8612
|
+
<div class="panel-section-header">
|
|
8613
|
+
${BOOKMARK_SVG}
|
|
8614
|
+
<span>Bookmarks</span>
|
|
8615
|
+
<span class="panel-section-count">${currentPins.length}</span>
|
|
8616
|
+
</div>
|
|
8617
|
+
<ul class="zen-bookmark-list">${items}</ul>${moreHtml}
|
|
8618
|
+
</div>`;
|
|
8619
|
+
}
|
|
8620
|
+
|
|
8621
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
8622
|
+
function toggleZenBookmarks(sessionId) {
|
|
8623
|
+
toggleSetMember(zenBookmarksExpanded, sessionId);
|
|
8624
|
+
const section = document.querySelector('.zen-bookmarks-section');
|
|
8625
|
+
if (section) section.outerHTML = renderZenBookmarksHtml(sessionId);
|
|
8626
|
+
}
|
|
8627
|
+
|
|
8510
8628
|
// No TTL: the server holds the scan until the transcript grows, so a repeat costs one
|
|
8511
8629
|
// stat, and an artifact published while the panel is open still appears.
|
|
8512
8630
|
const artifactsSection = makeSectionLoader({
|
|
@@ -8954,6 +9072,10 @@ function setupEventSource() {
|
|
|
8954
9072
|
applyPaneLayout(data.sessionId, data.layout);
|
|
8955
9073
|
}
|
|
8956
9074
|
|
|
9075
|
+
if (data.type === 'pane:file-changed') {
|
|
9076
|
+
onPaneFileChanged(data.path);
|
|
9077
|
+
}
|
|
9078
|
+
|
|
8957
9079
|
if (data.type === 'team-update') {
|
|
8958
9080
|
const teamSession = sessions.find((s) => s.isTeam && s.teamName === data.teamName);
|
|
8959
9081
|
if (teamSession) {
|
|
@@ -11916,14 +12038,31 @@ async function paneRequest(method, path, body) {
|
|
|
11916
12038
|
}
|
|
11917
12039
|
}
|
|
11918
12040
|
|
|
11919
|
-
// sessionStorage
|
|
12041
|
+
// Per-session pane state in sessionStorage, {[sessionId]: value} in recency order.
|
|
12042
|
+
const PANE_STATE_CAP = 20;
|
|
12043
|
+
|
|
12044
|
+
function loadPaneState(key) {
|
|
12045
|
+
try {
|
|
12046
|
+
const map = JSON.parse(sessionStorage.getItem(key));
|
|
12047
|
+
if (map && typeof map === 'object') return map;
|
|
12048
|
+
} catch {}
|
|
12049
|
+
return {};
|
|
12050
|
+
}
|
|
12051
|
+
|
|
12052
|
+
// Puts `sid` last, so the cap drops the least recent session. A null value removes it.
|
|
12053
|
+
function savePaneState(key, map, sid, value) {
|
|
12054
|
+
delete map[sid];
|
|
12055
|
+
if (value != null) map[sid] = value;
|
|
12056
|
+
const capped = Object.fromEntries(Object.entries(map).slice(-PANE_STATE_CAP));
|
|
12057
|
+
try {
|
|
12058
|
+
sessionStorage.setItem(key, JSON.stringify(capped));
|
|
12059
|
+
} catch {}
|
|
12060
|
+
return capped;
|
|
12061
|
+
}
|
|
12062
|
+
|
|
12063
|
+
// `cck.paneActive`: {[sessionId]: paneId}.
|
|
11920
12064
|
const PANE_ACTIVE_KEY = 'cck.paneActive';
|
|
11921
|
-
|
|
11922
|
-
let paneActive = {};
|
|
11923
|
-
try {
|
|
11924
|
-
const map = JSON.parse(sessionStorage.getItem(PANE_ACTIVE_KEY));
|
|
11925
|
-
if (map && typeof map === 'object') paneActive = map;
|
|
11926
|
-
} catch {}
|
|
12065
|
+
let paneActive = loadPaneState(PANE_ACTIVE_KEY);
|
|
11927
12066
|
|
|
11928
12067
|
function getActivePaneId(sid) {
|
|
11929
12068
|
return paneActive[sid] || 'board';
|
|
@@ -11931,12 +12070,136 @@ function getActivePaneId(sid) {
|
|
|
11931
12070
|
|
|
11932
12071
|
function setActivePaneId(sid, id) {
|
|
11933
12072
|
if (getActivePaneId(sid) === id) return;
|
|
11934
|
-
|
|
11935
|
-
|
|
11936
|
-
|
|
11937
|
-
|
|
11938
|
-
|
|
11939
|
-
|
|
12073
|
+
paneActive = savePaneState(PANE_ACTIVE_KEY, paneActive, sid, id === 'board' ? null : id);
|
|
12074
|
+
notePairFocus(sid, id);
|
|
12075
|
+
}
|
|
12076
|
+
|
|
12077
|
+
// `cck.panePairs`: {[sessionId]: [{l, r, f}]}; sides are pane ids or 'board', `f` the side focused
|
|
12078
|
+
// last. A tab is in one pair at most, and the pair that holds the active tab is the split on
|
|
12079
|
+
// screen. Clicks never change a pair; only the split keys and drags do.
|
|
12080
|
+
const PANE_PAIRS_KEY = 'cck.panePairs';
|
|
12081
|
+
const PANE_SPLIT_RATIO_KEY = 'pane-split-ratio';
|
|
12082
|
+
let panePairs = loadPaneState(PANE_PAIRS_KEY);
|
|
12083
|
+
const clampSplitRatio = (r) => Math.min(0.85, Math.max(0.15, r));
|
|
12084
|
+
let paneSplitRatio = clampSplitRatio(Number.parseFloat(store.getItem(PANE_SPLIT_RATIO_KEY)) || 0.5);
|
|
12085
|
+
|
|
12086
|
+
const paneTabIds = (sid) => ['board', ...paneLayout(sid).panes.map((p) => p.id)];
|
|
12087
|
+
const splitSideOf = (split, id) => (split.l === id ? 'l' : split.r === id ? 'r' : null);
|
|
12088
|
+
const otherSide = (side) => (side === 'l' ? 'r' : 'l');
|
|
12089
|
+
|
|
12090
|
+
// Pairs whose tabs are gone, or taken by an earlier pair, drop out.
|
|
12091
|
+
function getPanePairs(sid) {
|
|
12092
|
+
if (!panePairs[sid]) return [];
|
|
12093
|
+
const ids = new Set(paneTabIds(sid));
|
|
12094
|
+
const taken = new Set();
|
|
12095
|
+
return panePairs[sid].filter((p) => {
|
|
12096
|
+
const ok = p.l !== p.r && ids.has(p.l) && ids.has(p.r) && !taken.has(p.l) && !taken.has(p.r);
|
|
12097
|
+
if (ok) taken.add(p.l).add(p.r);
|
|
12098
|
+
return ok;
|
|
12099
|
+
});
|
|
12100
|
+
}
|
|
12101
|
+
|
|
12102
|
+
function setPanePairs(sid, pairs) {
|
|
12103
|
+
panePairs = savePaneState(PANE_PAIRS_KEY, panePairs, sid, pairs.length ? pairs : null);
|
|
12104
|
+
}
|
|
12105
|
+
|
|
12106
|
+
const withoutTabs = (pairs, ...ids) => pairs.filter((p) => !ids.some((id) => splitSideOf(p, id)));
|
|
12107
|
+
const pairOf = (sid, id) => getPanePairs(sid).find((p) => splitSideOf(p, id)) || null;
|
|
12108
|
+
|
|
12109
|
+
function getPaneSplit(sid) {
|
|
12110
|
+
return pairOf(sid, getActivePaneId(sid));
|
|
12111
|
+
}
|
|
12112
|
+
|
|
12113
|
+
function notePairFocus(sid, id) {
|
|
12114
|
+
const pairs = getPanePairs(sid);
|
|
12115
|
+
const pair = pairs.find((p) => splitSideOf(p, id));
|
|
12116
|
+
const side = pair && splitSideOf(pair, id);
|
|
12117
|
+
if (!side || pair.f === side) return;
|
|
12118
|
+
pair.f = side;
|
|
12119
|
+
setPanePairs(sid, pairs);
|
|
12120
|
+
}
|
|
12121
|
+
|
|
12122
|
+
// Tabs that are not in a pair, plus `id`.
|
|
12123
|
+
function freePaneTabs(sid, id, pairs) {
|
|
12124
|
+
return paneTabIds(sid).filter((t) => t === id || !pairs.some((p) => splitSideOf(p, t)));
|
|
12125
|
+
}
|
|
12126
|
+
|
|
12127
|
+
// The free tab before it, else the one after it.
|
|
12128
|
+
function neighborPair(sid, id, pairs) {
|
|
12129
|
+
const free = freePaneTabs(sid, id, pairs);
|
|
12130
|
+
const i = free.indexOf(id);
|
|
12131
|
+
if (i < 0 || free.length < 2) return null;
|
|
12132
|
+
return i > 0 ? { l: free[i - 1], r: id } : { l: id, r: free[1] };
|
|
12133
|
+
}
|
|
12134
|
+
|
|
12135
|
+
// The strip in order, as {ids, pair}: a lone tab, or a pair at the place of its first tab.
|
|
12136
|
+
function paneStripItems(sid) {
|
|
12137
|
+
const pairs = getPanePairs(sid);
|
|
12138
|
+
const items = [];
|
|
12139
|
+
for (const id of paneTabIds(sid)) {
|
|
12140
|
+
const pair = pairs.find((p) => splitSideOf(p, id)) || null;
|
|
12141
|
+
if (!pair) items.push({ ids: [id], pair });
|
|
12142
|
+
else if (!items.some((it) => it.pair === pair)) items.push({ ids: [pair.l, pair.r], pair });
|
|
12143
|
+
}
|
|
12144
|
+
return items;
|
|
12145
|
+
}
|
|
12146
|
+
|
|
12147
|
+
function unpairPanes(sid, pair, keep) {
|
|
12148
|
+
setPanePairs(sid, withoutTabs(getPanePairs(sid), pair.l));
|
|
12149
|
+
if (keep) setActivePaneId(sid, keep);
|
|
12150
|
+
syncPanes();
|
|
12151
|
+
}
|
|
12152
|
+
|
|
12153
|
+
// Returns false when the panes are not on screen, so the key keeps its other meaning.
|
|
12154
|
+
function togglePaneSplit() {
|
|
12155
|
+
const sid = paneSessionId();
|
|
12156
|
+
if (!sid || wantsTerminal()) return false;
|
|
12157
|
+
const active = getActivePaneId(sid);
|
|
12158
|
+
const split = getPaneSplit(sid);
|
|
12159
|
+
if (split) {
|
|
12160
|
+
unpairPanes(sid, split, active);
|
|
12161
|
+
return true;
|
|
12162
|
+
}
|
|
12163
|
+
const pairs = getPanePairs(sid);
|
|
12164
|
+
const pair = neighborPair(sid, active, pairs);
|
|
12165
|
+
if (!pair) {
|
|
12166
|
+
showToast(paneLayout(sid).panes.length ? 'No free tab to split with' : 'Add a pane to split the board with');
|
|
12167
|
+
return true;
|
|
12168
|
+
}
|
|
12169
|
+
setPanePairs(sid, [...pairs, { ...pair, f: splitSideOf(pair, active) }]);
|
|
12170
|
+
syncPanes();
|
|
12171
|
+
return true;
|
|
12172
|
+
}
|
|
12173
|
+
|
|
12174
|
+
function focusPaneSide(side) {
|
|
12175
|
+
const sid = paneSessionId();
|
|
12176
|
+
const split = sid && !wantsTerminal() && getPaneSplit(sid);
|
|
12177
|
+
if (!split) return false;
|
|
12178
|
+
if (getActivePaneId(sid) !== split[side]) {
|
|
12179
|
+
setActivePaneId(sid, split[side]);
|
|
12180
|
+
syncPanes();
|
|
12181
|
+
}
|
|
12182
|
+
return true;
|
|
12183
|
+
}
|
|
12184
|
+
|
|
12185
|
+
function dropPaneOnSide(id, side) {
|
|
12186
|
+
const sid = paneSessionId();
|
|
12187
|
+
if (!sid) return;
|
|
12188
|
+
const split = getPaneSplit(sid);
|
|
12189
|
+
const other = otherSide(side);
|
|
12190
|
+
const pairs = getPanePairs(sid);
|
|
12191
|
+
const shownId = getActivePaneId(sid);
|
|
12192
|
+
let partner;
|
|
12193
|
+
if (split) partner = split[other] === id ? split[side] : split[other];
|
|
12194
|
+
else if (shownId !== id) partner = shownId;
|
|
12195
|
+
else {
|
|
12196
|
+
const pair = neighborPair(sid, id, pairs);
|
|
12197
|
+
partner = pair && (pair.l === id ? pair.r : pair.l);
|
|
12198
|
+
}
|
|
12199
|
+
if (!partner) return;
|
|
12200
|
+
setPanePairs(sid, [...withoutTabs(pairs, id, partner), { [side]: id, [other]: partner, f: side }]);
|
|
12201
|
+
setActivePaneId(sid, id);
|
|
12202
|
+
syncPanes();
|
|
11940
12203
|
}
|
|
11941
12204
|
|
|
11942
12205
|
const PANE_FRAME_LIMIT = 3;
|
|
@@ -11953,7 +12216,17 @@ const PANE_ICONS = {
|
|
|
11953
12216
|
// and the limit spans sessions, so a session switch cannot pile up frames.
|
|
11954
12217
|
const paneFrames = new Map();
|
|
11955
12218
|
const paneReviews = new WeakMap();
|
|
12219
|
+
// Off-screen views whose file changed; they reload when shown.
|
|
12220
|
+
const stalePaneViews = new WeakSet();
|
|
12221
|
+
const paneReloads = new Map();
|
|
11956
12222
|
const paneViews = document.getElementById('pane-views');
|
|
12223
|
+
const paneSplitBox = document.getElementById('pane-split');
|
|
12224
|
+
const paneDivider = document.getElementById('pane-divider');
|
|
12225
|
+
const paneDrop = document.getElementById('pane-drop');
|
|
12226
|
+
const paneSplitBtn = document.getElementById('pane-split-btn');
|
|
12227
|
+
const paneBoard = document.getElementById('board-side');
|
|
12228
|
+
const paneLog = document.getElementById('message-panel');
|
|
12229
|
+
const paneLogHome = { parent: paneLog.parentNode, next: paneLog.nextSibling };
|
|
11957
12230
|
const paneTabs = document.getElementById('pane-tabs');
|
|
11958
12231
|
const panePin = document.getElementById('pane-pin');
|
|
11959
12232
|
const paneStrip = document.getElementById('pane-strip');
|
|
@@ -11962,6 +12235,8 @@ const panePopInput = document.getElementById('pane-pop-input');
|
|
|
11962
12235
|
const panePopDocs = document.getElementById('pane-pop-docs');
|
|
11963
12236
|
const panePopDetect = document.getElementById('pane-pop-detect');
|
|
11964
12237
|
let paneTabsHtml = '';
|
|
12238
|
+
let paneDragId = null;
|
|
12239
|
+
applyAgentFooterCollapsed();
|
|
11965
12240
|
|
|
11966
12241
|
function paneSessionId() {
|
|
11967
12242
|
return viewMode === 'session' ? currentSessionId : null;
|
|
@@ -11980,42 +12255,99 @@ function syncPanes() {
|
|
|
11980
12255
|
}
|
|
11981
12256
|
const layout = sid ? paneLayout(sid) : null;
|
|
11982
12257
|
const pane = sid ? activePane(sid) : null;
|
|
12258
|
+
const split = sid ? getPaneSplit(sid) : null;
|
|
11983
12259
|
sessionView.classList.toggle('has-panes', !!layout);
|
|
11984
|
-
|
|
12260
|
+
const boardSide = split ? splitSideOf(split, 'board') : null;
|
|
12261
|
+
sessionView.classList.toggle('pane-mode', split ? !boardSide : !!pane);
|
|
12262
|
+
sessionView.classList.toggle('split-mode', !!split);
|
|
11985
12263
|
if (!layout || wantsTerminal()) closePanePop();
|
|
11986
|
-
|
|
11987
|
-
|
|
12264
|
+
// key → side ('' unsplit). The focused side mounts last, so the frame limit evicts it last.
|
|
12265
|
+
const sides = new Map();
|
|
12266
|
+
if (split) {
|
|
12267
|
+
const focusSide = splitSideOf(split, pane?.id || 'board');
|
|
12268
|
+
for (const side of [otherSide(focusSide), focusSide]) {
|
|
12269
|
+
if (side !== boardSide) sides.set(mountPane(sid, paneById(split[side])), side);
|
|
12270
|
+
}
|
|
12271
|
+
applySplitRatio();
|
|
12272
|
+
} else if (pane) {
|
|
12273
|
+
sides.set(mountPane(sid, pane), '');
|
|
12274
|
+
}
|
|
12275
|
+
const focusKey = pane ? `${sid}/${pane.id}` : null;
|
|
12276
|
+
for (const el of paneViews.children) {
|
|
12277
|
+
const side = sides.get(el.dataset.key);
|
|
12278
|
+
el.classList.toggle('on', side !== undefined);
|
|
12279
|
+
markPaneSide(el, side, el.dataset.key === focusKey);
|
|
12280
|
+
}
|
|
12281
|
+
const boardWasSplit = boardInSplit();
|
|
12282
|
+
markPaneSide(paneBoard, boardSide, !!boardSide && !pane);
|
|
12283
|
+
if (boardWasSplit !== !!boardSide) applyAgentFooterCollapsed();
|
|
12284
|
+
placeSessionLog(wantsTerminal() ? null : boardSide);
|
|
11988
12285
|
// Off screen, a framed page keeps focus and takes keys the user aims at the board.
|
|
11989
12286
|
const focused = document.activeElement;
|
|
11990
12287
|
if (paneViews.contains(focused) && (wantsTerminal() || !focused.closest('.pane-view.on'))) focused.blur();
|
|
11991
12288
|
syncPaneReview();
|
|
11992
12289
|
if (!layout) return;
|
|
11993
12290
|
for (const b of document.querySelectorAll('.pane-tool')) b.disabled = !pane;
|
|
11994
|
-
|
|
12291
|
+
paneSplitBtn.disabled = !layout.panes.length;
|
|
12292
|
+
paneSplitBtn.classList.toggle('on', !!split);
|
|
12293
|
+
paneSplitBtn.setAttribute('aria-pressed', String(!!split));
|
|
12294
|
+
paneSplitBtn.title = split ? 'Unsplit (|)' : 'Split with the tab before it (|)';
|
|
12295
|
+
renderPaneTabs(sid, layout, split);
|
|
12296
|
+
}
|
|
12297
|
+
|
|
12298
|
+
// With the board in a split, the session log docks in the board's side instead of the window.
|
|
12299
|
+
function placeSessionLog(boardSide) {
|
|
12300
|
+
const parent = boardSide ? paneSplitBox : paneLogHome.parent;
|
|
12301
|
+
if (paneLog.parentNode !== parent) {
|
|
12302
|
+
const content = document.getElementById('message-panel-content');
|
|
12303
|
+
const top = content.scrollTop;
|
|
12304
|
+
parent.insertBefore(paneLog, boardSide ? null : paneLogHome.next);
|
|
12305
|
+
content.scrollTop = top;
|
|
12306
|
+
}
|
|
12307
|
+
paneLog.classList.toggle('log-l', boardSide === 'l');
|
|
12308
|
+
paneLog.classList.toggle('log-r', boardSide === 'r');
|
|
12309
|
+
}
|
|
12310
|
+
|
|
12311
|
+
function markPaneSide(el, side, focused) {
|
|
12312
|
+
el.classList.toggle('side-l', side === 'l');
|
|
12313
|
+
el.classList.toggle('side-r', side === 'r');
|
|
12314
|
+
el.classList.toggle('pane-focus', focused);
|
|
11995
12315
|
}
|
|
11996
12316
|
|
|
11997
|
-
function renderPaneTabs(sid, layout) {
|
|
12317
|
+
function renderPaneTabs(sid, layout, split) {
|
|
12318
|
+
// A rebuild mid-drag removes the dragged tab, and then no dragend fires.
|
|
12319
|
+
if (paneDragId) return;
|
|
11998
12320
|
const active = getActivePaneId(sid);
|
|
12321
|
+
const state = (id) => (active === id ? ' on' : split && splitSideOf(split, id) ? ' shown' : '');
|
|
11999
12322
|
const tab = (id, cls, inner, tip) =>
|
|
12000
|
-
`<div class="pane-tab ${escapeHtml(cls)}${
|
|
12001
|
-
const
|
|
12002
|
-
|
|
12003
|
-
'
|
|
12004
|
-
|
|
12005
|
-
|
|
12006
|
-
|
|
12007
|
-
|
|
12008
|
-
|
|
12009
|
-
|
|
12323
|
+
`<div class="pane-tab ${escapeHtml(cls)}${state(id)}" role="tab" draggable="true" data-pane="${escapeHtml(id)}" title="${escapeHtml(tip)}">${inner}</div>`;
|
|
12324
|
+
const byId = new Map(layout.panes.map((p) => [p.id, p]));
|
|
12325
|
+
const tabHtml = (id) => {
|
|
12326
|
+
if (id === 'board') {
|
|
12327
|
+
return tab(
|
|
12328
|
+
'board',
|
|
12329
|
+
'pinned',
|
|
12330
|
+
`${PANE_ICONS.board}<span class="pane-title">Board</span><span class="pin-meta">${currentTasks.length}</span>`,
|
|
12331
|
+
'Board',
|
|
12332
|
+
);
|
|
12333
|
+
}
|
|
12334
|
+
const p = byId.get(id);
|
|
12335
|
+
const frame = !paneFrameable(p) ? 'blocked' : paneFrames.has(`${sid}/${p.id}`) ? 'loaded' : '';
|
|
12336
|
+
const frameTip = frame === 'blocked' ? 'Site refuses framing' : frame ? 'Loaded' : 'Unloaded (loads on open)';
|
|
12010
12337
|
return tab(
|
|
12011
12338
|
p.id,
|
|
12012
12339
|
'',
|
|
12013
|
-
`${p.kind === 'url' ? PANE_ICONS.url : PANE_ICONS.file}<span class="pane-title">${escapeHtml(p.title)}</span><span class="state ${escapeHtml(
|
|
12340
|
+
`${p.kind === 'url' ? PANE_ICONS.url : PANE_ICONS.file}<span class="pane-title">${escapeHtml(p.title)}</span><span class="state ${escapeHtml(frame)}" title="${escapeHtml(frameTip)}"></span><button class="pane-x" data-close="${escapeHtml(p.id)}" title="Close pane (Alt+W)" aria-label="Close pane">×</button>`,
|
|
12014
12341
|
p.target,
|
|
12015
12342
|
);
|
|
12016
|
-
}
|
|
12017
|
-
const
|
|
12018
|
-
|
|
12343
|
+
};
|
|
12344
|
+
const itemHtml = ({ ids, pair }) =>
|
|
12345
|
+
pair
|
|
12346
|
+
? `<div class="pane-pair${pair === split ? ' on' : ''}">${ids.map(tabHtml).join('<span class="pane-pair-sep"></span>')}</div>`
|
|
12347
|
+
: tabHtml(ids[0]);
|
|
12348
|
+
const [pinned, ...rest] = paneStripItems(sid).map(itemHtml);
|
|
12349
|
+
const pin = pinned + (rest.length ? '<span class="pane-sep"></span>' : '');
|
|
12350
|
+
const html = rest.join('');
|
|
12019
12351
|
// syncPanes runs on every task refresh and search keystroke; skip the rebuild and the reflows.
|
|
12020
12352
|
if (pin + html === paneTabsHtml) return;
|
|
12021
12353
|
paneTabsHtml = pin + html;
|
|
@@ -12042,6 +12374,7 @@ function mountPane(sid, pane) {
|
|
|
12042
12374
|
if (existing) {
|
|
12043
12375
|
paneFrames.delete(key);
|
|
12044
12376
|
paneFrames.set(key, existing);
|
|
12377
|
+
if (stalePaneViews.delete(existing)) reloadPaneView(sid, pane, existing);
|
|
12045
12378
|
return key;
|
|
12046
12379
|
}
|
|
12047
12380
|
const view = document.createElement('div');
|
|
@@ -12058,7 +12391,7 @@ function mountPane(sid, pane) {
|
|
|
12058
12391
|
return key;
|
|
12059
12392
|
}
|
|
12060
12393
|
|
|
12061
|
-
async function loadPaneView(sid, pane, view) {
|
|
12394
|
+
async function loadPaneView(sid, pane, view, scroll) {
|
|
12062
12395
|
if (paneOwnOrigin(pane)) {
|
|
12063
12396
|
view.insertAdjacentHTML(
|
|
12064
12397
|
'beforeend',
|
|
@@ -12099,7 +12432,11 @@ async function loadPaneView(sid, pane, view) {
|
|
|
12099
12432
|
if (r.ok) data = await r.json();
|
|
12100
12433
|
} catch {}
|
|
12101
12434
|
if (data?.kind === 'html') {
|
|
12102
|
-
|
|
12435
|
+
// The frame's origin is opaque, so the page restores its own scroll on load.
|
|
12436
|
+
const restore = scroll
|
|
12437
|
+
? `<script>addEventListener('load',()=>requestAnimationFrame(()=>scrollTo(${Number(scroll.x) || 0},${Number(scroll.y) || 0})))</script>`
|
|
12438
|
+
: '';
|
|
12439
|
+
const frame = createPreviewFrame('pane-frame modal-zoomable', data.content + REVIEW_BRIDGE_TAG + restore);
|
|
12103
12440
|
frame.title = pane.title;
|
|
12104
12441
|
view.appendChild(frame);
|
|
12105
12442
|
attachPaneReview(view, sid, fileReviewOpts(data.path, data.kind, frame, frame));
|
|
@@ -12126,6 +12463,58 @@ async function loadPaneView(sid, pane, view) {
|
|
|
12126
12463
|
view.insertAdjacentHTML('beforeend', paneCardHtml(title, text, button));
|
|
12127
12464
|
}
|
|
12128
12465
|
|
|
12466
|
+
function onPaneFileChanged(filePath) {
|
|
12467
|
+
for (const [key, view] of paneFrames) {
|
|
12468
|
+
const [sid, paneId] = key.split('/');
|
|
12469
|
+
const pane = paneLayout(sid).panes.find((p) => p.id === paneId);
|
|
12470
|
+
if (pane?.target !== filePath) continue;
|
|
12471
|
+
if (view.classList.contains('on')) reloadPaneView(sid, pane, view);
|
|
12472
|
+
else stalePaneViews.add(view);
|
|
12473
|
+
}
|
|
12474
|
+
}
|
|
12475
|
+
|
|
12476
|
+
// Builds the new view hidden over the old one and swaps them once it has loaded and scrolled,
|
|
12477
|
+
// so a reload shows no blank frame and no jump. The newest reload of a view wins.
|
|
12478
|
+
async function reloadPaneView(sid, pane, view) {
|
|
12479
|
+
const key = view.dataset.key;
|
|
12480
|
+
const token = {};
|
|
12481
|
+
paneReloads.set(key, token);
|
|
12482
|
+
const live = () => {
|
|
12483
|
+
const ok = paneReloads.get(key) === token && paneFrames.get(key) === view;
|
|
12484
|
+
if (!ok && paneReloads.get(key) === token) paneReloads.delete(key);
|
|
12485
|
+
return ok;
|
|
12486
|
+
};
|
|
12487
|
+
const frame = view.querySelector(':scope > .pane-frame');
|
|
12488
|
+
const fresh = document.createElement('div');
|
|
12489
|
+
fresh.dataset.key = key;
|
|
12490
|
+
await loadPaneView(sid, pane, fresh, frame && paneFrameScroll.get(frame));
|
|
12491
|
+
if (!live()) return;
|
|
12492
|
+
fresh.className = view.className;
|
|
12493
|
+
fresh.style.visibility = 'hidden';
|
|
12494
|
+
view.after(fresh);
|
|
12495
|
+
const newBody = fresh.querySelector('.pane-doc-body');
|
|
12496
|
+
if (newBody) newBody.scrollTop = view.querySelector('.pane-doc-body')?.scrollTop || 0;
|
|
12497
|
+
const newFrame = fresh.querySelector(':scope > .pane-frame');
|
|
12498
|
+
if (newFrame) {
|
|
12499
|
+
await new Promise((resolve) => {
|
|
12500
|
+
newFrame.addEventListener('load', resolve, { once: true });
|
|
12501
|
+
setTimeout(resolve, 3000);
|
|
12502
|
+
});
|
|
12503
|
+
// The page scrolls itself one frame after its load.
|
|
12504
|
+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
12505
|
+
}
|
|
12506
|
+
if (!live()) {
|
|
12507
|
+
fresh.remove();
|
|
12508
|
+
return;
|
|
12509
|
+
}
|
|
12510
|
+
paneReloads.delete(key);
|
|
12511
|
+
fresh.className = view.className;
|
|
12512
|
+
fresh.style.visibility = '';
|
|
12513
|
+
view.remove();
|
|
12514
|
+
paneFrames.set(key, fresh);
|
|
12515
|
+
syncPaneReview();
|
|
12516
|
+
}
|
|
12517
|
+
|
|
12129
12518
|
// One reading width for every pane, kept apart from the modals': a modal's saved size
|
|
12130
12519
|
// includes its padding and review panel.
|
|
12131
12520
|
const PANE_DOC_W_KEY = 'pane-doc-width';
|
|
@@ -12163,7 +12552,7 @@ function paneDocGrip(doc) {
|
|
|
12163
12552
|
}
|
|
12164
12553
|
|
|
12165
12554
|
function activePaneView() {
|
|
12166
|
-
return wantsTerminal() ? null : paneViews.querySelector('.pane-view.on');
|
|
12555
|
+
return wantsTerminal() ? null : paneViews.querySelector('.pane-view.on.pane-focus');
|
|
12167
12556
|
}
|
|
12168
12557
|
|
|
12169
12558
|
function isPaneDocOnScreen() {
|
|
@@ -12199,20 +12588,55 @@ function selectPane(id) {
|
|
|
12199
12588
|
syncPanes();
|
|
12200
12589
|
}
|
|
12201
12590
|
|
|
12202
|
-
//
|
|
12591
|
+
// Flips the focused side of the split through the free tabs. Returns false with no split on
|
|
12592
|
+
// screen, so the key keeps its other meaning.
|
|
12203
12593
|
function cyclePane(step) {
|
|
12594
|
+
const sid = paneSessionId();
|
|
12595
|
+
const split = sid && !wantsTerminal() && getPaneSplit(sid);
|
|
12596
|
+
if (!split) return false;
|
|
12597
|
+
const active = getActivePaneId(sid);
|
|
12598
|
+
const pairs = getPanePairs(sid);
|
|
12599
|
+
const ids = freePaneTabs(sid, active, pairs);
|
|
12600
|
+
if (ids.length < 2) return true;
|
|
12601
|
+
const next = ids[(ids.indexOf(active) + step + ids.length) % ids.length];
|
|
12602
|
+
const side = splitSideOf(split, active);
|
|
12603
|
+
setPanePairs(
|
|
12604
|
+
sid,
|
|
12605
|
+
pairs.map((p) => (p === split ? { ...p, [side]: next } : p)),
|
|
12606
|
+
);
|
|
12607
|
+
selectPane(next);
|
|
12608
|
+
return true;
|
|
12609
|
+
}
|
|
12610
|
+
|
|
12611
|
+
function cyclePaneStrip(step) {
|
|
12204
12612
|
const sid = paneSessionId();
|
|
12205
12613
|
if (!sid || wantsTerminal()) return false;
|
|
12206
|
-
const
|
|
12207
|
-
const
|
|
12208
|
-
|
|
12614
|
+
const active = getActivePaneId(sid);
|
|
12615
|
+
const items = paneStripItems(sid);
|
|
12616
|
+
const i = items.findIndex((it) => it.ids.includes(active));
|
|
12617
|
+
const { ids, pair } = items[(i + step + items.length) % items.length];
|
|
12618
|
+
selectPane(pair ? pair[pair.f || 'l'] : ids[0]);
|
|
12619
|
+
return true;
|
|
12620
|
+
}
|
|
12621
|
+
|
|
12622
|
+
function closeFocusedPane() {
|
|
12623
|
+
const sid = paneSessionId();
|
|
12624
|
+
const id = sid && !wantsTerminal() && getActivePaneId(sid);
|
|
12625
|
+
if (!id || id === 'board') return false;
|
|
12626
|
+
closePane(id);
|
|
12209
12627
|
return true;
|
|
12210
12628
|
}
|
|
12211
12629
|
|
|
12212
12630
|
async function closePane(paneId) {
|
|
12213
12631
|
const sid = paneSessionId();
|
|
12214
12632
|
if (!sid) return;
|
|
12215
|
-
|
|
12633
|
+
const split = getPaneSplit(sid);
|
|
12634
|
+
const side = split && splitSideOf(split, paneId);
|
|
12635
|
+
// The layout keeps the pane until the server answers, so its pair goes now.
|
|
12636
|
+
setPanePairs(sid, withoutTabs(getPanePairs(sid), paneId));
|
|
12637
|
+
if (side) {
|
|
12638
|
+
setActivePaneId(sid, split[otherSide(side)]);
|
|
12639
|
+
} else if (getActivePaneId(sid) === paneId) {
|
|
12216
12640
|
const panes = paneLayout(sid).panes;
|
|
12217
12641
|
const i = panes.findIndex((p) => p.id === paneId);
|
|
12218
12642
|
setActivePaneId(sid, (panes[i + 1] || panes[i - 1])?.id || 'board');
|
|
@@ -12235,7 +12659,13 @@ function paneById(id) {
|
|
|
12235
12659
|
function reloadPane(id) {
|
|
12236
12660
|
const pane = paneById(id);
|
|
12237
12661
|
if (!pane) return;
|
|
12238
|
-
|
|
12662
|
+
const sid = paneSessionId();
|
|
12663
|
+
const view = paneFrames.get(`${sid}/${pane.id}`);
|
|
12664
|
+
if (pane.kind !== 'url' && view) {
|
|
12665
|
+
reloadPaneView(sid, pane, view);
|
|
12666
|
+
return;
|
|
12667
|
+
}
|
|
12668
|
+
unmountPane(sid, pane.id);
|
|
12239
12669
|
paneRefused.delete(pane.target);
|
|
12240
12670
|
syncPanes();
|
|
12241
12671
|
}
|
|
@@ -12468,8 +12898,130 @@ document.addEventListener('click', (e) => {
|
|
|
12468
12898
|
if (act) PANE_MENU_ACTIONS[act](menu.dataset.pane);
|
|
12469
12899
|
if (act || !menu.contains(e.target)) closePaneMenu();
|
|
12470
12900
|
});
|
|
12901
|
+
function focusSideOf(el) {
|
|
12902
|
+
if (el?.classList.contains('side-l')) focusPaneSide('l');
|
|
12903
|
+
else if (el?.classList.contains('side-r')) focusPaneSide('r');
|
|
12904
|
+
}
|
|
12905
|
+
|
|
12471
12906
|
// A click inside a pane's frame never reaches this document, but it takes focus from the window.
|
|
12472
|
-
|
|
12907
|
+
// That blur is also the only sign that a click in a split moved into a pane's frame.
|
|
12908
|
+
window.addEventListener('blur', () => {
|
|
12909
|
+
closePaneMenu();
|
|
12910
|
+
setTimeout(() => focusSideOf(document.activeElement?.closest?.('.pane-view.on')));
|
|
12911
|
+
});
|
|
12912
|
+
paneSplitBox.addEventListener(
|
|
12913
|
+
'pointerdown',
|
|
12914
|
+
(e) => focusSideOf(paneLog.contains(e.target) ? paneBoard : e.target.closest('.side-l, .side-r')),
|
|
12915
|
+
true,
|
|
12916
|
+
);
|
|
12917
|
+
|
|
12918
|
+
function endPaneDrag() {
|
|
12919
|
+
if (!paneDragId) return;
|
|
12920
|
+
paneDragId = null;
|
|
12921
|
+
paneSplitBox.classList.remove('dropping');
|
|
12922
|
+
paneStrip.classList.remove('drop-hot');
|
|
12923
|
+
for (const el of paneDrop.children) el.classList.remove('hot');
|
|
12924
|
+
paneStrip.querySelector('.pane-tab.dragging')?.classList.remove('dragging');
|
|
12925
|
+
syncPanes();
|
|
12926
|
+
}
|
|
12927
|
+
|
|
12928
|
+
function draggedPair() {
|
|
12929
|
+
const sid = paneSessionId();
|
|
12930
|
+
return (sid && paneDragId && pairOf(sid, paneDragId)) || null;
|
|
12931
|
+
}
|
|
12932
|
+
|
|
12933
|
+
paneStrip.addEventListener('dragstart', (e) => {
|
|
12934
|
+
const tab = e.target.closest('.pane-tab');
|
|
12935
|
+
if (!tab || !paneSessionId() || wantsTerminal()) return;
|
|
12936
|
+
paneDragId = tab.dataset.pane;
|
|
12937
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
12938
|
+
e.dataTransfer.setData('text/plain', paneDragId);
|
|
12939
|
+
tab.classList.add('dragging');
|
|
12940
|
+
paneSplitBox.classList.add('dropping');
|
|
12941
|
+
});
|
|
12942
|
+
paneStrip.addEventListener('dragend', endPaneDrag);
|
|
12943
|
+
paneStrip.addEventListener('dragover', (e) => {
|
|
12944
|
+
if (!draggedPair()) return;
|
|
12945
|
+
e.preventDefault();
|
|
12946
|
+
paneStrip.classList.add('drop-hot');
|
|
12947
|
+
});
|
|
12948
|
+
paneStrip.addEventListener('dragleave', (e) => {
|
|
12949
|
+
if (!paneStrip.contains(e.relatedTarget)) paneStrip.classList.remove('drop-hot');
|
|
12950
|
+
});
|
|
12951
|
+
paneStrip.addEventListener('drop', (e) => {
|
|
12952
|
+
const pair = draggedPair();
|
|
12953
|
+
if (!pair) return;
|
|
12954
|
+
e.preventDefault();
|
|
12955
|
+
const sid = paneSessionId();
|
|
12956
|
+
const shown = !!splitSideOf(pair, getActivePaneId(sid));
|
|
12957
|
+
const keep = shown ? pair[otherSide(splitSideOf(pair, paneDragId))] : null;
|
|
12958
|
+
endPaneDrag();
|
|
12959
|
+
unpairPanes(sid, pair, keep);
|
|
12960
|
+
});
|
|
12961
|
+
paneDrop.addEventListener('dragover', (e) => {
|
|
12962
|
+
if (!paneDragId) return;
|
|
12963
|
+
e.preventDefault();
|
|
12964
|
+
const half = e.target.closest('[data-side]');
|
|
12965
|
+
for (const el of paneDrop.children) el.classList.toggle('hot', el === half);
|
|
12966
|
+
});
|
|
12967
|
+
paneDrop.addEventListener('dragleave', (e) => {
|
|
12968
|
+
if (!paneDrop.contains(e.relatedTarget)) for (const el of paneDrop.children) el.classList.remove('hot');
|
|
12969
|
+
});
|
|
12970
|
+
paneDrop.addEventListener('drop', (e) => {
|
|
12971
|
+
e.preventDefault();
|
|
12972
|
+
const id = paneDragId;
|
|
12973
|
+
const side = e.target.closest('[data-side]')?.dataset.side;
|
|
12974
|
+
endPaneDrag();
|
|
12975
|
+
if (id && side) dropPaneOnSide(id, side);
|
|
12976
|
+
});
|
|
12977
|
+
|
|
12978
|
+
// syncPanes calls this on every refresh; an unchanged value is not written again.
|
|
12979
|
+
function applySplitRatio() {
|
|
12980
|
+
const value = `${paneSplitRatio * 100}%`;
|
|
12981
|
+
if (paneSplitBox.style.getPropertyValue('--pane-split') !== value)
|
|
12982
|
+
paneSplitBox.style.setProperty('--pane-split', value);
|
|
12983
|
+
}
|
|
12984
|
+
|
|
12985
|
+
{
|
|
12986
|
+
let start;
|
|
12987
|
+
let width;
|
|
12988
|
+
let collapse = null;
|
|
12989
|
+
_initDragResize(paneDivider, {
|
|
12990
|
+
onStart() {
|
|
12991
|
+
start = paneSplitRatio;
|
|
12992
|
+
width = paneSplitBox.clientWidth;
|
|
12993
|
+
paneSplitBox.classList.add('resizing');
|
|
12994
|
+
},
|
|
12995
|
+
onMove(dx) {
|
|
12996
|
+
const raw = start + dx / width;
|
|
12997
|
+
collapse = raw < 0.1 ? 'l' : raw > 0.9 ? 'r' : null;
|
|
12998
|
+
paneSplitBox.classList.toggle('collapse-l', collapse === 'l');
|
|
12999
|
+
paneSplitBox.classList.toggle('collapse-r', collapse === 'r');
|
|
13000
|
+
paneSplitRatio = clampSplitRatio(raw);
|
|
13001
|
+
applySplitRatio();
|
|
13002
|
+
},
|
|
13003
|
+
onEnd() {
|
|
13004
|
+
paneSplitBox.classList.remove('resizing', 'collapse-l', 'collapse-r');
|
|
13005
|
+
const hidden = collapse;
|
|
13006
|
+
collapse = null;
|
|
13007
|
+
const sid = paneSessionId();
|
|
13008
|
+
const split = hidden && sid && getPaneSplit(sid);
|
|
13009
|
+
if (!split) {
|
|
13010
|
+
store.setItem(PANE_SPLIT_RATIO_KEY, String(paneSplitRatio));
|
|
13011
|
+
return;
|
|
13012
|
+
}
|
|
13013
|
+
paneSplitRatio = start;
|
|
13014
|
+
applySplitRatio();
|
|
13015
|
+
unpairPanes(sid, split, split[otherSide(hidden)]);
|
|
13016
|
+
},
|
|
13017
|
+
});
|
|
13018
|
+
paneDivider.addEventListener('dblclick', () => {
|
|
13019
|
+
paneSplitRatio = 0.5;
|
|
13020
|
+
store.removeItem(PANE_SPLIT_RATIO_KEY);
|
|
13021
|
+
applySplitRatio();
|
|
13022
|
+
});
|
|
13023
|
+
}
|
|
13024
|
+
|
|
12473
13025
|
paneViews.addEventListener('click', (e) => {
|
|
12474
13026
|
if (e.target.closest('[data-pane-open]')) openPaneExternally();
|
|
12475
13027
|
});
|
|
@@ -12487,6 +13039,7 @@ paneTabs.addEventListener(
|
|
|
12487
13039
|
);
|
|
12488
13040
|
new ResizeObserver(updatePaneFades).observe(paneTabs);
|
|
12489
13041
|
document.getElementById('pane-add').addEventListener('click', openPanePop);
|
|
13042
|
+
paneSplitBtn.addEventListener('click', togglePaneSplit);
|
|
12490
13043
|
document.getElementById('pane-reload').addEventListener('click', () => reloadPane());
|
|
12491
13044
|
document.getElementById('pane-open').addEventListener('click', () => openPaneExternally());
|
|
12492
13045
|
panePopDocs.addEventListener('click', (e) => {
|
|
@@ -12986,7 +13539,7 @@ function keyClaims() {
|
|
|
12986
13539
|
}
|
|
12987
13540
|
|
|
12988
13541
|
function bridgeClaims(claims) {
|
|
12989
|
-
return { ...claims, keys: [...claims.keys, ...ZOOM_CLAIMS] };
|
|
13542
|
+
return { ...claims, keys: [...claims.keys, ...ZOOM_CLAIMS, ...PANE_CLAIMS] };
|
|
12990
13543
|
}
|
|
12991
13544
|
|
|
12992
13545
|
function pushTerminalClaims() {
|