claude-code-kanban 7.1.1 → 7.2.0

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