claude-code-kanban 7.1.2 → 7.3.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 +1 -0
- package/lib/panes.js +9 -1
- package/lib/terminal.js +54 -14
- package/package.json +1 -1
- package/public/app.js +773 -88
- package/public/index.html +18 -8
- package/public/style.css +300 -10
- package/public/terminal-frame.js +6 -4
- package/public/vendor/claude-hub-sdk.js +40 -6
- 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');
|
|
@@ -2472,8 +2494,13 @@ function clampModalZoom(v) {
|
|
|
2472
2494
|
return Math.min(MODAL_ZOOM_MAX, Math.max(MODAL_ZOOM_MIN, v));
|
|
2473
2495
|
}
|
|
2474
2496
|
|
|
2497
|
+
function modalZoomLabel() {
|
|
2498
|
+
return `${Math.round(modalZoom * 100)}%`;
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2475
2501
|
function applyModalZoom() {
|
|
2476
2502
|
document.documentElement.style.setProperty('--modal-zoom', String(modalZoom));
|
|
2503
|
+
for (const el of document.querySelectorAll('.pane-zoom-val')) el.textContent = modalZoomLabel();
|
|
2477
2504
|
}
|
|
2478
2505
|
|
|
2479
2506
|
// delta 0 resets to 100%
|
|
@@ -2483,7 +2510,7 @@ function adjustModalZoom(delta) {
|
|
|
2483
2510
|
modalZoom = next;
|
|
2484
2511
|
store.setItem(MODAL_ZOOM_KEY, String(modalZoom));
|
|
2485
2512
|
applyModalZoom();
|
|
2486
|
-
showToast(`Text ${
|
|
2513
|
+
showToast(`Text ${modalZoomLabel()}`);
|
|
2487
2514
|
}
|
|
2488
2515
|
|
|
2489
2516
|
// The markup decides what scales: `.modal-zoomable` marks a modal's reading
|
|
@@ -3131,10 +3158,6 @@ function renderAgentFooter() {
|
|
|
3131
3158
|
footer.classList.add('visible');
|
|
3132
3159
|
label.textContent = `Agents Log (${visible.length})`;
|
|
3133
3160
|
|
|
3134
|
-
const collapsed = store.getItem('agentFooterCollapsed') === 'true';
|
|
3135
|
-
footer.classList.toggle('collapsed', collapsed);
|
|
3136
|
-
document.getElementById('agent-footer-toggle').innerHTML = collapsed ? '▴' : '▾';
|
|
3137
|
-
|
|
3138
3161
|
const permHtml = permFresh
|
|
3139
3162
|
? `<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
3163
|
: '';
|
|
@@ -3206,9 +3229,24 @@ function renderAgentFooter() {
|
|
|
3206
3229
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
3207
3230
|
function toggleAgentFooter() {
|
|
3208
3231
|
const footer = document.getElementById('agent-footer');
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3232
|
+
store.setItem(agentFooterCollapseKey(), !footer.classList.contains('collapsed'));
|
|
3233
|
+
applyAgentFooterCollapsed();
|
|
3234
|
+
}
|
|
3235
|
+
|
|
3236
|
+
function boardInSplit() {
|
|
3237
|
+
const board = document.getElementById('board-side');
|
|
3238
|
+
return board.classList.contains('side-l') || board.classList.contains('side-r');
|
|
3239
|
+
}
|
|
3240
|
+
|
|
3241
|
+
// With the board in a split, the agents log keeps its own collapse preference and starts collapsed.
|
|
3242
|
+
function agentFooterCollapseKey() {
|
|
3243
|
+
return boardInSplit() ? 'agentFooterCollapsedSplit' : 'agentFooterCollapsed';
|
|
3244
|
+
}
|
|
3245
|
+
|
|
3246
|
+
function applyAgentFooterCollapsed() {
|
|
3247
|
+
const saved = store.getItem(agentFooterCollapseKey());
|
|
3248
|
+
const collapsed = saved === null ? boardInSplit() : saved === 'true';
|
|
3249
|
+
document.getElementById('agent-footer').classList.toggle('collapsed', collapsed);
|
|
3212
3250
|
document.getElementById('agent-footer-toggle').innerHTML = collapsed ? '▴' : '▾';
|
|
3213
3251
|
}
|
|
3214
3252
|
|
|
@@ -3649,7 +3687,7 @@ function renderSessions() {
|
|
|
3649
3687
|
${renderWorkflowBadge(session)}
|
|
3650
3688
|
${renderLoopBadge(session)}
|
|
3651
3689
|
${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' : ''}"
|
|
3690
|
+
${bookmarksCount > 0 ? `<span class="bookmarks-badge" onclick="event.stopPropagation(); openSessionWithBookmarks('${sid}')" title="${bookmarksCount} bookmarked message${bookmarksCount > 1 ? 's' : ''}">${BOOKMARK_SVG}${bookmarksCount}</span>` : ''}
|
|
3653
3691
|
${linkedDocsCount > 0 ? `<span class="linked-docs-badge" onclick="event.stopPropagation(); showSessionInfoModal('${sid}')" title="${linkedDocsCount} linked document${linkedDocsCount > 1 ? 's' : ''}">${linkSvg(10)}${linkedDocsCount}</span>` : ''}
|
|
3654
3692
|
${session.hasPlan && !session.planSourceSessionId ? `<span class="plan-indicator" onclick="event.stopPropagation(); openPlanForSession('${sid}')" title="View plan">${ICON_PLAN}</span>` : ''}
|
|
3655
3693
|
${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 +3712,7 @@ function renderSessions() {
|
|
|
3674
3712
|
${renderScratchpadRow(zenSession)}
|
|
3675
3713
|
${renderLinkedDocsHtml(zenSession.id)}
|
|
3676
3714
|
${renderArtifactsHtml(zenSession.id)}
|
|
3715
|
+
${renderZenBookmarksHtml(zenSession.id)}
|
|
3677
3716
|
</div>
|
|
3678
3717
|
${zenSession.hasWorkflow ? renderWorkflowLiveHtml(zenSession.id) : ''}`;
|
|
3679
3718
|
bindLinkedDocsHandlers(sessionsList.querySelector('.linked-docs-section'), zenSession.id);
|
|
@@ -5387,6 +5426,8 @@ function setFocusZone(zone, target) {
|
|
|
5387
5426
|
|
|
5388
5427
|
focusZone = zone;
|
|
5389
5428
|
if (zone === 'sidebar') {
|
|
5429
|
+
const active = document.activeElement;
|
|
5430
|
+
if (active && document.getElementById('main-content').contains(active)) active.blur();
|
|
5390
5431
|
expandSidebar();
|
|
5391
5432
|
const items = getNavigableItems();
|
|
5392
5433
|
if (items.length > 0) {
|
|
@@ -5418,6 +5459,7 @@ function setFocusZone(zone, target) {
|
|
|
5418
5459
|
if (card) card.classList.add('selected');
|
|
5419
5460
|
} else {
|
|
5420
5461
|
navigateVertical(1);
|
|
5462
|
+
if (!selectedTaskId) document.querySelector('.column-add')?.focus();
|
|
5421
5463
|
}
|
|
5422
5464
|
if (selectedTaskId && detailPanel.classList.contains('visible')) {
|
|
5423
5465
|
showTaskDetail(selectedTaskId, selectedSessionId);
|
|
@@ -5834,7 +5876,11 @@ const SHORTCUT_TABS = [
|
|
|
5834
5876
|
title: 'View',
|
|
5835
5877
|
rows: [
|
|
5836
5878
|
{ keys: ['\\'], label: 'Toggle sidebar' },
|
|
5837
|
-
{ keys: ['[', ']'], label: 'Previous / next tab (
|
|
5879
|
+
{ keys: ['[', ']'], label: 'Previous / next tab (a split counts as one)' },
|
|
5880
|
+
{ keys: ['Alt', '[ / ]'], combo: true, label: 'Change the tab in the focused side of a split' },
|
|
5881
|
+
{ keys: ['|'], label: 'Split with the tab before it / unsplit (or drag a tab onto a side)' },
|
|
5882
|
+
{ keys: ['{', '}'], label: 'Focus left / right side of a split' },
|
|
5883
|
+
{ keys: ['Alt', 'W'], combo: true, label: 'Close the focused tab' },
|
|
5838
5884
|
{ keys: ['T'], label: 'Toggle theme' },
|
|
5839
5885
|
{ keys: ['Shift', 'S'], combo: true, label: 'Storage manager' },
|
|
5840
5886
|
{ keys: ['Ctrl', 'Shift', 'Z'], combo: true, label: 'Zen mode (current session only)' },
|
|
@@ -5876,9 +5922,9 @@ const SHORTCUT_TABS = [
|
|
|
5876
5922
|
{
|
|
5877
5923
|
title: 'Sessions',
|
|
5878
5924
|
rows: [
|
|
5879
|
-
{ keys: ['Ctrl', 'Alt', 'N'], combo: true, label: 'New session' },
|
|
5880
|
-
{ keys: ['Ctrl', 'Alt', 'R'], combo: true, label: 'Resume session (claude -r)' },
|
|
5881
|
-
{ keys: ['Ctrl', 'Alt', 'S'], combo: true, label: 'Swap to previous session' },
|
|
5925
|
+
{ keys: ['Ctrl', 'Alt', 'N'], combo: true, hubMod: true, label: 'New session' },
|
|
5926
|
+
{ keys: ['Ctrl', 'Alt', 'R'], combo: true, hubMod: true, label: 'Resume session (claude -r)' },
|
|
5927
|
+
{ keys: ['Ctrl', 'Alt', 'S'], combo: true, hubMod: true, label: 'Swap to previous session' },
|
|
5882
5928
|
],
|
|
5883
5929
|
},
|
|
5884
5930
|
],
|
|
@@ -5908,17 +5954,57 @@ const SHORTCUT_TABS = [
|
|
|
5908
5954
|
{ keys: ['M'], label: 'Jump to marketplace' },
|
|
5909
5955
|
{ keys: ['$'], label: 'Jump to cost' },
|
|
5910
5956
|
{ keys: ['Ctrl', 'M'], combo: true, label: 'Jump to memory' },
|
|
5911
|
-
{
|
|
5912
|
-
|
|
5913
|
-
|
|
5914
|
-
|
|
5915
|
-
|
|
5957
|
+
{
|
|
5958
|
+
keys: ['Ctrl', 'Alt', '←/→'],
|
|
5959
|
+
action: ['hub.prevApp', 'hub.nextApp'],
|
|
5960
|
+
combo: true,
|
|
5961
|
+
hubMod: true,
|
|
5962
|
+
label: 'Previous / next hub app',
|
|
5963
|
+
},
|
|
5964
|
+
{
|
|
5965
|
+
keys: ['Alt', '1…9'],
|
|
5966
|
+
action: 'hub.appByNumber',
|
|
5967
|
+
combo: true,
|
|
5968
|
+
hubMod: true,
|
|
5969
|
+
label: 'Jump to hub app by number',
|
|
5970
|
+
},
|
|
5971
|
+
{
|
|
5972
|
+
keys: ['Ctrl', 'Alt', 'P'],
|
|
5973
|
+
action: 'hub.projectPicker',
|
|
5974
|
+
combo: true,
|
|
5975
|
+
hubMod: true,
|
|
5976
|
+
label: 'Project picker',
|
|
5977
|
+
},
|
|
5978
|
+
{
|
|
5979
|
+
keys: ['Ctrl', 'Alt', 'W'],
|
|
5980
|
+
action: 'hub.configDirPicker',
|
|
5981
|
+
combo: true,
|
|
5982
|
+
hubMod: true,
|
|
5983
|
+
label: 'Config dir picker',
|
|
5984
|
+
},
|
|
5985
|
+
{ keys: ['Ctrl', 'Alt', 'A'], action: 'hub.appLauncher', combo: true, hubMod: true, label: 'App launcher' },
|
|
5916
5986
|
],
|
|
5917
5987
|
},
|
|
5918
5988
|
],
|
|
5919
5989
|
},
|
|
5920
5990
|
];
|
|
5921
5991
|
|
|
5992
|
+
// macOS names Control, Option and Shift by their symbols.
|
|
5993
|
+
const IS_MAC = /^Mac/i.test(navigator.userAgentData?.platform || navigator.platform || '');
|
|
5994
|
+
const MAC_KEYS = { Ctrl: '⌃', Alt: '⌥', Shift: '⇧' };
|
|
5995
|
+
if (IS_MAC) document.getElementById('new-session-btn')?.setAttribute('title', 'New session (⌃⌥N)');
|
|
5996
|
+
|
|
5997
|
+
// The keys of a help row as they read on this system. The hub's modifier is Ctrl+Alt, and on
|
|
5998
|
+
// macOS Control+Option for the tab numbers too (Windows and Linux use bare Alt for those). A hub
|
|
5999
|
+
// row shows the keys the hub reports, which the user can change; row.keys until it answers.
|
|
6000
|
+
function helpKeys(row, mac = IS_MAC, keyLabel = hub.keyLabel) {
|
|
6001
|
+
const live = row.action && keyLabel(row.action);
|
|
6002
|
+
if (live) return live;
|
|
6003
|
+
if (!mac) return row.keys;
|
|
6004
|
+
if (row.hubMod) return ['⌃', '⌥', ...row.keys.filter((k) => k !== 'Ctrl' && k !== 'Alt')];
|
|
6005
|
+
return row.keys.map((k) => MAC_KEYS[k] || k);
|
|
6006
|
+
}
|
|
6007
|
+
|
|
5922
6008
|
// Interleaves each pair's rows left-then-right so CSS grid auto-placement lands
|
|
5923
6009
|
// them on shared row tracks (see .shortcuts in style.css).
|
|
5924
6010
|
function buildHelpShortcuts(groups) {
|
|
@@ -5934,9 +6020,10 @@ function buildHelpShortcuts(groups) {
|
|
|
5934
6020
|
for (let i = 0; i < Math.max(...sides.map(([g]) => g.rows.length)); i++) {
|
|
5935
6021
|
for (const [group, side] of sides) {
|
|
5936
6022
|
const row = group.rows[i];
|
|
5937
|
-
|
|
6023
|
+
const parts = row && helpKeys(row);
|
|
6024
|
+
if (!parts?.length) continue;
|
|
5938
6025
|
const sep = row.combo ? '<span class="sc-plus">+</span>' : '<span class="sc-or">/</span>';
|
|
5939
|
-
const keys =
|
|
6026
|
+
const keys = parts.map((k) => `<kbd>${escapeHtml(k)}</kbd>`).join(sep);
|
|
5940
6027
|
const label = escapeHtml(row.label);
|
|
5941
6028
|
cells.push(`<dt class="${escapeHtml(side)}">${keys}</dt><dd class="${escapeHtml(side)}">${label}</dd>`);
|
|
5942
6029
|
}
|
|
@@ -5955,7 +6042,10 @@ const helpTabs = () => SHORTCUT_TABS.filter((t) => hub.inHub || !t.hub);
|
|
|
5955
6042
|
function filteredShortcutGroups(query) {
|
|
5956
6043
|
return helpTabs()
|
|
5957
6044
|
.flatMap((t) => t.groups)
|
|
5958
|
-
.map((g) => ({
|
|
6045
|
+
.map((g) => ({
|
|
6046
|
+
title: g.title,
|
|
6047
|
+
rows: g.rows.filter((r) => fuzzyMatch(`${r.label} ${helpKeys(r).join(' ')}`, query)),
|
|
6048
|
+
}))
|
|
5959
6049
|
.filter((g) => g.rows.length);
|
|
5960
6050
|
}
|
|
5961
6051
|
|
|
@@ -7005,7 +7095,25 @@ document.addEventListener('keydown', (e) => {
|
|
|
7005
7095
|
toggleSidebar();
|
|
7006
7096
|
return;
|
|
7007
7097
|
}
|
|
7008
|
-
if (matchKey(e, '[', ']') &&
|
|
7098
|
+
if (matchKey(e, '[', ']') && cyclePaneStrip(e.key === ']' ? 1 : -1)) {
|
|
7099
|
+
e.preventDefault();
|
|
7100
|
+
return;
|
|
7101
|
+
}
|
|
7102
|
+
const altOnly = e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey;
|
|
7103
|
+
if (altOnly && (e.code === 'BracketLeft' || e.code === 'BracketRight')) {
|
|
7104
|
+
if (cyclePane(e.code === 'BracketRight' ? 1 : -1)) e.preventDefault();
|
|
7105
|
+
return;
|
|
7106
|
+
}
|
|
7107
|
+
if (altOnly && e.code === 'KeyW' && closeFocusedPane()) {
|
|
7108
|
+
e.preventDefault();
|
|
7109
|
+
return;
|
|
7110
|
+
}
|
|
7111
|
+
const plainShift = !e.ctrlKey && !e.altKey && !e.metaKey;
|
|
7112
|
+
if (plainShift && e.key === '|' && togglePaneSplit()) {
|
|
7113
|
+
e.preventDefault();
|
|
7114
|
+
return;
|
|
7115
|
+
}
|
|
7116
|
+
if (plainShift && (e.key === '{' || e.key === '}') && focusPaneSide(e.key === '{' ? 'l' : 'r')) {
|
|
7009
7117
|
e.preventDefault();
|
|
7010
7118
|
return;
|
|
7011
7119
|
}
|
|
@@ -7062,10 +7170,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
7062
7170
|
else leaveTerminalPane();
|
|
7063
7171
|
return;
|
|
7064
7172
|
}
|
|
7065
|
-
if (focusZone === 'sidebar')
|
|
7066
|
-
const hasCards = document.querySelector('.task-card');
|
|
7067
|
-
if (!hasCards) return;
|
|
7068
|
-
}
|
|
7173
|
+
if (focusZone === 'sidebar' && !document.querySelector('.task-card, .column-add')) return;
|
|
7069
7174
|
setFocusZone(focusZone === 'board' ? 'sidebar' : 'board');
|
|
7070
7175
|
return;
|
|
7071
7176
|
}
|
|
@@ -7102,12 +7207,6 @@ document.addEventListener('keydown', (e) => {
|
|
|
7102
7207
|
e.preventDefault();
|
|
7103
7208
|
return;
|
|
7104
7209
|
}
|
|
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
7210
|
}
|
|
7112
7211
|
|
|
7113
7212
|
// Board navigation
|
|
@@ -7156,7 +7255,11 @@ document.addEventListener('keydown', (e) => {
|
|
|
7156
7255
|
if (detailPanel.classList.contains('visible')) closeDetailPanel();
|
|
7157
7256
|
else if (agentLogMode) exitAgentLogMode();
|
|
7158
7257
|
else if (messagePanelOpen) toggleMessagePanel();
|
|
7159
|
-
else {
|
|
7258
|
+
else if (focusZone === 'sidebar') {
|
|
7259
|
+
// Plain unfocus — drop the keyboard cursor without jumping into the board
|
|
7260
|
+
clearKbSelection();
|
|
7261
|
+
focusZone = 'board';
|
|
7262
|
+
} else {
|
|
7160
7263
|
// Nothing open — plain unfocus: drop the task-card selection highlight
|
|
7161
7264
|
clearTaskSelection();
|
|
7162
7265
|
selectedTaskId = null;
|
|
@@ -7576,9 +7679,10 @@ function reviewBridge(textBefore, headingBefore, rangeAt, comboOf, fieldSelector
|
|
|
7576
7679
|
const mods = [e.ctrlKey && 'ctrl', e.altKey && 'alt', e.shiftKey && 'shift', e.metaKey && 'meta']
|
|
7577
7680
|
.filter(Boolean)
|
|
7578
7681
|
.join('+');
|
|
7579
|
-
return [
|
|
7580
|
-
|
|
7581
|
-
|
|
7682
|
+
return [
|
|
7683
|
+
e.code && !e.getModifierState?.('AltGraph') && `${mods}|c:${e.code}`,
|
|
7684
|
+
typeof e.key === 'string' && `${mods}|k:${e.key.toLowerCase()}`,
|
|
7685
|
+
].filter(Boolean);
|
|
7582
7686
|
};
|
|
7583
7687
|
let claims = new Set();
|
|
7584
7688
|
let forward = new Set();
|
|
@@ -7588,6 +7692,11 @@ function reviewBridge(textBefore, headingBefore, rangeAt, comboOf, fieldSelector
|
|
|
7588
7692
|
e.preventDefault();
|
|
7589
7693
|
send({ type: 'key', key: e.key, code: e.code, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey, meta: e.metaKey });
|
|
7590
7694
|
});
|
|
7695
|
+
let scrollTimer = 0;
|
|
7696
|
+
addEventListener('scroll', () => {
|
|
7697
|
+
clearTimeout(scrollTimer);
|
|
7698
|
+
scrollTimer = setTimeout(() => send({ type: 'scroll', x: scrollX, y: scrollY }), 150);
|
|
7699
|
+
});
|
|
7591
7700
|
addEventListener('message', (e) => {
|
|
7592
7701
|
if (e.source !== parent || typeof e.data?.type !== 'string' || !e.data.type.startsWith(P)) return;
|
|
7593
7702
|
const type = e.data.type.slice(P.length);
|
|
@@ -7624,6 +7733,7 @@ const REVIEW_BRIDGE_TAG = `<script>(${reviewBridge})(${reviewTextBefore}, ${revi
|
|
|
7624
7733
|
// Frames from createPreviewFrame, which carry the bridge. A URL pane is another site and must
|
|
7625
7734
|
// not drive the board.
|
|
7626
7735
|
const bridgedFrames = new WeakSet();
|
|
7736
|
+
const paneFrameScroll = new WeakMap();
|
|
7627
7737
|
|
|
7628
7738
|
function bridgedFrameEls() {
|
|
7629
7739
|
return [...document.querySelectorAll('iframe[srcdoc]')].filter((f) => bridgedFrames.has(f));
|
|
@@ -7641,6 +7751,15 @@ const ZOOM_CLAIMS = ['ctrlKey', 'metaKey'].flatMap((mod) =>
|
|
|
7641
7751
|
),
|
|
7642
7752
|
);
|
|
7643
7753
|
|
|
7754
|
+
// The sidebar and tab keys, so they work while a previewed page has focus. Matched by key where
|
|
7755
|
+
// the board matches by key, so they follow the keyboard layout. A page that handles one first
|
|
7756
|
+
// keeps it, and text fields keep every key.
|
|
7757
|
+
const PANE_CLAIMS = [
|
|
7758
|
+
...['\\', '[', ']'].map((key) => ({ key, code: '' })),
|
|
7759
|
+
...['|', '{', '}'].map((key) => ({ shiftKey: true, key, code: '' })),
|
|
7760
|
+
...['BracketLeft', 'BracketRight', 'KeyW'].map((code) => ({ altKey: true, key: 'Unidentified', code })),
|
|
7761
|
+
];
|
|
7762
|
+
|
|
7644
7763
|
function sendBridgeClaims(frames, claims) {
|
|
7645
7764
|
for (const f of frames) f.contentWindow?.postMessage({ type: `${REVIEW_MSG}claims`, ...claims }, '*');
|
|
7646
7765
|
}
|
|
@@ -7648,10 +7767,11 @@ function sendBridgeClaims(frames, claims) {
|
|
|
7648
7767
|
window.addEventListener('message', (e) => {
|
|
7649
7768
|
const type =
|
|
7650
7769
|
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;
|
|
7770
|
+
if (type !== 'link' && type !== 'key' && type !== 'scroll') return;
|
|
7652
7771
|
const frame = bridgedFrameEls().find((f) => f.contentWindow === e.source);
|
|
7653
7772
|
if (!frame) return;
|
|
7654
|
-
if (type === '
|
|
7773
|
+
if (type === 'scroll') paneFrameScroll.set(frame, { x: e.data.x, y: e.data.y });
|
|
7774
|
+
else if (type === 'key') replayKey(e.data, frame);
|
|
7655
7775
|
else {
|
|
7656
7776
|
const url = linkUrl(e.data.href);
|
|
7657
7777
|
if (url) hub.openExternal(url);
|
|
@@ -8507,6 +8627,54 @@ function renderArtifactsHtml(sessionId) {
|
|
|
8507
8627
|
return `<div class="artifacts-section panel-section" data-artifacts-for="${escapeHtml(sessionId)}">${artifactsInnerHtml(sessionId)}</div>`;
|
|
8508
8628
|
}
|
|
8509
8629
|
|
|
8630
|
+
const ZEN_BOOKMARKS_COLLAPSED = 3;
|
|
8631
|
+
const zenBookmarksExpanded = new Set();
|
|
8632
|
+
|
|
8633
|
+
function zenBookmarkParts(p) {
|
|
8634
|
+
if (p.type === 'tool_use') return { who: p.tool || 'Tool', text: p.description || p.detail || '' };
|
|
8635
|
+
if (p.type === 'agent') return { who: p.agentType || 'Agent', text: stripTeammateWrapper(p.lastMessage || '') };
|
|
8636
|
+
return { who: p.type === 'assistant' ? 'Claude' : 'You', text: p.text || '' };
|
|
8637
|
+
}
|
|
8638
|
+
|
|
8639
|
+
// Zen shows the open session only, so its pins are currentPins and a row opens through
|
|
8640
|
+
// showPinnedMsgDetail by index.
|
|
8641
|
+
function renderZenBookmarksHtml(sessionId) {
|
|
8642
|
+
if (sessionId !== currentSessionId || !currentPins.length) return '';
|
|
8643
|
+
const { shown, moreHtml } = showAllToggle(
|
|
8644
|
+
zenBookmarksExpanded,
|
|
8645
|
+
sessionId,
|
|
8646
|
+
currentPins,
|
|
8647
|
+
ZEN_BOOKMARKS_COLLAPSED,
|
|
8648
|
+
`onclick="toggleZenBookmarks('${escAttrJs(sessionId)}')"`,
|
|
8649
|
+
);
|
|
8650
|
+
const items = shown
|
|
8651
|
+
.map((p, i) => {
|
|
8652
|
+
const { who, text } = zenBookmarkParts(p);
|
|
8653
|
+
const label = truncateAtWord(cleanMessageText(text), 200);
|
|
8654
|
+
return `<li class="zen-bookmark-item">
|
|
8655
|
+
<span class="zen-bookmark-who">${escapeHtml(who)}</span>
|
|
8656
|
+
<button type="button" class="zen-bookmark-link" onclick="showPinnedMsgDetail(${i})" title="${escapeHtml(label)}">${escapeHtml(label || '(empty)')}</button>
|
|
8657
|
+
<span class="scratch-file-time">${formatDate(p.timestamp)}</span>
|
|
8658
|
+
</li>`;
|
|
8659
|
+
})
|
|
8660
|
+
.join('');
|
|
8661
|
+
return `<div class="zen-bookmarks-section panel-section">
|
|
8662
|
+
<div class="panel-section-header">
|
|
8663
|
+
${BOOKMARK_SVG}
|
|
8664
|
+
<span>Bookmarks</span>
|
|
8665
|
+
<span class="panel-section-count">${currentPins.length}</span>
|
|
8666
|
+
</div>
|
|
8667
|
+
<ul class="zen-bookmark-list">${items}</ul>${moreHtml}
|
|
8668
|
+
</div>`;
|
|
8669
|
+
}
|
|
8670
|
+
|
|
8671
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
8672
|
+
function toggleZenBookmarks(sessionId) {
|
|
8673
|
+
toggleSetMember(zenBookmarksExpanded, sessionId);
|
|
8674
|
+
const section = document.querySelector('.zen-bookmarks-section');
|
|
8675
|
+
if (section) section.outerHTML = renderZenBookmarksHtml(sessionId);
|
|
8676
|
+
}
|
|
8677
|
+
|
|
8510
8678
|
// No TTL: the server holds the scan until the transcript grows, so a repeat costs one
|
|
8511
8679
|
// stat, and an artifact published while the panel is open still appears.
|
|
8512
8680
|
const artifactsSection = makeSectionLoader({
|
|
@@ -8954,6 +9122,10 @@ function setupEventSource() {
|
|
|
8954
9122
|
applyPaneLayout(data.sessionId, data.layout);
|
|
8955
9123
|
}
|
|
8956
9124
|
|
|
9125
|
+
if (data.type === 'pane:file-changed') {
|
|
9126
|
+
onPaneFileChanged(data.path);
|
|
9127
|
+
}
|
|
9128
|
+
|
|
8957
9129
|
if (data.type === 'team-update') {
|
|
8958
9130
|
const teamSession = sessions.find((s) => s.isTeam && s.teamName === data.teamName);
|
|
8959
9131
|
if (teamSession) {
|
|
@@ -11916,14 +12088,31 @@ async function paneRequest(method, path, body) {
|
|
|
11916
12088
|
}
|
|
11917
12089
|
}
|
|
11918
12090
|
|
|
11919
|
-
// sessionStorage
|
|
12091
|
+
// Per-session pane state in sessionStorage, {[sessionId]: value} in recency order.
|
|
12092
|
+
const PANE_STATE_CAP = 20;
|
|
12093
|
+
|
|
12094
|
+
function loadPaneState(key) {
|
|
12095
|
+
try {
|
|
12096
|
+
const map = JSON.parse(sessionStorage.getItem(key));
|
|
12097
|
+
if (map && typeof map === 'object') return map;
|
|
12098
|
+
} catch {}
|
|
12099
|
+
return {};
|
|
12100
|
+
}
|
|
12101
|
+
|
|
12102
|
+
// Puts `sid` last, so the cap drops the least recent session. A null value removes it.
|
|
12103
|
+
function savePaneState(key, map, sid, value) {
|
|
12104
|
+
delete map[sid];
|
|
12105
|
+
if (value != null) map[sid] = value;
|
|
12106
|
+
const capped = Object.fromEntries(Object.entries(map).slice(-PANE_STATE_CAP));
|
|
12107
|
+
try {
|
|
12108
|
+
sessionStorage.setItem(key, JSON.stringify(capped));
|
|
12109
|
+
} catch {}
|
|
12110
|
+
return capped;
|
|
12111
|
+
}
|
|
12112
|
+
|
|
12113
|
+
// `cck.paneActive`: {[sessionId]: paneId}.
|
|
11920
12114
|
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 {}
|
|
12115
|
+
let paneActive = loadPaneState(PANE_ACTIVE_KEY);
|
|
11927
12116
|
|
|
11928
12117
|
function getActivePaneId(sid) {
|
|
11929
12118
|
return paneActive[sid] || 'board';
|
|
@@ -11931,12 +12120,141 @@ function getActivePaneId(sid) {
|
|
|
11931
12120
|
|
|
11932
12121
|
function setActivePaneId(sid, id) {
|
|
11933
12122
|
if (getActivePaneId(sid) === id) return;
|
|
11934
|
-
|
|
11935
|
-
|
|
11936
|
-
|
|
11937
|
-
|
|
11938
|
-
|
|
11939
|
-
|
|
12123
|
+
paneActive = savePaneState(PANE_ACTIVE_KEY, paneActive, sid, id === 'board' ? null : id);
|
|
12124
|
+
notePairFocus(sid, id);
|
|
12125
|
+
}
|
|
12126
|
+
|
|
12127
|
+
// `cck.panePairs`: {[sessionId]: [{l, r, f}]}; sides are pane ids or 'board', `f` the side focused
|
|
12128
|
+
// last. A tab is in one pair at most, and the pair that holds the active tab is the split on
|
|
12129
|
+
// screen. Clicks never change a pair; only the split keys and drags do.
|
|
12130
|
+
const PANE_PAIRS_KEY = 'cck.panePairs';
|
|
12131
|
+
const PANE_SPLIT_RATIO_KEY = 'pane-split-ratio';
|
|
12132
|
+
let panePairs = loadPaneState(PANE_PAIRS_KEY);
|
|
12133
|
+
const clampSplitRatio = (r) => Math.min(0.85, Math.max(0.15, r));
|
|
12134
|
+
let paneSplitRatio = clampSplitRatio(Number.parseFloat(store.getItem(PANE_SPLIT_RATIO_KEY)) || 0.5);
|
|
12135
|
+
|
|
12136
|
+
const paneTabIds = (sid) => ['board', ...paneLayout(sid).panes.map((p) => p.id)];
|
|
12137
|
+
const splitSideOf = (split, id) => (split.l === id ? 'l' : split.r === id ? 'r' : null);
|
|
12138
|
+
const otherSide = (side) => (side === 'l' ? 'r' : 'l');
|
|
12139
|
+
|
|
12140
|
+
// Pairs whose tabs are gone, or taken by an earlier pair, drop out.
|
|
12141
|
+
function getPanePairs(sid) {
|
|
12142
|
+
if (!panePairs[sid]) return [];
|
|
12143
|
+
const ids = new Set(paneTabIds(sid));
|
|
12144
|
+
const taken = new Set();
|
|
12145
|
+
return panePairs[sid].filter((p) => {
|
|
12146
|
+
const ok = p.l !== p.r && ids.has(p.l) && ids.has(p.r) && !taken.has(p.l) && !taken.has(p.r);
|
|
12147
|
+
if (ok) taken.add(p.l).add(p.r);
|
|
12148
|
+
return ok;
|
|
12149
|
+
});
|
|
12150
|
+
}
|
|
12151
|
+
|
|
12152
|
+
function setPanePairs(sid, pairs) {
|
|
12153
|
+
panePairs = savePaneState(PANE_PAIRS_KEY, panePairs, sid, pairs.length ? pairs : null);
|
|
12154
|
+
}
|
|
12155
|
+
|
|
12156
|
+
const withoutTabs = (pairs, ...ids) => pairs.filter((p) => !ids.some((id) => splitSideOf(p, id)));
|
|
12157
|
+
const pairOf = (sid, id) => getPanePairs(sid).find((p) => splitSideOf(p, id)) || null;
|
|
12158
|
+
|
|
12159
|
+
function getPaneSplit(sid) {
|
|
12160
|
+
return pairOf(sid, getActivePaneId(sid));
|
|
12161
|
+
}
|
|
12162
|
+
|
|
12163
|
+
function notePairFocus(sid, id) {
|
|
12164
|
+
const pairs = getPanePairs(sid);
|
|
12165
|
+
const pair = pairs.find((p) => splitSideOf(p, id));
|
|
12166
|
+
const side = pair && splitSideOf(pair, id);
|
|
12167
|
+
if (!side || pair.f === side) return;
|
|
12168
|
+
pair.f = side;
|
|
12169
|
+
setPanePairs(sid, pairs);
|
|
12170
|
+
}
|
|
12171
|
+
|
|
12172
|
+
// Tabs that are not in a pair, plus `id`.
|
|
12173
|
+
function freePaneTabs(sid, id, pairs) {
|
|
12174
|
+
return paneTabIds(sid).filter((t) => t === id || !pairs.some((p) => splitSideOf(p, t)));
|
|
12175
|
+
}
|
|
12176
|
+
|
|
12177
|
+
// The free tab before it, else the one after it.
|
|
12178
|
+
function neighborPair(sid, id, pairs) {
|
|
12179
|
+
const free = freePaneTabs(sid, id, pairs);
|
|
12180
|
+
const i = free.indexOf(id);
|
|
12181
|
+
if (i < 0 || free.length < 2) return null;
|
|
12182
|
+
return i > 0 ? { l: free[i - 1], r: id } : { l: id, r: free[1] };
|
|
12183
|
+
}
|
|
12184
|
+
|
|
12185
|
+
// The strip in order, as {ids, pair}: a lone tab, or a pair at the place of its first tab.
|
|
12186
|
+
function paneStripItems(sid) {
|
|
12187
|
+
const pairs = getPanePairs(sid);
|
|
12188
|
+
const items = [];
|
|
12189
|
+
for (const id of paneTabIds(sid)) {
|
|
12190
|
+
const pair = pairs.find((p) => splitSideOf(p, id)) || null;
|
|
12191
|
+
if (!pair) items.push({ ids: [id], pair });
|
|
12192
|
+
else if (!items.some((it) => it.pair === pair)) items.push({ ids: [pair.l, pair.r], pair });
|
|
12193
|
+
}
|
|
12194
|
+
return items;
|
|
12195
|
+
}
|
|
12196
|
+
|
|
12197
|
+
function unpairPanes(sid, pair, keep) {
|
|
12198
|
+
setPanePairs(sid, withoutTabs(getPanePairs(sid), pair.l));
|
|
12199
|
+
if (keep) setActivePaneId(sid, keep);
|
|
12200
|
+
syncPanes();
|
|
12201
|
+
}
|
|
12202
|
+
|
|
12203
|
+
// Returns false when the panes are not on screen, so the key keeps its other meaning.
|
|
12204
|
+
function togglePaneSplit() {
|
|
12205
|
+
const sid = paneSessionId();
|
|
12206
|
+
if (!sid || wantsTerminal()) return false;
|
|
12207
|
+
const active = getActivePaneId(sid);
|
|
12208
|
+
const split = getPaneSplit(sid);
|
|
12209
|
+
if (split) {
|
|
12210
|
+
unpairPanes(sid, split, active);
|
|
12211
|
+
return true;
|
|
12212
|
+
}
|
|
12213
|
+
const pairs = getPanePairs(sid);
|
|
12214
|
+
const pair = neighborPair(sid, active, pairs);
|
|
12215
|
+
if (!pair) {
|
|
12216
|
+
showToast(paneLayout(sid).panes.length ? 'No free tab to split with' : 'Add a pane to split the board with');
|
|
12217
|
+
return true;
|
|
12218
|
+
}
|
|
12219
|
+
setPanePairs(sid, [...pairs, { ...pair, f: splitSideOf(pair, active) }]);
|
|
12220
|
+
syncPanes();
|
|
12221
|
+
return true;
|
|
12222
|
+
}
|
|
12223
|
+
|
|
12224
|
+
function shownPaneSplit() {
|
|
12225
|
+
const sid = paneSessionId();
|
|
12226
|
+
return sid && !wantsTerminal() && getPaneSplit(sid);
|
|
12227
|
+
}
|
|
12228
|
+
|
|
12229
|
+
function focusPaneSide(side) {
|
|
12230
|
+
const sid = paneSessionId();
|
|
12231
|
+
const split = shownPaneSplit();
|
|
12232
|
+
if (!split) return false;
|
|
12233
|
+
if (getActivePaneId(sid) !== split[side]) {
|
|
12234
|
+
setActivePaneId(sid, split[side]);
|
|
12235
|
+
syncPanes();
|
|
12236
|
+
}
|
|
12237
|
+
return true;
|
|
12238
|
+
}
|
|
12239
|
+
|
|
12240
|
+
function dropPaneOnSide(id, side) {
|
|
12241
|
+
const sid = paneSessionId();
|
|
12242
|
+
if (!sid) return;
|
|
12243
|
+
const split = getPaneSplit(sid);
|
|
12244
|
+
const other = otherSide(side);
|
|
12245
|
+
const pairs = getPanePairs(sid);
|
|
12246
|
+
const shownId = getActivePaneId(sid);
|
|
12247
|
+
let partner;
|
|
12248
|
+
if (split) partner = split[other] === id ? split[side] : split[other];
|
|
12249
|
+
else if (shownId !== id) partner = shownId;
|
|
12250
|
+
else {
|
|
12251
|
+
const pair = neighborPair(sid, id, pairs);
|
|
12252
|
+
partner = pair && (pair.l === id ? pair.r : pair.l);
|
|
12253
|
+
}
|
|
12254
|
+
if (!partner) return;
|
|
12255
|
+
setPanePairs(sid, [...withoutTabs(pairs, id, partner), { [side]: id, [other]: partner, f: side }]);
|
|
12256
|
+
setActivePaneId(sid, id);
|
|
12257
|
+
syncPanes();
|
|
11940
12258
|
}
|
|
11941
12259
|
|
|
11942
12260
|
const PANE_FRAME_LIMIT = 3;
|
|
@@ -11953,7 +12271,17 @@ const PANE_ICONS = {
|
|
|
11953
12271
|
// and the limit spans sessions, so a session switch cannot pile up frames.
|
|
11954
12272
|
const paneFrames = new Map();
|
|
11955
12273
|
const paneReviews = new WeakMap();
|
|
12274
|
+
// Off-screen views whose file changed; they reload when shown.
|
|
12275
|
+
const stalePaneViews = new WeakSet();
|
|
12276
|
+
const paneReloads = new Map();
|
|
11956
12277
|
const paneViews = document.getElementById('pane-views');
|
|
12278
|
+
const paneSplitBox = document.getElementById('pane-split');
|
|
12279
|
+
const paneDivider = document.getElementById('pane-divider');
|
|
12280
|
+
const paneDrop = document.getElementById('pane-drop');
|
|
12281
|
+
const paneSplitBtn = document.getElementById('pane-split-btn');
|
|
12282
|
+
const paneBoard = document.getElementById('board-side');
|
|
12283
|
+
const paneLog = document.getElementById('message-panel');
|
|
12284
|
+
const paneLogHome = { parent: paneLog.parentNode, next: paneLog.nextSibling };
|
|
11957
12285
|
const paneTabs = document.getElementById('pane-tabs');
|
|
11958
12286
|
const panePin = document.getElementById('pane-pin');
|
|
11959
12287
|
const paneStrip = document.getElementById('pane-strip');
|
|
@@ -11962,6 +12290,8 @@ const panePopInput = document.getElementById('pane-pop-input');
|
|
|
11962
12290
|
const panePopDocs = document.getElementById('pane-pop-docs');
|
|
11963
12291
|
const panePopDetect = document.getElementById('pane-pop-detect');
|
|
11964
12292
|
let paneTabsHtml = '';
|
|
12293
|
+
let paneDragId = null;
|
|
12294
|
+
applyAgentFooterCollapsed();
|
|
11965
12295
|
|
|
11966
12296
|
function paneSessionId() {
|
|
11967
12297
|
return viewMode === 'session' ? currentSessionId : null;
|
|
@@ -11980,42 +12310,99 @@ function syncPanes() {
|
|
|
11980
12310
|
}
|
|
11981
12311
|
const layout = sid ? paneLayout(sid) : null;
|
|
11982
12312
|
const pane = sid ? activePane(sid) : null;
|
|
12313
|
+
const split = sid ? getPaneSplit(sid) : null;
|
|
11983
12314
|
sessionView.classList.toggle('has-panes', !!layout);
|
|
11984
|
-
|
|
12315
|
+
const boardSide = split ? splitSideOf(split, 'board') : null;
|
|
12316
|
+
sessionView.classList.toggle('pane-mode', split ? !boardSide : !!pane);
|
|
12317
|
+
sessionView.classList.toggle('split-mode', !!split);
|
|
11985
12318
|
if (!layout || wantsTerminal()) closePanePop();
|
|
11986
|
-
|
|
11987
|
-
|
|
12319
|
+
// key → side ('' unsplit). The focused side mounts last, so the frame limit evicts it last.
|
|
12320
|
+
const sides = new Map();
|
|
12321
|
+
if (split) {
|
|
12322
|
+
const focusSide = splitSideOf(split, pane?.id || 'board');
|
|
12323
|
+
for (const side of [otherSide(focusSide), focusSide]) {
|
|
12324
|
+
if (side !== boardSide) sides.set(mountPane(sid, paneById(split[side])), side);
|
|
12325
|
+
}
|
|
12326
|
+
applySplitRatio();
|
|
12327
|
+
} else if (pane) {
|
|
12328
|
+
sides.set(mountPane(sid, pane), '');
|
|
12329
|
+
}
|
|
12330
|
+
const focusKey = pane ? `${sid}/${pane.id}` : null;
|
|
12331
|
+
for (const el of paneViews.children) {
|
|
12332
|
+
const side = sides.get(el.dataset.key);
|
|
12333
|
+
el.classList.toggle('on', side !== undefined);
|
|
12334
|
+
markPaneSide(el, side, el.dataset.key === focusKey);
|
|
12335
|
+
}
|
|
12336
|
+
const boardWasSplit = boardInSplit();
|
|
12337
|
+
markPaneSide(paneBoard, boardSide, !!boardSide && !pane);
|
|
12338
|
+
if (boardWasSplit !== !!boardSide) applyAgentFooterCollapsed();
|
|
12339
|
+
placeSessionLog(wantsTerminal() ? null : boardSide);
|
|
11988
12340
|
// Off screen, a framed page keeps focus and takes keys the user aims at the board.
|
|
11989
12341
|
const focused = document.activeElement;
|
|
11990
12342
|
if (paneViews.contains(focused) && (wantsTerminal() || !focused.closest('.pane-view.on'))) focused.blur();
|
|
11991
12343
|
syncPaneReview();
|
|
11992
12344
|
if (!layout) return;
|
|
11993
12345
|
for (const b of document.querySelectorAll('.pane-tool')) b.disabled = !pane;
|
|
11994
|
-
|
|
12346
|
+
paneSplitBtn.disabled = !layout.panes.length;
|
|
12347
|
+
paneSplitBtn.classList.toggle('on', !!split);
|
|
12348
|
+
paneSplitBtn.setAttribute('aria-pressed', String(!!split));
|
|
12349
|
+
paneSplitBtn.title = split ? 'Unsplit (|)' : 'Split with the tab before it (|)';
|
|
12350
|
+
renderPaneTabs(sid, layout, split);
|
|
12351
|
+
}
|
|
12352
|
+
|
|
12353
|
+
// With the board in a split, the session log docks in the board's side instead of the window.
|
|
12354
|
+
function placeSessionLog(boardSide) {
|
|
12355
|
+
const parent = boardSide ? paneSplitBox : paneLogHome.parent;
|
|
12356
|
+
if (paneLog.parentNode !== parent) {
|
|
12357
|
+
const content = document.getElementById('message-panel-content');
|
|
12358
|
+
const top = content.scrollTop;
|
|
12359
|
+
parent.insertBefore(paneLog, boardSide ? null : paneLogHome.next);
|
|
12360
|
+
content.scrollTop = top;
|
|
12361
|
+
}
|
|
12362
|
+
paneLog.classList.toggle('log-l', boardSide === 'l');
|
|
12363
|
+
paneLog.classList.toggle('log-r', boardSide === 'r');
|
|
12364
|
+
}
|
|
12365
|
+
|
|
12366
|
+
function markPaneSide(el, side, focused) {
|
|
12367
|
+
el.classList.toggle('side-l', side === 'l');
|
|
12368
|
+
el.classList.toggle('side-r', side === 'r');
|
|
12369
|
+
el.classList.toggle('pane-focus', focused);
|
|
11995
12370
|
}
|
|
11996
12371
|
|
|
11997
|
-
function renderPaneTabs(sid, layout) {
|
|
12372
|
+
function renderPaneTabs(sid, layout, split) {
|
|
12373
|
+
// A rebuild mid-drag removes the dragged tab, and then no dragend fires.
|
|
12374
|
+
if (paneDragId) return;
|
|
11998
12375
|
const active = getActivePaneId(sid);
|
|
12376
|
+
const state = (id) => (active === id ? ' on' : split && splitSideOf(split, id) ? ' shown' : '');
|
|
11999
12377
|
const tab = (id, cls, inner, tip) =>
|
|
12000
|
-
`<div class="pane-tab ${escapeHtml(cls)}${
|
|
12001
|
-
const
|
|
12002
|
-
|
|
12003
|
-
'
|
|
12004
|
-
|
|
12005
|
-
|
|
12006
|
-
|
|
12007
|
-
|
|
12008
|
-
|
|
12009
|
-
|
|
12378
|
+
`<div class="pane-tab ${escapeHtml(cls)}${state(id)}" role="tab" draggable="true" data-pane="${escapeHtml(id)}" title="${escapeHtml(tip)}">${inner}</div>`;
|
|
12379
|
+
const byId = new Map(layout.panes.map((p) => [p.id, p]));
|
|
12380
|
+
const tabHtml = (id) => {
|
|
12381
|
+
if (id === 'board') {
|
|
12382
|
+
return tab(
|
|
12383
|
+
'board',
|
|
12384
|
+
'pinned',
|
|
12385
|
+
`${PANE_ICONS.board}<span class="pane-title">Board</span><span class="pin-meta">${currentTasks.length}</span>`,
|
|
12386
|
+
'Board',
|
|
12387
|
+
);
|
|
12388
|
+
}
|
|
12389
|
+
const p = byId.get(id);
|
|
12390
|
+
const frame = !paneFrameable(p) ? 'blocked' : paneFrames.has(`${sid}/${p.id}`) ? 'loaded' : '';
|
|
12391
|
+
const frameTip = frame === 'blocked' ? 'Site refuses framing' : frame ? 'Loaded' : 'Unloaded (loads on open)';
|
|
12010
12392
|
return tab(
|
|
12011
12393
|
p.id,
|
|
12012
12394
|
'',
|
|
12013
|
-
`${p.kind === 'url' ? PANE_ICONS.url : PANE_ICONS.file}<span class="pane-title">${escapeHtml(p.title)}</span><span class="state ${escapeHtml(
|
|
12395
|
+
`${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
12396
|
p.target,
|
|
12015
12397
|
);
|
|
12016
|
-
}
|
|
12017
|
-
const
|
|
12018
|
-
|
|
12398
|
+
};
|
|
12399
|
+
const itemHtml = ({ ids, pair }) =>
|
|
12400
|
+
pair
|
|
12401
|
+
? `<div class="pane-pair${pair === split ? ' on' : ''}">${ids.map(tabHtml).join('<span class="pane-pair-sep"></span>')}</div>`
|
|
12402
|
+
: tabHtml(ids[0]);
|
|
12403
|
+
const [pinned, ...rest] = paneStripItems(sid).map(itemHtml);
|
|
12404
|
+
const pin = pinned + (rest.length ? '<span class="pane-sep"></span>' : '');
|
|
12405
|
+
const html = rest.join('');
|
|
12019
12406
|
// syncPanes runs on every task refresh and search keystroke; skip the rebuild and the reflows.
|
|
12020
12407
|
if (pin + html === paneTabsHtml) return;
|
|
12021
12408
|
paneTabsHtml = pin + html;
|
|
@@ -12031,6 +12418,20 @@ function updatePaneFades() {
|
|
|
12031
12418
|
paneTabs.classList.toggle('fade-r', scrollLeft + clientWidth < scrollWidth - 1);
|
|
12032
12419
|
}
|
|
12033
12420
|
|
|
12421
|
+
// A URL pane's page is on another origin, so its key presses never reach the board: the zoom
|
|
12422
|
+
// keys zoom the whole window and the pane keys do nothing. The bar zooms without keys and
|
|
12423
|
+
// says when the page has the keyboard; a click on it gives the keys back.
|
|
12424
|
+
function paneUrlBarHtml() {
|
|
12425
|
+
return `<div class="pane-url-bar"><button class="icon-btn pane-keys" title="The page has keyboard focus. Click to turn the board shortcuts back on">Board shortcuts off · click to turn on</button><button class="icon-btn" data-pane-zoom="-0.1" title="Smaller" aria-label="Smaller">−</button><button class="icon-btn pane-zoom-val" data-pane-zoom="0" title="Reset size">${modalZoomLabel()}</button><button class="icon-btn" data-pane-zoom="0.1" title="Larger" aria-label="Larger">+</button></div>`;
|
|
12426
|
+
}
|
|
12427
|
+
|
|
12428
|
+
function markPaneFrameFocus() {
|
|
12429
|
+
const el = document.activeElement;
|
|
12430
|
+
const view = el?.classList.contains('pane-url-frame') ? el.parentElement : null;
|
|
12431
|
+
for (const v of paneViews.querySelectorAll('.pane-view.frame-focus')) v.classList.remove('frame-focus');
|
|
12432
|
+
view?.classList.add('frame-focus');
|
|
12433
|
+
}
|
|
12434
|
+
|
|
12034
12435
|
function paneCardHtml(title, text, button) {
|
|
12035
12436
|
return `<div class="pane-card"><div class="pane-card-box">${PANE_ICONS.warning}<h3>${escapeHtml(title)}</h3><p>${escapeHtml(text)}</p><button class="btn btn-primary" data-pane-open>${escapeHtml(button)}</button></div></div>`;
|
|
12036
12437
|
}
|
|
@@ -12042,6 +12443,7 @@ function mountPane(sid, pane) {
|
|
|
12042
12443
|
if (existing) {
|
|
12043
12444
|
paneFrames.delete(key);
|
|
12044
12445
|
paneFrames.set(key, existing);
|
|
12446
|
+
if (stalePaneViews.delete(existing)) reloadPaneView(sid, pane, existing);
|
|
12045
12447
|
return key;
|
|
12046
12448
|
}
|
|
12047
12449
|
const view = document.createElement('div');
|
|
@@ -12058,7 +12460,7 @@ function mountPane(sid, pane) {
|
|
|
12058
12460
|
return key;
|
|
12059
12461
|
}
|
|
12060
12462
|
|
|
12061
|
-
async function loadPaneView(sid, pane, view) {
|
|
12463
|
+
async function loadPaneView(sid, pane, view, scroll) {
|
|
12062
12464
|
if (paneOwnOrigin(pane)) {
|
|
12063
12465
|
view.insertAdjacentHTML(
|
|
12064
12466
|
'beforeend',
|
|
@@ -12079,12 +12481,13 @@ async function loadPaneView(sid, pane, view) {
|
|
|
12079
12481
|
}
|
|
12080
12482
|
if (pane.kind === 'url') {
|
|
12081
12483
|
const frame = document.createElement('iframe');
|
|
12082
|
-
frame.className = 'pane-frame modal-zoomable';
|
|
12484
|
+
frame.className = 'pane-frame pane-url-frame modal-zoomable';
|
|
12083
12485
|
frame.setAttribute('sandbox', PANE_URL_SANDBOX);
|
|
12084
12486
|
frame.setAttribute('referrerpolicy', 'no-referrer');
|
|
12085
12487
|
frame.title = pane.title;
|
|
12086
12488
|
frame.src = pane.target;
|
|
12087
12489
|
view.appendChild(frame);
|
|
12490
|
+
view.insertAdjacentHTML('beforeend', paneUrlBarHtml());
|
|
12088
12491
|
// Framed at once; a refusal found by the probe swaps the frame for the card.
|
|
12089
12492
|
if ((await probePaneFraming(sid, pane)) === false) {
|
|
12090
12493
|
frame.remove();
|
|
@@ -12099,7 +12502,11 @@ async function loadPaneView(sid, pane, view) {
|
|
|
12099
12502
|
if (r.ok) data = await r.json();
|
|
12100
12503
|
} catch {}
|
|
12101
12504
|
if (data?.kind === 'html') {
|
|
12102
|
-
|
|
12505
|
+
// The frame's origin is opaque, so the page restores its own scroll on load.
|
|
12506
|
+
const restore = scroll
|
|
12507
|
+
? `<script>addEventListener('load',()=>requestAnimationFrame(()=>scrollTo(${Number(scroll.x) || 0},${Number(scroll.y) || 0})))</script>`
|
|
12508
|
+
: '';
|
|
12509
|
+
const frame = createPreviewFrame('pane-frame modal-zoomable', data.content + REVIEW_BRIDGE_TAG + restore);
|
|
12103
12510
|
frame.title = pane.title;
|
|
12104
12511
|
view.appendChild(frame);
|
|
12105
12512
|
attachPaneReview(view, sid, fileReviewOpts(data.path, data.kind, frame, frame));
|
|
@@ -12126,6 +12533,58 @@ async function loadPaneView(sid, pane, view) {
|
|
|
12126
12533
|
view.insertAdjacentHTML('beforeend', paneCardHtml(title, text, button));
|
|
12127
12534
|
}
|
|
12128
12535
|
|
|
12536
|
+
function onPaneFileChanged(filePath) {
|
|
12537
|
+
for (const [key, view] of paneFrames) {
|
|
12538
|
+
const [sid, paneId] = key.split('/');
|
|
12539
|
+
const pane = paneLayout(sid).panes.find((p) => p.id === paneId);
|
|
12540
|
+
if (pane?.target !== filePath) continue;
|
|
12541
|
+
if (view.classList.contains('on')) reloadPaneView(sid, pane, view);
|
|
12542
|
+
else stalePaneViews.add(view);
|
|
12543
|
+
}
|
|
12544
|
+
}
|
|
12545
|
+
|
|
12546
|
+
// Builds the new view hidden over the old one and swaps them once it has loaded and scrolled,
|
|
12547
|
+
// so a reload shows no blank frame and no jump. The newest reload of a view wins.
|
|
12548
|
+
async function reloadPaneView(sid, pane, view) {
|
|
12549
|
+
const key = view.dataset.key;
|
|
12550
|
+
const token = {};
|
|
12551
|
+
paneReloads.set(key, token);
|
|
12552
|
+
const live = () => {
|
|
12553
|
+
const ok = paneReloads.get(key) === token && paneFrames.get(key) === view;
|
|
12554
|
+
if (!ok && paneReloads.get(key) === token) paneReloads.delete(key);
|
|
12555
|
+
return ok;
|
|
12556
|
+
};
|
|
12557
|
+
const frame = view.querySelector(':scope > .pane-frame');
|
|
12558
|
+
const fresh = document.createElement('div');
|
|
12559
|
+
fresh.dataset.key = key;
|
|
12560
|
+
await loadPaneView(sid, pane, fresh, frame && paneFrameScroll.get(frame));
|
|
12561
|
+
if (!live()) return;
|
|
12562
|
+
fresh.className = view.className;
|
|
12563
|
+
fresh.style.visibility = 'hidden';
|
|
12564
|
+
view.after(fresh);
|
|
12565
|
+
const newBody = fresh.querySelector('.pane-doc-body');
|
|
12566
|
+
if (newBody) newBody.scrollTop = view.querySelector('.pane-doc-body')?.scrollTop || 0;
|
|
12567
|
+
const newFrame = fresh.querySelector(':scope > .pane-frame');
|
|
12568
|
+
if (newFrame) {
|
|
12569
|
+
await new Promise((resolve) => {
|
|
12570
|
+
newFrame.addEventListener('load', resolve, { once: true });
|
|
12571
|
+
setTimeout(resolve, 3000);
|
|
12572
|
+
});
|
|
12573
|
+
// The page scrolls itself one frame after its load.
|
|
12574
|
+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
12575
|
+
}
|
|
12576
|
+
if (!live()) {
|
|
12577
|
+
fresh.remove();
|
|
12578
|
+
return;
|
|
12579
|
+
}
|
|
12580
|
+
paneReloads.delete(key);
|
|
12581
|
+
fresh.className = view.className;
|
|
12582
|
+
fresh.style.visibility = '';
|
|
12583
|
+
view.remove();
|
|
12584
|
+
paneFrames.set(key, fresh);
|
|
12585
|
+
syncPaneReview();
|
|
12586
|
+
}
|
|
12587
|
+
|
|
12129
12588
|
// One reading width for every pane, kept apart from the modals': a modal's saved size
|
|
12130
12589
|
// includes its padding and review panel.
|
|
12131
12590
|
const PANE_DOC_W_KEY = 'pane-doc-width';
|
|
@@ -12163,7 +12622,7 @@ function paneDocGrip(doc) {
|
|
|
12163
12622
|
}
|
|
12164
12623
|
|
|
12165
12624
|
function activePaneView() {
|
|
12166
|
-
return wantsTerminal() ? null : paneViews.querySelector('.pane-view.on');
|
|
12625
|
+
return wantsTerminal() ? null : paneViews.querySelector('.pane-view.on.pane-focus');
|
|
12167
12626
|
}
|
|
12168
12627
|
|
|
12169
12628
|
function isPaneDocOnScreen() {
|
|
@@ -12199,20 +12658,55 @@ function selectPane(id) {
|
|
|
12199
12658
|
syncPanes();
|
|
12200
12659
|
}
|
|
12201
12660
|
|
|
12202
|
-
//
|
|
12661
|
+
// Flips the focused side of the split through the free tabs. Returns false with no split on
|
|
12662
|
+
// screen, so the key keeps its other meaning.
|
|
12203
12663
|
function cyclePane(step) {
|
|
12664
|
+
const sid = paneSessionId();
|
|
12665
|
+
const split = sid && !wantsTerminal() && getPaneSplit(sid);
|
|
12666
|
+
if (!split) return false;
|
|
12667
|
+
const active = getActivePaneId(sid);
|
|
12668
|
+
const pairs = getPanePairs(sid);
|
|
12669
|
+
const ids = freePaneTabs(sid, active, pairs);
|
|
12670
|
+
if (ids.length < 2) return true;
|
|
12671
|
+
const next = ids[(ids.indexOf(active) + step + ids.length) % ids.length];
|
|
12672
|
+
const side = splitSideOf(split, active);
|
|
12673
|
+
setPanePairs(
|
|
12674
|
+
sid,
|
|
12675
|
+
pairs.map((p) => (p === split ? { ...p, [side]: next } : p)),
|
|
12676
|
+
);
|
|
12677
|
+
selectPane(next);
|
|
12678
|
+
return true;
|
|
12679
|
+
}
|
|
12680
|
+
|
|
12681
|
+
function cyclePaneStrip(step) {
|
|
12204
12682
|
const sid = paneSessionId();
|
|
12205
12683
|
if (!sid || wantsTerminal()) return false;
|
|
12206
|
-
const
|
|
12207
|
-
const
|
|
12208
|
-
|
|
12684
|
+
const active = getActivePaneId(sid);
|
|
12685
|
+
const items = paneStripItems(sid);
|
|
12686
|
+
const i = items.findIndex((it) => it.ids.includes(active));
|
|
12687
|
+
const { ids, pair } = items[(i + step + items.length) % items.length];
|
|
12688
|
+
selectPane(pair ? pair[pair.f || 'l'] : ids[0]);
|
|
12689
|
+
return true;
|
|
12690
|
+
}
|
|
12691
|
+
|
|
12692
|
+
function closeFocusedPane() {
|
|
12693
|
+
const sid = paneSessionId();
|
|
12694
|
+
const id = sid && !wantsTerminal() && getActivePaneId(sid);
|
|
12695
|
+
if (!id || id === 'board') return false;
|
|
12696
|
+
closePane(id);
|
|
12209
12697
|
return true;
|
|
12210
12698
|
}
|
|
12211
12699
|
|
|
12212
12700
|
async function closePane(paneId) {
|
|
12213
12701
|
const sid = paneSessionId();
|
|
12214
12702
|
if (!sid) return;
|
|
12215
|
-
|
|
12703
|
+
const split = getPaneSplit(sid);
|
|
12704
|
+
const side = split && splitSideOf(split, paneId);
|
|
12705
|
+
// The layout keeps the pane until the server answers, so its pair goes now.
|
|
12706
|
+
setPanePairs(sid, withoutTabs(getPanePairs(sid), paneId));
|
|
12707
|
+
if (side) {
|
|
12708
|
+
setActivePaneId(sid, split[otherSide(side)]);
|
|
12709
|
+
} else if (getActivePaneId(sid) === paneId) {
|
|
12216
12710
|
const panes = paneLayout(sid).panes;
|
|
12217
12711
|
const i = panes.findIndex((p) => p.id === paneId);
|
|
12218
12712
|
setActivePaneId(sid, (panes[i + 1] || panes[i - 1])?.id || 'board');
|
|
@@ -12235,7 +12729,13 @@ function paneById(id) {
|
|
|
12235
12729
|
function reloadPane(id) {
|
|
12236
12730
|
const pane = paneById(id);
|
|
12237
12731
|
if (!pane) return;
|
|
12238
|
-
|
|
12732
|
+
const sid = paneSessionId();
|
|
12733
|
+
const view = paneFrames.get(`${sid}/${pane.id}`);
|
|
12734
|
+
if (pane.kind !== 'url' && view) {
|
|
12735
|
+
reloadPaneView(sid, pane, view);
|
|
12736
|
+
return;
|
|
12737
|
+
}
|
|
12738
|
+
unmountPane(sid, pane.id);
|
|
12239
12739
|
paneRefused.delete(pane.target);
|
|
12240
12740
|
syncPanes();
|
|
12241
12741
|
}
|
|
@@ -12468,10 +12968,168 @@ document.addEventListener('click', (e) => {
|
|
|
12468
12968
|
if (act) PANE_MENU_ACTIONS[act](menu.dataset.pane);
|
|
12469
12969
|
if (act || !menu.contains(e.target)) closePaneMenu();
|
|
12470
12970
|
});
|
|
12971
|
+
function focusSideOf(el) {
|
|
12972
|
+
if (el?.classList.contains('side-l')) focusPaneSide('l');
|
|
12973
|
+
else if (el?.classList.contains('side-r')) focusPaneSide('r');
|
|
12974
|
+
}
|
|
12975
|
+
|
|
12471
12976
|
// A click inside a pane's frame never reaches this document, but it takes focus from the window.
|
|
12472
|
-
|
|
12977
|
+
// That blur is also the only sign that a click in a split moved into a pane's frame.
|
|
12978
|
+
window.addEventListener('blur', () => {
|
|
12979
|
+
closePaneMenu();
|
|
12980
|
+
setTimeout(() => {
|
|
12981
|
+
syncPaneFrameFocus();
|
|
12982
|
+
watchPaneFrameFocus();
|
|
12983
|
+
});
|
|
12984
|
+
});
|
|
12985
|
+
window.addEventListener('focus', () => {
|
|
12986
|
+
clearInterval(paneFrameFocusPoll);
|
|
12987
|
+
markPaneFrameFocus();
|
|
12988
|
+
});
|
|
12989
|
+
|
|
12990
|
+
function syncPaneFrameFocus() {
|
|
12991
|
+
focusSideOf(document.activeElement?.closest?.('.pane-view.on'));
|
|
12992
|
+
markPaneFrameFocus();
|
|
12993
|
+
}
|
|
12994
|
+
|
|
12995
|
+
function splitPaneFrameFocused() {
|
|
12996
|
+
const el = document.activeElement;
|
|
12997
|
+
return el?.tagName === 'IFRAME' && paneViews.contains(el) && !!shownPaneSplit();
|
|
12998
|
+
}
|
|
12999
|
+
|
|
13000
|
+
// A click from one frame into another fires no event in this document, so while a split's frame
|
|
13001
|
+
// has focus the board checks which one it is: one pointer compare four times a second.
|
|
13002
|
+
let paneFrameFocusPoll = 0;
|
|
13003
|
+
function watchPaneFrameFocus() {
|
|
13004
|
+
clearInterval(paneFrameFocusPoll);
|
|
13005
|
+
if (!splitPaneFrameFocused()) return;
|
|
13006
|
+
let last = document.activeElement;
|
|
13007
|
+
paneFrameFocusPoll = setInterval(() => {
|
|
13008
|
+
const el = document.activeElement;
|
|
13009
|
+
if (!splitPaneFrameFocused()) clearInterval(paneFrameFocusPoll);
|
|
13010
|
+
else if (el !== last) {
|
|
13011
|
+
last = el;
|
|
13012
|
+
syncPaneFrameFocus();
|
|
13013
|
+
}
|
|
13014
|
+
}, 250);
|
|
13015
|
+
}
|
|
13016
|
+
paneSplitBox.addEventListener(
|
|
13017
|
+
'pointerdown',
|
|
13018
|
+
(e) => focusSideOf(paneLog.contains(e.target) ? paneBoard : e.target.closest('.side-l, .side-r')),
|
|
13019
|
+
true,
|
|
13020
|
+
);
|
|
13021
|
+
|
|
13022
|
+
function endPaneDrag() {
|
|
13023
|
+
if (!paneDragId) return;
|
|
13024
|
+
paneDragId = null;
|
|
13025
|
+
paneSplitBox.classList.remove('dropping');
|
|
13026
|
+
paneStrip.classList.remove('drop-hot');
|
|
13027
|
+
for (const el of paneDrop.children) el.classList.remove('hot');
|
|
13028
|
+
paneStrip.querySelector('.pane-tab.dragging')?.classList.remove('dragging');
|
|
13029
|
+
syncPanes();
|
|
13030
|
+
}
|
|
13031
|
+
|
|
13032
|
+
function draggedPair() {
|
|
13033
|
+
const sid = paneSessionId();
|
|
13034
|
+
return (sid && paneDragId && pairOf(sid, paneDragId)) || null;
|
|
13035
|
+
}
|
|
13036
|
+
|
|
13037
|
+
paneStrip.addEventListener('dragstart', (e) => {
|
|
13038
|
+
const tab = e.target.closest('.pane-tab');
|
|
13039
|
+
if (!tab || !paneSessionId() || wantsTerminal()) return;
|
|
13040
|
+
paneDragId = tab.dataset.pane;
|
|
13041
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
13042
|
+
e.dataTransfer.setData('text/plain', paneDragId);
|
|
13043
|
+
tab.classList.add('dragging');
|
|
13044
|
+
paneSplitBox.classList.add('dropping');
|
|
13045
|
+
});
|
|
13046
|
+
paneStrip.addEventListener('dragend', endPaneDrag);
|
|
13047
|
+
paneStrip.addEventListener('dragover', (e) => {
|
|
13048
|
+
if (!draggedPair()) return;
|
|
13049
|
+
e.preventDefault();
|
|
13050
|
+
paneStrip.classList.add('drop-hot');
|
|
13051
|
+
});
|
|
13052
|
+
paneStrip.addEventListener('dragleave', (e) => {
|
|
13053
|
+
if (!paneStrip.contains(e.relatedTarget)) paneStrip.classList.remove('drop-hot');
|
|
13054
|
+
});
|
|
13055
|
+
paneStrip.addEventListener('drop', (e) => {
|
|
13056
|
+
const pair = draggedPair();
|
|
13057
|
+
if (!pair) return;
|
|
13058
|
+
e.preventDefault();
|
|
13059
|
+
const sid = paneSessionId();
|
|
13060
|
+
const shown = !!splitSideOf(pair, getActivePaneId(sid));
|
|
13061
|
+
const keep = shown ? pair[otherSide(splitSideOf(pair, paneDragId))] : null;
|
|
13062
|
+
endPaneDrag();
|
|
13063
|
+
unpairPanes(sid, pair, keep);
|
|
13064
|
+
});
|
|
13065
|
+
paneDrop.addEventListener('dragover', (e) => {
|
|
13066
|
+
if (!paneDragId) return;
|
|
13067
|
+
e.preventDefault();
|
|
13068
|
+
const half = e.target.closest('[data-side]');
|
|
13069
|
+
for (const el of paneDrop.children) el.classList.toggle('hot', el === half);
|
|
13070
|
+
});
|
|
13071
|
+
paneDrop.addEventListener('dragleave', (e) => {
|
|
13072
|
+
if (!paneDrop.contains(e.relatedTarget)) for (const el of paneDrop.children) el.classList.remove('hot');
|
|
13073
|
+
});
|
|
13074
|
+
paneDrop.addEventListener('drop', (e) => {
|
|
13075
|
+
e.preventDefault();
|
|
13076
|
+
const id = paneDragId;
|
|
13077
|
+
const side = e.target.closest('[data-side]')?.dataset.side;
|
|
13078
|
+
endPaneDrag();
|
|
13079
|
+
if (id && side) dropPaneOnSide(id, side);
|
|
13080
|
+
});
|
|
13081
|
+
|
|
13082
|
+
// syncPanes calls this on every refresh; an unchanged value is not written again.
|
|
13083
|
+
function applySplitRatio() {
|
|
13084
|
+
const value = `${paneSplitRatio * 100}%`;
|
|
13085
|
+
if (paneSplitBox.style.getPropertyValue('--pane-split') !== value)
|
|
13086
|
+
paneSplitBox.style.setProperty('--pane-split', value);
|
|
13087
|
+
}
|
|
13088
|
+
|
|
13089
|
+
{
|
|
13090
|
+
let start;
|
|
13091
|
+
let width;
|
|
13092
|
+
let collapse = null;
|
|
13093
|
+
_initDragResize(paneDivider, {
|
|
13094
|
+
onStart() {
|
|
13095
|
+
start = paneSplitRatio;
|
|
13096
|
+
width = paneSplitBox.clientWidth;
|
|
13097
|
+
paneSplitBox.classList.add('resizing');
|
|
13098
|
+
},
|
|
13099
|
+
onMove(dx) {
|
|
13100
|
+
const raw = start + dx / width;
|
|
13101
|
+
collapse = raw < 0.1 ? 'l' : raw > 0.9 ? 'r' : null;
|
|
13102
|
+
paneSplitBox.classList.toggle('collapse-l', collapse === 'l');
|
|
13103
|
+
paneSplitBox.classList.toggle('collapse-r', collapse === 'r');
|
|
13104
|
+
paneSplitRatio = clampSplitRatio(raw);
|
|
13105
|
+
applySplitRatio();
|
|
13106
|
+
},
|
|
13107
|
+
onEnd() {
|
|
13108
|
+
paneSplitBox.classList.remove('resizing', 'collapse-l', 'collapse-r');
|
|
13109
|
+
const hidden = collapse;
|
|
13110
|
+
collapse = null;
|
|
13111
|
+
const sid = paneSessionId();
|
|
13112
|
+
const split = hidden && sid && getPaneSplit(sid);
|
|
13113
|
+
if (!split) {
|
|
13114
|
+
store.setItem(PANE_SPLIT_RATIO_KEY, String(paneSplitRatio));
|
|
13115
|
+
return;
|
|
13116
|
+
}
|
|
13117
|
+
paneSplitRatio = start;
|
|
13118
|
+
applySplitRatio();
|
|
13119
|
+
unpairPanes(sid, split, split[otherSide(hidden)]);
|
|
13120
|
+
},
|
|
13121
|
+
});
|
|
13122
|
+
paneDivider.addEventListener('dblclick', () => {
|
|
13123
|
+
paneSplitRatio = 0.5;
|
|
13124
|
+
store.removeItem(PANE_SPLIT_RATIO_KEY);
|
|
13125
|
+
applySplitRatio();
|
|
13126
|
+
});
|
|
13127
|
+
}
|
|
13128
|
+
|
|
12473
13129
|
paneViews.addEventListener('click', (e) => {
|
|
12474
13130
|
if (e.target.closest('[data-pane-open]')) openPaneExternally();
|
|
13131
|
+
const zoom = e.target.closest('[data-pane-zoom]');
|
|
13132
|
+
if (zoom) adjustModalZoom(Number(zoom.dataset.paneZoom));
|
|
12475
13133
|
});
|
|
12476
13134
|
bindPreviewRelativeLinks(paneViews, (a) => a.closest('.pane-doc')?.dataset.path);
|
|
12477
13135
|
paneTabs.addEventListener('scroll', updatePaneFades, { passive: true });
|
|
@@ -12487,6 +13145,7 @@ paneTabs.addEventListener(
|
|
|
12487
13145
|
);
|
|
12488
13146
|
new ResizeObserver(updatePaneFades).observe(paneTabs);
|
|
12489
13147
|
document.getElementById('pane-add').addEventListener('click', openPanePop);
|
|
13148
|
+
paneSplitBtn.addEventListener('click', togglePaneSplit);
|
|
12490
13149
|
document.getElementById('pane-reload').addEventListener('click', () => reloadPane());
|
|
12491
13150
|
document.getElementById('pane-open').addEventListener('click', () => openPaneExternally());
|
|
12492
13151
|
panePopDocs.addEventListener('click', (e) => {
|
|
@@ -12640,6 +13299,12 @@ function terminalPaneFocused() {
|
|
|
12640
13299
|
return document.getElementById('terminal-pane').contains(document.activeElement);
|
|
12641
13300
|
}
|
|
12642
13301
|
|
|
13302
|
+
// Kanban's own session keys (new, resume, swap) use the hub's modifier, so the hub leaves them alone.
|
|
13303
|
+
// AltGr reaches the page as Ctrl+Alt; when it types a character (Polish AltGr+S is 'ś'), the press is text.
|
|
13304
|
+
function hubModDown(e) {
|
|
13305
|
+
return e.ctrlKey && e.altKey && !e.metaKey && !e.shiftKey && !e.getModifierState?.('AltGraph');
|
|
13306
|
+
}
|
|
13307
|
+
|
|
12643
13308
|
function inPageField(e) {
|
|
12644
13309
|
const field = e.target?.closest?.(TEXT_FIELD_SELECTOR);
|
|
12645
13310
|
return !!field && !document.getElementById('terminal-pane').contains(field);
|
|
@@ -12647,7 +13312,7 @@ function inPageField(e) {
|
|
|
12647
13312
|
|
|
12648
13313
|
// `probe` asks which keys the terminal frame hands back, and the frame sees keys only while it has focus.
|
|
12649
13314
|
function terminalShortcut(e, probe = false) {
|
|
12650
|
-
const ctrlAlt = e
|
|
13315
|
+
const ctrlAlt = hubModDown(e);
|
|
12651
13316
|
const ctrlShift = e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey;
|
|
12652
13317
|
// Ctrl+_ is Claude Code's undo, so the zoom keys leave it to the terminal.
|
|
12653
13318
|
const zoom = e.key === '_' ? undefined : zoomDelta(e);
|
|
@@ -12986,7 +13651,7 @@ function keyClaims() {
|
|
|
12986
13651
|
}
|
|
12987
13652
|
|
|
12988
13653
|
function bridgeClaims(claims) {
|
|
12989
|
-
return { ...claims, keys: [...claims.keys, ...ZOOM_CLAIMS] };
|
|
13654
|
+
return { ...claims, keys: [...claims.keys, ...ZOOM_CLAIMS, ...PANE_CLAIMS] };
|
|
12990
13655
|
}
|
|
12991
13656
|
|
|
12992
13657
|
function pushTerminalClaims() {
|
|
@@ -13255,6 +13920,7 @@ async function openTerminal(sessionId, mode, attempt = 0) {
|
|
|
13255
13920
|
worktree: spec.worktree,
|
|
13256
13921
|
model: spec.model,
|
|
13257
13922
|
prompt: spec.prompt,
|
|
13923
|
+
edit: spec.edit,
|
|
13258
13924
|
}),
|
|
13259
13925
|
},
|
|
13260
13926
|
});
|
|
@@ -13544,12 +14210,14 @@ const newSpecs = new Map();
|
|
|
13544
14210
|
const dispatchSpecs = new Map();
|
|
13545
14211
|
const ns = { projects: [], picked: [], matches: [], idx: -1, folder: '', busy: false, resume: false };
|
|
13546
14212
|
const NEW_SESSION_STAY_KEY = 'new-session-stay';
|
|
14213
|
+
const NEW_SESSION_EDIT_KEY = 'new-session-edit';
|
|
13547
14214
|
|
|
13548
14215
|
const PLACEHOLDER_NAMES = { pick: 'Resume session', dispatch: 'Started session', new: 'New session' };
|
|
13549
14216
|
const PLACEHOLDER_HINTS = {
|
|
13550
14217
|
pick: 'pick a session to resume',
|
|
13551
14218
|
dispatch: 'starting',
|
|
13552
14219
|
new: 'waiting for your first message',
|
|
14220
|
+
edit: 'press Enter in the terminal to start',
|
|
13553
14221
|
};
|
|
13554
14222
|
|
|
13555
14223
|
function placeholderSession(id, spec) {
|
|
@@ -13557,6 +14225,7 @@ function placeholderSession(id, spec) {
|
|
|
13557
14225
|
id,
|
|
13558
14226
|
placeholder: true,
|
|
13559
14227
|
mode: spec.mode,
|
|
14228
|
+
edit: !!spec.edit,
|
|
13560
14229
|
name: spec.name || PLACEHOLDER_NAMES[spec.mode] || PLACEHOLDER_NAMES.new,
|
|
13561
14230
|
dispatchGroup: spec.group || undefined,
|
|
13562
14231
|
dispatched: spec.mode === 'dispatch' ? { parent: spec.parent || null } : undefined,
|
|
@@ -13636,7 +14305,7 @@ function renderPlaceholderCard(session) {
|
|
|
13636
14305
|
<button onclick="fetchTasks('${escAttrJs(session.id)}')" data-session-id="${escapeHtml(session.id)}" class="session-item session-placeholder ${isActive ? 'active' : ''}" title="${escapeHtml(`${session.id} | ${session.project}`)}">
|
|
13637
14306
|
<div class="session-name">${escapeHtml(session.name)}</div>
|
|
13638
14307
|
${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
|
|
13639
|
-
<div class="session-waiting"><span class="pulse"></span>${PLACEHOLDER_HINTS[session.mode] || PLACEHOLDER_HINTS.new}</div>
|
|
14308
|
+
<div class="session-waiting"><span class="pulse"></span>${PLACEHOLDER_HINTS[session.edit ? 'edit' : session.mode] || PLACEHOLDER_HINTS.new}</div>
|
|
13640
14309
|
</button>
|
|
13641
14310
|
`;
|
|
13642
14311
|
}
|
|
@@ -13650,7 +14319,14 @@ async function restorePendingSessions() {
|
|
|
13650
14319
|
try {
|
|
13651
14320
|
for (const t of await terminals) {
|
|
13652
14321
|
if ((t.mode !== 'new' && t.mode !== 'pick') || newSpecs.has(t.id) || dispatchSpecs.has(t.id)) continue;
|
|
13653
|
-
newSpecs.set(t.id, {
|
|
14322
|
+
newSpecs.set(t.id, {
|
|
14323
|
+
cwd: t.cwd,
|
|
14324
|
+
name: t.name,
|
|
14325
|
+
worktree: t.worktree,
|
|
14326
|
+
mode: t.mode,
|
|
14327
|
+
edit: t.edit,
|
|
14328
|
+
startedAt: t.startedAt,
|
|
14329
|
+
});
|
|
13654
14330
|
}
|
|
13655
14331
|
} catch (_) {}
|
|
13656
14332
|
}
|
|
@@ -13688,6 +14364,7 @@ function openNewSession(folder, resume = false) {
|
|
|
13688
14364
|
document.getElementById('ns-wt').checked = false;
|
|
13689
14365
|
document.getElementById('ns-model').value = '';
|
|
13690
14366
|
document.getElementById('ns-stay').checked = store.getItem(NEW_SESSION_STAY_KEY) === 'true';
|
|
14367
|
+
document.getElementById('ns-edit').checked = store.getItem(NEW_SESSION_EDIT_KEY) === 'true';
|
|
13691
14368
|
setNewSessionError('');
|
|
13692
14369
|
const current = viewMode === 'session' ? sessions.find((s) => s.id === currentSessionId)?.project : null;
|
|
13693
14370
|
setNewSessionFolder(folder || current || '');
|
|
@@ -13787,12 +14464,15 @@ function folderListOpen() {
|
|
|
13787
14464
|
function newSessionValues() {
|
|
13788
14465
|
const wtOn = document.getElementById('ns-wt').checked;
|
|
13789
14466
|
const wtName = document.getElementById('ns-wt-name').value.trim();
|
|
14467
|
+
const edit = !ns.resume && document.getElementById('ns-edit').checked;
|
|
13790
14468
|
return {
|
|
13791
14469
|
cwd: ns.folder,
|
|
13792
14470
|
name: document.getElementById('ns-name').value.trim() || null,
|
|
13793
14471
|
worktree: wtOn ? wtName || true : false,
|
|
13794
14472
|
model: document.getElementById('ns-model').value || null,
|
|
13795
14473
|
prompt: document.getElementById('ns-prompt').value.trim() || null,
|
|
14474
|
+
edit,
|
|
14475
|
+
stay: !ns.resume && !edit && document.getElementById('ns-stay').checked,
|
|
13796
14476
|
};
|
|
13797
14477
|
}
|
|
13798
14478
|
|
|
@@ -13818,11 +14498,12 @@ function renderNewSessionForm() {
|
|
|
13818
14498
|
const hint = document.getElementById('ns-wt-hint');
|
|
13819
14499
|
hint.hidden = !wtOn;
|
|
13820
14500
|
hint.textContent = `.claude/worktrees/${typeof v.worktree === 'string' ? v.worktree : '<name chosen by Claude>'}`;
|
|
14501
|
+
document.getElementById('ns-stay').disabled = v.edit;
|
|
13821
14502
|
const problem = newSessionProblem(v);
|
|
13822
14503
|
setNewSessionError(problem);
|
|
13823
14504
|
const start = document.getElementById('ns-start');
|
|
13824
14505
|
start.disabled = !v.cwd || !!problem || ns.busy;
|
|
13825
|
-
const verb = ns.resume ? 'Resume' : 'Start';
|
|
14506
|
+
const verb = ns.resume ? 'Resume' : v.edit ? 'Open' : 'Start';
|
|
13826
14507
|
start.textContent = v.cwd ? `${verb} in ${pathBasename(v.cwd)}` : verb;
|
|
13827
14508
|
}
|
|
13828
14509
|
|
|
@@ -13871,7 +14552,7 @@ function startNewSession() {
|
|
|
13871
14552
|
const id = crypto.randomUUID();
|
|
13872
14553
|
newSpecs.set(id, { ...(ns.resume ? { cwd: v.cwd } : v), mode: ns.resume ? 'pick' : 'new', startedAt: Date.now() });
|
|
13873
14554
|
setTerminalMode(id, true);
|
|
13874
|
-
if (
|
|
14555
|
+
if (v.stay) return startNewSessionInBackground(id, v);
|
|
13875
14556
|
sessions = mergePlaceholders(sessions.filter((s) => !s.placeholder));
|
|
13876
14557
|
closeNewSession();
|
|
13877
14558
|
fetchTasks(id).then(() => sessionsList.querySelector('.session-item.active')?.scrollIntoView({ block: 'nearest' }));
|
|
@@ -13975,6 +14656,10 @@ function initNewSession() {
|
|
|
13975
14656
|
document
|
|
13976
14657
|
.getElementById('ns-stay')
|
|
13977
14658
|
.addEventListener('change', (e) => store.setItem(NEW_SESSION_STAY_KEY, String(e.target.checked)));
|
|
14659
|
+
document.getElementById('ns-edit').addEventListener('change', (e) => {
|
|
14660
|
+
store.setItem(NEW_SESSION_EDIT_KEY, String(e.target.checked));
|
|
14661
|
+
renderNewSessionForm();
|
|
14662
|
+
});
|
|
13978
14663
|
for (const id of ['ns-name', 'ns-wt-name', 'ns-prompt', 'ns-model']) {
|
|
13979
14664
|
document.getElementById(id).addEventListener('input', renderNewSessionForm);
|
|
13980
14665
|
}
|