claude-code-kanban 6.1.1 → 7.0.0

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