claude-code-kanban 6.1.2 → 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/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, newTasks.length === 0);
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
- function autoRevealLog(sessionId, empty) {
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;
@@ -2414,13 +2417,8 @@ function initModalResize() {
2414
2417
  modal.classList.add('user-sized');
2415
2418
  }
2416
2419
 
2417
- const handle = document.createElement('div');
2418
- handle.className = 'modal-resize-handle';
2419
- handle.title = 'Drag to resize · double-click to reset';
2420
- modal.appendChild(handle);
2421
-
2422
2420
  let startW, startH, w, h;
2423
- _initDragResize(handle, {
2421
+ const handle = createResizeGrip({
2424
2422
  onStart() {
2425
2423
  startW = modal.offsetWidth;
2426
2424
  startH = modal.offsetHeight;
@@ -2446,16 +2444,25 @@ function initModalResize() {
2446
2444
  store.setItem(hKey, `${h}px`);
2447
2445
  }
2448
2446
  },
2447
+ onReset() {
2448
+ modal.classList.remove('user-sized');
2449
+ store.removeItem(wKey);
2450
+ store.removeItem(hKey);
2451
+ },
2449
2452
  });
2450
-
2451
- handle.addEventListener('dblclick', () => {
2452
- modal.classList.remove('user-sized');
2453
- store.removeItem(wKey);
2454
- store.removeItem(hKey);
2455
- });
2453
+ modal.appendChild(handle);
2456
2454
  });
2457
2455
  }
2458
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
+
2459
2466
  const MODAL_ZOOM_KEY = 'modal-zoom';
2460
2467
  const MODAL_ZOOM_MIN = 0.7;
2461
2468
  const MODAL_ZOOM_MAX = 2.0;
@@ -2490,6 +2497,11 @@ function isAnyModalOpen() {
2490
2497
  return document.querySelector('.modal-overlay.visible') !== null;
2491
2498
  }
2492
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
+
2493
2505
  const ZOOM_KEYS = { '+': 0.1, '=': 0.1, NumpadAdd: 0.1, '-': -0.1, _: -0.1, NumpadSubtract: -0.1, 0: 0, Numpad0: 0 };
2494
2506
 
