claude-code-kanban 7.1.2 → 7.3.0

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