claude-code-kanban 6.1.1 → 7.0.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/README.md +3 -2
- package/cli.js +206 -168
- package/lib/frame-policy.js +79 -0
- package/lib/http-error.js +6 -0
- package/lib/linked-docs.js +5 -7
- package/lib/one-line.js +6 -0
- package/lib/panes.js +174 -0
- package/lib/parsers.js +150 -41
- package/lib/rate-limits.js +21 -0
- package/lib/retention.js +67 -19
- package/lib/scratchpad-dir.js +41 -0
- package/lib/session-cache.js +1 -1
- package/lib/session-events.js +3 -4
- package/lib/terminal.js +46 -6
- package/lib/worktrees.js +11 -2
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/hooks/activity.ts +3 -2
- package/plugin/plugins/claude-code-kanban/hooks/context.ts +18 -19
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +31 -21
- package/plugin/plugins/claude-code-kanban/tests/activity.test.ts +1 -1
- package/plugin/plugins/claude-code-kanban/tests/context.test.ts +33 -2
- package/public/app.js +1023 -170
- package/public/index.html +37 -0
- package/public/link-url.js +6 -3
- package/public/style.css +487 -41
- package/public/tree-highlight.js +74 -0
- package/server.js +194 -84
- package/skill-guides/dispatch.md +1 -1
package/public/app.js
CHANGED
|
@@ -624,7 +624,7 @@ async function fetchTasks(sessionId) {
|
|
|
624
624
|
const switched = sessionId !== currentSessionId;
|
|
625
625
|
currentSessionId = sessionId;
|
|
626
626
|
markOpenSessionRead();
|
|
627
|
-
if (switched) autoRevealLog(sessionId
|
|
627
|
+
if (switched) autoRevealLog(sessionId);
|
|
628
628
|
// A task change in the open session must not reset the messages, the agents or the owner
|
|
629
629
|
// filter: that reset rebuilds the panels and reads as a reload after each card move.
|
|
630
630
|
if (!refresh) {
|
|
@@ -799,8 +799,11 @@ function setMessagePanelVisible(open) {
|
|
|
799
799
|
}
|
|
800
800
|
|
|
801
801
|
// An empty board has nothing to show, so the log opens in its place. It is not the user's choice,
|
|
802
|
-
// so it is not stored, it closes again on a session with tasks, and a manual close sticks for that session.
|
|
803
|
-
|
|
802
|
+
// so it is not stored, it closes again on a session with tasks or panes, and a manual close sticks for that session.
|
|
803
|
+
// The pane layout loads after the switch, and applyPaneLayout asks again once it is in.
|
|
804
|
+
function autoRevealLog(sessionId) {
|
|
805
|
+
if (!paneLayouts.has(sessionId)) return;
|
|
806
|
+
const empty = !currentTasks.length && !paneLayout(sessionId).panes.length;
|
|
804
807
|
if (empty && !messagePanelOpen && !logDismissed.has(sessionId) && !wantsTerminal()) {
|
|
805
808
|
setMessagePanelVisible(true);
|
|
806
809
|
logAutoOpened = true;
|
|
@@ -889,7 +892,12 @@ async function viewAgentLog(agentId) {
|
|
|
889
892
|
if (!messagePanelOpen) toggleMessagePanel();
|
|
890
893
|
const header = document.querySelector('.message-panel-header h3');
|
|
891
894
|
if (header) {
|
|
892
|
-
|
|
895
|
+
const fullModel = agentModel(agent);
|
|
896
|
+
const model = shortModelName(fullModel);
|
|
897
|
+
const modelHtml = model
|
|
898
|
+
? `<span class="agent-log-model" title="${escapeHtml(fullModel)}">${escapeHtml(model)}</span> <span class="agent-ident-sep">·</span> `
|
|
899
|
+
: '';
|
|
900
|
+
header.innerHTML = `<span class="agent-log-title"><button class="agent-log-back" onclick="exitAgentLogMode()" title="Back to session log">←</button> ${modelHtml}${escapeHtml(agent.type || 'unknown')} <code class="agent-log-id">(${escapeHtml(shortId)})</code></span>`;
|
|
893
901
|
}
|
|
894
902
|
fetchAgentMessages();
|
|
895
903
|
if (agentLogSSE) {
|
|
@@ -2409,13 +2417,8 @@ function initModalResize() {
|
|
|
2409
2417
|
modal.classList.add('user-sized');
|
|
2410
2418
|
}
|
|
2411
2419
|
|
|
2412
|
-
const handle = document.createElement('div');
|
|
2413
|
-
handle.className = 'modal-resize-handle';
|
|
2414
|
-
handle.title = 'Drag to resize · double-click to reset';
|
|
2415
|
-
modal.appendChild(handle);
|
|
2416
|
-
|
|
2417
2420
|
let startW, startH, w, h;
|
|
2418
|
-
|
|
2421
|
+
const handle = createResizeGrip({
|
|
2419
2422
|
onStart() {
|
|
2420
2423
|
startW = modal.offsetWidth;
|
|
2421
2424
|
startH = modal.offsetHeight;
|
|
@@ -2441,16 +2444,25 @@ function initModalResize() {
|
|
|
2441
2444
|
store.setItem(hKey, `${h}px`);
|
|
2442
2445
|
}
|
|
2443
2446
|
},
|
|
2447
|
+
onReset() {
|
|
2448
|
+
modal.classList.remove('user-sized');
|
|
2449
|
+
store.removeItem(wKey);
|
|
2450
|
+
store.removeItem(hKey);
|
|
2451
|
+
},
|
|
2444
2452
|
});
|
|
2445
|
-
|
|
2446
|
-
handle.addEventListener('dblclick', () => {
|
|
2447
|
-
modal.classList.remove('user-sized');
|
|
2448
|
-
store.removeItem(wKey);
|
|
2449
|
-
store.removeItem(hKey);
|
|
2450
|
-
});
|
|
2453
|
+
modal.appendChild(handle);
|
|
2451
2454
|
});
|
|
2452
2455
|
}
|
|
2453
2456
|
|
|
2457
|
+
function createResizeGrip({ onReset, ...drag }) {
|
|
2458
|
+
const handle = document.createElement('div');
|
|
2459
|
+
handle.className = 'modal-resize-handle';
|
|
2460
|
+
handle.title = 'Drag to resize · double-click to reset';
|
|
2461
|
+
_initDragResize(handle, drag);
|
|
2462
|
+
handle.addEventListener('dblclick', onReset);
|
|
2463
|
+
return handle;
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2454
2466
|
const MODAL_ZOOM_KEY = 'modal-zoom';
|
|
2455
2467
|
const MODAL_ZOOM_MIN = 0.7;
|
|
2456
2468
|
const MODAL_ZOOM_MAX = 2.0;
|
|
@@ -2485,6 +2497,11 @@ function isAnyModalOpen() {
|
|
|
2485
2497
|
return document.querySelector('.modal-overlay.visible') !== null;
|
|
2486
2498
|
}
|
|
2487
2499
|
|
|
2500
|
+
// An open modal owns the zoom keys even when it has nothing to scale.
|
|
2501
|
+
function isZoomableOnScreen() {
|
|
2502
|
+
return isAnyModalOpen() ? isZoomableModalOpen() : isPaneDocOnScreen();
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2488
2505
|
const ZOOM_KEYS = { '+': 0.1, '=': 0.1, NumpadAdd: 0.1, '-': -0.1, _: -0.1, NumpadSubtract: -0.1, 0: 0, Numpad0: 0 };
|
|
2489
2506
|
|
|
2490
2507
|
function zoomDelta(e) {
|
|
@@ -3138,15 +3155,12 @@ function renderAgentFooter() {
|
|
|
3138
3155
|
: `active · ${formatDuration(elapsed)}`;
|
|
3139
3156
|
const descText = a.description || '';
|
|
3140
3157
|
const promptTrimmed = stripAnsi(stripTeammateWrapper((a.prompt || '').trim())).replace(/[\r\n]+/g, ' ');
|
|
3141
|
-
const displayText = descText || promptTrimmed;
|
|
3142
|
-
const displayTrunc = displayText.length > 60 ? `${displayText.substring(0, 60)}…` : displayText;
|
|
3143
|
-
const msgHtml = displayTrunc
|
|
3144
|
-
? `<div class="agent-message" title="${escapeHtml(displayText)}">${escapeHtml(displayTrunc)}</div>`
|
|
3145
|
-
: '';
|
|
3146
3158
|
const rawType = a.type || 'unknown';
|
|
3147
3159
|
const colonIdx = rawType.indexOf(':');
|
|
3148
3160
|
const typeNs = colonIdx > 0 ? rawType.substring(0, colonIdx + 1) : '';
|
|
3149
3161
|
const typeName = colonIdx > 0 ? rawType.substring(colonIdx + 1) : rawType;
|
|
3162
|
+
const displayText = descText || promptTrimmed || typeName;
|
|
3163
|
+
const taskHtml = `<div class="agent-task" title="${escapeHtml(displayText)}">${escapeHtml(displayText)}</div>`;
|
|
3150
3164
|
const agentNameVal = a.agentName || null;
|
|
3151
3165
|
const nameColor = agentNameVal ? getOwnerColor(agentNameVal) : null;
|
|
3152
3166
|
const nameBadgeHtml = nameColor
|
|
@@ -3155,15 +3169,16 @@ function renderAgentFooter() {
|
|
|
3155
3169
|
const agentColor = resolveNamedColor(a.color);
|
|
3156
3170
|
const colorStyle = agentColor ? ` style="border-left:3px solid ${agentColor.color}"` : '';
|
|
3157
3171
|
const selectedClass = a.agentId === currentAgentModalId ? ' selected' : '';
|
|
3158
|
-
const
|
|
3159
|
-
const
|
|
3160
|
-
|
|
3172
|
+
const fullModel = agentModel(a);
|
|
3173
|
+
const cardModel = shortModelName(fullModel);
|
|
3174
|
+
const modelHtml = cardModel
|
|
3175
|
+
? `<span class="agent-card-model" title="${escapeHtml(fullModel)}">${escapeHtml(cardModel)}</span><span class="agent-ident-sep">·</span>`
|
|
3161
3176
|
: '';
|
|
3162
3177
|
return `<div class="agent-card${selectedClass}" data-agent-id="${escapeHtml(a.agentId)}"${colorStyle} onclick="showAgentModal('${escAttrJs(a.agentId)}')">
|
|
3163
3178
|
${agentLogButton(a.agentId, 'agent-card-log-btn')}
|
|
3164
|
-
|
|
3165
|
-
<div class="agent-
|
|
3166
|
-
${
|
|
3179
|
+
${taskHtml}
|
|
3180
|
+
<div class="agent-ident-row">${modelHtml}<span class="agent-type-name" title="${escapeHtml(rawType)}">${typeNs ? `<span class="agent-type-ns">${escapeHtml(typeNs)}</span>` : ''}${escapeHtml(typeName)}</span>${nameBadgeHtml}</div>
|
|
3181
|
+
<div class="agent-status-row"><span class="agent-dot ${a.status}"></span><span class="agent-status">${statusText}</span></div>
|
|
3167
3182
|
</div>`;
|
|
3168
3183
|
})
|
|
3169
3184
|
.join('');
|
|
@@ -3266,7 +3281,7 @@ function showAgentModal(agentId) {
|
|
|
3266
3281
|
const modalNameLabel = agent.agentName ? ` · ${escapeHtml(agent.agentName)}` : '';
|
|
3267
3282
|
title.innerHTML = `${statusDot} ${escapeHtml(agent.type || 'unknown')}${modalNameLabel}`;
|
|
3268
3283
|
|
|
3269
|
-
const shortModel = shortModelName(agent
|
|
3284
|
+
const shortModel = shortModelName(agentModel(agent));
|
|
3270
3285
|
const shortId = agent.agentId ? agent.agentId.slice(0, 8) : '';
|
|
3271
3286
|
const chip = (label, value, opts = {}) => {
|
|
3272
3287
|
const cls = opts.cls ? ` ${opts.cls}` : '';
|
|
@@ -4874,7 +4889,7 @@ function initSessionGroupsDnd() {
|
|
|
4874
4889
|
sessionsList.addEventListener('contextmenu', sgOnContextMenu);
|
|
4875
4890
|
// The Menu key fires contextmenu at the focused element, which by then is our menu.
|
|
4876
4891
|
document.addEventListener('contextmenu', (e) => {
|
|
4877
|
-
if (e.target.closest?.('.sg-menu')) e.preventDefault();
|
|
4892
|
+
if (e.target.closest?.('.sg-menu, .pane-menu')) e.preventDefault();
|
|
4878
4893
|
});
|
|
4879
4894
|
sessionsList.addEventListener('keydown', (e) => {
|
|
4880
4895
|
const input = e.target.closest?.('.sg-name-input');
|
|
@@ -4915,11 +4930,8 @@ function sgCloseMenu() {
|
|
|
4915
4930
|
if (menu) menu.remove();
|
|
4916
4931
|
}
|
|
4917
4932
|
|
|
4918
|
-
//
|
|
4919
|
-
function
|
|
4920
|
-
const menu = document.getElementById('sg-menu');
|
|
4921
|
-
if (!menu || e.ctrlKey || e.altKey || e.metaKey) return;
|
|
4922
|
-
const items = [...menu.querySelectorAll('.sg-menu-item')];
|
|
4933
|
+
// Run in the capture phase: the global handler would otherwise read these keys as sidebar shortcuts.
|
|
4934
|
+
function menuKeydown(e, items, close) {
|
|
4923
4935
|
const i = items.indexOf(document.activeElement);
|
|
4924
4936
|
e.stopPropagation();
|
|
4925
4937
|
if (e.key === 'Enter' || e.key === ' ') return;
|
|
@@ -4929,10 +4941,23 @@ function sgMenuKeydown(e) {
|
|
|
4929
4941
|
else if (matchKey(e, 'ArrowUp')) next = i < 0 ? items.length - 1 : (i - 1 + items.length) % items.length;
|
|
4930
4942
|
else if (e.key === 'Home') next = 0;
|
|
4931
4943
|
else if (e.key === 'End') next = items.length - 1;
|
|
4932
|
-
else if (e.key === 'Escape' || e.key === 'Tab')
|
|
4944
|
+
else if (e.key === 'Escape' || e.key === 'Tab') close();
|
|
4933
4945
|
if (next !== null) items[next]?.focus();
|
|
4934
4946
|
}
|
|
4935
4947
|
|
|
4948
|
+
function placeMenu(menu, x, y) {
|
|
4949
|
+
document.body.appendChild(menu);
|
|
4950
|
+
const rect = menu.getBoundingClientRect();
|
|
4951
|
+
menu.style.left = `${Math.min(x, window.innerWidth - rect.width - 8)}px`;
|
|
4952
|
+
menu.style.top = `${Math.min(y, window.innerHeight - rect.height - 8)}px`;
|
|
4953
|
+
}
|
|
4954
|
+
|
|
4955
|
+
function sgMenuKeydown(e) {
|
|
4956
|
+
const menu = document.getElementById('sg-menu');
|
|
4957
|
+
if (!menu || e.ctrlKey || e.altKey || e.metaKey) return;
|
|
4958
|
+
menuKeydown(e, [...menu.querySelectorAll('.sg-menu-item')], sgCloseMenu);
|
|
4959
|
+
}
|
|
4960
|
+
|
|
4936
4961
|
function sgOpenMenuForKbSelection() {
|
|
4937
4962
|
const el = sessionsList.querySelector('.kb-selected');
|
|
4938
4963
|
if (!el?.matches('.session-item, .project-group-header')) return false;
|
|
@@ -4945,6 +4970,7 @@ function sgOpenMenuForKbSelection() {
|
|
|
4945
4970
|
|
|
4946
4971
|
function sgOpenMenu(x, y, kind, ref) {
|
|
4947
4972
|
sgCloseMenu();
|
|
4973
|
+
closePaneMenu();
|
|
4948
4974
|
const current = sgGroupOf(kind, ref);
|
|
4949
4975
|
const label = kind === 'project' ? 'project' : 'session';
|
|
4950
4976
|
const rows = sessionGroups
|
|
@@ -4966,10 +4992,7 @@ function sgOpenMenu(x, y, kind, ref) {
|
|
|
4966
4992
|
${rows}
|
|
4967
4993
|
<button class="sg-menu-item" role="menuitem" data-sg-move="__new__">+ New group…</button>
|
|
4968
4994
|
${current ? `<button class="sg-menu-item sg-menu-remove" role="menuitem" data-sg-move="__none__">Remove from “${escapeHtml(current.name)}”</button>` : ''}`;
|
|
4969
|
-
|
|
4970
|
-
const rect = menu.getBoundingClientRect();
|
|
4971
|
-
menu.style.left = `${Math.min(x, window.innerWidth - rect.width - 8)}px`;
|
|
4972
|
-
menu.style.top = `${Math.min(y, window.innerHeight - rect.height - 8)}px`;
|
|
4995
|
+
placeMenu(menu, x, y);
|
|
4973
4996
|
document.addEventListener('keydown', sgMenuKeydown, true);
|
|
4974
4997
|
menu.querySelector('.sg-menu-item')?.focus();
|
|
4975
4998
|
}
|
|
@@ -5811,7 +5834,8 @@ const SHORTCUT_TABS = [
|
|
|
5811
5834
|
{
|
|
5812
5835
|
title: 'View',
|
|
5813
5836
|
rows: [
|
|
5814
|
-
{ keys: ['
|
|
5837
|
+
{ keys: ['\\'], label: 'Toggle sidebar' },
|
|
5838
|
+
{ keys: ['[', ']'], label: 'Previous / next tab (Board and panes)' },
|
|
5815
5839
|
{ keys: ['T'], label: 'Toggle theme' },
|
|
5816
5840
|
{ keys: ['Shift', 'S'], combo: true, label: 'Storage manager' },
|
|
5817
5841
|
{ keys: ['Ctrl', 'Shift', 'Z'], combo: true, label: 'Zen mode (current session only)' },
|
|
@@ -6868,14 +6892,15 @@ const MODAL_CLOSERS = {
|
|
|
6868
6892
|
'terminal-manager-modal': () => closeTerminalManager(),
|
|
6869
6893
|
'new-session-modal': () => closeNewSession(),
|
|
6870
6894
|
'confirm-modal': () => closeConfirmModal?.(false),
|
|
6895
|
+
'pane-pop': () => closePanePop(),
|
|
6871
6896
|
};
|
|
6872
6897
|
|
|
6873
6898
|
document.addEventListener('keydown', (e) => {
|
|
6874
|
-
// Scale the open modal's reading surface instead of letting the browser zoom
|
|
6899
|
+
// Scale the open modal's reading surface, or the file pane's, instead of letting the browser zoom
|
|
6875
6900
|
// the whole page. Sits above the text-field guard so it still works with the
|
|
6876
6901
|
// caret in a field inside the modal.
|
|
6877
6902
|
const zoom = zoomDelta(e);
|
|
6878
|
-
if (zoom !== undefined &&
|
|
6903
|
+
if (zoom !== undefined && isZoomableOnScreen()) {
|
|
6879
6904
|
e.preventDefault();
|
|
6880
6905
|
adjustModalZoom(zoom);
|
|
6881
6906
|
return;
|
|
@@ -6976,11 +7001,15 @@ document.addEventListener('keydown', (e) => {
|
|
|
6976
7001
|
}
|
|
6977
7002
|
|
|
6978
7003
|
// Global shortcuts
|
|
6979
|
-
if (e
|
|
7004
|
+
if (matchKey(e, '\\')) {
|
|
6980
7005
|
e.preventDefault();
|
|
6981
7006
|
toggleSidebar();
|
|
6982
7007
|
return;
|
|
6983
7008
|
}
|
|
7009
|
+
if (matchKey(e, '[', ']') && cyclePane(e.key === ']' ? 1 : -1)) {
|
|
7010
|
+
e.preventDefault();
|
|
7011
|
+
return;
|
|
7012
|
+
}
|
|
6984
7013
|
if (e.code === 'KeyL' && e.shiftKey) {
|
|
6985
7014
|
e.preventDefault();
|
|
6986
7015
|
toggleMessagePanel();
|
|
@@ -7263,25 +7292,52 @@ function reviewItems() {
|
|
|
7263
7292
|
return (activeReview && reviewDrafts.get(activeReview.key)) || [];
|
|
7264
7293
|
}
|
|
7265
7294
|
|
|
7295
|
+
// Only one review is live. Each modal mounts on top of the stack, keyed by its panel, and
|
|
7296
|
+
// closing it brings back the review beneath. `reviewBase` (the pane on screen) is live only
|
|
7297
|
+
// when the stack is empty.
|
|
7298
|
+
const reviewStack = [];
|
|
7299
|
+
let reviewBase = null;
|
|
7300
|
+
|
|
7266
7301
|
// `context(range)` adds view-specific citation fields to a comment on the host DOM;
|
|
7267
7302
|
// `onComment()` runs after a comment is added; `onSent()` runs after a batch is delivered.
|
|
7268
|
-
function mountReview({
|
|
7269
|
-
|
|
7270
|
-
|
|
7271
|
-
|
|
7272
|
-
|
|
7273
|
-
|
|
7274
|
-
|
|
7275
|
-
|
|
7276
|
-
|
|
7277
|
-
|
|
7278
|
-
}
|
|
7279
|
-
|
|
7303
|
+
function mountReview(opts) {
|
|
7304
|
+
dropReviewEntry(opts.panelEl);
|
|
7305
|
+
reviewStack.push({ sessionId: currentSessionId, ...opts });
|
|
7306
|
+
showTopReview();
|
|
7307
|
+
}
|
|
7308
|
+
|
|
7309
|
+
// Unmounts only the review that panel holds, so a modal under another one keeps it.
|
|
7310
|
+
function unmountReview(panelEl) {
|
|
7311
|
+
dropReviewEntry(panelEl);
|
|
7312
|
+
showTopReview();
|
|
7313
|
+
}
|
|
7314
|
+
|
|
7315
|
+
function setBaseReview(opts) {
|
|
7316
|
+
if (reviewBase === opts) return;
|
|
7317
|
+
reviewBase = opts;
|
|
7318
|
+
showTopReview();
|
|
7319
|
+
}
|
|
7320
|
+
|
|
7321
|
+
function dropReviewEntry(panelEl) {
|
|
7322
|
+
const i = reviewStack.findIndex((o) => o.panelEl === panelEl);
|
|
7323
|
+
if (i >= 0) reviewStack.splice(i, 1);
|
|
7324
|
+
}
|
|
7325
|
+
|
|
7326
|
+
function showTopReview() {
|
|
7327
|
+
const opts = reviewStack.at(-1) || reviewBase;
|
|
7328
|
+
if (activeReview && activeReview.opts === opts) return;
|
|
7329
|
+
stopReview();
|
|
7330
|
+
if (opts) startReview(opts);
|
|
7331
|
+
}
|
|
7332
|
+
|
|
7333
|
+
function startReview(opts) {
|
|
7334
|
+
const { contentEl, panelEl, hostEl, source, frame, context, onComment, onSent, sessionId } = opts;
|
|
7280
7335
|
if (!sessionId) return;
|
|
7281
7336
|
const ctl = new AbortController();
|
|
7282
7337
|
const { signal } = ctl;
|
|
7283
7338
|
const key = reviewKey(sessionId, source);
|
|
7284
7339
|
activeReview = {
|
|
7340
|
+
opts,
|
|
7285
7341
|
contentEl,
|
|
7286
7342
|
panelEl,
|
|
7287
7343
|
hostEl,
|
|
@@ -7309,9 +7365,7 @@ function mountReview({
|
|
|
7309
7365
|
renderReviewPanel();
|
|
7310
7366
|
}
|
|
7311
7367
|
|
|
7312
|
-
|
|
7313
|
-
function unmountReview(ownPanel) {
|
|
7314
|
-
if (ownPanel && activeReview?.panelEl !== ownPanel) return;
|
|
7368
|
+
function stopReview() {
|
|
7315
7369
|
closeReviewPop();
|
|
7316
7370
|
if (!activeReview) return;
|
|
7317
7371
|
const { ctl, panelEl, hostEl } = activeReview;
|
|
@@ -7421,7 +7475,7 @@ function postToReviewFrame(type, id) {
|
|
|
7421
7475
|
|
|
7422
7476
|
// Runs inside the preview iframe as source text, so it must not close over anything outside
|
|
7423
7477
|
// its own body and arguments.
|
|
7424
|
-
function reviewBridge(textBefore, headingBefore, rangeAt) {
|
|
7478
|
+
function reviewBridge(textBefore, headingBefore, rangeAt, comboOf, fieldSelector) {
|
|
7425
7479
|
const P = 'cck-review:';
|
|
7426
7480
|
const marks = new Map();
|
|
7427
7481
|
let pending = null;
|
|
@@ -7498,11 +7552,51 @@ function reviewBridge(textBefore, headingBefore, rangeAt) {
|
|
|
7498
7552
|
};
|
|
7499
7553
|
document.addEventListener('mousedown', clear);
|
|
7500
7554
|
document.addEventListener('scroll', clear, true);
|
|
7555
|
+
// The sandbox has no allow-top-navigation and the board never sees events in here, so web links
|
|
7556
|
+
// and the keys the board claims go up as messages. A srcdoc document resolves relative hrefs,
|
|
7557
|
+
// `#id` too, against the board's URL, and loading that here ends on an error page because the
|
|
7558
|
+
// board refuses framing; a hash goes to location.hash instead, which stays on about:srcdoc.
|
|
7559
|
+
const openLink = (e) => {
|
|
7560
|
+
if (e.defaultPrevented || (e.type === 'auxclick' && e.button !== 1)) return;
|
|
7561
|
+
const a = e.target.closest?.('a[href]');
|
|
7562
|
+
const href = a?.getAttribute('href');
|
|
7563
|
+
if (!href) return;
|
|
7564
|
+
if (/^https?:\/\//i.test(href)) {
|
|
7565
|
+
e.preventDefault();
|
|
7566
|
+
send({ type: 'link', href: a.href });
|
|
7567
|
+
return;
|
|
7568
|
+
}
|
|
7569
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(href)) return;
|
|
7570
|
+
e.preventDefault();
|
|
7571
|
+
if (href.startsWith('#') && e.type === 'click') location.hash = href;
|
|
7572
|
+
};
|
|
7573
|
+
document.addEventListener('click', openLink);
|
|
7574
|
+
document.addEventListener('auxclick', openLink);
|
|
7575
|
+
// The terminal frame's claim format (terminal-frame.js keySigs).
|
|
7576
|
+
const keySigs = (e) => {
|
|
7577
|
+
const mods = [e.ctrlKey && 'ctrl', e.altKey && 'alt', e.shiftKey && 'shift', e.metaKey && 'meta']
|
|
7578
|
+
.filter(Boolean)
|
|
7579
|
+
.join('+');
|
|
7580
|
+
return [e.code && `${mods}|c:${e.code}`, typeof e.key === 'string' && `${mods}|k:${e.key.toLowerCase()}`].filter(
|
|
7581
|
+
Boolean,
|
|
7582
|
+
);
|
|
7583
|
+
};
|
|
7584
|
+
let claims = new Set();
|
|
7585
|
+
let forward = new Set();
|
|
7586
|
+
document.addEventListener('keydown', (e) => {
|
|
7587
|
+
if (e.defaultPrevented || e.target.closest?.(fieldSelector)) return;
|
|
7588
|
+
if (!forward.has(comboOf(e)) && !keySigs(e).some((s) => claims.has(s))) return;
|
|
7589
|
+
e.preventDefault();
|
|
7590
|
+
send({ type: 'key', key: e.key, code: e.code, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey, meta: e.metaKey });
|
|
7591
|
+
});
|
|
7501
7592
|
addEventListener('message', (e) => {
|
|
7502
7593
|
if (e.source !== parent || typeof e.data?.type !== 'string' || !e.data.type.startsWith(P)) return;
|
|
7503
7594
|
const type = e.data.type.slice(P.length);
|
|
7504
7595
|
const range = marks.get(e.data.id);
|
|
7505
|
-
if (type === '
|
|
7596
|
+
if (type === 'claims') {
|
|
7597
|
+
claims = new Set(e.data.keys.map((c) => keySigs(c)[0]));
|
|
7598
|
+
forward = new Set(e.data.forward);
|
|
7599
|
+
} else if (type === 'mark' && pending) {
|
|
7506
7600
|
marks.set(e.data.id, pending);
|
|
7507
7601
|
hl?.add(pending);
|
|
7508
7602
|
pending = null;
|
|
@@ -7524,7 +7618,34 @@ function reviewBridge(textBefore, headingBefore, rangeAt) {
|
|
|
7524
7618
|
});
|
|
7525
7619
|
}
|
|
7526
7620
|
|
|
7527
|
-
const
|
|
7621
|
+
const TEXT_FIELD_SELECTOR = 'input, textarea, select, [contenteditable]';
|
|
7622
|
+
|
|
7623
|
+
const REVIEW_BRIDGE_TAG = `<script>(${reviewBridge})(${reviewTextBefore}, ${reviewHeadingBefore}, ${reviewRangeAt}, ${ClaudeHub.comboOf}, ${JSON.stringify(TEXT_FIELD_SELECTOR)});</script>`;
|
|
7624
|
+
|
|
7625
|
+
// Frames from createPreviewFrame, which carry the bridge. A URL pane is another site and must
|
|
7626
|
+
// not drive the board.
|
|
7627
|
+
const bridgedFrames = new WeakSet();
|
|
7628
|
+
|
|
7629
|
+
function bridgedFrameEls() {
|
|
7630
|
+
return [...document.querySelectorAll('iframe[srcdoc]')].filter((f) => bridgedFrames.has(f));
|
|
7631
|
+
}
|
|
7632
|
+
|
|
7633
|
+
function sendBridgeClaims(frames, claims) {
|
|
7634
|
+
for (const f of frames) f.contentWindow?.postMessage({ type: `${REVIEW_MSG}claims`, ...claims }, '*');
|
|
7635
|
+
}
|
|
7636
|
+
|
|
7637
|
+
window.addEventListener('message', (e) => {
|
|
7638
|
+
const type =
|
|
7639
|
+
typeof e.data?.type === 'string' && e.data.type.startsWith(REVIEW_MSG) && e.data.type.slice(REVIEW_MSG.length);
|
|
7640
|
+
if (type !== 'link' && type !== 'key') return;
|
|
7641
|
+
const frame = bridgedFrameEls().find((f) => f.contentWindow === e.source);
|
|
7642
|
+
if (!frame) return;
|
|
7643
|
+
if (type === 'key') replayKey(e.data, frame);
|
|
7644
|
+
else {
|
|
7645
|
+
const url = linkUrl(e.data.href);
|
|
7646
|
+
if (url) hub.openExternal(url);
|
|
7647
|
+
}
|
|
7648
|
+
});
|
|
7528
7649
|
|
|
7529
7650
|
function reviewPopEl() {
|
|
7530
7651
|
let el = document.getElementById('review-pop');
|
|
@@ -7771,14 +7892,20 @@ function addSessionPreviewPath(sessionId, filePath) {
|
|
|
7771
7892
|
const key = canonicalPath(filePath);
|
|
7772
7893
|
const paths = getSessionPreviewPaths(sessionId).filter((p) => canonicalPath(p) !== key);
|
|
7773
7894
|
paths.unshift(filePath);
|
|
7774
|
-
|
|
7895
|
+
saveSessionPreviewPaths(sessionId, paths);
|
|
7775
7896
|
}
|
|
7776
7897
|
|
|
7777
7898
|
function removeSessionPreviewPath(sessionId, filePath) {
|
|
7778
7899
|
if (!sessionId) return;
|
|
7779
7900
|
const key = canonicalPath(filePath);
|
|
7780
|
-
|
|
7781
|
-
|
|
7901
|
+
saveSessionPreviewPaths(
|
|
7902
|
+
sessionId,
|
|
7903
|
+
getSessionPreviewPaths(sessionId).filter((p) => canonicalPath(p) !== key),
|
|
7904
|
+
);
|
|
7905
|
+
}
|
|
7906
|
+
|
|
7907
|
+
function saveSessionPreviewPaths(sessionId, paths) {
|
|
7908
|
+
if (paths.length) store.setItem(PREVIEW_STORAGE_PREFIX + sessionId, JSON.stringify(paths.slice(0, MAX_LINKED_DOCS)));
|
|
7782
7909
|
else store.removeItem(PREVIEW_STORAGE_PREFIX + sessionId);
|
|
7783
7910
|
}
|
|
7784
7911
|
|
|
@@ -7808,7 +7935,7 @@ async function mergeServerLinkedDocs() {
|
|
|
7808
7935
|
const known = new Set(local.map(canonicalPath));
|
|
7809
7936
|
const missing = (paths || []).filter((p) => !known.has(canonicalPath(p)));
|
|
7810
7937
|
if (!missing.length) continue;
|
|
7811
|
-
|
|
7938
|
+
saveSessionPreviewPaths(sessionId, [...missing, ...local]);
|
|
7812
7939
|
afterLinkedDocsChanged(sessionId);
|
|
7813
7940
|
}
|
|
7814
7941
|
}
|
|
@@ -7851,19 +7978,29 @@ function renderFrontmatterBlock(fm) {
|
|
|
7851
7978
|
// allow-same-origin puts it on an opaque origin, so it cannot touch this app's
|
|
7852
7979
|
// storage, DOM or API. srcdoc has no base URL, so the server has already embedded
|
|
7853
7980
|
// the document's local assets (lib/inline-assets.js); remote refs load normally.
|
|
7854
|
-
function
|
|
7855
|
-
bodyEl.innerHTML = '';
|
|
7981
|
+
function createPreviewFrame(className, srcdoc) {
|
|
7856
7982
|
const frame = document.createElement('iframe');
|
|
7857
|
-
frame.className =
|
|
7983
|
+
frame.className = className;
|
|
7858
7984
|
frame.setAttribute('sandbox', 'allow-scripts allow-popups');
|
|
7859
7985
|
frame.setAttribute('referrerpolicy', 'no-referrer');
|
|
7986
|
+
frame.srcdoc = srcdoc;
|
|
7987
|
+
bridgedFrames.add(frame);
|
|
7988
|
+
frame.addEventListener('load', () => sendBridgeClaims([frame], keyClaims()));
|
|
7989
|
+
return frame;
|
|
7990
|
+
}
|
|
7991
|
+
|
|
7992
|
+
function renderHtmlPreview(bodyEl, content) {
|
|
7993
|
+
bodyEl.innerHTML = '';
|
|
7860
7994
|
// After the document's end the parser still adds the script to <body>, and it runs after
|
|
7861
7995
|
// the page's own scripts.
|
|
7862
|
-
frame
|
|
7863
|
-
bodyEl.appendChild(frame);
|
|
7996
|
+
bodyEl.appendChild(createPreviewFrame('preview-html-frame', content + REVIEW_BRIDGE_TAG));
|
|
7864
7997
|
}
|
|
7865
7998
|
|
|
7866
|
-
function
|
|
7999
|
+
function isAbsolutePath(p) {
|
|
8000
|
+
return /^([a-zA-Z]:[\\/]|[\\/])/.test(p);
|
|
8001
|
+
}
|
|
8002
|
+
|
|
8003
|
+
function bindPreviewRelativeLinks(bodyEl, baseOf = () => currentPreviewPath) {
|
|
7867
8004
|
if (bodyEl.dataset.relLinkBound) return;
|
|
7868
8005
|
bodyEl.addEventListener('click', (e) => {
|
|
7869
8006
|
const a = e.target.closest('a[href]');
|
|
@@ -7871,11 +8008,10 @@ function bindPreviewRelativeLinks(bodyEl) {
|
|
|
7871
8008
|
const href = a.getAttribute('href');
|
|
7872
8009
|
if (!href || href.startsWith('#')) return;
|
|
7873
8010
|
const isAbsoluteUrl = /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith('//');
|
|
7874
|
-
const isAbsolutePath = href.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(href);
|
|
7875
8011
|
if (isAbsoluteUrl) return;
|
|
7876
8012
|
const cleanHref = href.replace(/#.*$/, '');
|
|
7877
8013
|
e.preventDefault();
|
|
7878
|
-
openPreviewByPath(cleanHref, isAbsolutePath ? undefined :
|
|
8014
|
+
openPreviewByPath(cleanHref, isAbsolutePath(href) ? undefined : baseOf(a), openFileInEditor);
|
|
7879
8015
|
});
|
|
7880
8016
|
bodyEl.dataset.relLinkBound = '1';
|
|
7881
8017
|
}
|
|
@@ -7898,41 +8034,50 @@ function renderSourcePreview(filePath, content) {
|
|
|
7898
8034
|
return `<pre class="preview-source"><code class="hljs">${escapeHtml(content)}</code></pre>`;
|
|
7899
8035
|
}
|
|
7900
8036
|
|
|
7901
|
-
|
|
7902
|
-
|
|
7903
|
-
|
|
7904
|
-
document.getElementById('preview-modal-title').textContent = fileName;
|
|
7905
|
-
const bodyEl = document.getElementById('preview-modal-body');
|
|
7906
|
-
const isHtml = kind === 'html';
|
|
7907
|
-
document.querySelector('#preview-modal .modal').classList.toggle('preview-html', isHtml);
|
|
7908
|
-
bindPreviewRelativeLinks(bodyEl);
|
|
7909
|
-
if (isHtml) {
|
|
7910
|
-
renderHtmlPreview(bodyEl, content);
|
|
7911
|
-
} else if (kind === 'text') {
|
|
8037
|
+
// Every kind but html, which needs a frame.
|
|
8038
|
+
function renderPreviewContent(bodyEl, filePath, content, kind) {
|
|
8039
|
+
if (kind === 'text') {
|
|
7912
8040
|
bodyEl.innerHTML = renderSourcePreview(filePath, content);
|
|
7913
8041
|
} else if (kind === 'image') {
|
|
7914
8042
|
const img = document.createElement('img');
|
|
7915
8043
|
img.className = 'preview-image';
|
|
7916
8044
|
img.src = `/api/preview/image?${new URLSearchParams({ path: filePath })}`;
|
|
7917
|
-
img.alt =
|
|
8045
|
+
img.alt = filePath.split(/[\\/]/).pop();
|
|
7918
8046
|
bodyEl.replaceChildren(img);
|
|
7919
8047
|
} else {
|
|
7920
8048
|
const { fm, body } = splitFrontmatter(content);
|
|
7921
8049
|
bodyEl.innerHTML = (fm ? renderFrontmatterBlock(fm) : '') + renderMarkdown(body);
|
|
7922
8050
|
}
|
|
8051
|
+
}
|
|
8052
|
+
|
|
8053
|
+
function openPreviewModal(filePath, content, kind) {
|
|
8054
|
+
currentPreviewPath = filePath;
|
|
8055
|
+
const fileName = filePath.split(/[\\/]/).pop();
|
|
8056
|
+
document.getElementById('preview-modal-title').textContent = fileName;
|
|
8057
|
+
const bodyEl = document.getElementById('preview-modal-body');
|
|
8058
|
+
const isHtml = kind === 'html';
|
|
8059
|
+
document.querySelector('#preview-modal .modal').classList.toggle('preview-html', isHtml);
|
|
8060
|
+
bindPreviewRelativeLinks(bodyEl);
|
|
8061
|
+
if (isHtml) renderHtmlPreview(bodyEl, content);
|
|
8062
|
+
else renderPreviewContent(bodyEl, filePath, content, kind);
|
|
7923
8063
|
document.getElementById('preview-modal-meta').textContent = filePath;
|
|
7924
8064
|
document.getElementById('preview-modal').classList.add('visible');
|
|
7925
8065
|
updatePreviewLinkBtn();
|
|
7926
|
-
|
|
7927
|
-
|
|
7928
|
-
|
|
7929
|
-
|
|
7930
|
-
|
|
7931
|
-
|
|
7932
|
-
|
|
7933
|
-
|
|
7934
|
-
|
|
7935
|
-
|
|
8066
|
+
const panelEl = document.getElementById('preview-review-panel');
|
|
8067
|
+
const opts = fileReviewOpts(filePath, kind, bodyEl, isHtml ? bodyEl.querySelector('iframe') : null);
|
|
8068
|
+
if (!opts) return unmountReview(panelEl);
|
|
8069
|
+
mountReview({ ...opts, panelEl, hostEl: document.querySelector('#preview-modal .modal'), onSent: closePreviewModal });
|
|
8070
|
+
}
|
|
8071
|
+
|
|
8072
|
+
// Null for an image, which takes no comments.
|
|
8073
|
+
function fileReviewOpts(filePath, kind, contentEl, frame) {
|
|
8074
|
+
if (kind === 'image') return null;
|
|
8075
|
+
return {
|
|
8076
|
+
contentEl,
|
|
8077
|
+
frame,
|
|
8078
|
+
source: { kind: 'file', label: pathBasename(filePath), path: filePath },
|
|
8079
|
+
context: kind === 'text' ? (range) => ({ line: sourceLineOf(contentEl, range) }) : null,
|
|
8080
|
+
};
|
|
7936
8081
|
}
|
|
7937
8082
|
|
|
7938
8083
|
function sourceLineOf(bodyEl, range) {
|
|
@@ -7948,6 +8093,8 @@ function isPreviewLinkedToCurrentSession() {
|
|
|
7948
8093
|
}
|
|
7949
8094
|
|
|
7950
8095
|
function updatePreviewLinkBtn() {
|
|
8096
|
+
const paneBtn = document.getElementById('preview-pane-btn');
|
|
8097
|
+
if (paneBtn) paneBtn.style.display = paneSessionId() ? '' : 'none';
|
|
7951
8098
|
const btn = document.getElementById('preview-link-btn');
|
|
7952
8099
|
if (!btn) return;
|
|
7953
8100
|
if (!currentSessionId) {
|
|
@@ -8216,11 +8363,35 @@ function openLinkedDoc(p, baseDir) {
|
|
|
8216
8363
|
postAndToast(url, body(p), toast);
|
|
8217
8364
|
}
|
|
8218
8365
|
|
|
8366
|
+
const LINKED_DOCS_COLLAPSED = 10;
|
|
8367
|
+
const linkedDocsExpanded = new Set();
|
|
8368
|
+
|
|
8369
|
+
function showAllToggle(expandedSet, key, list, limit, btnAttrs) {
|
|
8370
|
+
const expanded = expandedSet.has(key);
|
|
8371
|
+
const shown = expanded ? list : list.slice(0, limit);
|
|
8372
|
+
const moreHtml =
|
|
8373
|
+
list.length > limit
|
|
8374
|
+
? `<button type="button" class="expand-toggle-btn list-more" ${btnAttrs}>${expanded ? 'Show less' : `Show all ${list.length}`}</button>`
|
|
8375
|
+
: '';
|
|
8376
|
+
return { shown, moreHtml };
|
|
8377
|
+
}
|
|
8378
|
+
|
|
8379
|
+
function toggleSetMember(set, key) {
|
|
8380
|
+
if (!set.delete(key)) set.add(key);
|
|
8381
|
+
}
|
|
8382
|
+
|
|
8219
8383
|
function renderLinkedDocsHtml(sessionId) {
|
|
8220
8384
|
const paths = getSessionPreviewPaths(sessionId);
|
|
8385
|
+
const { shown, moreHtml } = showAllToggle(
|
|
8386
|
+
linkedDocsExpanded,
|
|
8387
|
+
sessionId,
|
|
8388
|
+
paths,
|
|
8389
|
+
LINKED_DOCS_COLLAPSED,
|
|
8390
|
+
'data-linked-docs-more',
|
|
8391
|
+
);
|
|
8221
8392
|
// Kicked off from the render rather than the three call sites, so a new surface that
|
|
8222
8393
|
// shows linked docs cannot forget it. Returns at once once every path is cached.
|
|
8223
|
-
const rows =
|
|
8394
|
+
const rows = shown.map((p) => ({ p, opener: linkedDocOpener(p) }));
|
|
8224
8395
|
const filePaths = rows.filter((r) => r.opener !== 'url').map((r) => r.p);
|
|
8225
8396
|
loadPreviewKinds(sessionId, filePaths);
|
|
8226
8397
|
const baseDir = getSessionBaseDir(sessionId);
|
|
@@ -8245,7 +8416,7 @@ function renderLinkedDocsHtml(sessionId) {
|
|
|
8245
8416
|
.join('');
|
|
8246
8417
|
// Rendered even when empty — the add button has to stay reachable.
|
|
8247
8418
|
const body = paths.length
|
|
8248
|
-
? `<ul class="linked-doc-list">${items}</ul
|
|
8419
|
+
? `<ul class="linked-doc-list">${items}</ul>${moreHtml}`
|
|
8249
8420
|
: '<div class="linked-docs-empty">No linked documents yet</div>';
|
|
8250
8421
|
return `<div class="linked-docs-section panel-section">
|
|
8251
8422
|
<div class="panel-section-header">
|
|
@@ -8464,13 +8635,14 @@ function scratchRowsHtml(rows) {
|
|
|
8464
8635
|
function scratchFilesInnerHtml(sessionId) {
|
|
8465
8636
|
const list = scratchFilesSection.get(sessionId);
|
|
8466
8637
|
if (!list.length) return '';
|
|
8467
|
-
const
|
|
8468
|
-
|
|
8469
|
-
|
|
8470
|
-
list
|
|
8471
|
-
|
|
8472
|
-
|
|
8473
|
-
|
|
8638
|
+
const { shown, moreHtml } = showAllToggle(
|
|
8639
|
+
scratchFilesExpanded,
|
|
8640
|
+
sessionId,
|
|
8641
|
+
list,
|
|
8642
|
+
SCRATCH_FILES_COLLAPSED,
|
|
8643
|
+
`onclick="toggleScratchFiles('${escAttrJs(sessionId)}')"`,
|
|
8644
|
+
);
|
|
8645
|
+
return `${scratchRowsHtml(shown)}${moreHtml}`;
|
|
8474
8646
|
}
|
|
8475
8647
|
|
|
8476
8648
|
// Delegated at the document because the whole zen panel is re-rendered on every SSE tick,
|
|
@@ -8510,8 +8682,7 @@ function renderScratchFilesHtml(sessionId) {
|
|
|
8510
8682
|
|
|
8511
8683
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
8512
8684
|
function toggleScratchFiles(sessionId) {
|
|
8513
|
-
|
|
8514
|
-
else scratchFilesExpanded.add(sessionId);
|
|
8685
|
+
toggleSetMember(scratchFilesExpanded, sessionId);
|
|
8515
8686
|
scratchFilesSection.repaint(sessionId);
|
|
8516
8687
|
}
|
|
8517
8688
|
|
|
@@ -8520,10 +8691,17 @@ function toggleScratchFiles(sessionId) {
|
|
|
8520
8691
|
function bindLinkedDocsHandlers(container, sessionId) {
|
|
8521
8692
|
if (!container) return;
|
|
8522
8693
|
container.addEventListener('click', (e) => {
|
|
8523
|
-
const hit = e.target.closest(
|
|
8694
|
+
const hit = e.target.closest(
|
|
8695
|
+
'.linked-doc-link, .linked-doc-copy, .linked-doc-remove, .linked-docs-add-btn, [data-linked-docs-more]',
|
|
8696
|
+
);
|
|
8524
8697
|
if (!hit || hit.target === '_blank') return;
|
|
8525
8698
|
e.preventDefault();
|
|
8526
|
-
if (hit.
|
|
8699
|
+
if (hit.hasAttribute('data-linked-docs-more')) {
|
|
8700
|
+
toggleSetMember(linkedDocsExpanded, sessionId);
|
|
8701
|
+
const wrap = document.createElement('div');
|
|
8702
|
+
wrap.innerHTML = renderLinkedDocsHtml(sessionId);
|
|
8703
|
+
container.replaceChildren(...wrap.firstElementChild.childNodes);
|
|
8704
|
+
} else if (hit.classList.contains('linked-docs-add-btn')) {
|
|
8527
8705
|
startLinkedDocInput(container, sessionId);
|
|
8528
8706
|
} else if (hit.classList.contains('linked-doc-copy')) {
|
|
8529
8707
|
copyWithFeedback(hit.dataset.path, hit);
|
|
@@ -8653,6 +8831,7 @@ function setupEventSource() {
|
|
|
8653
8831
|
console.warn('[SSE] Reconnected after drop — forcing full refresh');
|
|
8654
8832
|
fetchSessions().catch(() => {});
|
|
8655
8833
|
if (currentSessionId) fetchTasks(currentSessionId);
|
|
8834
|
+
refetchPaneLayout();
|
|
8656
8835
|
if (terminalAvailable())
|
|
8657
8836
|
loadTerminals()
|
|
8658
8837
|
.then(renderSessions)
|
|
@@ -8760,6 +8939,10 @@ function setupEventSource() {
|
|
|
8760
8939
|
handleSessionPinEvent(data);
|
|
8761
8940
|
}
|
|
8762
8941
|
|
|
8942
|
+
if (data.type === 'pane:changed') {
|
|
8943
|
+
applyPaneLayout(data.sessionId, data.layout);
|
|
8944
|
+
}
|
|
8945
|
+
|
|
8763
8946
|
if (data.type === 'team-update') {
|
|
8764
8947
|
const teamSession = sessions.find((s) => s.isTeam && s.teamName === data.teamName);
|
|
8765
8948
|
if (teamSession) {
|
|
@@ -8948,10 +9131,10 @@ const INPUT_PRICE_PER_MTOK = [
|
|
|
8948
9131
|
function cacheRewrite(usage, modelId, writeRate) {
|
|
8949
9132
|
const price = INPUT_PRICE_PER_MTOK.find(([prefix]) => modelId?.startsWith(prefix))?.[1];
|
|
8950
9133
|
const tokens =
|
|
8951
|
-
(usage
|
|
8952
|
-
(usage
|
|
8953
|
-
(usage
|
|
8954
|
-
(usage
|
|
9134
|
+
(usage?.input_tokens || 0) +
|
|
9135
|
+
(usage?.cache_read_input_tokens || 0) +
|
|
9136
|
+
(usage?.cache_creation_input_tokens || 0) +
|
|
9137
|
+
(usage?.output_tokens || 0);
|
|
8955
9138
|
if (!price || !tokens) return null;
|
|
8956
9139
|
return { tokens, usd: (tokens / 1e6) * price * writeRate };
|
|
8957
9140
|
}
|
|
@@ -8982,47 +9165,48 @@ function tickCacheTimers() {
|
|
|
8982
9165
|
}
|
|
8983
9166
|
}
|
|
8984
9167
|
|
|
8985
|
-
function renderCacheTimer(
|
|
9168
|
+
function renderCacheTimer({ at, ttl, usage, model }) {
|
|
8986
9169
|
const cache = CACHE_TTL[ttl];
|
|
8987
|
-
if (!
|
|
8988
|
-
const expiresAt =
|
|
8989
|
-
const rewrite = cacheRewrite(usage,
|
|
9170
|
+
if (!at || !cache) return '';
|
|
9171
|
+
const expiresAt = at + cache.ms;
|
|
9172
|
+
const rewrite = cacheRewrite(usage, model, cache.write);
|
|
8990
9173
|
const expiredText = rewrite ? `expired · ~${formatCost(rewrite.usd)}` : 'expired';
|
|
8991
9174
|
const { text, color } = formatCacheLeft(expiresAt, expiredText);
|
|
8992
9175
|
cacheTimerInterval ??= setInterval(tickCacheTimers, 1000);
|
|
8993
|
-
const
|
|
9176
|
+
const clock = new Date(expiresAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
8994
9177
|
const cost = rewrite
|
|
8995
9178
|
? ` It writes ${formatTokens(rewrite.tokens / 1000)} tokens again, about ${formatCost(rewrite.usd)} at list price.`
|
|
8996
9179
|
: '';
|
|
8997
|
-
const title = `Prompt cache, ${ttl} TTL. Expires at ${
|
|
9180
|
+
const title = `Prompt cache, ${ttl} TTL. Expires at ${clock}; the next request after that writes it again.${cost}`;
|
|
8998
9181
|
return `<div class="stat-item" title="${escapeHtml(title)}"><span class="stat-label">Cache</span><span class="stat-value cache-timer" data-expires="${escapeHtml(expiresAt)}" data-expired-text="${escapeHtml(expiredText)}" style="color:${color}">${text}</span></div>`;
|
|
8999
9182
|
}
|
|
9000
9183
|
|
|
9184
|
+
// The Cache row and the current-usage rows come from the transcript (session.cache); the rest needs the plugin's file.
|
|
9001
9185
|
function renderContextDetail(session, { tokens = true } = {}) {
|
|
9002
9186
|
const ctx = getCtx(session);
|
|
9003
|
-
|
|
9004
|
-
|
|
9005
|
-
const
|
|
9006
|
-
const
|
|
9007
|
-
|
|
9008
|
-
const
|
|
9009
|
-
|
|
9010
|
-
const
|
|
9011
|
-
|
|
9012
|
-
|
|
9013
|
-
<div class="stat-item"><span class="stat-label">Cache write</span><span class="stat-value">${formatTokens(
|
|
9014
|
-
<div class="stat-item"><span class="stat-label">Current input</span><span class="stat-value">${formatTokens(
|
|
9015
|
-
<div class="stat-item"><span class="stat-label">Current output</span><span class="stat-value">${formatTokens(
|
|
9016
|
-
<div class="stat-divider"></div
|
|
9017
|
-
|
|
9187
|
+
const reply = session.cache;
|
|
9188
|
+
if (!ctx && !reply) return '';
|
|
9189
|
+
const usage = reply?.usage;
|
|
9190
|
+
const cost = session.contextStatus?.cost || {};
|
|
9191
|
+
const color = ctx && getContextColor(ctx.usedTokens, ctx.modelName);
|
|
9192
|
+
const title = ctx?.modelName || reply?.modelName || 'Context Window';
|
|
9193
|
+
|
|
9194
|
+
const usageRows =
|
|
9195
|
+
tokens && usage
|
|
9196
|
+
? `<div class="stat-item"><span class="stat-label">Cache read</span><span class="stat-value">${formatTokens(usage.cache_read_input_tokens / 1000)}</span></div>
|
|
9197
|
+
<div class="stat-item"><span class="stat-label">Cache write</span><span class="stat-value">${formatTokens(usage.cache_creation_input_tokens / 1000)}</span></div>
|
|
9198
|
+
<div class="stat-item"><span class="stat-label">Current input</span><span class="stat-value">${formatTokens(usage.input_tokens / 1000)}</span></div>
|
|
9199
|
+
<div class="stat-item"><span class="stat-label">Current output</span><span class="stat-value">${formatTokens(usage.output_tokens / 1000)}</span></div>
|
|
9200
|
+
<div class="stat-divider"></div>`
|
|
9201
|
+
: '';
|
|
9202
|
+
const totalRows =
|
|
9203
|
+
tokens && ctx
|
|
9204
|
+
? `<div class="stat-item"><span class="stat-label">Total input</span><span class="stat-value">${formatTokens(ctx.inputTokens / 1000)}</span></div>
|
|
9018
9205
|
${ctx.outputTokens ? `<div class="stat-item"><span class="stat-label">Total output</span><span class="stat-value">${formatTokens(ctx.outputTokens / 1000)}</span></div>` : ''}
|
|
9019
9206
|
<div class="stat-divider"></div>`
|
|
9020
|
-
|
|
9021
|
-
|
|
9022
|
-
|
|
9023
|
-
<div class="detail-context">
|
|
9024
|
-
<div class="detail-context-title">${ctx.modelName ? escapeHtml(ctx.modelName) : 'Context Window'}</div>
|
|
9025
|
-
<div class="detail-context-bar">
|
|
9207
|
+
: '';
|
|
9208
|
+
const bar = ctx
|
|
9209
|
+
? `<div class="detail-context-bar">
|
|
9026
9210
|
<div class="context-bar-track">
|
|
9027
9211
|
<div class="context-bar-fill" style="width:${ctx.pct}%;background:${color}"></div>
|
|
9028
9212
|
${renderMarkers(ctx.markers)}
|
|
@@ -9030,12 +9214,19 @@ function renderContextDetail(session, { tokens = true } = {}) {
|
|
|
9030
9214
|
</div>
|
|
9031
9215
|
<div class="detail-context-summary">
|
|
9032
9216
|
<span style="color:${color}">${Math.round(ctx.pct)}% used</span>
|
|
9033
|
-
<span${ctx.compacts ? ` title="Auto-compact window · model window ${escapeHtml(formatTokens(ctx.modelSize / 1000))}"` : ''}>${formatTokens(ctx.usedTokens / 1000)} / ${formatTokens(
|
|
9034
|
-
</div
|
|
9217
|
+
<span${ctx.compacts ? ` title="Auto-compact window · model window ${escapeHtml(formatTokens(ctx.modelSize / 1000))}"` : ''}>${formatTokens(ctx.usedTokens / 1000)} / ${formatTokens(ctx.size / 1000)}</span>
|
|
9218
|
+
</div>`
|
|
9219
|
+
: '';
|
|
9220
|
+
|
|
9221
|
+
return `
|
|
9222
|
+
<div class="detail-context">
|
|
9223
|
+
<div class="detail-context-title">${escapeHtml(title)}</div>
|
|
9224
|
+
${bar}
|
|
9035
9225
|
<div class="detail-context-stats">
|
|
9036
|
-
${
|
|
9037
|
-
|
|
9038
|
-
${
|
|
9226
|
+
${usageRows}
|
|
9227
|
+
${totalRows}
|
|
9228
|
+
${ctx ? `<div class="stat-item"><span class="stat-label">Cost</span><span class="stat-value" style="color:${getCostColor(cost.total_cost_usd)}">${formatCost(cost.total_cost_usd)}</span></div>` : ''}
|
|
9229
|
+
${reply ? renderCacheTimer(reply) : ''}
|
|
9039
9230
|
${cost.total_duration_ms != null ? `<div class="stat-item"><span class="stat-label">Duration</span><span class="stat-value">${formatDuration(cost.total_duration_ms)}</span></div>` : ''}
|
|
9040
9231
|
${cost.total_api_duration_ms != null ? `<div class="stat-item"><span class="stat-label">API time</span><span class="stat-value">${formatDuration(cost.total_api_duration_ms)}</span></div>` : ''}
|
|
9041
9232
|
${cost.total_lines_added != null ? `<div class="stat-item"><span class="stat-label">Lines</span><span class="stat-value"><span style="color:${CONTEXT_COLORS.green}">+${(cost.total_lines_added || 0).toLocaleString()}</span> / <span style="color:${CONTEXT_COLORS.red}">-${(cost.total_lines_removed || 0).toLocaleString()}</span></span></div>` : ''}
|
|
@@ -10530,9 +10721,10 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
|
|
|
10530
10721
|
});
|
|
10531
10722
|
html += `</div>`;
|
|
10532
10723
|
|
|
10533
|
-
|
|
10724
|
+
const contextHtml = renderContextDetail(session);
|
|
10725
|
+
if (contextHtml) {
|
|
10534
10726
|
html += `<hr style="border: none; border-top: 1px solid var(--border); margin: 12px 0;">`;
|
|
10535
|
-
html +=
|
|
10727
|
+
html += contextHtml;
|
|
10536
10728
|
}
|
|
10537
10729
|
|
|
10538
10730
|
if (planContent) {
|
|
@@ -11266,6 +11458,10 @@ function shortModelName(model) {
|
|
|
11266
11458
|
return model ? model.replace(/^claude-/, '').replace(/-\d{8}$/, '') : '';
|
|
11267
11459
|
}
|
|
11268
11460
|
|
|
11461
|
+
function agentModel(agent) {
|
|
11462
|
+
return agent.model || agent.modelAlias || '';
|
|
11463
|
+
}
|
|
11464
|
+
|
|
11269
11465
|
function renderWorkflowRun(run) {
|
|
11270
11466
|
const body = document.getElementById('workflow-modal-body');
|
|
11271
11467
|
const allDone = run.startedCount > 0 && run.doneCount >= run.startedCount;
|
|
@@ -11617,6 +11813,644 @@ function filterByOwner(value) {
|
|
|
11617
11813
|
|
|
11618
11814
|
//#endregion
|
|
11619
11815
|
|
|
11816
|
+
//#region PANES
|
|
11817
|
+
// Layouts come from /api/panes and `pane:changed` (the pane contract, lib/panes.js). Every write
|
|
11818
|
+
// bumps the layout's rev and the event carries the whole layout, so a copy is replaced only by
|
|
11819
|
+
// a higher rev: the echo of this tab's own write and a late GET both lose to what it holds.
|
|
11820
|
+
// Which tab is on screen is this browser tab's own state, never the server's.
|
|
11821
|
+
const PANE_EMPTY_LAYOUT = { panes: [] };
|
|
11822
|
+
const paneLayouts = new Map();
|
|
11823
|
+
let paneLayoutSid = null;
|
|
11824
|
+
|
|
11825
|
+
// Targets the server's probe found refusing framing, kept for the page's life; a reload asks again.
|
|
11826
|
+
const paneRefused = new Set();
|
|
11827
|
+
|
|
11828
|
+
// Every origin above a pane's frame. Firefox has no ancestorOrigins, so under the hub there it
|
|
11829
|
+
// names the board only; a refused frame then shows the browser's own error.
|
|
11830
|
+
function paneAncestors() {
|
|
11831
|
+
return [location.origin, ...(location.ancestorOrigins || [])];
|
|
11832
|
+
}
|
|
11833
|
+
|
|
11834
|
+
function paneFrameable(pane) {
|
|
11835
|
+
return pane.kind !== 'url' || !paneRefused.has(pane.target);
|
|
11836
|
+
}
|
|
11837
|
+
|
|
11838
|
+
// allow-scripts with allow-same-origin on the board's or the hub's origin would lift the sandbox.
|
|
11839
|
+
// The server refuses the board's own names, but under the hub the page is served from the
|
|
11840
|
+
// hub's proxy port, which only the page knows. Any loopback name reaches the same port.
|
|
11841
|
+
const PANE_LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
11842
|
+
|
|
11843
|
+
function paneOwnOrigin(pane) {
|
|
11844
|
+
if (pane.kind !== 'url') return false;
|
|
11845
|
+
const u = new URL(pane.target);
|
|
11846
|
+
const loopback = PANE_LOOPBACK.has(u.hostname);
|
|
11847
|
+
return paneAncestors().some((origin) => {
|
|
11848
|
+
const a = new URL(origin);
|
|
11849
|
+
const host = a.hostname === u.hostname || (loopback && PANE_LOOPBACK.has(a.hostname));
|
|
11850
|
+
return a.protocol === u.protocol && a.port === u.port && host;
|
|
11851
|
+
});
|
|
11852
|
+
}
|
|
11853
|
+
|
|
11854
|
+
async function probePaneFraming(sid, pane) {
|
|
11855
|
+
const q = new URLSearchParams({ ancestors: paneAncestors().join(',') });
|
|
11856
|
+
const { frameable } = await paneRequest(
|
|
11857
|
+
'GET',
|
|
11858
|
+
`/api/panes/${encodeURIComponent(sid)}/${encodeURIComponent(pane.id)}/framing?${q}`,
|
|
11859
|
+
);
|
|
11860
|
+
if (frameable === false) paneRefused.add(pane.target);
|
|
11861
|
+
return frameable;
|
|
11862
|
+
}
|
|
11863
|
+
|
|
11864
|
+
function paneLayout(sid) {
|
|
11865
|
+
return paneLayouts.get(sid) || PANE_EMPTY_LAYOUT;
|
|
11866
|
+
}
|
|
11867
|
+
|
|
11868
|
+
function applyPaneLayout(sid, layout) {
|
|
11869
|
+
const held = paneLayouts.get(sid);
|
|
11870
|
+
if (!layout || (held && layout.rev <= held.rev)) return;
|
|
11871
|
+
paneLayouts.set(sid, layout);
|
|
11872
|
+
const targets = new Map(layout.panes.map((p) => [p.id, p.target]));
|
|
11873
|
+
for (const p of held?.panes || []) {
|
|
11874
|
+
if (targets.get(p.id) !== p.target) unmountPane(sid, p.id);
|
|
11875
|
+
}
|
|
11876
|
+
if (!targets.has(getActivePaneId(sid))) setActivePaneId(sid, 'board');
|
|
11877
|
+
if (sid !== paneSessionId()) return;
|
|
11878
|
+
syncPanes();
|
|
11879
|
+
autoRevealLog(sid);
|
|
11880
|
+
}
|
|
11881
|
+
|
|
11882
|
+
async function fetchPaneLayout(sid) {
|
|
11883
|
+
try {
|
|
11884
|
+
const r = await fetch(`/api/panes/${encodeURIComponent(sid)}`);
|
|
11885
|
+
if (r.ok) applyPaneLayout(sid, await r.json());
|
|
11886
|
+
} catch {}
|
|
11887
|
+
}
|
|
11888
|
+
|
|
11889
|
+
function refetchPaneLayout() {
|
|
11890
|
+
const sid = paneSessionId();
|
|
11891
|
+
if (sid) fetchPaneLayout(sid);
|
|
11892
|
+
}
|
|
11893
|
+
|
|
11894
|
+
async function paneRequest(method, path, body) {
|
|
11895
|
+
try {
|
|
11896
|
+
const r = await fetch(path, {
|
|
11897
|
+
method,
|
|
11898
|
+
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
|
11899
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
11900
|
+
});
|
|
11901
|
+
const data = await r.json().catch(() => ({}));
|
|
11902
|
+
return r.ok ? data : { error: data.error || `${method} failed (${r.status})`, code: data.code };
|
|
11903
|
+
} catch {
|
|
11904
|
+
return { error: 'The board is not reachable', code: 'network' };
|
|
11905
|
+
}
|
|
11906
|
+
}
|
|
11907
|
+
|
|
11908
|
+
// sessionStorage `cck.paneActive`: {[sessionId]: paneId} in recency order.
|
|
11909
|
+
const PANE_ACTIVE_KEY = 'cck.paneActive';
|
|
11910
|
+
const PANE_ACTIVE_CAP = 20;
|
|
11911
|
+
let paneActive = {};
|
|
11912
|
+
try {
|
|
11913
|
+
const map = JSON.parse(sessionStorage.getItem(PANE_ACTIVE_KEY));
|
|
11914
|
+
if (map && typeof map === 'object') paneActive = map;
|
|
11915
|
+
} catch {}
|
|
11916
|
+
|
|
11917
|
+
function getActivePaneId(sid) {
|
|
11918
|
+
return paneActive[sid] || 'board';
|
|
11919
|
+
}
|
|
11920
|
+
|
|
11921
|
+
function setActivePaneId(sid, id) {
|
|
11922
|
+
if (getActivePaneId(sid) === id) return;
|
|
11923
|
+
delete paneActive[sid];
|
|
11924
|
+
if (id !== 'board') paneActive[sid] = id;
|
|
11925
|
+
paneActive = Object.fromEntries(Object.entries(paneActive).slice(-PANE_ACTIVE_CAP));
|
|
11926
|
+
try {
|
|
11927
|
+
sessionStorage.setItem(PANE_ACTIVE_KEY, JSON.stringify(paneActive));
|
|
11928
|
+
} catch {}
|
|
11929
|
+
}
|
|
11930
|
+
|
|
11931
|
+
const PANE_FRAME_LIMIT = 3;
|
|
11932
|
+
const PANE_URL_SANDBOX = 'allow-scripts allow-same-origin allow-forms allow-popups allow-downloads';
|
|
11933
|
+
const PANE_ICONS = {
|
|
11934
|
+
board:
|
|
11935
|
+
'<svg class="kind" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="5" height="16" rx="1"/><rect x="10" y="4" width="5" height="10" rx="1"/><rect x="17" y="4" width="4" height="13" rx="1"/></svg>',
|
|
11936
|
+
url: '<svg class="kind" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15 15 0 0 1 0 20M12 2a15 15 0 0 0 0 20"/></svg>',
|
|
11937
|
+
file: '<svg class="kind" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>',
|
|
11938
|
+
warning:
|
|
11939
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12" y2="17"/></svg>',
|
|
11940
|
+
};
|
|
11941
|
+
// Map order is recency: the first entry is the one evicted. Keys are `${sessionId}/${paneId}`,
|
|
11942
|
+
// and the limit spans sessions, so a session switch cannot pile up frames.
|
|
11943
|
+
const paneFrames = new Map();
|
|
11944
|
+
const paneReviews = new WeakMap();
|
|
11945
|
+
const paneViews = document.getElementById('pane-views');
|
|
11946
|
+
const paneTabs = document.getElementById('pane-tabs');
|
|
11947
|
+
const panePin = document.getElementById('pane-pin');
|
|
11948
|
+
const paneStrip = document.getElementById('pane-strip');
|
|
11949
|
+
const panePop = document.getElementById('pane-pop');
|
|
11950
|
+
const panePopInput = document.getElementById('pane-pop-input');
|
|
11951
|
+
const panePopDocs = document.getElementById('pane-pop-docs');
|
|
11952
|
+
const panePopDetect = document.getElementById('pane-pop-detect');
|
|
11953
|
+
let paneTabsHtml = '';
|
|
11954
|
+
|
|
11955
|
+
function paneSessionId() {
|
|
11956
|
+
return viewMode === 'session' ? currentSessionId : null;
|
|
11957
|
+
}
|
|
11958
|
+
|
|
11959
|
+
function activePane(sid) {
|
|
11960
|
+
const id = getActivePaneId(sid);
|
|
11961
|
+
return paneLayout(sid).panes.find((p) => p.id === id) || null;
|
|
11962
|
+
}
|
|
11963
|
+
|
|
11964
|
+
function syncPanes() {
|
|
11965
|
+
const sid = paneSessionId();
|
|
11966
|
+
if (sid !== paneLayoutSid) {
|
|
11967
|
+
paneLayoutSid = sid;
|
|
11968
|
+
if (sid) fetchPaneLayout(sid);
|
|
11969
|
+
}
|
|
11970
|
+
const layout = sid ? paneLayout(sid) : null;
|
|
11971
|
+
const pane = sid ? activePane(sid) : null;
|
|
11972
|
+
sessionView.classList.toggle('has-panes', !!layout);
|
|
11973
|
+
sessionView.classList.toggle('pane-mode', !!pane);
|
|
11974
|
+
if (!layout || wantsTerminal()) closePanePop();
|
|
11975
|
+
const key = pane ? mountPane(sid, pane) : null;
|
|
11976
|
+
for (const el of paneViews.children) el.classList.toggle('on', el.dataset.key === key);
|
|
11977
|
+
// Off screen, a framed page keeps focus and takes keys the user aims at the board.
|
|
11978
|
+
const focused = document.activeElement;
|
|
11979
|
+
if (paneViews.contains(focused) && (wantsTerminal() || !focused.closest('.pane-view.on'))) focused.blur();
|
|
11980
|
+
syncPaneReview();
|
|
11981
|
+
if (!layout) return;
|
|
11982
|
+
for (const b of document.querySelectorAll('.pane-tool')) b.disabled = !pane;
|
|
11983
|
+
renderPaneTabs(sid, layout);
|
|
11984
|
+
}
|
|
11985
|
+
|
|
11986
|
+
function renderPaneTabs(sid, layout) {
|
|
11987
|
+
const active = getActivePaneId(sid);
|
|
11988
|
+
const tab = (id, cls, inner, tip) =>
|
|
11989
|
+
`<div class="pane-tab ${cls}${active === id ? ' on' : ''}" role="tab" data-pane="${escapeHtml(id)}" title="${escapeHtml(tip)}">${inner}</div>`;
|
|
11990
|
+
const board = tab(
|
|
11991
|
+
'board',
|
|
11992
|
+
'pinned',
|
|
11993
|
+
`${PANE_ICONS.board}<span class="pane-title">Board</span><span class="pin-meta">${currentTasks.length}</span>`,
|
|
11994
|
+
'Board',
|
|
11995
|
+
);
|
|
11996
|
+
const panes = layout.panes.map((p) => {
|
|
11997
|
+
const state = !paneFrameable(p) ? 'blocked' : paneFrames.has(`${sid}/${p.id}`) ? 'loaded' : '';
|
|
11998
|
+
const stateTip = state === 'blocked' ? 'Site refuses framing' : state ? 'Loaded' : 'Unloaded (loads on open)';
|
|
11999
|
+
return tab(
|
|
12000
|
+
p.id,
|
|
12001
|
+
'',
|
|
12002
|
+
`${p.kind === 'url' ? PANE_ICONS.url : PANE_ICONS.file}<span class="pane-title">${escapeHtml(p.title)}</span><span class="state ${state}" title="${stateTip}"></span><button class="pane-x" data-close="${escapeHtml(p.id)}" title="Close pane" aria-label="Close pane">×</button>`,
|
|
12003
|
+
p.target,
|
|
12004
|
+
);
|
|
12005
|
+
});
|
|
12006
|
+
const pin = board + (panes.length ? '<span class="pane-sep"></span>' : '');
|
|
12007
|
+
const html = panes.join('');
|
|
12008
|
+
// syncPanes runs on every task refresh and search keystroke; skip the rebuild and the reflows.
|
|
12009
|
+
if (pin + html === paneTabsHtml) return;
|
|
12010
|
+
paneTabsHtml = pin + html;
|
|
12011
|
+
panePin.innerHTML = pin;
|
|
12012
|
+
paneTabs.innerHTML = html;
|
|
12013
|
+
paneTabs.querySelector('.pane-tab.on')?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
12014
|
+
updatePaneFades();
|
|
12015
|
+
}
|
|
12016
|
+
|
|
12017
|
+
function updatePaneFades() {
|
|
12018
|
+
const { scrollLeft, scrollWidth, clientWidth } = paneTabs;
|
|
12019
|
+
paneTabs.classList.toggle('fade-l', scrollLeft > 1);
|
|
12020
|
+
paneTabs.classList.toggle('fade-r', scrollLeft + clientWidth < scrollWidth - 1);
|
|
12021
|
+
}
|
|
12022
|
+
|
|
12023
|
+
function paneCardHtml(title, text, button) {
|
|
12024
|
+
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>`;
|
|
12025
|
+
}
|
|
12026
|
+
|
|
12027
|
+
// Returns the key of the view to show.
|
|
12028
|
+
function mountPane(sid, pane) {
|
|
12029
|
+
const key = `${sid}/${pane.id}`;
|
|
12030
|
+
const existing = paneFrames.get(key);
|
|
12031
|
+
if (existing) {
|
|
12032
|
+
paneFrames.delete(key);
|
|
12033
|
+
paneFrames.set(key, existing);
|
|
12034
|
+
return key;
|
|
12035
|
+
}
|
|
12036
|
+
const view = document.createElement('div');
|
|
12037
|
+
view.className = 'pane-view';
|
|
12038
|
+
view.dataset.key = key;
|
|
12039
|
+
paneViews.appendChild(view);
|
|
12040
|
+
paneFrames.set(key, view);
|
|
12041
|
+
loadPaneView(sid, pane, view);
|
|
12042
|
+
while (paneFrames.size > PANE_FRAME_LIMIT) {
|
|
12043
|
+
const [oldKey, oldView] = paneFrames.entries().next().value;
|
|
12044
|
+
paneFrames.delete(oldKey);
|
|
12045
|
+
oldView.remove();
|
|
12046
|
+
}
|
|
12047
|
+
return key;
|
|
12048
|
+
}
|
|
12049
|
+
|
|
12050
|
+
async function loadPaneView(sid, pane, view) {
|
|
12051
|
+
if (paneOwnOrigin(pane)) {
|
|
12052
|
+
view.insertAdjacentHTML(
|
|
12053
|
+
'beforeend',
|
|
12054
|
+
paneCardHtml('A pane cannot show the board or the hub', pane.target, 'Open in new tab'),
|
|
12055
|
+
);
|
|
12056
|
+
return;
|
|
12057
|
+
}
|
|
12058
|
+
if (!paneFrameable(pane)) {
|
|
12059
|
+
view.insertAdjacentHTML(
|
|
12060
|
+
'beforeend',
|
|
12061
|
+
paneCardHtml(
|
|
12062
|
+
`${new URL(pane.target).host} does not allow framing`,
|
|
12063
|
+
'The site sends X-Frame-Options or a frame-ancestors policy. The pane keeps the link with the session.',
|
|
12064
|
+
'Open in new tab',
|
|
12065
|
+
),
|
|
12066
|
+
);
|
|
12067
|
+
return;
|
|
12068
|
+
}
|
|
12069
|
+
if (pane.kind === 'url') {
|
|
12070
|
+
const frame = document.createElement('iframe');
|
|
12071
|
+
frame.className = 'pane-frame';
|
|
12072
|
+
frame.setAttribute('sandbox', PANE_URL_SANDBOX);
|
|
12073
|
+
frame.setAttribute('referrerpolicy', 'no-referrer');
|
|
12074
|
+
frame.title = pane.title;
|
|
12075
|
+
frame.src = pane.target;
|
|
12076
|
+
view.appendChild(frame);
|
|
12077
|
+
// Framed at once; a refusal found by the probe swaps the frame for the card.
|
|
12078
|
+
if ((await probePaneFraming(sid, pane)) === false) {
|
|
12079
|
+
frame.remove();
|
|
12080
|
+
loadPaneView(sid, pane, view);
|
|
12081
|
+
if (sid === paneSessionId()) syncPanes();
|
|
12082
|
+
}
|
|
12083
|
+
return;
|
|
12084
|
+
}
|
|
12085
|
+
let data = null;
|
|
12086
|
+
try {
|
|
12087
|
+
const r = await fetch(`/api/preview?${new URLSearchParams({ path: pane.target })}`);
|
|
12088
|
+
if (r.ok) data = await r.json();
|
|
12089
|
+
} catch {}
|
|
12090
|
+
if (data?.kind === 'html') {
|
|
12091
|
+
const frame = createPreviewFrame('pane-frame', data.content + REVIEW_BRIDGE_TAG);
|
|
12092
|
+
frame.title = pane.title;
|
|
12093
|
+
view.appendChild(frame);
|
|
12094
|
+
attachPaneReview(view, sid, fileReviewOpts(data.path, data.kind, frame, frame));
|
|
12095
|
+
return;
|
|
12096
|
+
}
|
|
12097
|
+
if (data?.kind) {
|
|
12098
|
+
const doc = document.createElement('div');
|
|
12099
|
+
doc.className = 'pane-doc';
|
|
12100
|
+
doc.dataset.path = data.path;
|
|
12101
|
+
const body = document.createElement('div');
|
|
12102
|
+
body.className = 'pane-doc-body rendered-md modal-zoomable';
|
|
12103
|
+
renderPreviewContent(body, data.path, data.content, data.kind);
|
|
12104
|
+
doc.append(body, paneDocGrip(doc));
|
|
12105
|
+
view.appendChild(doc);
|
|
12106
|
+
attachPaneReview(view, sid, fileReviewOpts(data.path, data.kind, body, null));
|
|
12107
|
+
return;
|
|
12108
|
+
}
|
|
12109
|
+
const [title, text] = !data
|
|
12110
|
+
? ['Preview unavailable', 'The server could not read this file.']
|
|
12111
|
+
: data.exists === false
|
|
12112
|
+
? ['File not found', pane.target]
|
|
12113
|
+
: ['Nothing to preview', 'cck cannot render this file.'];
|
|
12114
|
+
const button = data && data.exists !== false ? 'Open in editor' : 'Try in preview';
|
|
12115
|
+
view.insertAdjacentHTML('beforeend', paneCardHtml(title, text, button));
|
|
12116
|
+
}
|
|
12117
|
+
|
|
12118
|
+
// One reading width for every pane, kept apart from the modals': a modal's saved size
|
|
12119
|
+
// includes its padding and review panel.
|
|
12120
|
+
const PANE_DOC_W_KEY = 'pane-doc-width';
|
|
12121
|
+
|
|
12122
|
+
function applyPaneDocWidth(w) {
|
|
12123
|
+
if (w) document.documentElement.style.setProperty('--pane-doc-w', w);
|
|
12124
|
+
else document.documentElement.style.removeProperty('--pane-doc-w');
|
|
12125
|
+
}
|
|
12126
|
+
applyPaneDocWidth(store.getItem(PANE_DOC_W_KEY));
|
|
12127
|
+
|
|
12128
|
+
// The drag sizes only this sheet; the shared width lands once on release, so a
|
|
12129
|
+
// drag does not restyle the whole page on every move.
|
|
12130
|
+
function paneDocGrip(doc) {
|
|
12131
|
+
let startW;
|
|
12132
|
+
return createResizeGrip({
|
|
12133
|
+
onStart() {
|
|
12134
|
+
startW = doc.offsetWidth;
|
|
12135
|
+
},
|
|
12136
|
+
// The sheet is centered, so both edges move: double the delta to keep the grip under the cursor.
|
|
12137
|
+
onMove(dx) {
|
|
12138
|
+
doc.style.maxWidth = `${Math.max(MODAL_MIN_W, startW + dx * 2)}px`;
|
|
12139
|
+
},
|
|
12140
|
+
onEnd() {
|
|
12141
|
+
const w = doc.style.maxWidth;
|
|
12142
|
+
if (!w) return;
|
|
12143
|
+
doc.style.maxWidth = '';
|
|
12144
|
+
store.setItem(PANE_DOC_W_KEY, w);
|
|
12145
|
+
applyPaneDocWidth(w);
|
|
12146
|
+
},
|
|
12147
|
+
onReset() {
|
|
12148
|
+
store.removeItem(PANE_DOC_W_KEY);
|
|
12149
|
+
applyPaneDocWidth(null);
|
|
12150
|
+
},
|
|
12151
|
+
});
|
|
12152
|
+
}
|
|
12153
|
+
|
|
12154
|
+
function activePaneView() {
|
|
12155
|
+
return wantsTerminal() ? null : paneViews.querySelector('.pane-view.on');
|
|
12156
|
+
}
|
|
12157
|
+
|
|
12158
|
+
function isPaneDocOnScreen() {
|
|
12159
|
+
return !!activePaneView()?.querySelector('.modal-zoomable');
|
|
12160
|
+
}
|
|
12161
|
+
|
|
12162
|
+
function attachPaneReview(view, sessionId, opts) {
|
|
12163
|
+
if (!opts) return;
|
|
12164
|
+
const panelEl = document.createElement('aside');
|
|
12165
|
+
panelEl.className = 'review-panel';
|
|
12166
|
+
panelEl.hidden = true;
|
|
12167
|
+
view.appendChild(panelEl);
|
|
12168
|
+
paneReviews.set(view, { ...opts, panelEl, hostEl: view, sessionId });
|
|
12169
|
+
syncPaneReview();
|
|
12170
|
+
}
|
|
12171
|
+
|
|
12172
|
+
function syncPaneReview() {
|
|
12173
|
+
const view = activePaneView();
|
|
12174
|
+
setBaseReview((view && paneReviews.get(view)) || null);
|
|
12175
|
+
}
|
|
12176
|
+
|
|
12177
|
+
function unmountPane(sid, paneId) {
|
|
12178
|
+
const key = `${sid}/${paneId}`;
|
|
12179
|
+
paneFrames.get(key)?.remove();
|
|
12180
|
+
paneFrames.delete(key);
|
|
12181
|
+
}
|
|
12182
|
+
|
|
12183
|
+
function selectPane(id) {
|
|
12184
|
+
const sid = paneSessionId();
|
|
12185
|
+
if (!sid) return;
|
|
12186
|
+
setActivePaneId(sid, id);
|
|
12187
|
+
closePanePop();
|
|
12188
|
+
syncPanes();
|
|
12189
|
+
}
|
|
12190
|
+
|
|
12191
|
+
// Returns false when the tabs are not on screen, so the key keeps its other meaning.
|
|
12192
|
+
function cyclePane(step) {
|
|
12193
|
+
const sid = paneSessionId();
|
|
12194
|
+
if (!sid || wantsTerminal()) return false;
|
|
12195
|
+
const ids = ['board', ...paneLayout(sid).panes.map((p) => p.id)];
|
|
12196
|
+
const i = ids.indexOf(getActivePaneId(sid));
|
|
12197
|
+
selectPane(ids[(i + step + ids.length) % ids.length]);
|
|
12198
|
+
return true;
|
|
12199
|
+
}
|
|
12200
|
+
|
|
12201
|
+
async function closePane(paneId) {
|
|
12202
|
+
const sid = paneSessionId();
|
|
12203
|
+
if (!sid) return;
|
|
12204
|
+
if (getActivePaneId(sid) === paneId) {
|
|
12205
|
+
const panes = paneLayout(sid).panes;
|
|
12206
|
+
const i = panes.findIndex((p) => p.id === paneId);
|
|
12207
|
+
setActivePaneId(sid, (panes[i + 1] || panes[i - 1])?.id || 'board');
|
|
12208
|
+
}
|
|
12209
|
+
unmountPane(sid, paneId);
|
|
12210
|
+
syncPanes();
|
|
12211
|
+
const res = await paneRequest('DELETE', `/api/panes/${encodeURIComponent(sid)}/${encodeURIComponent(paneId)}`);
|
|
12212
|
+
if (res.layout) applyPaneLayout(sid, res.layout);
|
|
12213
|
+
else if (res.code === 'no_pane') fetchPaneLayout(sid);
|
|
12214
|
+
else showToast(res.error, 'error');
|
|
12215
|
+
}
|
|
12216
|
+
|
|
12217
|
+
// With no id, the active pane.
|
|
12218
|
+
function paneById(id) {
|
|
12219
|
+
const sid = paneSessionId();
|
|
12220
|
+
if (!sid) return null;
|
|
12221
|
+
return id == null ? activePane(sid) : paneLayout(sid).panes.find((p) => p.id === id) || null;
|
|
12222
|
+
}
|
|
12223
|
+
|
|
12224
|
+
function reloadPane(id) {
|
|
12225
|
+
const pane = paneById(id);
|
|
12226
|
+
if (!pane) return;
|
|
12227
|
+
unmountPane(paneSessionId(), pane.id);
|
|
12228
|
+
paneRefused.delete(pane.target);
|
|
12229
|
+
syncPanes();
|
|
12230
|
+
}
|
|
12231
|
+
|
|
12232
|
+
function openPaneExternally(id) {
|
|
12233
|
+
const pane = paneById(id);
|
|
12234
|
+
if (!pane) return;
|
|
12235
|
+
openLinkedDoc(pane.target, getSessionBaseDir(paneSessionId()));
|
|
12236
|
+
}
|
|
12237
|
+
|
|
12238
|
+
function copyPaneTarget(id) {
|
|
12239
|
+
const pane = paneById(id);
|
|
12240
|
+
if (!pane) return;
|
|
12241
|
+
navigator.clipboard.writeText(pane.target).then(
|
|
12242
|
+
() => showToast(pane.kind === 'url' ? 'URL copied' : 'Path copied', 'success'),
|
|
12243
|
+
() => showToast('Copy failed', 'error'),
|
|
12244
|
+
);
|
|
12245
|
+
}
|
|
12246
|
+
|
|
12247
|
+
function closePaneMenu() {
|
|
12248
|
+
document.removeEventListener('keydown', paneMenuKeydown, true);
|
|
12249
|
+
document.getElementById('pane-menu')?.remove();
|
|
12250
|
+
}
|
|
12251
|
+
|
|
12252
|
+
function paneMenuKeydown(e) {
|
|
12253
|
+
const menu = document.getElementById('pane-menu');
|
|
12254
|
+
if (!menu || e.ctrlKey || e.altKey || e.metaKey) return;
|
|
12255
|
+
menuKeydown(e, [...menu.querySelectorAll('.pane-menu-item')], closePaneMenu);
|
|
12256
|
+
}
|
|
12257
|
+
|
|
12258
|
+
function openPaneMenu(x, y, id) {
|
|
12259
|
+
const pane = paneById(id);
|
|
12260
|
+
if (!pane) return;
|
|
12261
|
+
closePaneMenu();
|
|
12262
|
+
sgCloseMenu();
|
|
12263
|
+
const url = pane.kind === 'url';
|
|
12264
|
+
const item = (act, label) =>
|
|
12265
|
+
`<button class="pane-menu-item" role="menuitem" data-pane-act="${act}">${label}</button>`;
|
|
12266
|
+
const menu = document.createElement('div');
|
|
12267
|
+
menu.id = 'pane-menu';
|
|
12268
|
+
menu.className = 'pane-menu';
|
|
12269
|
+
menu.setAttribute('role', 'menu');
|
|
12270
|
+
menu.setAttribute('aria-label', 'Pane actions');
|
|
12271
|
+
menu.dataset.pane = pane.id;
|
|
12272
|
+
menu.innerHTML =
|
|
12273
|
+
`<div class="pane-menu-label" title="${escapeHtml(pane.target)}">${escapeHtml(pane.target)}</div>` +
|
|
12274
|
+
item('copy', url ? 'Copy URL' : 'Copy path') +
|
|
12275
|
+
item('open', url ? 'Open in new tab' : 'Open in preview') +
|
|
12276
|
+
item('reload', 'Reload') +
|
|
12277
|
+
item('close', 'Close pane');
|
|
12278
|
+
placeMenu(menu, x, y);
|
|
12279
|
+
document.addEventListener('keydown', paneMenuKeydown, true);
|
|
12280
|
+
menu.querySelector('.pane-menu-item').focus();
|
|
12281
|
+
}
|
|
12282
|
+
|
|
12283
|
+
const PANE_MENU_ACTIONS = {
|
|
12284
|
+
copy: copyPaneTarget,
|
|
12285
|
+
open: openPaneExternally,
|
|
12286
|
+
reload: reloadPane,
|
|
12287
|
+
close: closePane,
|
|
12288
|
+
};
|
|
12289
|
+
|
|
12290
|
+
// The server takes only absolute paths, and linked docs can be relative to the session's folder.
|
|
12291
|
+
function paneTargetFrom(raw) {
|
|
12292
|
+
const value = raw.trim().replace(/^(["'])(.*)\1$/, '$2');
|
|
12293
|
+
if (!value) return null;
|
|
12294
|
+
const url = linkUrl(value);
|
|
12295
|
+
if (!url) return { kind: 'file', target: value };
|
|
12296
|
+
if (paneOwnOrigin({ kind: 'url', target: url })) return { error: 'A pane cannot show the board or the hub' };
|
|
12297
|
+
return { kind: 'url', target: url };
|
|
12298
|
+
}
|
|
12299
|
+
|
|
12300
|
+
// The same target again answers the existing pane, so this also switches to it.
|
|
12301
|
+
async function addPane(raw) {
|
|
12302
|
+
const sid = paneSessionId();
|
|
12303
|
+
const t = sid && paneTargetFrom(raw);
|
|
12304
|
+
if (!t) return;
|
|
12305
|
+
if (t.error) {
|
|
12306
|
+
showToast(t.error);
|
|
12307
|
+
return;
|
|
12308
|
+
}
|
|
12309
|
+
const res = await paneRequest('POST', `/api/panes/${encodeURIComponent(sid)}`, {
|
|
12310
|
+
target: t.target,
|
|
12311
|
+
base: getSessionBaseDir(sid) || undefined,
|
|
12312
|
+
});
|
|
12313
|
+
if (!res.pane) {
|
|
12314
|
+
showToast(res.error, 'error');
|
|
12315
|
+
return;
|
|
12316
|
+
}
|
|
12317
|
+
setActivePaneId(sid, res.pane.id);
|
|
12318
|
+
applyPaneLayout(sid, res.layout);
|
|
12319
|
+
if (sid !== paneSessionId()) return;
|
|
12320
|
+
closePanePop();
|
|
12321
|
+
syncPanes();
|
|
12322
|
+
}
|
|
12323
|
+
|
|
12324
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
12325
|
+
function openPreviewInPane() {
|
|
12326
|
+
const filePath = currentPreviewPath;
|
|
12327
|
+
if (!filePath || !paneSessionId()) return;
|
|
12328
|
+
closePreviewModal();
|
|
12329
|
+
addPane(filePath);
|
|
12330
|
+
}
|
|
12331
|
+
|
|
12332
|
+
let panePopMatches = [];
|
|
12333
|
+
// -1 means Enter adds the typed value.
|
|
12334
|
+
let panePopIdx = -1;
|
|
12335
|
+
|
|
12336
|
+
function describePaneInput() {
|
|
12337
|
+
const doc = panePopMatches[panePopIdx];
|
|
12338
|
+
const t = doc ? null : paneTargetFrom(panePopInput.value);
|
|
12339
|
+
panePopDetect.classList.toggle('error', !!t?.error);
|
|
12340
|
+
panePopDetect.textContent = doc
|
|
12341
|
+
? `Enter opens ${linkedDocLabel(doc)}`
|
|
12342
|
+
: !t
|
|
12343
|
+
? 'Search linked docs · http(s) → url pane · a file path → file pane'
|
|
12344
|
+
: t.error || (t.kind === 'url' ? 'Kind: url · framed live' : 'Kind: file · rendered like the preview');
|
|
12345
|
+
}
|
|
12346
|
+
|
|
12347
|
+
function renderPanePopDocs() {
|
|
12348
|
+
const docs = getSessionPreviewPaths(paneSessionId());
|
|
12349
|
+
const query = panePopInput.value.trim();
|
|
12350
|
+
panePopMatches = query ? rankFolders(docs, query) : docs;
|
|
12351
|
+
panePopIdx = query && panePopMatches.length ? 0 : -1;
|
|
12352
|
+
panePopDocs.innerHTML = panePopMatches.length
|
|
12353
|
+
? panePopMatches
|
|
12354
|
+
.map(
|
|
12355
|
+
(d, i) =>
|
|
12356
|
+
`<button class="pane-pop-row${i === panePopIdx ? ' active' : ''}" data-doc="${escapeHtml(d)}" title="${escapeHtml(d)}">${linkUrl(d) ? PANE_ICONS.url : PANE_ICONS.file}<span>${escapeHtml(linkedDocLabel(d))}</span></button>`,
|
|
12357
|
+
)
|
|
12358
|
+
.join('')
|
|
12359
|
+
: `<div class="pane-pop-empty">${docs.length ? 'No linked doc matches. Enter adds what you typed.' : 'This session has no linked docs.'}</div>`;
|
|
12360
|
+
describePaneInput();
|
|
12361
|
+
}
|
|
12362
|
+
|
|
12363
|
+
function movePanePopHighlight(step) {
|
|
12364
|
+
panePopIdx = Math.max(-1, Math.min(panePopMatches.length - 1, panePopIdx + step));
|
|
12365
|
+
panePopDocs.querySelectorAll('.pane-pop-row').forEach((row, i) => {
|
|
12366
|
+
row.classList.toggle('active', i === panePopIdx);
|
|
12367
|
+
if (i === panePopIdx) row.scrollIntoView({ block: 'nearest' });
|
|
12368
|
+
});
|
|
12369
|
+
describePaneInput();
|
|
12370
|
+
}
|
|
12371
|
+
|
|
12372
|
+
function openPanePop() {
|
|
12373
|
+
if (!paneSessionId()) return;
|
|
12374
|
+
panePopInput.value = '';
|
|
12375
|
+
renderPanePopDocs();
|
|
12376
|
+
panePop.classList.add('visible');
|
|
12377
|
+
panePopInput.focus();
|
|
12378
|
+
}
|
|
12379
|
+
|
|
12380
|
+
function closePanePop() {
|
|
12381
|
+
panePop.classList.remove('visible');
|
|
12382
|
+
}
|
|
12383
|
+
|
|
12384
|
+
paneStrip.addEventListener('click', (e) => {
|
|
12385
|
+
const close = e.target.closest('[data-close]');
|
|
12386
|
+
if (close) {
|
|
12387
|
+
closePane(close.dataset.close);
|
|
12388
|
+
return;
|
|
12389
|
+
}
|
|
12390
|
+
const tab = e.target.closest('.pane-tab');
|
|
12391
|
+
if (tab) selectPane(tab.dataset.pane);
|
|
12392
|
+
});
|
|
12393
|
+
paneStrip.addEventListener('auxclick', (e) => {
|
|
12394
|
+
const tab = e.target.closest('.pane-tab');
|
|
12395
|
+
if (e.button === 1 && tab && tab.dataset.pane !== 'board') closePane(tab.dataset.pane);
|
|
12396
|
+
});
|
|
12397
|
+
paneStrip.addEventListener('contextmenu', (e) => {
|
|
12398
|
+
const tab = e.target.closest('.pane-tab');
|
|
12399
|
+
if (!tab || tab.dataset.pane === 'board') return;
|
|
12400
|
+
e.preventDefault();
|
|
12401
|
+
openPaneMenu(e.clientX, e.clientY, tab.dataset.pane);
|
|
12402
|
+
});
|
|
12403
|
+
document.addEventListener('click', (e) => {
|
|
12404
|
+
const menu = document.getElementById('pane-menu');
|
|
12405
|
+
if (!menu) return;
|
|
12406
|
+
const act = e.target.closest('.pane-menu-item')?.dataset.paneAct;
|
|
12407
|
+
if (act) PANE_MENU_ACTIONS[act](menu.dataset.pane);
|
|
12408
|
+
if (act || !menu.contains(e.target)) closePaneMenu();
|
|
12409
|
+
});
|
|
12410
|
+
// A click inside a pane's frame never reaches this document, but it takes focus from the window.
|
|
12411
|
+
window.addEventListener('blur', closePaneMenu);
|
|
12412
|
+
paneViews.addEventListener('click', (e) => {
|
|
12413
|
+
if (e.target.closest('[data-pane-open]')) openPaneExternally();
|
|
12414
|
+
});
|
|
12415
|
+
bindPreviewRelativeLinks(paneViews, (a) => a.closest('.pane-doc')?.dataset.path);
|
|
12416
|
+
paneTabs.addEventListener('scroll', updatePaneFades, { passive: true });
|
|
12417
|
+
paneTabs.addEventListener(
|
|
12418
|
+
'wheel',
|
|
12419
|
+
(e) => {
|
|
12420
|
+
if (!e.deltaY || Math.abs(e.deltaX) > Math.abs(e.deltaY)) return;
|
|
12421
|
+
if (paneTabs.scrollWidth <= paneTabs.clientWidth) return;
|
|
12422
|
+
e.preventDefault();
|
|
12423
|
+
paneTabs.scrollLeft += e.deltaY;
|
|
12424
|
+
},
|
|
12425
|
+
{ passive: false },
|
|
12426
|
+
);
|
|
12427
|
+
new ResizeObserver(updatePaneFades).observe(paneTabs);
|
|
12428
|
+
document.getElementById('pane-add').addEventListener('click', openPanePop);
|
|
12429
|
+
document.getElementById('pane-reload').addEventListener('click', () => reloadPane());
|
|
12430
|
+
document.getElementById('pane-open').addEventListener('click', () => openPaneExternally());
|
|
12431
|
+
panePopDocs.addEventListener('click', (e) => {
|
|
12432
|
+
const row = e.target.closest('[data-doc]');
|
|
12433
|
+
if (row) addPane(row.dataset.doc);
|
|
12434
|
+
});
|
|
12435
|
+
panePopInput.addEventListener('input', renderPanePopDocs);
|
|
12436
|
+
panePop.addEventListener('keydown', (e) => {
|
|
12437
|
+
if (e.key === 'Escape') {
|
|
12438
|
+
e.preventDefault();
|
|
12439
|
+
e.stopPropagation();
|
|
12440
|
+
closePanePop();
|
|
12441
|
+
return;
|
|
12442
|
+
}
|
|
12443
|
+
if (e.target !== panePopInput) return;
|
|
12444
|
+
if (e.key === 'Enter') {
|
|
12445
|
+
e.preventDefault();
|
|
12446
|
+
addPane(panePopMatches[panePopIdx] ?? panePopInput.value);
|
|
12447
|
+
} else if (matchKey(e, 'ArrowDown', 'ArrowUp')) {
|
|
12448
|
+
e.preventDefault();
|
|
12449
|
+
movePanePopHighlight(e.key === 'ArrowDown' ? 1 : -1);
|
|
12450
|
+
}
|
|
12451
|
+
});
|
|
12452
|
+
//#endregion
|
|
12453
|
+
|
|
11620
12454
|
//#region TERMINAL
|
|
11621
12455
|
// The kanban and the terminal share one slot: terminal mode hides #main-content and shows
|
|
11622
12456
|
// the pane. The PTY lives on the server (lib/terminal.js) and outlives the pane, so
|
|
@@ -11745,8 +12579,6 @@ function terminalPaneFocused() {
|
|
|
11745
12579
|
return document.getElementById('terminal-pane').contains(document.activeElement);
|
|
11746
12580
|
}
|
|
11747
12581
|
|
|
11748
|
-
const TEXT_FIELD_SELECTOR = 'input, textarea, select, [contenteditable]';
|
|
11749
|
-
|
|
11750
12582
|
function inPageField(e) {
|
|
11751
12583
|
const field = e.target?.closest?.(TEXT_FIELD_SELECTOR);
|
|
11752
12584
|
return !!field && !document.getElementById('terminal-pane').contains(field);
|
|
@@ -11837,6 +12669,7 @@ function syncTerminal() {
|
|
|
11837
12669
|
btn.classList.toggle('active', on);
|
|
11838
12670
|
}
|
|
11839
12671
|
sessionView.classList.toggle('terminal-mode', on);
|
|
12672
|
+
syncPanes();
|
|
11840
12673
|
pushTerminalClaims();
|
|
11841
12674
|
if (!on) {
|
|
11842
12675
|
// A hidden xterm textarea drops focus, but a display:none iframe keeps it and would take keys unseen.
|
|
@@ -12016,7 +12849,7 @@ function onTerminalFrameMessage(t, m) {
|
|
|
12016
12849
|
termFrame.reload = false;
|
|
12017
12850
|
reconnectTerminal();
|
|
12018
12851
|
}
|
|
12019
|
-
} else if (t === 'key')
|
|
12852
|
+
} else if (t === 'key') replayKey(m, document.getElementById('terminal-key-proxy'));
|
|
12020
12853
|
// Chrome does not always fire focusin on the iframe element when its content takes focus.
|
|
12021
12854
|
else if (t === 'focused') terminalHadFocus = terminalPaneFocused();
|
|
12022
12855
|
else if (t === 'opening') {
|
|
@@ -12036,8 +12869,7 @@ function onTerminalFrameMessage(t, m) {
|
|
|
12036
12869
|
// The frame hands back the keys the board acts on. Replaying them on a textarea inside the pane gives
|
|
12037
12870
|
// the document listeners the target they saw when xterm lived here: the shortcut runs, the SDK
|
|
12038
12871
|
// forwards a hub key, and the text-field guard keeps the board's own keys out.
|
|
12039
|
-
function
|
|
12040
|
-
const target = document.getElementById('terminal-key-proxy');
|
|
12872
|
+
function replayKey(m, target) {
|
|
12041
12873
|
target.dispatchEvent(
|
|
12042
12874
|
new KeyboardEvent('keydown', {
|
|
12043
12875
|
key: m.key,
|
|
@@ -12088,13 +12920,20 @@ let terminalClaimsQueued = false;
|
|
|
12088
12920
|
|
|
12089
12921
|
// The claims depend on state (the hub's welcome, a slow connect, the session), so every change re-sends
|
|
12090
12922
|
// them. One state change calls this several times, and the probe is about 1,500 shortcut checks.
|
|
12923
|
+
function keyClaims() {
|
|
12924
|
+
return { keys: terminalClaims(), forward: hub.forwardCombos() };
|
|
12925
|
+
}
|
|
12926
|
+
|
|
12091
12927
|
function pushTerminalClaims() {
|
|
12092
|
-
if (
|
|
12928
|
+
if (terminalClaimsQueued) return;
|
|
12093
12929
|
terminalClaimsQueued = true;
|
|
12094
12930
|
queueMicrotask(() => {
|
|
12095
12931
|
terminalClaimsQueued = false;
|
|
12932
|
+
const bridged = bridgedFrameEls();
|
|
12933
|
+
if (!termFrame.inited && !bridged.length) return;
|
|
12934
|
+
const claims = keyClaims();
|
|
12935
|
+
sendBridgeClaims(bridged, claims);
|
|
12096
12936
|
if (!termFrame.inited) return;
|
|
12097
|
-
const claims = { keys: terminalClaims(), forward: hub.forwardCombos() };
|
|
12098
12937
|
const sig = JSON.stringify(claims);
|
|
12099
12938
|
if (sig === termFrame.claims) return;
|
|
12100
12939
|
termFrame.claims = sig;
|
|
@@ -13127,6 +13966,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
13127
13966
|
if (lang === 'mermaid') {
|
|
13128
13967
|
return `<pre class="mermaid" data-original="${escapeHtml(text)}">${escapeHtml(text)}</pre>`;
|
|
13129
13968
|
}
|
|
13969
|
+
if (treeHighlight.wantsTree(lang, text)) {
|
|
13970
|
+
return `<pre><code class="hljs language-tree">${treeHighlight.highlightTree(text)}</code></pre>`;
|
|
13971
|
+
}
|
|
13130
13972
|
let highlighted;
|
|
13131
13973
|
if (lang && hljs.getLanguage(lang)) {
|
|
13132
13974
|
highlighted = hljs.highlight(text, { language: lang }).value;
|
|
@@ -13209,7 +14051,7 @@ msgContentEl.addEventListener('wheel', function (e) {
|
|
|
13209
14051
|
}
|
|
13210
14052
|
});
|
|
13211
14053
|
|
|
13212
|
-
const footerState = { version: null, plugin: null, limitsKey: null, timer: null };
|
|
14054
|
+
const footerState = { version: null, plugin: null, limitsKey: null, timer: null, resetTimer: null };
|
|
13213
14055
|
function formatResetIn(epochSec) {
|
|
13214
14056
|
if (!epochSec) return null;
|
|
13215
14057
|
const ms = epochSec * 1000 - Date.now();
|
|
@@ -13298,14 +14140,10 @@ function refreshRateLimits() {
|
|
|
13298
14140
|
if (footerState.timer) return;
|
|
13299
14141
|
footerState.timer = setTimeout(() => {
|
|
13300
14142
|
footerState.timer = null;
|
|
13301
|
-
fetch('/api/
|
|
14143
|
+
fetch('/api/rate-limits')
|
|
13302
14144
|
.then((r) => r.json())
|
|
13303
|
-
.then((
|
|
13304
|
-
|
|
13305
|
-
for (const e of Object.values(all || {})) {
|
|
13306
|
-
if (e?.rate_limits && (!freshest || (e._updatedAt || 0) > (freshest._updatedAt || 0))) freshest = e;
|
|
13307
|
-
}
|
|
13308
|
-
const rl = freshest?.rate_limits || null;
|
|
14145
|
+
.then((rl) => {
|
|
14146
|
+
scheduleRateLimitReset(rl);
|
|
13309
14147
|
const fh = rl?.five_hour?.used_percentage ?? null;
|
|
13310
14148
|
const sd = rl?.seven_day?.used_percentage ?? null;
|
|
13311
14149
|
const key = `${fh}|${sd}`;
|
|
@@ -13316,6 +14154,18 @@ function refreshRateLimits() {
|
|
|
13316
14154
|
.catch(() => {});
|
|
13317
14155
|
}, 1500);
|
|
13318
14156
|
}
|
|
14157
|
+
|
|
14158
|
+
// No context-update comes when a window resets on an idle board, so refetch at the earliest reset.
|
|
14159
|
+
function scheduleRateLimitReset(rl) {
|
|
14160
|
+
clearTimeout(footerState.resetTimer);
|
|
14161
|
+
footerState.resetTimer = null;
|
|
14162
|
+
const resets = Object.values(rl || {})
|
|
14163
|
+
.map((b) => b?.resets_at)
|
|
14164
|
+
.filter((s) => s > 0);
|
|
14165
|
+
if (!resets.length) return;
|
|
14166
|
+
const ms = Math.min(...resets) * 1000 - Date.now() + 1000;
|
|
14167
|
+
footerState.resetTimer = setTimeout(refreshRateLimits, Math.min(Math.max(ms, 0), 2 ** 31 - 1));
|
|
14168
|
+
}
|
|
13319
14169
|
fetch('/api/version')
|
|
13320
14170
|
.then((r) => r.json())
|
|
13321
14171
|
.then((d) => {
|
|
@@ -13488,20 +14338,23 @@ document.getElementById('help-docs')?.addEventListener('click', (e) => {
|
|
|
13488
14338
|
hub.openExternal(e.currentTarget.href);
|
|
13489
14339
|
});
|
|
13490
14340
|
|
|
13491
|
-
|
|
13492
|
-
if (!hub.inHub) return;
|
|
13493
|
-
const a = e.target.closest?.('a[href]');
|
|
13494
|
-
if (!a) return;
|
|
13495
|
-
const href = a.getAttribute('href');
|
|
13496
|
-
if (!href) return;
|
|
14341
|
+
function webUrl(href) {
|
|
13497
14342
|
let url;
|
|
13498
14343
|
try {
|
|
13499
14344
|
url = new URL(href, window.location.href);
|
|
13500
14345
|
} catch (_) {
|
|
13501
|
-
return;
|
|
14346
|
+
return null;
|
|
13502
14347
|
}
|
|
13503
|
-
|
|
13504
|
-
|
|
14348
|
+
return url.protocol === 'http:' || url.protocol === 'https:' ? url : null;
|
|
14349
|
+
}
|
|
14350
|
+
|
|
14351
|
+
document.addEventListener('click', (e) => {
|
|
14352
|
+
const a = e.target.closest?.('a[href]');
|
|
14353
|
+
if (!a) return;
|
|
14354
|
+
const href = a.getAttribute('href');
|
|
14355
|
+
if (!href) return;
|
|
14356
|
+
const url = webUrl(href);
|
|
14357
|
+
if (!url || url.origin === window.location.origin) return;
|
|
13505
14358
|
e.preventDefault();
|
|
13506
14359
|
e.stopPropagation();
|
|
13507
14360
|
hub.openExternal(url.href);
|