2495
2507
  function zoomDelta(e) {
@@ -4877,7 +4889,7 @@ function initSessionGroupsDnd() {
4877
4889
  sessionsList.addEventListener('contextmenu', sgOnContextMenu);
4878
4890
  // The Menu key fires contextmenu at the focused element, which by then is our menu.
4879
4891
  document.addEventListener('contextmenu', (e) => {
4880
- if (e.target.closest?.('.sg-menu')) e.preventDefault();
4892
+ if (e.target.closest?.('.sg-menu, .pane-menu')) e.preventDefault();
4881
4893
  });
4882
4894
  sessionsList.addEventListener('keydown', (e) => {
4883
4895
  const input = e.target.closest?.('.sg-name-input');
@@ -4918,11 +4930,8 @@ function sgCloseMenu() {
4918
4930
  if (menu) menu.remove();
4919
4931
  }
4920
4932
 
4921
- // Capture phase: the global handler would otherwise read these keys as sidebar shortcuts.
4922
- function sgMenuKeydown(e) {
4923
- const menu = document.getElementById('sg-menu');
4924
- if (!menu || e.ctrlKey || e.altKey || e.metaKey) return;
4925
- 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) {
4926
4935
  const i = items.indexOf(document.activeElement);
4927
4936
  e.stopPropagation();
4928
4937
  if (e.key === 'Enter' || e.key === ' ') return;
@@ -4932,10 +4941,23 @@ function sgMenuKeydown(e) {
4932
4941
  else if (matchKey(e, 'ArrowUp')) next = i < 0 ? items.length - 1 : (i - 1 + items.length) % items.length;
4933
4942
  else if (e.key === 'Home') next = 0;
4934
4943
  else if (e.key === 'End') next = items.length - 1;
4935
- else if (e.key === 'Escape' || e.key === 'Tab') sgCloseMenu();
4944
+ else if (e.key === 'Escape' || e.key === 'Tab') close();
4936
4945
  if (next !== null) items[next]?.focus();
4937
4946
  }
4938
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
+
4939
4961
  function sgOpenMenuForKbSelection() {
4940
4962
  const el = sessionsList.querySelector('.kb-selected');
4941
4963
  if (!el?.matches('.session-item, .project-group-header')) return false;
@@ -4948,6 +4970,7 @@ function sgOpenMenuForKbSelection() {
4948
4970
 
4949
4971
  function sgOpenMenu(x, y, kind, ref) {
4950
4972
  sgCloseMenu();
4973
+ closePaneMenu();
4951
4974
  const current = sgGroupOf(kind, ref);
4952
4975
  const label = kind === 'project' ? 'project' : 'session';
4953
4976
  const rows = sessionGroups
@@ -4969,10 +4992,7 @@ function sgOpenMenu(x, y, kind, ref) {
4969
4992
  ${rows}
4970
4993
  <button class="sg-menu-item" role="menuitem" data-sg-move="__new__">+ New group…</button>
4971
4994
  ${current ? `<button class="sg-menu-item sg-menu-remove" role="menuitem" data-sg-move="__none__">Remove from “${escapeHtml(current.name)}”</button>` : ''}`;
4972
- document.body.appendChild(menu);
4973
- const rect = menu.getBoundingClientRect();
4974
- menu.style.left = `${Math.min(x, window.innerWidth - rect.width - 8)}px`;
4975
- menu.style.top = `${Math.min(y, window.innerHeight - rect.height - 8)}px`;
4995
+ placeMenu(menu, x, y);
4976
4996
  document.addEventListener('keydown', sgMenuKeydown, true);
4977
4997
  menu.querySelector('.sg-menu-item')?.focus();
4978
4998
  }
@@ -5814,7 +5834,8 @@ const SHORTCUT_TABS = [
5814
5834
  {
5815
5835
  title: 'View',
5816
5836
  rows: [
5817
- { keys: ['['], label: 'Toggle sidebar' },
5837
+ { keys: ['\\'], label: 'Toggle sidebar' },
5838
+ { keys: ['[', ']'], label: 'Previous / next tab (Board and panes)' },
5818
5839
  { keys: ['T'], label: 'Toggle theme' },
5819
5840
  { keys: ['Shift', 'S'], combo: true, label: 'Storage manager' },
5820
5841
  { keys: ['Ctrl', 'Shift', 'Z'], combo: true, label: 'Zen mode (current session only)' },
@@ -6871,14 +6892,15 @@ const MODAL_CLOSERS = {
6871
6892
  'terminal-manager-modal': () => closeTerminalManager(),
6872
6893
  'new-session-modal': () => closeNewSession(),
6873
6894
  'confirm-modal': () => closeConfirmModal?.(false),
6895
+ 'pane-pop': () => closePanePop(),
6874
6896
  };
6875
6897
 
6876
6898
  document.addEventListener('keydown', (e) => {
6877
- // 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
6878
6900
  // the whole page. Sits above the text-field guard so it still works with the
6879
6901
  // caret in a field inside the modal.
6880
6902
  const zoom = zoomDelta(e);
6881
- if (zoom !== undefined && isZoomableModalOpen()) {
6903
+ if (zoom !== undefined && isZoomableOnScreen()) {
6882
6904
  e.preventDefault();
6883
6905
  adjustModalZoom(zoom);
6884
6906
  return;
@@ -6979,11 +7001,15 @@ document.addEventListener('keydown', (e) => {
6979
7001
  }
6980
7002
 
6981
7003
  // Global shortcuts
6982
- if (e.key === '[') {
7004
+ if (matchKey(e, '\\')) {
6983
7005
  e.preventDefault();
6984
7006
  toggleSidebar();
6985
7007
  return;
6986
7008
  }
7009
+ if (matchKey(e, '[', ']') && cyclePane(e.key === ']' ? 1 : -1)) {
7010
+ e.preventDefault();
7011
+ return;
7012
+ }
6987
7013
  if (e.code === 'KeyL' && e.shiftKey) {
6988
7014
  e.preventDefault();
6989
7015
  toggleMessagePanel();
@@ -7266,25 +7292,52 @@ function reviewItems() {
7266
7292
  return (activeReview && reviewDrafts.get(activeReview.key)) || [];
7267
7293
  }
7268
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
+
7269
7301
  // `context(range)` adds view-specific citation fields to a comment on the host DOM;
7270
7302
  // `onComment()` runs after a comment is added; `onSent()` runs after a batch is delivered.
7271
- function mountReview({
7272
- contentEl,
7273
- panelEl,
7274
- hostEl,
7275
- source,
7276
- frame,
7277
- context,
7278
- onComment,
7279
- onSent,
7280
- sessionId = currentSessionId,
7281
- }) {
7282
- unmountReview();
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;
7283
7335
  if (!sessionId) return;
7284
7336
  const ctl = new AbortController();
7285
7337
  const { signal } = ctl;
7286
7338
  const key = reviewKey(sessionId, source);
7287
7339
  activeReview = {
7340
+ opts,
7288
7341
  contentEl,
7289
7342
  panelEl,
7290
7343
  hostEl,
@@ -7312,9 +7365,7 @@ function mountReview({
7312
7365
  renderReviewPanel();
7313
7366
  }
7314
7367
 
7315
- // With `ownPanel`, unmounts only the review that panel holds, so a modal under another one keeps it.
7316
- function unmountReview(ownPanel) {
7317
- if (ownPanel && activeReview?.panelEl !== ownPanel) return;
7368
+ function stopReview() {
7318
7369
  closeReviewPop();
7319
7370
  if (!activeReview) return;
7320
7371
  const { ctl, panelEl, hostEl } = activeReview;
@@ -7424,7 +7475,7 @@ function postToReviewFrame(type, id) {
7424
7475
 
7425
7476
  // Runs inside the preview iframe as source text, so it must not close over anything outside
7426
7477
  // its own body and arguments.
7427
- function reviewBridge(textBefore, headingBefore, rangeAt) {
7478
+ function reviewBridge(textBefore, headingBefore, rangeAt, comboOf, fieldSelector) {
7428
7479
  const P = 'cck-review:';
7429
7480
  const marks = new Map();
7430
7481
  let pending = null;
@@ -7501,11 +7552,51 @@ function reviewBridge(textBefore, headingBefore, rangeAt) {
7501
7552
  };
7502
7553
  document.addEventListener('mousedown', clear);
7503
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
+ });
7504
7592
  addEventListener('message', (e) => {
7505
7593
  if (e.source !== parent || typeof e.data?.type !== 'string' || !e.data.type.startsWith(P)) return;
7506
7594
  const type = e.data.type.slice(P.length);
7507
7595
  const range = marks.get(e.data.id);
7508
- if (type === 'mark' && pending) {
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) {
7509
7600
  marks.set(e.data.id, pending);
7510
7601
  hl?.add(pending);
7511
7602
  pending = null;
@@ -7527,7 +7618,34 @@ function reviewBridge(textBefore, headingBefore, rangeAt) {
7527
7618
  });
7528
7619
  }
7529
7620
 
7530
- const REVIEW_BRIDGE_TAG = `<script>(${reviewBridge})(${reviewTextBefore}, ${reviewHeadingBefore}, ${reviewRangeAt});</script>`;
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
+ });
7531
7649
 
7532
7650
  function reviewPopEl() {
7533
7651
  let el = document.getElementById('review-pop');
@@ -7774,14 +7892,20 @@ function addSessionPreviewPath(sessionId, filePath) {
7774
7892
  const key = canonicalPath(filePath);
7775
7893
  const paths = getSessionPreviewPaths(sessionId).filter((p) => canonicalPath(p) !== key);
7776
7894
  paths.unshift(filePath);
7777
- store.setItem(PREVIEW_STORAGE_PREFIX + sessionId, JSON.stringify(paths.slice(0, 20)));
7895
+ saveSessionPreviewPaths(sessionId, paths);
7778
7896
  }
7779
7897
 
7780
7898
  function removeSessionPreviewPath(sessionId, filePath) {
7781
7899
  if (!sessionId) return;
7782
7900
  const key = canonicalPath(filePath);
7783
- const paths = getSessionPreviewPaths(sessionId).filter((p) => canonicalPath(p) !== key);
7784
- if (paths.length) store.setItem(PREVIEW_STORAGE_PREFIX + sessionId, JSON.stringify(paths));
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)));
7785
7909
  else store.removeItem(PREVIEW_STORAGE_PREFIX + sessionId);
7786
7910
  }
7787
7911
 
@@ -7811,7 +7935,7 @@ async function mergeServerLinkedDocs() {
7811
7935
  const known = new Set(local.map(canonicalPath));
7812
7936
  const missing = (paths || []).filter((p) => !known.has(canonicalPath(p)));
7813
7937
  if (!missing.length) continue;
7814
- store.setItem(PREVIEW_STORAGE_PREFIX + sessionId, JSON.stringify([...missing, ...local].slice(0, 20)));
7938
+ saveSessionPreviewPaths(sessionId, [...missing, ...local]);
7815
7939
  afterLinkedDocsChanged(sessionId);
7816
7940
  }
7817
7941
  }
@@ -7854,19 +7978,29 @@ function renderFrontmatterBlock(fm) {
7854
7978
  // allow-same-origin puts it on an opaque origin, so it cannot touch this app's
7855
7979
  // storage, DOM or API. srcdoc has no base URL, so the server has already embedded
7856
7980
  // the document's local assets (lib/inline-assets.js); remote refs load normally.
7857
- function renderHtmlPreview(bodyEl, content) {
7858
- bodyEl.innerHTML = '';
7981
+ function createPreviewFrame(className, srcdoc) {
7859
7982
  const frame = document.createElement('iframe');
7860
- frame.className = 'preview-html-frame';
7983
+ frame.className = className;
7861
7984
  frame.setAttribute('sandbox', 'allow-scripts allow-popups');
7862
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 = '';
7863
7994
  // After the document's end the parser still adds the script to <body>, and it runs after
7864
7995
  // the page's own scripts.
7865
- frame.srcdoc = content + REVIEW_BRIDGE_TAG;
7866
- bodyEl.appendChild(frame);
7996
+ bodyEl.appendChild(createPreviewFrame('preview-html-frame', content + REVIEW_BRIDGE_TAG));
7867
7997
  }
7868
7998
 
7869
- function bindPreviewRelativeLinks(bodyEl) {
7999
+ function isAbsolutePath(p) {
8000
+ return /^([a-zA-Z]:[\\/]|[\\/])/.test(p);
8001
+ }
8002
+
8003
+ function bindPreviewRelativeLinks(bodyEl, baseOf = () => currentPreviewPath) {
7870
8004
  if (bodyEl.dataset.relLinkBound) return;
7871
8005
  bodyEl.addEventListener('click', (e) => {
7872
8006
  const a = e.target.closest('a[href]');
@@ -7874,11 +8008,10 @@ function bindPreviewRelativeLinks(bodyEl) {
7874
8008
  const href = a.getAttribute('href');
7875
8009
  if (!href || href.startsWith('#')) return;
7876
8010
  const isAbsoluteUrl = /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith('//');
7877
- const isAbsolutePath = href.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(href);
7878
8011
  if (isAbsoluteUrl) return;
7879
8012
  const cleanHref = href.replace(/#.*$/, '');
7880
8013
  e.preventDefault();
7881
- openPreviewByPath(cleanHref, isAbsolutePath ? undefined : currentPreviewPath, openFileInEditor);
8014
+ openPreviewByPath(cleanHref, isAbsolutePath(href) ? undefined : baseOf(a), openFileInEditor);
7882
8015
  });
7883
8016
  bodyEl.dataset.relLinkBound = '1';
7884
8017
  }
@@ -7901,41 +8034,50 @@ function renderSourcePreview(filePath, content) {
7901
8034
  return `<pre class="preview-source"><code class="hljs">${escapeHtml(content)}</code></pre>`;
7902
8035
  }
7903
8036
 
7904
- function openPreviewModal(filePath, content, kind) {
7905
- currentPreviewPath = filePath;
7906
- const fileName = filePath.split(/[\\/]/).pop();
7907
- document.getElementById('preview-modal-title').textContent = fileName;
7908
- const bodyEl = document.getElementById('preview-modal-body');
7909
- const isHtml = kind === 'html';
7910
- document.querySelector('#preview-modal .modal').classList.toggle('preview-html', isHtml);
7911
- bindPreviewRelativeLinks(bodyEl);
7912
- if (isHtml) {
7913
- renderHtmlPreview(bodyEl, content);
7914
- } else if (kind === 'text') {
8037
+ // Every kind but html, which needs a frame.
8038
+ function renderPreviewContent(bodyEl, filePath, content, kind) {
8039
+ if (kind === 'text') {
7915
8040
  bodyEl.innerHTML = renderSourcePreview(filePath, content);
7916
8041
  } else if (kind === 'image') {
7917
8042
  const img = document.createElement('img');
7918
8043
  img.className = 'preview-image';
7919
8044
  img.src = `/api/preview/image?${new URLSearchParams({ path: filePath })}`;
7920
- img.alt = fileName;
8045
+ img.alt = filePath.split(/[\\/]/).pop();
7921
8046
  bodyEl.replaceChildren(img);
7922
8047
  } else {
7923
8048
  const { fm, body } = splitFrontmatter(content);
7924
8049
  bodyEl.innerHTML = (fm ? renderFrontmatterBlock(fm) : '') + renderMarkdown(body);
7925
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);
7926
8063
  document.getElementById('preview-modal-meta').textContent = filePath;
7927
8064
  document.getElementById('preview-modal').classList.add('visible');
7928
8065
  updatePreviewLinkBtn();
7929
- if (kind === 'image') return unmountReview();
7930
- mountReview({
7931
- contentEl: bodyEl,
7932
- panelEl: document.getElementById('preview-review-panel'),
7933
- hostEl: document.querySelector('#preview-modal .modal'),
7934
- source: { kind: 'file', label: fileName, path: filePath },
7935
- frame: isHtml ? bodyEl.querySelector('iframe') : null,
7936
- context: kind === 'text' ? (range) => ({ line: sourceLineOf(bodyEl, range) }) : null,
7937
- onSent: closePreviewModal,
7938
- });
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
+ };
7939
8081
  }
7940
8082
 
7941
8083
  function sourceLineOf(bodyEl, range) {
@@ -7951,6 +8093,8 @@ function isPreviewLinkedToCurrentSession() {
7951
8093
  }
7952
8094
 
7953
8095
  function updatePreviewLinkBtn() {
8096
+ const paneBtn = document.getElementById('preview-pane-btn');
8097
+ if (paneBtn) paneBtn.style.display = paneSessionId() ? '' : 'none';
7954
8098
  const btn = document.getElementById('preview-link-btn');
7955
8099
  if (!btn) return;
7956
8100
  if (!currentSessionId) {
@@ -8219,11 +8363,35 @@ function openLinkedDoc(p, baseDir) {
8219
8363
  postAndToast(url, body(p), toast);
8220
8364
  }
8221
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
+
8222
8383
  function renderLinkedDocsHtml(sessionId) {
8223
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
+ );
8224
8392
  // Kicked off from the render rather than the three call sites, so a new surface that
8225
8393
  // shows linked docs cannot forget it. Returns at once once every path is cached.
8226
- const rows = paths.map((p) => ({ p, opener: linkedDocOpener(p) }));
8394
+ const rows = shown.map((p) => ({ p, opener: linkedDocOpener(p) }));
8227
8395
  const filePaths = rows.filter((r) => r.opener !== 'url').map((r) => r.p);
8228
8396
  loadPreviewKinds(sessionId, filePaths);
8229
8397
  const baseDir = getSessionBaseDir(sessionId);
@@ -8248,7 +8416,7 @@ function renderLinkedDocsHtml(sessionId) {
8248
8416
  .join('');
8249
8417
  // Rendered even when empty — the add button has to stay reachable.
8250
8418
  const body = paths.length
8251
- ? `<ul class="linked-doc-list">${items}</ul>`
8419
+ ? `<ul class="linked-doc-list">${items}</ul>${moreHtml}`
8252
8420
  : '<div class="linked-docs-empty">No linked documents yet</div>';
8253
8421
  return `<div class="linked-docs-section panel-section">
8254
8422
  <div class="panel-section-header">
@@ -8467,13 +8635,14 @@ function scratchRowsHtml(rows) {
8467
8635
  function scratchFilesInnerHtml(sessionId) {
8468
8636
  const list = scratchFilesSection.get(sessionId);
8469
8637
  if (!list.length) return '';
8470
- const expanded = scratchFilesExpanded.has(sessionId);
8471
- const shown = expanded ? list : list.slice(0, SCRATCH_FILES_COLLAPSED);
8472
- const more =
8473
- list.length > SCRATCH_FILES_COLLAPSED
8474
- ? `<button type="button" class="expand-toggle-btn scratch-files-more" onclick="toggleScratchFiles('${escAttrJs(sessionId)}')">${expanded ? 'Show less' : `Show all ${list.length}`}</button>`
8475
- : '';
8476
- return `${scratchRowsHtml(shown)}${more}`;
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}`;
8477
8646
  }
8478
8647
 
8479
8648
  // Delegated at the document because the whole zen panel is re-rendered on every SSE tick,
@@ -8513,8 +8682,7 @@ function renderScratchFilesHtml(sessionId) {
8513
8682
 
8514
8683
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
8515
8684
  function toggleScratchFiles(sessionId) {
8516
- if (scratchFilesExpanded.has(sessionId)) scratchFilesExpanded.delete(sessionId);
8517
- else scratchFilesExpanded.add(sessionId);
8685
+ toggleSetMember(scratchFilesExpanded, sessionId);
8518
8686
  scratchFilesSection.repaint(sessionId);
8519
8687
  }
8520
8688
 
@@ -8523,10 +8691,17 @@ function toggleScratchFiles(sessionId) {
8523
8691
  function bindLinkedDocsHandlers(container, sessionId) {
8524
8692
  if (!container) return;
8525
8693
  container.addEventListener('click', (e) => {
8526
- const hit = e.target.closest('.linked-doc-link, .linked-doc-copy, .linked-doc-remove, .linked-docs-add-btn');
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
+ );
8527
8697
  if (!hit || hit.target === '_blank') return;
8528
8698
  e.preventDefault();
8529
- if (hit.classList.contains('linked-docs-add-btn')) {
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')) {
8530
8705
  startLinkedDocInput(container, sessionId);
8531
8706
  } else if (hit.classList.contains('linked-doc-copy')) {
8532
8707
  copyWithFeedback(hit.dataset.path, hit);
@@ -8656,6 +8831,7 @@ function setupEventSource() {
8656
8831
  console.warn('[SSE] Reconnected after drop — forcing full refresh');
8657
8832
  fetchSessions().catch(() => {});
8658
8833
  if (currentSessionId) fetchTasks(currentSessionId);
8834
+ refetchPaneLayout();
8659
8835
  if (terminalAvailable())
8660
8836
  loadTerminals()
8661
8837
  .then(renderSessions)
@@ -8763,6 +8939,10 @@ function setupEventSource() {
8763
8939
  handleSessionPinEvent(data);
8764
8940
  }
8765
8941
 
8942
+ if (data.type === 'pane:changed') {
8943
+ applyPaneLayout(data.sessionId, data.layout);
8944
+ }
8945
+
8766
8946
  if (data.type === 'team-update') {
8767
8947
  const teamSession = sessions.find((s) => s.isTeam && s.teamName === data.teamName);
8768
8948
  if (teamSession) {
@@ -11633,6 +11813,644 @@ function filterByOwner(value) {
11633
11813
 
11634
11814
  //#endregion
11635
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
+
11636
12454
  //#region TERMINAL
11637
12455
  // The kanban and the terminal share one slot: terminal mode hides #main-content and shows
11638
12456
  // the pane. The PTY lives on the server (lib/terminal.js) and outlives the pane, so
@@ -11761,8 +12579,6 @@ function terminalPaneFocused() {
11761
12579
  return document.getElementById('terminal-pane').contains(document.activeElement);
11762
12580
  }
11763
12581
 
11764
- const TEXT_FIELD_SELECTOR = 'input, textarea, select, [contenteditable]';
11765
-
11766
12582
  function inPageField(e) {
11767
12583
  const field = e.target?.closest?.(TEXT_FIELD_SELECTOR);
11768
12584
  return !!field && !document.getElementById('terminal-pane').contains(field);
@@ -11853,6 +12669,7 @@ function syncTerminal() {
11853
12669
  btn.classList.toggle('active', on);
11854
12670
  }
11855
12671
  sessionView.classList.toggle('terminal-mode', on);
12672
+ syncPanes();
11856
12673
  pushTerminalClaims();
11857
12674
  if (!on) {
11858
12675
  // A hidden xterm textarea drops focus, but a display:none iframe keeps it and would take keys unseen.
@@ -12032,7 +12849,7 @@ function onTerminalFrameMessage(t, m) {
12032
12849
  termFrame.reload = false;
12033
12850
  reconnectTerminal();
12034
12851
  }
12035
- } else if (t === 'key') replayTerminalKey(m);
12852
+ } else if (t === 'key') replayKey(m, document.getElementById('terminal-key-proxy'));
12036
12853
  // Chrome does not always fire focusin on the iframe element when its content takes focus.
12037
12854
  else if (t === 'focused') terminalHadFocus = terminalPaneFocused();
12038
12855
  else if (t === 'opening') {
@@ -12052,8 +12869,7 @@ function onTerminalFrameMessage(t, m) {
12052
12869
  // The frame hands back the keys the board acts on. Replaying them on a textarea inside the pane gives
12053
12870
  // the document listeners the target they saw when xterm lived here: the shortcut runs, the SDK
12054
12871
  // forwards a hub key, and the text-field guard keeps the board's own keys out.
12055
- function replayTerminalKey(m) {
12056
- const target = document.getElementById('terminal-key-proxy');
12872
+ function replayKey(m, target) {
12057
12873
  target.dispatchEvent(
12058
12874
  new KeyboardEvent('keydown', {
12059
12875
  key: m.key,
@@ -12104,13 +12920,20 @@ let terminalClaimsQueued = false;
12104
12920
 
12105
12921
  // The claims depend on state (the hub's welcome, a slow connect, the session), so every change re-sends
12106
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
+
12107
12927
  function pushTerminalClaims() {
12108
- if (!termFrame.inited || terminalClaimsQueued) return;
12928
+ if (terminalClaimsQueued) return;
12109
12929
  terminalClaimsQueued = true;
12110
12930
  queueMicrotask(() => {
12111
12931
  terminalClaimsQueued = false;
12932
+ const bridged = bridgedFrameEls();
12933
+ if (!termFrame.inited && !bridged.length) return;
12934
+ const claims = keyClaims();
12935
+ sendBridgeClaims(bridged, claims);
12112
12936
  if (!termFrame.inited) return;
12113
- const claims = { keys: terminalClaims(), forward: hub.forwardCombos() };
12114
12937
  const sig = JSON.stringify(claims);
12115
12938
  if (sig === termFrame.claims) return;
12116
12939
  termFrame.claims = sig;
@@ -13143,6 +13966,9 @@ document.addEventListener('DOMContentLoaded', () => {
13143
13966
  if (lang === 'mermaid') {
13144
13967
  return `<pre class="mermaid" data-original="${escapeHtml(text)}">${escapeHtml(text)}</pre>`;
13145
13968
  }
13969
+ if (treeHighlight.wantsTree(lang, text)) {
13970
+ return `<pre><code class="hljs language-tree">${treeHighlight.highlightTree(text)}</code></pre>`;
13971
+ }
13146
13972
  let highlighted;
13147
13973
  if (lang && hljs.getLanguage(lang)) {
13148
13974
  highlighted = hljs.highlight(text, { language: lang }).value;
@@ -13512,20 +14338,23 @@ document.getElementById('help-docs')?.addEventListener('click', (e) => {
13512
14338
  hub.openExternal(e.currentTarget.href);
13513
14339
  });
13514
14340
 
13515
- document.addEventListener('click', (e) => {
13516
- if (!hub.inHub) return;
13517
- const a = e.target.closest?.('a[href]');
13518
- if (!a) return;
13519
- const href = a.getAttribute('href');
13520
- if (!href) return;
14341
+ function webUrl(href) {
13521
14342
  let url;
13522
14343
  try {
13523
14344
  url = new URL(href, window.location.href);
13524
14345
  } catch (_) {
13525
- return;
14346
+ return null;
13526
14347
  }
13527
- if (url.origin === window.location.origin) return;
13528
- if (url.protocol !== 'http:' && url.protocol !== 'https:') return;
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;
13529
14358
  e.preventDefault();
13530
14359
  e.stopPropagation();
13531
14360
  hub.openExternal(url.href);