claude-code-kanban 7.21.0 → 7.23.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
@@ -2769,32 +2769,58 @@ function createResizeGrip({ onReset, ...drag }) {
2769
2769
  }
2770
2770
 
2771
2771
  const MODAL_ZOOM_KEY = 'modal-zoom';
2772
- const MODAL_ZOOM_MIN = 0.7;
2773
- const MODAL_ZOOM_MAX = 2.0;
2774
- let modalZoom = clampModalZoom(Number.parseFloat(store.getItem(MODAL_ZOOM_KEY)) || 1);
2772
+ const SHOW_ZOOM_KEY = 'show-zoom';
2773
+ const ZOOM_MIN = 0.7;
2774
+ const ZOOM_MAX = 2.0;
2775
+ let modalZoom = readZoom(MODAL_ZOOM_KEY);
2776
+ let showZoom = readZoom(SHOW_ZOOM_KEY);
2775
2777
 
2776
- function clampModalZoom(v) {
2777
- return Math.min(MODAL_ZOOM_MAX, Math.max(MODAL_ZOOM_MIN, v));
2778
+ function clampZoom(v) {
2779
+ return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, v));
2780
+ }
2781
+
2782
+ function readZoom(key) {
2783
+ return clampZoom(Number.parseFloat(store.getItem(key)) || 1);
2784
+ }
2785
+
2786
+ function zoomLabel(z) {
2787
+ return `${Math.round(z * 100)}%`;
2778
2788
  }
2779
2789
 
2780
2790
  function modalZoomLabel() {
2781
- return `${Math.round(modalZoom * 100)}%`;
2791
+ return zoomLabel(modalZoom);
2792
+ }
2793
+
2794
+ // delta 0 resets to 100%. At a limit it returns z as is; else it saves and toasts the new zoom.
2795
+ function stepZoom(z, delta, key, label) {
2796
+ const next = delta === 0 ? 1 : clampZoom(Math.round((z + delta) * 10) / 10);
2797
+ if (next === z && delta !== 0) return z;
2798
+ store.setItem(key, String(next));
2799
+ showToast(`${label} ${zoomLabel(next)}`);
2800
+ return next;
2782
2801
  }
2783
2802
 
2784
2803
  function applyModalZoom() {
2785
2804
  document.documentElement.style.setProperty('--modal-zoom', String(modalZoom));
2786
2805
  for (const el of document.querySelectorAll('.pane-zoom-val')) el.textContent = modalZoomLabel();
2787
- postToShowFrames(() => ({ type: `${SHOW_MSG}zoom`, z: modalZoom }));
2806
+ postToShowFrames(() => ({ type: `${SHOW_MSG}zoom`, z: modalZoom }), document.querySelectorAll(PANE_SHOW_FRAMES));
2788
2807
  }
2789
2808
 
2790
- // delta 0 resets to 100%
2791
2809
  function adjustModalZoom(delta) {
2792
- const next = delta === 0 ? 1 : clampModalZoom(Math.round((modalZoom + delta) * 10) / 10);
2793
- if (next === modalZoom && delta !== 0) return;
2794
- modalZoom = next;
2795
- store.setItem(MODAL_ZOOM_KEY, String(modalZoom));
2810
+ modalZoom = stepZoom(modalZoom, delta, MODAL_ZOOM_KEY, 'Text');
2796
2811
  applyModalZoom();
2797
- showToast(`Text ${modalZoomLabel()}`);
2812
+ }
2813
+
2814
+ // The show card above the terminal keeps its own text zoom: it sits beside a pane, so one
2815
+ // shared scale would resize both.
2816
+ function applyShowZoom() {
2817
+ document.documentElement.style.setProperty('--show-zoom', String(showZoom));
2818
+ postToShowFrames(() => ({ type: `${SHOW_MSG}zoom`, z: showZoom }), [showState.frame]);
2819
+ }
2820
+
2821
+ function adjustShowZoom(delta) {
2822
+ showZoom = stepZoom(showZoom, delta, SHOW_ZOOM_KEY, 'Card text');
2823
+ applyShowZoom();
2798
2824
  }
2799
2825
 
2800
2826
  // The markup decides what scales: `.modal-zoomable` marks a modal's reading
@@ -2808,9 +2834,12 @@ function isAnyModalOpen() {
2808
2834
  return document.querySelector('.modal-overlay.visible') !== null;
2809
2835
  }
2810
2836
 
2811
- // An open modal owns the zoom keys even when it has nothing to scale.
2812
- function isZoomableOnScreen() {
2813
- return isAnyModalOpen() ? isZoomableModalOpen() : isAskOnScreen() || isPaneDocOnScreen() || isShowCardFocused();
2837
+ // The zoom the keys change, or null. An open modal owns the keys even when it has nothing to scale.
2838
+ function zoomTarget() {
2839
+ if (isAnyModalOpen()) return isZoomableModalOpen() ? adjustModalZoom : null;
2840
+ if (isAskOnScreen()) return adjustModalZoom;
2841
+ if (isShowCardFocused()) return adjustShowZoom;
2842
+ return isPaneDocOnScreen() ? adjustModalZoom : null;
2814
2843
  }
2815
2844
 
2816
2845
  const ZOOM_KEYS = { '+': 0.1, '=': 0.1, NumpadAdd: 0.1, '-': -0.1, _: -0.1, NumpadSubtract: -0.1, 0: 0, Numpad0: 0 };
@@ -3957,6 +3986,10 @@ function renderSessions() {
3957
3986
  const childDepth = new Map();
3958
3987
  const parentOf = new Map();
3959
3988
  for (const g of sessionGroups) for (const m of g.members) if (m.parent) parentOf.set(m.ref, m.parent);
3989
+ childAnchors = computeChildAnchors(filteredSessions);
3990
+ for (const s of filteredSessions) {
3991
+ if (childAnchors.has(s.id) && !parentOf.has(s.id)) parentOf.set(s.id, s.dispatched.parent);
3992
+ }
3960
3993
  const nestChildren = (list) => {
3961
3994
  if (parentOf.size === 0) return list;
3962
3995
  const ids = new Set(list.map((s) => s.id));
@@ -4132,6 +4165,10 @@ function renderSessions() {
4132
4165
  return;
4133
4166
  }
4134
4167
 
4168
+ const nestedCards = (list) =>
4169
+ nestChildren(list)
4170
+ .map((s) => renderSessionCard(s))
4171
+ .join('');
4135
4172
  const groupPinned = store.getItem(GROUP_PINNED_KEY) !== 'false';
4136
4173
  const pinWeight = (s) => (isPlacedSticky(s.id) ? 2 : isInPinnedGroup(s) ? 1 : 0);
4137
4174
  const pinSort = (a, b) => pinWeight(b) - pinWeight(a);
@@ -4252,13 +4289,14 @@ function renderSessions() {
4252
4289
  // Placed sessions follow the order the user dragged them into; a session that is here through
4253
4290
  // its project takes the project's slot.
4254
4291
  const slot = new Map(group.members.map((m, i) => [`${m.type}:${m.ref}`, i]));
4255
- const slotOf = (s) => slot.get(`session:${s.id}`) ?? slot.get(`project:${sessionProjectKey(s)}`) ?? 0;
4292
+ const slotOf = (placed) => {
4293
+ const s = placementOf(placed);
4294
+ return slot.get(`session:${s.id}`) ?? slot.get(`project:${sessionProjectKey(s)}`) ?? 0;
4295
+ };
4256
4296
  const byMemberOrder = (a, b) => (groupPinned ? 0 : pinSort(a, b)) || slotOf(a) - slotOf(b);
4257
4297
  let body = '';
4258
4298
  if (flat) {
4259
- body = nestChildren([...bucket].sort(byMemberOrder))
4260
- .map((s) => renderSessionCard(s))
4261
- .join('');
4299
+ body = nestedCards([...bucket].sort(byMemberOrder));
4262
4300
  } else {
4263
4301
  const loose = [];
4264
4302
  const byProject = new Map();
@@ -4312,7 +4350,7 @@ function renderSessions() {
4312
4350
  const groups = new Map();
4313
4351
  const ungrouped = [];
4314
4352
  for (const session of sgRest) {
4315
- const key = sessionProjectKey(session);
4353
+ const key = sessionProjectKey(placementOf(session));
4316
4354
  if (key) {
4317
4355
  if (!groups.has(key)) groups.set(key, []);
4318
4356
  groups.get(key).push(session);
@@ -4363,7 +4401,7 @@ function renderSessions() {
4363
4401
  </div>
4364
4402
  `;
4365
4403
  } else {
4366
- projectsHtml += ungrouped.map((s) => renderSessionCard(s)).join('');
4404
+ projectsHtml += nestedCards(ungrouped);
4367
4405
  }
4368
4406
  }
4369
4407
 
@@ -4373,8 +4411,7 @@ function renderSessions() {
4373
4411
  sessionsList.innerHTML = html + outsideHtml;
4374
4412
  } else {
4375
4413
  const sectioned = sessionGroups.length > 0;
4376
- const restHtml =
4377
- sectioned && collapsedProjectGroups.has(SECTION_SESSIONS) ? '' : sgRest.map((s) => renderSessionCard(s)).join('');
4414
+ const restHtml = sectioned && collapsedProjectGroups.has(SECTION_SESSIONS) ? '' : nestedCards(sgRest);
4378
4415
  const tail = sectioned ? sectionHtml(SECTION_SESSIONS, 'Sessions', true, restHtml, sgRest.length === 0) : restHtml;
4379
4416
  sessionsList.innerHTML = html + sgSectionHtml(true) + tail + outsideHtml;
4380
4417
  }
@@ -4989,6 +5026,12 @@ function sgGroupById(id) {
4989
5026
  // Matches MAX_DEPTH in lib/user-groups.js.
4990
5027
  const SG_MAX_DEPTH = 8;
4991
5028
 
5029
+ function kbSelectedGroup() {
5030
+ if (focusZone !== 'sidebar') return null;
5031
+ const header = sessionsList.querySelector('.session-group-header.kb-selected');
5032
+ return header ? sgGroupById(header.dataset.groupId) : null;
5033
+ }
5034
+
4992
5035
  function sgSubtreeIds(rootId) {
4993
5036
  const ids = new Set([rootId]);
4994
5037
  for (const id of ids) for (const g of sessionGroups) if (g.parent === id) ids.add(g.id);
@@ -5013,8 +5056,35 @@ function sgSessionParent(id) {
5013
5056
 
5014
5057
  // An individually placed session wins over the placement of its project.
5015
5058
  function sgGroupForSession(session) {
5016
- const project = sessionProjectKey(session);
5017
- return sgGroupOf('session', session.id) || (project ? sgGroupOf('project', project) : null);
5059
+ const s = placementOf(session);
5060
+ const project = sessionProjectKey(s);
5061
+ return sgGroupOf('session', s.id) || (project ? sgGroupOf('project', project) : null);
5062
+ }
5063
+
5064
+ // A `dispatch start --child` session renders in its starter's place, unless the user placed it or
5065
+ // the starter is not in the rendered list. Set by renderSessions, so every other placement lookup
5066
+ // (drag, pins, search, reveal) agrees with what the sidebar shows.
5067
+ let childAnchors = new Map();
5068
+ const placementOf = (session) => childAnchors.get(session.id) ?? session;
5069
+
5070
+ function computeChildAnchors(list) {
5071
+ const out = new Map();
5072
+ if (!list.some((s) => s.dispatched?.child)) return out;
5073
+ const byId = new Map(list.map((s) => [s.id, s]));
5074
+ const placed = new Set();
5075
+ for (const g of sessionGroups) for (const m of g.members) if (m.type === 'session') placed.add(m.ref);
5076
+ const up = (s) => (s.dispatched?.child && !placed.has(s.id) ? byId.get(s.dispatched.parent) : null);
5077
+ for (const s of list) {
5078
+ let anchor = up(s);
5079
+ if (!anchor) continue;
5080
+ for (let hop = 1; hop < 4; hop++) {
5081
+ const next = up(anchor);
5082
+ if (!next || next === s) break;
5083
+ anchor = next;
5084
+ }
5085
+ out.set(s.id, anchor);
5086
+ }
5087
+ return out;
5018
5088
  }
5019
5089
 
5020
5090
  function sgListFiltered() {
@@ -5060,8 +5130,9 @@ function sgDetach(type, ref) {
5060
5130
 
5061
5131
  // Where a session renders inside a group: the project block it was moved under, its own
5062
5132
  // project's block, or - when the user lifted it out - the group's own loose list.
5063
- function sgHostOf(group, session) {
5064
- if (!group || !session) return null;
5133
+ function sgHostOf(group, placed) {
5134
+ if (!group || !placed) return null;
5135
+ const session = placementOf(placed);
5065
5136
  const m = group.members.find((x) => x.type === 'session' && x.ref === session.id);
5066
5137
  const isMemberProject = (path) => !!path && group.members.some((x) => x.type === 'project' && x.ref === path);
5067
5138
  if (m?.under && isMemberProject(m.under)) return m.under;
@@ -5946,7 +6017,8 @@ function expandActiveGroups({ onlyNew = false } = {}) {
5946
6017
  // (a collapsed Pinned sub-section opens only for the open session, see syncPinPeek):
5947
6018
  // Groups for a grouped session, else Projects or Sessions, which depends on the view. Returns
5948
6019
  // whether anything changed.
5949
- function uncollapseFor(session) {
6020
+ function uncollapseFor(placed) {
6021
+ const session = placementOf(placed);
5950
6022
  const group = sgGroupForSession(session);
5951
6023
  const keys = [
5952
6024
  ...(group
@@ -5963,7 +6035,7 @@ function uncollapseFor(session) {
5963
6035
  function pinnedCollapseKeys(session) {
5964
6036
  if (!isInPinnedGroup(session)) return [];
5965
6037
  const group = sgGroupForSession(session);
5966
- if (!group) return [pinKey(sessionProjectKey(session) || '__ungrouped__')];
6038
+ if (!group) return [pinKey(sessionProjectKey(placementOf(session)) || '__ungrouped__')];
5967
6039
  const host = sgHostOf(group, session);
5968
6040
  return [host ? pinKey(host) : `__pinned_group_${group.id}__`];
5969
6041
  }
@@ -6514,7 +6586,7 @@ const SHORTCUT_TABS = [
6514
6586
  { keys: ['>'], label: 'Toggle sticky' },
6515
6587
  { keys: ['Ctrl', 'Shift', '.'], combo: true, label: 'Toggle sticky (from the terminal)' },
6516
6588
  { keys: ['Shift', 'F10'], combo: true, label: 'Session menu: move to group, fork (or Menu key)' },
6517
- { keys: ['Ctrl', 'D'], combo: true, label: 'Dismiss session (pinned: to Pinned group)' },
6589
+ { keys: ['Ctrl', 'D'], combo: true, label: 'Dismiss session, or delete the group under the cursor' },
6518
6590
  { keys: ['Ctrl', 'Shift', 'L'], combo: true, label: 'Toggle session log' },
6519
6591
  { keys: ['Ctrl', 'Shift', 'M'], combo: true, label: 'Open last message' },
6520
6592
  { keys: ['↑', '↓'], label: 'Previous / next message in detail' },
@@ -6552,6 +6624,7 @@ const SHORTCUT_TABS = [
6552
6624
  rows: [
6553
6625
  { keys: ['[', ']'], label: 'Previous / next post' },
6554
6626
  { keys: ['Alt', 'Enter'], combo: true, label: 'Fill the terminal / back to the card' },
6627
+ { keys: ['Alt', 'Shift', 'Enter'], combo: true, label: 'Dock beside the terminal / back to the overlay' },
6555
6628
  { keys: ['Esc'], label: 'Back to the card, else collapse to the header' },
6556
6629
  { keys: ['Alt', 'W'], combo: true, label: 'Close until the next post' },
6557
6630
  { keys: ['Ctrl', 'D'], combo: true, label: 'Remove the post' },
@@ -7767,15 +7840,20 @@ document.addEventListener('keydown', (e) => {
7767
7840
  // the whole page. Sits above the text-field guard so it still works with the
7768
7841
  // caret in a field inside the modal.
7769
7842
  const zoom = zoomDelta(e);
7770
- if (zoom !== undefined && isZoomableOnScreen()) {
7843
+ const zoomTo = zoom !== undefined && zoomTarget();
7844
+ if (zoomTo) {
7771
7845
  e.preventDefault();
7772
- adjustModalZoom(zoom);
7846
+ zoomTo(zoom);
7773
7847
  return;
7774
7848
  }
7775
7849
  if (isShowCardFocused() && !isAnyModalOpen() && showCardKey(e)) {
7776
7850
  e.preventDefault();
7777
7851
  return;
7778
7852
  }
7853
+ if (isDockFocused() && !isAnyModalOpen() && dockHeadKey(e)) {
7854
+ e.preventDefault();
7855
+ return;
7856
+ }
7779
7857
 
7780
7858
  // Above the text-field guard: xterm's input is a textarea, and the toggle must work from it.
7781
7859
  const terminalPrompt = document.getElementById('terminal-prompt');
@@ -8123,7 +8201,9 @@ document.addEventListener('keydown', (e) => {
8123
8201
  }
8124
8202
  if (ClaudeHub.comboOf(e) === 'ctrl+d') {
8125
8203
  e.preventDefault();
8126
- dismissContextSession();
8204
+ const group = kbSelectedGroup();
8205
+ if (group) sgConfirmDelete(group);
8206
+ else dismissContextSession();
8127
8207
  return;
8128
8208
  }
8129
8209
  if (e.code === 'KeyC' && e.shiftKey && !e.altKey && !e.metaKey) {
@@ -8732,10 +8812,11 @@ const PANE_CLAIMS = [
8732
8812
  ...'123456789'.split('').map((n) => ({ ctrlKey: true, altKey: true, key: 'Unidentified', code: `Digit${n}` })),
8733
8813
  ];
8734
8814
 
8735
- // Ctrl+D and Alt+Enter act on the show card (showCardKey); other pages keep them. Alt+W and Esc are in PANE_CLAIMS.
8815
+ // Ctrl+D, Alt+Enter and Alt+Shift+Enter act on the show card (showCardKey); other pages keep them. Alt+W and Esc are in PANE_CLAIMS.
8736
8816
  const SHOW_CLAIMS = [
8737
8817
  ...['ctrlKey', 'metaKey'].map((mod) => ({ [mod]: true, key: 'd', code: '' })),
8738
8818
  { altKey: true, key: 'Enter', code: '' },
8819
+ { altKey: true, shiftKey: true, key: 'Enter', code: '' },
8739
8820
  ];
8740
8821
 
8741
8822
  // Claimed only while comments wait, so a previewed page keeps Ctrl+Enter otherwise.
@@ -9434,6 +9515,13 @@ function openFileInEditor(filePath) {
9434
9515
  postAndToast('/api/open-in-editor', { file: filePath }, 'in editor');
9435
9516
  }
9436
9517
 
9518
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
9519
+ async function copyPreviewPath(el) {
9520
+ if (currentPreviewPath && (await copyText(currentPreviewPath, 'path'))) {
9521
+ el.animate([{ color: 'var(--success)' }, {}], { duration: 800, easing: 'ease-out' });
9522
+ }
9523
+ }
9524
+
9437
9525
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
9438
9526
  function openPreviewInEditor() {
9439
9527
  if (!currentPreviewPath) return;
@@ -9491,6 +9579,25 @@ function handleSessionOpenEvent(data) {
9491
9579
  fetchTasks(id);
9492
9580
  }
9493
9581
 
9582
+ // `claude-code-kanban new` started the PTY; this tab shows it as if its New session dialog had.
9583
+ // Under the hub the action round trip brings this tab on screen first.
9584
+ async function handleSessionLaunchedEvent(t) {
9585
+ const { id } = t;
9586
+ if (!id || !terminalAvailable()) return;
9587
+ const claim = await terminalFetch(apiPath`/api/terminal/launched/${id}`, 'POST').catch(() => null);
9588
+ if (!claim?.ok) return;
9589
+ if (!newSpecs.has(id)) newSpecs.set(id, specFromTerminal({ mode: 'new', startedAt: Date.now(), ...t }));
9590
+ setTerminalMode(id, true);
9591
+ termState.focusNext = true;
9592
+ if (hub.can('session.open')) hub.invoke('session.open', { session: id });
9593
+ else openSessionFromOutside(id);
9594
+ }
9595
+
9596
+ function openSessionFromOutside(id) {
9597
+ if (newSpecs.has(id)) openPlaceholder(id);
9598
+ else handleSessionOpenEvent({ id });
9599
+ }
9600
+
9494
9601
  async function handlePreviewOpenEvent(data) {
9495
9602
  const { path: filePath, sessionId } = data;
9496
9603
  if (sessionId && sessionId !== currentSessionId) {
@@ -10292,6 +10399,10 @@ function setupEventSource() {
10292
10399
  handleSessionOpenEvent(data);
10293
10400
  }
10294
10401
 
10402
+ if (data.type === 'session:launched') {
10403
+ handleSessionLaunchedEvent(data);
10404
+ }
10405
+
10295
10406
  // Alone, the page would open a tab without a user gesture, which the browser blocks; the
10296
10407
  // CLI prints the link instead.
10297
10408
  if (data.type === 'session:cost' && hub.inHub) {
@@ -10310,6 +10421,15 @@ function setupEventSource() {
10310
10421
  applyPaneLayout(data.sessionId, data.layout);
10311
10422
  }
10312
10423
 
10424
+ if (data.type === 'addon:changed' && dockSid) {
10425
+ fetchDockAddons(dockSid);
10426
+ }
10427
+
10428
+ if (data.type === 'addon:restarted' && dockFrames.has(data.id)) {
10429
+ dropDockView(data.id);
10430
+ renderShow();
10431
+ }
10432
+
10313
10433
  if (data.type === 'pane:file-changed') {
10314
10434
  onPaneFileChanged(data.path);
10315
10435
  }
@@ -11650,8 +11770,7 @@ function toggleZenMode() {
11650
11770
  if (zenMode) zenMode = false;
11651
11771
  else if (zenGroup) zenGroup = null;
11652
11772
  else {
11653
- const header = focusZone === 'sidebar' ? sessionsList.querySelector('.session-group-header.kb-selected') : null;
11654
- zenGroup = header?.dataset.groupId || null;
11773
+ zenGroup = kbSelectedGroup()?.id || null;
11655
11774
  zenMode = !zenGroup;
11656
11775
  }
11657
11776
  store.setItem(ZEN_KEY, String(zenMode));
@@ -13380,8 +13499,10 @@ const PANE_EMPTY_LAYOUT = { panes: [] };
13380
13499
  const paneLayouts = new Map();
13381
13500
  let paneLayoutSid = null;
13382
13501
 
13383
- // Targets the server's probe found refusing framing, kept for the page's life; a reload asks again.
13384
- const paneRefused = new Set();
13502
+ // Target → whether the server's probe found it frameable, kept for the page's life; a reload asks again.
13503
+ const paneFraming = new Map();
13504
+ // Run add-ons whose port answered once while this page lives.
13505
+ const dockAddonUp = new Set();
13385
13506
 
13386
13507
  // Every origin above a pane's frame. Firefox has no ancestorOrigins, so under the hub there it
13387
13508
  // names the board only; a refused frame then shows the browser's own error.
@@ -13390,7 +13511,7 @@ function paneAncestors() {
13390
13511
  }
13391
13512
 
13392
13513
  function paneFrameable(pane) {
13393
- return pane.kind !== 'url' || !paneRefused.has(pane.target);
13514
+ return pane.kind !== 'url' || paneFraming.get(pane.target) !== false;
13394
13515
  }
13395
13516
 
13396
13517
  // allow-scripts with allow-same-origin on the board's or the hub's origin would lift the sandbox.
@@ -13412,7 +13533,7 @@ function paneOwnOrigin(pane) {
13412
13533
  async function probePaneFraming(sid, pane) {
13413
13534
  const q = new URLSearchParams({ ancestors: paneAncestors().join(',') });
13414
13535
  const { frameable } = await paneRequest('GET', `${apiPath`/api/panes/${sid}/${pane.id}/framing`}?${q}`);
13415
- if (frameable === false) paneRefused.add(pane.target);
13536
+ if (typeof frameable === 'boolean') paneFraming.set(pane.target, frameable);
13416
13537
  return frameable;
13417
13538
  }
13418
13539
 
@@ -13429,6 +13550,7 @@ function applyPaneLayout(sid, layout) {
13429
13550
  if (targets.get(p.id) !== p.target) unmountPane(sid, p.id);
13430
13551
  }
13431
13552
  if (!targets.has(getActivePaneId(sid))) setActivePaneId(sid, 'board');
13553
+ if (sid === dockSid) renderDockRail();
13432
13554
  if (sid !== paneSessionId()) return;
13433
13555
  syncPanes();
13434
13556
  autoRevealLog(sid);
@@ -13454,20 +13576,20 @@ async function paneRequest(method, path, body) {
13454
13576
  }
13455
13577
  }
13456
13578
 
13457
- // Per-session pane state in sessionStorage, {[sessionId]: value} in recency order.
13579
+ // Per-session pane state, {[sessionId]: value} in recency order; sessionStorage unless the caller passes `store`.
13458
13580
  const PANE_STATE_CAP = 20;
13459
13581
 
13460
- function loadPaneState(key) {
13461
- const map = tabStore.readJson(key);
13582
+ function loadPaneState(key, area = tabStore) {
13583
+ const map = area.readJson(key);
13462
13584
  return map && typeof map === 'object' ? map : {};
13463
13585
  }
13464
13586
 
13465
13587
  // Puts `sid` last, so the cap drops the least recent session. A null value removes it.
13466
- function savePaneState(key, map, sid, value) {
13588
+ function savePaneState(key, map, sid, value, area = tabStore) {
13467
13589
  delete map[sid];
13468
13590
  if (value != null) map[sid] = value;
13469
13591
  const capped = Object.fromEntries(Object.entries(map).slice(-PANE_STATE_CAP));
13470
- tabStore.writeJson(key, capped);
13592
+ area.writeJson(key, capped);
13471
13593
  return capped;
13472
13594
  }
13473
13595
 
@@ -13549,7 +13671,8 @@ function neighborPair(sid, id, pairs) {
13549
13671
  // tab, so grouping leaves the server's order alone; a drag saves the order on screen.
13550
13672
  const PANE_GROUPS_KEY = 'pane-groups';
13551
13673
  const PANE_GROUP_COLORS = ['blue', 'purple', 'teal', 'orange', 'yellow', 'pink', 'green', 'red'];
13552
- let paneGroups = loadPaneState(PANE_GROUPS_KEY);
13674
+ // Groups outlive the window; a save re-reads the map, so other windows' groups stay.
13675
+ let paneGroups = loadPaneState(PANE_GROUPS_KEY, store);
13553
13676
 
13554
13677
  // Tabs that are gone, or in an earlier group, drop out, and so do groups left empty.
13555
13678
  function getPaneGroups(sid) {
@@ -13567,7 +13690,8 @@ function getPaneGroups(sid) {
13567
13690
  }
13568
13691
 
13569
13692
  function setPaneGroups(sid, groups) {
13570
- paneGroups = savePaneState(PANE_GROUPS_KEY, paneGroups, sid, groups.length ? groups : null);
13693
+ const map = loadPaneState(PANE_GROUPS_KEY, store);
13694
+ paneGroups = savePaneState(PANE_GROUPS_KEY, map, sid, groups.length ? groups : null, store);
13571
13695
  }
13572
13696
 
13573
13697
  // A pair is in the group of either side, and the board is in none.
@@ -13875,9 +13999,10 @@ function markPaneFrameFocus() {
13875
13999
  view?.classList.add('frame-focus');
13876
14000
  }
13877
14001
 
13878
- function paneCardHtml(title, text, button) {
13879
- const btn = button ? `<button class="btn btn-primary" data-pane-open>${escapeHtml(button)}</button>` : '';
13880
- return `<div class="pane-card"><div class="pane-card-box">${PANE_ICONS.warning}<h3>${escapeHtml(title)}</h3><p>${escapeHtml(text)}</p>${btn}</div></div>`;
14002
+ // `extra` is trusted HTML put above the button.
14003
+ function paneCardHtml(title, text, button, { attr = 'data-pane-open', extra = '' } = {}) {
14004
+ const btn = button ? `<button class="btn btn-primary" ${attr}>${escapeHtml(button)}</button>` : '';
14005
+ return `<div class="pane-card"><div class="pane-card-box">${PANE_ICONS.warning}<h3>${escapeHtml(title)}</h3><p>${escapeHtml(text)}</p>${extra}${btn}</div></div>`;
13881
14006
  }
13882
14007
 
13883
14008
  // Returns the key of the view to show.
@@ -13906,32 +14031,37 @@ function mountPane(sid, pane) {
13906
14031
  return key;
13907
14032
  }
13908
14033
 
13909
- async function loadPaneView(sid, pane, view, scroll) {
14034
+ // The card in place of a URL pane that cannot be framed, or '' when it can.
14035
+ function paneBlockedHtml(pane) {
13910
14036
  if (paneOwnOrigin(pane)) {
13911
- view.insertAdjacentHTML(
13912
- 'beforeend',
13913
- paneCardHtml('A pane cannot show the board or the hub', pane.target, 'Open in new tab'),
13914
- );
13915
- return;
14037
+ return paneCardHtml('A pane cannot show the board or the hub', pane.target, 'Open in new tab');
13916
14038
  }
13917
- if (!paneFrameable(pane)) {
13918
- view.insertAdjacentHTML(
13919
- 'beforeend',
13920
- paneCardHtml(
13921
- `${new URL(pane.target).host} does not allow framing`,
13922
- 'The site sends X-Frame-Options or a frame-ancestors policy. The pane keeps the link with the session.',
13923
- 'Open in new tab',
13924
- ),
13925
- );
14039
+ if (paneFrameable(pane)) return '';
14040
+ return paneCardHtml(
14041
+ `${new URL(pane.target).host} does not allow framing`,
14042
+ 'The site sends X-Frame-Options or a frame-ancestors policy. The pane keeps the link with the session.',
14043
+ 'Open in new tab',
14044
+ );
14045
+ }
14046
+
14047
+ function paneUrlFrame(pane, className) {
14048
+ const frame = document.createElement('iframe');
14049
+ frame.className = className;
14050
+ frame.setAttribute('sandbox', PANE_URL_SANDBOX);
14051
+ frame.setAttribute('referrerpolicy', 'no-referrer');
14052
+ frame.title = pane.title;
14053
+ frame.src = pane.target;
14054
+ return frame;
14055
+ }
14056
+
14057
+ async function loadPaneView(sid, pane, view, scroll) {
14058
+ const blocked = paneBlockedHtml(pane);
14059
+ if (blocked) {
14060
+ view.insertAdjacentHTML('beforeend', blocked);
13926
14061
  return;
13927
14062
  }
13928
14063
  if (pane.kind === 'url') {
13929
- const frame = document.createElement('iframe');
13930
- frame.className = 'pane-frame pane-url-frame modal-zoomable';
13931
- frame.setAttribute('sandbox', PANE_URL_SANDBOX);
13932
- frame.setAttribute('referrerpolicy', 'no-referrer');
13933
- frame.title = pane.title;
13934
- frame.src = pane.target;
14064
+ const frame = paneUrlFrame(pane, 'pane-frame pane-url-frame modal-zoomable');
13935
14065
  frame.addEventListener('load', () => {
13936
14066
  view.classList.remove('keys-bridged');
13937
14067
  frame.hostKeysSig = null;
@@ -13955,7 +14085,7 @@ async function loadPaneView(sid, pane, view, scroll) {
13955
14085
  if (data?.kind === 'html') {
13956
14086
  // A show card's own zoom follows the text zoom (showBridge), so its frame is not .modal-zoomable.
13957
14087
  const [cls, doc] = pane.show
13958
- ? ['pane-frame show-pane-frame', showSrcdoc(data.content, scroll?.y)]
14088
+ ? ['pane-frame show-pane-frame', showSrcdoc(data.content, scroll?.y, modalZoom)]
13959
14089
  : ['pane-frame modal-zoomable themed-frame', themedSrcdoc(data.content)];
13960
14090
  const frame = createPreviewFrame(cls, doc);
13961
14091
  frame.title = pane.title;
@@ -14365,7 +14495,7 @@ async function reloadPane(id) {
14365
14495
  await reloadPaneView(sid, pane, view);
14366
14496
  } else {
14367
14497
  unmountPane(sid, pane.id);
14368
- paneRefused.delete(pane.target);
14498
+ paneFraming.delete(pane.target);
14369
14499
  syncPanes();
14370
14500
  }
14371
14501
  showToast('Pane reloaded', 'success');
@@ -14578,6 +14708,7 @@ function openPaneMenu(x, y, id) {
14578
14708
  : paneMenuItem('link', paneIsLinked(pane) ? 'Remove from linked documents' : 'Add to linked documents')) +
14579
14709
  paneMenuItem('rename', 'Rename') +
14580
14710
  paneMenuItem('reload', 'Reload') +
14711
+ (url ? paneMenuItem('dock', pane.dock ? 'Remove from the dock' : 'Dock beside the terminal') : '') +
14581
14712
  paneGroupMenuItems(pane.id) +
14582
14713
  paneMenuItem('close', 'Close pane'),
14583
14714
  );
@@ -14589,6 +14720,20 @@ function paneIsLinked(pane) {
14589
14720
  return getSessionPreviewPaths(paneSessionId()).some((p) => canonicalPath(p) === key);
14590
14721
  }
14591
14722
 
14723
+ function togglePaneDock(id) {
14724
+ const pane = paneById(id);
14725
+ const sid = paneSessionId();
14726
+ if (pane && sid) setPaneDock(sid, id, !pane.dock);
14727
+ }
14728
+
14729
+ async function setPaneDock(sid, id, on) {
14730
+ const res = await paneRequest('PATCH', apiPath`/api/panes/${sid}/${id}`, { dock: on });
14731
+ if (res.error) return showToast(res.error, 'error');
14732
+ applyPaneLayout(sid, res.layout);
14733
+ if (on) showToast('Docked: click its icon in the rail beside the terminal', 'success');
14734
+ else showToast('Removed from the dock. It stays a pane of the session', 'success');
14735
+ }
14736
+
14592
14737
  function togglePaneLink(id) {
14593
14738
  const pane = paneById(id);
14594
14739
  const sid = paneSessionId();
@@ -14620,6 +14765,7 @@ const PANE_MENU_ACTIONS = {
14620
14765
  link: togglePaneLink,
14621
14766
  rename: beginPaneRename,
14622
14767
  reload: reloadPane,
14768
+ dock: togglePaneDock,
14623
14769
  close: closePane,
14624
14770
  group: (id, to) => groupPaneTab(id, to || null),
14625
14771
  groupRename: beginPaneGroupRename,
@@ -15266,6 +15412,11 @@ function terminalShortcut(e, probe = false) {
15266
15412
  if (ctrlAlt && (e.code === 'KeyN' || e.code === 'KeyR') && terminalAvailable()) {
15267
15413
  return () => openNewSession(null, e.code === 'KeyR');
15268
15414
  }
15415
+ if ((e.code === 'ArrowUp' || e.code === 'ArrowDown') && e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey) {
15416
+ if (!(probe || terminalPaneFocused() || isShowCardFocused() || isDockFocused()) || dockRailItems().length < 2)
15417
+ return null;
15418
+ return () => stepDockRail(e.code === 'ArrowDown' ? 1 : -1);
15419
+ }
15269
15420
  if (e.code !== 'Backquote' || e.metaKey) return null;
15270
15421
  if (ctrlAlt) return toggleShowFocus;
15271
15422
  if (e.ctrlKey && !e.altKey) return e.shiftKey ? openTerminalManager : toggleTerminal;
@@ -15309,14 +15460,29 @@ function toggleTerminalFocus() {
15309
15460
  }
15310
15461
 
15311
15462
  function toggleShowFocus() {
15312
- if (isShowCardFocused()) {
15463
+ if (isShowCardFocused() || isDockFocused()) {
15313
15464
  focusTerminalPane();
15314
15465
  return;
15315
15466
  }
15316
- if (!wantsTerminal() || !showState.posts.length) return;
15317
- if (showState.closed || showState.collapsed) {
15318
- showState.closed = false;
15319
- showState.collapsed = false;
15467
+ if (!wantsTerminal()) return;
15468
+ const items = dockItems();
15469
+ const ids = items.map((p) => p.id);
15470
+ const rail = showState.posts.length ? ['show', ...ids] : ids;
15471
+ const last = showState.terminalId && dockLast[showState.terminalId];
15472
+ const pick = rail.includes(last) ? last : rail[0];
15473
+ if (!pick) return;
15474
+ if (pick !== 'show') {
15475
+ if (dockActivePane(items)?.id !== pick) {
15476
+ setDockAddon(pick);
15477
+ renderShow();
15478
+ }
15479
+ focusDockHead();
15480
+ return;
15481
+ }
15482
+ const addon = dockActivePane(items);
15483
+ if (addon) setDockAddon(null);
15484
+ if (addon || showState.closed || showState.collapsed) {
15485
+ Object.assign(showState, { closed: false, collapsed: false, ...(addon && { docked: true }) });
15320
15486
  renderShow();
15321
15487
  }
15322
15488
  focusShowBody();
@@ -15326,6 +15492,8 @@ function toggleShowFocus() {
15326
15492
  function syncTerminal() {
15327
15493
  const btn = document.getElementById('terminal-toggle');
15328
15494
  const on = wantsTerminal();
15495
+ // A focused frame keeps the keys after its pane hides, and then the board never sees them.
15496
+ if (!on && document.getElementById('terminal-pane').contains(document.activeElement)) document.activeElement.blur();
15329
15497
  const arrived = viewMode === 'session' ? currentSessionId : null;
15330
15498
  const view = `${arrived}:${on}`;
15331
15499
  // Only on a change of session or terminal state, so a panel opened by hand over the terminal stays.
@@ -15654,10 +15822,16 @@ const urlPaneFrames = () => [...paneViews.querySelectorAll('.pane-url-frame')];
15654
15822
  window.addEventListener('message', (e) => {
15655
15823
  const type = e.data?.type;
15656
15824
  if (type !== 'hub:hostKey' && type !== 'hub:hostReady') return;
15657
- const frame = urlPaneFrames().find((f) => f.contentWindow === e.source);
15825
+ const dock = dockFrameEls().find((f) => f.contentWindow === e.source);
15826
+ const frame = dock || urlPaneFrames().find((f) => f.contentWindow === e.source);
15658
15827
  if (!frame || URL.parse(frame.src)?.origin !== e.origin) return;
15659
- if (type === 'hub:hostReady') frame.parentElement.classList.add('keys-bridged');
15660
- else replayKey(e.data, frame);
15828
+ if (type === 'hub:hostReady') {
15829
+ if (!dock) frame.parentElement.classList.add('keys-bridged');
15830
+ return;
15831
+ }
15832
+ // A page has focus when it gets a keydown, even one that never posts its focus.
15833
+ if (dock) setDockFrameFocused(dock, true);
15834
+ replayKey(e.data, frame);
15661
15835
  });
15662
15836
 
15663
15837
  function pushTerminalClaims() {
@@ -15667,11 +15841,13 @@ function pushTerminalClaims() {
15667
15841
  terminalClaimsQueued = false;
15668
15842
  const bridged = bridgedFrameEls();
15669
15843
  const urlFrames = urlPaneFrames();
15670
- if (!termFrame.inited && !kanbotState.inited && !bridged.length && !urlFrames.length) return;
15844
+ const dockEls = dockFrameEls();
15845
+ if (!termFrame.inited && !kanbotState.inited && !bridged.length && !urlFrames.length && !dockEls.length) return;
15671
15846
  const claims = keyClaims();
15672
15847
  const base = bridgeClaims(claims);
15673
15848
  sendBridgeClaimsAll(bridged, base);
15674
15849
  sendHostKeys(urlFrames, base);
15850
+ sendHostKeys(dockEls, { keys: [...base.keys, ...DOCK_CLAIMS, ...claims.forward.flatMap(comboClaims)] });
15675
15851
  const sig = JSON.stringify(claims);
15676
15852
  sendFrameClaims(kanbotState, claims, sig, postToKanbotFrame);
15677
15853
  sendFrameClaims(termFrame, claims, sig, terminalFrameSend);
@@ -16018,7 +16194,10 @@ function onTerminalMessage(sessionId, msg, tail = '') {
16018
16194
  if (msg.t === 'ready') {
16019
16195
  // The server has the prompt now; a later start of the same placeholder must not send it again.
16020
16196
  const spec = newSpecs.get(sessionId);
16021
- if (spec) spec.prompt = null;
16197
+ if (spec) {
16198
+ spec.prompted ||= !!spec.prompt;
16199
+ spec.prompt = null;
16200
+ }
16022
16201
  clearTerminalSlow();
16023
16202
  hideTerminalPrompt();
16024
16203
  setTerminalStatus('');
@@ -16501,7 +16680,7 @@ function setAsk(a) {
16501
16680
  if (a.content == null) {
16502
16681
  body.innerHTML = '<p class="ask-missing">The card file is gone. Skip, so the agent stops waiting.</p>';
16503
16682
  } else {
16504
- const frame = createPreviewFrame('ask-frame', showSrcdoc(a.content, 0, ASK_BRIDGE_TAG), SHOW_SANDBOX);
16683
+ const frame = createPreviewFrame('ask-frame', showSrcdoc(a.content, 0, modalZoom, ASK_BRIDGE_TAG), SHOW_SANDBOX);
16505
16684
  frame.addEventListener('load', () => {
16506
16685
  if (askState.frame === frame && !askState.hidden) frame.focus();
16507
16686
  });
@@ -16631,6 +16810,11 @@ const SHOW_VIEW_KEY = 'cck-show-view-v1';
16631
16810
  const SHOW_MIN_W = 280;
16632
16811
  const SHOW_MIN_H = 120;
16633
16812
  const SHOW_INSET = 8;
16813
+ const SHOW_DOCK_KEY = 'cck-show-dock-w';
16814
+ const SHOW_DOCK_TERM_MIN = 320;
16815
+ const DOCK_ADDON_KEY = 'cck-dock-addon-v1';
16816
+ const DOCK_LAST_KEY = 'cck-dock-last-v1';
16817
+ const DOCK_EXPANDED_KEY = 'cck-dock-expanded-v1';
16634
16818
  const SHOW_SANDBOX = 'allow-scripts allow-forms';
16635
16819
  const SHOW_CSP =
16636
16820
  "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src data:; media-src data:; frame-src 'none'; connect-src 'none'; form-action 'none'";
@@ -16684,6 +16868,9 @@ const SHOW_ICONS = {
16684
16868
  clear: '<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>',
16685
16869
  close: '<path d="M18 6L6 18M6 6l12 12"/>',
16686
16870
  pane: '<use href="#icon-pane"/>',
16871
+ card: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M13 4v7h8"/>',
16872
+ external: '<path d="M15 3h6v6M10 14L21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>',
16873
+ reload: '<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/>',
16687
16874
  };
16688
16875
 
16689
16876
  // PTY id → terminal id, from the terminal list. A terminal started after the last list read is
@@ -16697,6 +16884,7 @@ const showState = {
16697
16884
  closed: false,
16698
16885
  expanded: false,
16699
16886
  collapsed: false,
16887
+ docked: false,
16700
16888
  marked: new Set(),
16701
16889
  rendered: null,
16702
16890
  frame: null,
@@ -16710,12 +16898,31 @@ const showState = {
16710
16898
  path: null,
16711
16899
  };
16712
16900
  let showSize = store.readJson(SHOW_SIZE_KEY);
16713
- // Terminal id → { closed?, collapsed?, expanded? }; a terminal with no entry yet starts collapsed. Ids gone from the terminal list are pruned.
16901
+ let showDockW = store.readJson(SHOW_DOCK_KEY);
16902
+ // Terminal id → { closed?, collapsed?, expanded?, docked? }; a terminal with no entry yet starts collapsed. Ids gone from the terminal list are pruned.
16714
16903
  const showViews = store.readJson(SHOW_VIEW_KEY, {});
16715
16904
  // Older boards stored the string 'collapsed' or 'expanded'.
16716
16905
  for (const [tid, v] of Object.entries(showViews)) if (typeof v === 'string') showViews[tid] = { [v]: true };
16717
- const SHOW_VIEW_FLAGS = ['closed', 'collapsed', 'expanded'];
16906
+ const SHOW_VIEW_FLAGS = ['closed', 'collapsed', 'expanded', 'docked'];
16718
16907
  let showHostObserver = null;
16908
+ // Terminal id → the id of the add-on pane in the dock. Ids gone from the terminal list are pruned.
16909
+ const dockAddons = store.readJson(DOCK_ADDON_KEY, {});
16910
+ // Terminal id → the rail item last on screen ('show' or an add-on id), kept while the dock is hidden,
16911
+ // so Ctrl+Alt+` goes back to it.
16912
+ const dockLast = store.readJson(DOCK_LAST_KEY, {});
16913
+ let dockExpanded = store.getItem(DOCK_EXPANDED_KEY) === '1';
16914
+ // Dock item id → its view, kept while its filled URL stays the same. A view hidden for
16915
+ // DOCK_UNLOAD_MS goes, so a page that polls stops until it is opened again.
16916
+ const dockFrames = new Map();
16917
+ const dockHideTimers = new Map();
16918
+ const DOCK_UNLOAD_MS = 60_000;
16919
+ let dockSid = null;
16920
+ // The add-ons from /api/addons for `sid`, refetched on `addon:changed`.
16921
+ let dockAddonList = null;
16922
+ let dockRailHtml = '';
16923
+ let dockRailClaimed = false;
16924
+ let dockShowDot = false;
16925
+ let dockObserver = null;
16719
16926
 
16720
16927
  function showCardEl() {
16721
16928
  return document.getElementById('show-card');
@@ -16742,7 +16949,12 @@ function focusShowBody() {
16742
16949
  else showCardEl()?.querySelector('.show-body')?.focus();
16743
16950
  }
16744
16951
 
16745
- const SHOW_KEY_COMMANDS = { 'alt+Enter': 'expand', 'alt+w': 'close' };
16952
+ const SHOW_KEY_COMMANDS = { 'alt+Enter': 'expand', 'alt+shift+Enter': 'dock', 'alt+w': 'close' };
16953
+
16954
+ // Docked, the card is a dock item, so it shares the dock's expand with the add-ons.
16955
+ const showExpanded = () => (showState.docked ? dockExpanded : showState.expanded);
16956
+
16957
+ const expandTitle = (on, home) => (on ? `Back to the ${home} · Alt+Enter or Esc` : 'Fill the terminal · Alt+Enter');
16746
16958
 
16747
16959
  // The body keeps focus, because a render replaces an HTML card's frame.
16748
16960
  function showCardKey(e) {
@@ -16752,10 +16964,11 @@ function showCardKey(e) {
16752
16964
  return true;
16753
16965
  }
16754
16966
  const combo = ClaudeHub.comboOf(e);
16755
- const cmd = combo === 'Escape' ? (showState.expanded ? 'expand' : 'collapse') : SHOW_KEY_COMMANDS[combo];
16967
+ let cmd = SHOW_KEY_COMMANDS[combo];
16968
+ if (combo === 'Escape') cmd = showExpanded() ? 'expand' : 'collapse';
16756
16969
  if (cmd) {
16757
16970
  showCommand(cmd);
16758
- if (cmd === 'expand') focusShowBody();
16971
+ if (cmd === 'expand' || cmd === 'dock') focusShowBody();
16759
16972
  else focusTerminalPane();
16760
16973
  return true;
16761
16974
  }
@@ -16790,10 +17003,16 @@ function rememberTerminalIds(list) {
16790
17003
  for (const t of list) if (t.terminalId) terminalIds.set(t.id, t.terminalId);
16791
17004
  if (!terminalIds.size) return;
16792
17005
  const live = new Set(terminalIds.values());
16793
- const gone = Object.keys(showViews).filter((tid) => !live.has(tid));
17006
+ pruneTerminalMap(showViews, SHOW_VIEW_KEY, live);
17007
+ pruneTerminalMap(dockAddons, DOCK_ADDON_KEY, live);
17008
+ pruneTerminalMap(dockLast, DOCK_LAST_KEY, live);
17009
+ }
17010
+
17011
+ function pruneTerminalMap(map, key, live) {
17012
+ const gone = Object.keys(map).filter((tid) => !live.has(tid));
16794
17013
  if (!gone.length) return;
16795
- for (const tid of gone) delete showViews[tid];
16796
- store.writeJson(SHOW_VIEW_KEY, showViews);
17014
+ for (const tid of gone) delete map[tid];
17015
+ store.writeJson(key, map);
16797
17016
  }
16798
17017
 
16799
17018
  // A session resumed after its terminal ended runs under the same PTY id in a new terminal, with a
@@ -16901,16 +17120,24 @@ function onShowRemoved(d) {
16901
17120
  if (dropShowPost(d.id)) renderShow();
16902
17121
  }
16903
17122
 
17123
+ // `attr` names the data attribute that the header's click handler reads: show or dock.
17124
+ function showBtnHtml(attr, cmd, icon, title, disabled = false, pressed = null) {
17125
+ return `<button type="button" class="show-btn${cmd === 'remove' ? ' remove' : ''}${pressed ? ' active' : ''}" data-${attr}="${escapeHtml(cmd)}" title="${escapeHtml(title)}" aria-label="${escapeHtml(title)}"${pressed === null ? '' : pressed ? ' aria-pressed="true"' : ' aria-pressed="false"'}${disabled ? ' disabled' : ''}>${showIcon(icon)}</button>`;
17126
+ }
17127
+
16904
17128
  function showHeaderHtml(p) {
16905
17129
  const n = showState.posts.length;
16906
17130
  const i = showState.idx;
16907
17131
  const key = p.key ? `<span class="show-sep">·</span><span class="show-key">${escapeHtml(p.key)}</span>` : '';
16908
- const btn = (cmd, icon, title, disabled = false) =>
16909
- `<button type="button" class="show-btn${cmd === 'remove' ? ' remove' : ''}" data-show="${escapeHtml(cmd)}" title="${escapeHtml(title)}" aria-label="${escapeHtml(title)}"${disabled ? ' disabled' : ''}>${showIcon(icon)}</button>`;
16910
- const { collapsed, expanded } = showState;
17132
+ const btn = (...args) => showBtnHtml('show', ...args);
17133
+ const { collapsed, docked } = showState;
17134
+ const expanded = showExpanded();
17135
+ const dockTitle = docked ? 'Back to the overlay · Alt+Shift+Enter' : 'Dock beside the terminal · Alt+Shift+Enter';
17136
+ const dock = btn('dock', 'dock', dockTitle, false, docked);
17137
+ const expand = btn('expand', expanded ? 'restore' : 'expand', expandTitle(expanded, docked ? 'dock' : 'card'));
16911
17138
  return `<span class="show-nav"><span class="show-title" title="${escapeHtml(p.title)}">${escapeHtml(p.title)}</span>${key}</span>
16912
17139
  <span class="show-pager">${btn('prev', 'prev', 'Previous post', i === 0)}<span class="show-pos">${i + 1}/${n}</span>${btn('next', 'next', 'Next post', i === n - 1)}</span>
16913
- <span class="show-actions">${btn('pane', 'pane', 'Open in a pane of the session')}${btn('collapse', collapsed ? 'uncollapse' : 'collapse', collapsed ? 'Show the post' : 'Collapse to the header · Esc')}${btn('expand', expanded ? 'restore' : 'expand', expanded ? 'Back to the card · Alt+Enter or Esc' : 'Fill the terminal · Alt+Enter')}${btn('remove', 'clear', 'Remove this post · Ctrl+D · Shift+click clears every post')}${btn('close', 'close', 'Close until the next post · Alt+W')}</span>`;
17140
+ <span class="show-actions">${btn('pane', 'pane', 'Open in a pane of the session')}${dock}${btn('collapse', collapsed ? 'uncollapse' : 'collapse', collapsed ? 'Show the post' : 'Collapse to the header · Esc')}${expand}${btn('remove', 'clear', 'Remove this post · Ctrl+D · Shift+click clears every post')}${btn('close', 'close', 'Close until the next post · Alt+W')}</span>`;
16914
17141
  }
16915
17142
 
16916
17143
  function showTicksHtml() {
@@ -16933,6 +17160,10 @@ function renderShow({ flash = false } = {}) {
16933
17160
  chip.hidden = !n || open;
16934
17161
  chip.querySelector('.show-reopen-n').textContent = `show · ${n}`;
16935
17162
  card.classList.toggle('open', open);
17163
+ const docked = open && showState.docked && !showState.collapsed;
17164
+ card.classList.toggle('docked', docked);
17165
+ if (flash && dockActivePane()) dockShowDot = true;
17166
+ renderDockRail();
16936
17167
  if (!open) {
16937
17168
  showState.rendered = null;
16938
17169
  showState.frame = null;
@@ -16944,12 +17175,13 @@ function renderShow({ flash = false } = {}) {
16944
17175
  }
16945
17176
  const p = showState.posts[showState.idx];
16946
17177
  showState.marked.delete(p.id);
16947
- card.classList.toggle('expanded', showState.expanded && !showState.collapsed);
17178
+ card.classList.toggle('expanded', showExpanded() && !showState.collapsed);
16948
17179
  card.classList.toggle('collapsed', showState.collapsed);
16949
17180
  if (!card.firstChild) {
16950
17181
  card.innerHTML = `<header class="show-head"></header><div class="show-ticks"></div><div class="show-body" tabindex="-1"></div>
16951
17182
  <aside class="review-panel show-review" hidden></aside>
16952
- <div class="show-grip" title="Resize · double-click to fit"><svg viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M1 1v8h8M1 5l4 4"/></svg></div>`;
17183
+ <div class="show-grip" title="Resize · double-click to fit"><svg viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M1 1v8h8M1 5l4 4"/></svg></div>
17184
+ <div class="show-dock-edge" title="Resize · double-click to reset"></div>`;
16953
17185
  }
16954
17186
  card.querySelector('.show-head').innerHTML = showHeaderHtml(p);
16955
17187
  card.querySelector('.show-ticks').innerHTML = showTicksHtml();
@@ -16987,10 +17219,10 @@ async function renderShowBody(p, samePost) {
16987
17219
  const restoreY = samePost && showState.frame ? showState.frameY : 0;
16988
17220
  showState.frameY = restoreY;
16989
17221
  if (samePost && showState.frame?.isConnected) {
16990
- showState.frame.srcdoc = showSrcdoc(data.content, restoreY);
17222
+ showState.frame.srcdoc = showSrcdoc(data.content, restoreY, showZoom);
16991
17223
  } else {
16992
17224
  showState.frameH = 0;
16993
- body.replaceChildren(createShowFrame(p.title, showSrcdoc(data.content, 0)));
17225
+ body.replaceChildren(createShowFrame(p.title, showSrcdoc(data.content, 0, showZoom)));
16994
17226
  }
16995
17227
  refocus();
16996
17228
  setShowReview(showReviewOpts(p, data, body, showState.frame));
@@ -17000,7 +17232,7 @@ async function renderShowBody(p, samePost) {
17000
17232
  showState.frame = null;
17001
17233
  const keepScroll = samePost ? body.scrollTop : 0;
17002
17234
  const md = document.createElement('div');
17003
- md.className = 'show-md modal-zoomable';
17235
+ md.className = 'show-md';
17004
17236
  if (!data) md.innerHTML = '<p class="show-gone">This post is gone.</p>';
17005
17237
  else if (data.waiting) md.innerHTML = '<p class="show-gone">Waiting for content.</p>';
17006
17238
  else renderPreviewContent(md, data.file || p.file || '', data.content ?? '', data.kind);
@@ -17087,9 +17319,9 @@ function withHeadFirst(content, head) {
17087
17319
  const FRAME_SCROLLBAR_CSS = ':where(html){scrollbar-width:thin;scrollbar-color:rgba(128,128,128,.45) transparent}';
17088
17320
 
17089
17321
  // A whole document gets the tokens and base styles first in its head, so its own styles win.
17090
- function showSrcdoc(content, restoreY, extraHead = '') {
17322
+ function showSrcdoc(content, restoreY, textZoom, extraHead = '') {
17091
17323
  const head = `<meta http-equiv="Content-Security-Policy" content="${SHOW_CSP}"><style>:root{${showTokenCss()}}${SHOW_FRAME_BASE}${FRAME_SCROLLBAR_CSS}</style>${extraHead}`;
17092
- const bridge = `<script>(${showBridge})(${Math.max(0, Math.round(restoreY) || 0)}, ${modalZoom});</script>${REVIEW_BRIDGE_TAG}`;
17324
+ const bridge = `<script>(${showBridge})(${Math.max(0, Math.round(restoreY) || 0)}, ${textZoom});</script>${REVIEW_BRIDGE_TAG}`;
17093
17325
  if (!/^\s*(<!doctype|<html)/i.test(content)) {
17094
17326
  return `<!doctype html><html><head><meta charset="utf-8">${head}</head><body>${content}${bridge}</body></html>`;
17095
17327
  }
@@ -17192,11 +17424,11 @@ new MutationObserver(() => {
17192
17424
  });
17193
17425
  }).observe(document.body, { attributes: true, attributeFilter: ['class', 'data-color-theme', 'style'] });
17194
17426
 
17427
+ const PANE_SHOW_FRAMES = '.show-pane-frame, .themed-frame, .ask-frame, .dock-addon-frame';
17428
+
17195
17429
  // Builds the message only when a frame is there, because showTokens forces a style recalc.
17196
- function postToShowFrames(makeMsg) {
17197
- const frames = [showState.frame, ...document.querySelectorAll('.show-pane-frame, .themed-frame, .ask-frame')].filter(
17198
- (f) => f?.contentWindow,
17199
- );
17430
+ function postToShowFrames(makeMsg, frames = [showState.frame, ...document.querySelectorAll(PANE_SHOW_FRAMES)]) {
17431
+ frames = [...frames].filter((f) => f?.contentWindow);
17200
17432
  if (!frames.length) return;
17201
17433
  const msg = makeMsg();
17202
17434
  if (!msg) return;
@@ -17214,16 +17446,16 @@ function showClampSize(size, w, h) {
17214
17446
 
17215
17447
  function layoutShow() {
17216
17448
  const card = showCardEl();
17217
- if (!card.classList.contains('open')) return;
17218
- const host = document.getElementById('terminal-host');
17219
- const w = host.clientWidth;
17220
- const h = host.clientHeight;
17221
- if (!w || !h) return;
17449
+ if (!card.classList.contains('open') || card.classList.contains('under-addon')) return;
17450
+ const w = dockHostWidth();
17451
+ const h = document.getElementById('terminal-host').clientHeight;
17452
+ if (w <= 0 || !h) return;
17222
17453
  const fit = showFitSize(w, h);
17223
17454
  card.style.setProperty('--show-fit-w', `${fit.w}px`);
17224
17455
  card.style.setProperty('--show-fit-h', `${fit.h}px`);
17225
17456
  card.style.setProperty('--show-term-font', `${currentTerminalFontSize()}px`);
17226
- const sized = !!showSize && !showState.expanded && !showState.collapsed;
17457
+ const docked = card.classList.contains('docked');
17458
+ const sized = !!showSize && !showState.expanded && !showState.collapsed && !docked;
17227
17459
  card.classList.toggle('sized', sized);
17228
17460
  if (sized) {
17229
17461
  const s = showClampSize(showSize, w, h);
@@ -17232,7 +17464,7 @@ function layoutShow() {
17232
17464
  }
17233
17465
  const frame = showState.frame;
17234
17466
  if (!frame) return;
17235
- const peek = !showState.expanded && !sized;
17467
+ const peek = !card.classList.contains('expanded') && !sized && !docked;
17236
17468
  const chrome =
17237
17469
  card.querySelector('.show-head').offsetHeight +
17238
17470
  card.querySelector('.show-ticks').offsetHeight +
@@ -17242,6 +17474,460 @@ function layoutShow() {
17242
17474
  frame.style.height = peek ? `${Math.min(showState.frameH || SHOW_MIN_H, fit.h - chrome)}px` : '';
17243
17475
  }
17244
17476
 
17477
+ // A docked card gets 40% of the host until dragged, and always leaves the terminal SHOW_DOCK_TERM_MIN.
17478
+ function showDockWidth(want, w) {
17479
+ return Math.max(SHOW_MIN_W, Math.min(want ?? Math.round(w * 0.4), w - SHOW_DOCK_TERM_MIN));
17480
+ }
17481
+
17482
+ // The terminal pane holds the width, because the terminal frame and the terminal bar read it too.
17483
+ // Each change refits the terminal and resizes its PTY, so a drag sets it only at the end.
17484
+ function setShowDockWidth(px) {
17485
+ const pane = document.getElementById('terminal-pane');
17486
+ const value = `${px}px`;
17487
+ if (pane.style.getPropertyValue('--show-dock-w') !== value) pane.style.setProperty('--show-dock-w', value);
17488
+ }
17489
+
17490
+ // The host's width less the rail, which is 0 wide while hidden.
17491
+ function dockHostWidth() {
17492
+ return document.getElementById('terminal-host').clientWidth - document.getElementById('dock-rail').offsetWidth;
17493
+ }
17494
+
17495
+ function fitDockWidth() {
17496
+ if (!document.getElementById('terminal-pane').classList.contains('show-docked')) return;
17497
+ const w = dockHostWidth();
17498
+ if (w > 0) setShowDockWidth(showDockWidth(showDockW, w));
17499
+ }
17500
+
17501
+ // A terminal with no posts has no session from the show list yet, so its PTY id stands in,
17502
+ // which is the session it started.
17503
+ function dockSessionId() {
17504
+ return showState.terminalId ? (showState.sessionId ?? currentSessionId) : null;
17505
+ }
17506
+
17507
+ function dockPanes(sid) {
17508
+ return sid ? paneLayout(sid).panes.filter((p) => p.dock) : [];
17509
+ }
17510
+
17511
+ function fillAddonUrl(url, values) {
17512
+ return url.replace(/\{([^{}]*)\}/g, (m, k) => (values[k] == null ? m : encodeURIComponent(values[k])));
17513
+ }
17514
+
17515
+ function addonValues(sid) {
17516
+ const s = sessions.find((x) => x.id === sid);
17517
+ const cwd = s?.cwd || s?.project || newSpecs.get(sid)?.cwd || '';
17518
+ return { sessionId: sid, cwd, project: s?.project || cwd };
17519
+ }
17520
+
17521
+ // The rail's items after the card: global add-ons, the session's, then its docked URL panes.
17522
+ // The same filled URL shows once. Until the list for a new session arrives, the global
17523
+ // entries of the last one stand, so their frames are kept when the URL stays the same.
17524
+ function dockItems(sid = dockSid) {
17525
+ if (!sid) return [];
17526
+ const list = dockAddonList;
17527
+ const own = list?.sid === sid ? list : null;
17528
+ const items = [];
17529
+ const seen = new Set();
17530
+ const push = (item) => {
17531
+ if (seen.has(item.target)) return;
17532
+ seen.add(item.target);
17533
+ items.push(item);
17534
+ };
17535
+ if (list && (list.global.length || own?.session.length)) {
17536
+ const values = addonValues(sid);
17537
+ const entries = [...list.global.map((a) => [a, true]), ...(own ? own.session.map((a) => [a, false]) : [])];
17538
+ for (const [a, global] of entries) {
17539
+ const target = fillAddonUrl(a.url, { ...a.params, ...own?.params[a.id], ...values });
17540
+ push({ id: a.id, kind: 'url', title: a.title, icon: a.icon, target, addon: true, global, run: !!a.run });
17541
+ }
17542
+ }
17543
+ for (const p of dockPanes(sid)) push(p);
17544
+ return items;
17545
+ }
17546
+
17547
+ async function fetchDockAddons(sid) {
17548
+ const list = await getJson(`${apiPath`/api/addons`}?session=${encodeURIComponent(sid)}`);
17549
+ if (!list || sid !== dockSid) return;
17550
+ dockAddonList = { sid, ...list };
17551
+ renderDockRail();
17552
+ }
17553
+
17554
+ function dockActivePane(items = dockItems()) {
17555
+ const id = showState.terminalId && dockAddons[showState.terminalId];
17556
+ return (id && items.find((p) => p.id === id)) || null;
17557
+ }
17558
+
17559
+ function dropDockView(id) {
17560
+ dockFrames.get(id)?.remove();
17561
+ dockFrames.delete(id);
17562
+ clearTimeout(dockHideTimers.get(id));
17563
+ dockHideTimers.delete(id);
17564
+ }
17565
+
17566
+ function dockShowOn(active) {
17567
+ return !active && showState.posts.length > 0 && !showState.closed;
17568
+ }
17569
+
17570
+ function dockOnScreen(active = dockActivePane()) {
17571
+ return active ? active.id : dockShowOn(active) ? 'show' : null;
17572
+ }
17573
+
17574
+ function setDockAddon(id) {
17575
+ const tid = showState.terminalId;
17576
+ if (!tid || (dockAddons[tid] ?? null) === id) return;
17577
+ if (id) dockAddons[tid] = id;
17578
+ else delete dockAddons[tid];
17579
+ store.writeJson(DOCK_ADDON_KEY, dockAddons);
17580
+ }
17581
+
17582
+ function dockRailButton(id, label, title, on, dot) {
17583
+ return `<button type="button" class="dock-rail-btn${on ? ' on' : ''}${dot ? ' dot' : ''}" data-rail="${escapeHtml(id)}" title="${escapeHtml(title)}" aria-label="${escapeHtml(title)}" aria-pressed="${on ? 'true' : 'false'}">${label}</button>`;
17584
+ }
17585
+
17586
+ // Runs from every renderShow, so it does no work past a class toggle until a session has add-ons.
17587
+ function renderDockRail() {
17588
+ const sid = dockSessionId();
17589
+ if (sid !== dockSid) {
17590
+ dockSid = sid;
17591
+ if (sid && !paneLayouts.has(sid)) fetchPaneLayout(sid);
17592
+ if (sid) fetchDockAddons(sid);
17593
+ }
17594
+ const addons = dockItems(sid);
17595
+ for (const [id, view] of dockFrames) {
17596
+ if (!addons.some((p) => p.id === id && p.target === view.dataset.target)) dropDockView(id);
17597
+ }
17598
+ const on = addons.length > 0;
17599
+ const termPane = document.getElementById('terminal-pane');
17600
+ const rail = document.getElementById('dock-rail');
17601
+ const card = showCardEl();
17602
+ const active = dockActivePane(addons);
17603
+ const onScreen = dockOnScreen(active);
17604
+ const tid = showState.terminalId;
17605
+ if (tid && onScreen && dockLast[tid] !== onScreen) {
17606
+ dockLast[tid] = onScreen;
17607
+ store.writeJson(DOCK_LAST_KEY, dockLast);
17608
+ }
17609
+ termPane.classList.toggle('dock-rail-on', on);
17610
+ rail.hidden = !on;
17611
+ card.classList.toggle('under-addon', !!active);
17612
+ const docked = !!active || card.classList.contains('docked');
17613
+ termPane.classList.toggle('show-docked', docked);
17614
+ if (on) document.getElementById('show-reopen').hidden = true;
17615
+ if (!active) dockShowDot = false;
17616
+ let html = '';
17617
+ if (on) {
17618
+ const n = showState.posts.length;
17619
+ if (n) html += dockRailButton('show', showIcon('card'), `Show · ${n}`, onScreen === 'show', dockShowDot);
17620
+ for (const p of addons) {
17621
+ html += dockRailButton(
17622
+ p.id,
17623
+ escapeHtml(p.icon || [...p.title][0]?.toUpperCase() || '?'),
17624
+ p.title,
17625
+ p === active,
17626
+ false,
17627
+ );
17628
+ }
17629
+ }
17630
+ if (html !== dockRailHtml) {
17631
+ dockRailHtml = html;
17632
+ rail.innerHTML = html;
17633
+ }
17634
+ const claimed = dockRailItems(addons).length > 1;
17635
+ if (claimed !== dockRailClaimed) {
17636
+ dockRailClaimed = claimed;
17637
+ pushTerminalClaims();
17638
+ }
17639
+ renderDockAddon(active);
17640
+ if (docked && !dockObserver) {
17641
+ dockObserver = new ResizeObserver(fitDockWidth);
17642
+ dockObserver.observe(document.getElementById('terminal-host'));
17643
+ } else if (!docked) {
17644
+ dockObserver?.disconnect();
17645
+ dockObserver = null;
17646
+ }
17647
+ fitDockWidth();
17648
+ }
17649
+
17650
+ function renderDockAddon(pane) {
17651
+ const panel = document.getElementById('dock-addon');
17652
+ panel.hidden = !pane;
17653
+ if (pane) {
17654
+ if (!panel.firstChild) {
17655
+ panel.innerHTML = `<header class="show-head dock-addon-head" tabindex="-1"><span class="show-nav"><span class="show-title"></span></span>
17656
+ <span class="show-actions">${showBtnHtml('dock', 'open', 'external', 'Open in a new tab')}
17657
+ ${showBtnHtml('dock', 'expand', 'expand', 'Fill the terminal · Alt+Enter')}
17658
+ ${showBtnHtml('dock', 'reload', 'reload', 'Reload')}
17659
+ ${showBtnHtml('dock', 'remove', 'clear', 'Remove from the rail')}
17660
+ ${showBtnHtml('dock', 'close', 'close', 'Hide the dock · Alt+W')}</span></header>
17661
+ <div class="dock-addon-body"></div><div class="show-dock-edge" title="Resize · double-click to reset"></div>`;
17662
+ }
17663
+ const title = panel.querySelector('.show-title');
17664
+ title.textContent = pane.title;
17665
+ title.title = pane.global ? `${pane.title} · every session` : pane.title;
17666
+ const open = panel.querySelector('[data-dock="open"]');
17667
+ open.title = `Open ${pane.target} in a new tab`;
17668
+ open.setAttribute('aria-label', open.title);
17669
+ panel.classList.toggle('expanded', dockExpanded);
17670
+ panel.style.setProperty('--show-term-font', `${currentTerminalFontSize()}px`);
17671
+ const expand = panel.querySelector('[data-dock="expand"]');
17672
+ const label = expandTitle(dockExpanded, 'dock');
17673
+ if (expand.title !== label) {
17674
+ expand.innerHTML = showIcon(dockExpanded ? 'restore' : 'expand');
17675
+ expand.title = label;
17676
+ expand.setAttribute('aria-label', label);
17677
+ }
17678
+ if (!dockFrames.has(pane.id)) {
17679
+ const view = createDockView(pane);
17680
+ panel.querySelector('.dock-addon-body').appendChild(view);
17681
+ dockFrames.set(pane.id, view);
17682
+ }
17683
+ }
17684
+ for (const [id, view] of dockFrames) {
17685
+ view.hidden = id !== pane?.id;
17686
+ if (!view.hidden) {
17687
+ clearTimeout(dockHideTimers.get(id));
17688
+ dockHideTimers.delete(id);
17689
+ } else if (!dockHideTimers.has(id))
17690
+ dockHideTimers.set(
17691
+ id,
17692
+ setTimeout(() => dropDockView(id), DOCK_UNLOAD_MS),
17693
+ );
17694
+ }
17695
+ syncDockFocus();
17696
+ }
17697
+
17698
+ function dockCardView(pane, html) {
17699
+ const view = document.createElement('div');
17700
+ view.className = 'dock-addon-view';
17701
+ view.dataset.target = pane.target;
17702
+ view.innerHTML = html;
17703
+ return view;
17704
+ }
17705
+
17706
+ function createDockView(pane) {
17707
+ const blocked = paneBlockedHtml(pane);
17708
+ if (blocked) return dockCardView(pane, blocked);
17709
+ if (pane.run && !dockAddonUp.has(pane.id)) return startDockAddon(pane);
17710
+ const frame = paneUrlFrame(pane, 'dock-addon-frame');
17711
+ frame.dataset.target = pane.target;
17712
+ frame.addEventListener('load', () => {
17713
+ postToShowFrames(() => ({ type: `${SHOW_MSG}tokens`, tokens: showTokens() }), [frame]);
17714
+ frame.hostKeysSig = null;
17715
+ pushTerminalClaims();
17716
+ });
17717
+ if (pane.addon || paneFraming.get(pane.target)) return frame;
17718
+ const sid = dockSid;
17719
+ probePaneFraming(sid, pane).then((frameable) => {
17720
+ if (frameable !== false || dockFrames.get(pane.id) !== frame) return;
17721
+ dropDockView(pane.id);
17722
+ if (sid === dockSid) renderDockRail();
17723
+ });
17724
+ return frame;
17725
+ }
17726
+
17727
+ // The board starts the page's server when its port does not answer (lib/addon-runner.js);
17728
+ // the frame comes once it does. Reload asks again.
17729
+ function startDockAddon(pane) {
17730
+ const view = dockCardView(
17731
+ pane,
17732
+ `<div class="pane-card"><div class="pane-card-box"><p>Starting ${escapeHtml(pane.title)}…</p></div></div>`,
17733
+ );
17734
+ paneRequest('POST', apiPath`/api/addons/${pane.id}/start`).then((r) => {
17735
+ if (dockFrames.get(pane.id) !== view) return;
17736
+ if (r.state === 'up' || r.state === 'none') {
17737
+ dockAddonUp.add(pane.id);
17738
+ dropDockView(pane.id);
17739
+ renderDockRail();
17740
+ return;
17741
+ }
17742
+ const log = r.log ? `<p>Log: <code>${escapeHtml(r.log)}</code></p>` : '';
17743
+ view.innerHTML = paneCardHtml(`${pane.title} did not start`, r.error || 'No answer', 'Retry', {
17744
+ attr: 'data-dock="reload"',
17745
+ extra: log,
17746
+ });
17747
+ });
17748
+ return view;
17749
+ }
17750
+
17751
+ async function removeDockItem(item) {
17752
+ if (!item.addon) return setPaneDock(dockSid, item.id, false);
17753
+ if (item.global) {
17754
+ const ok = await confirmModal({
17755
+ title: 'Remove this add-on from every session?',
17756
+ message: 'It was added with --global, so it is on the rail of each session.',
17757
+ chip: item.title,
17758
+ okLabel: 'Remove',
17759
+ });
17760
+ if (!ok) return;
17761
+ }
17762
+ const r = await paneRequest('DELETE', `${apiPath`/api/addons/${item.id}`}?session=${encodeURIComponent(dockSid)}`);
17763
+ if (r.error) showToast(r.error, 'error');
17764
+ }
17765
+
17766
+ // Page-wide, like the session log's full width. The panel covers the terminal above its prompt
17767
+ // rows, so the PTY keeps its size.
17768
+ function setDockExpanded(on) {
17769
+ dockExpanded = on;
17770
+ store.setItem(DOCK_EXPANDED_KEY, on ? '1' : '');
17771
+ renderShow();
17772
+ }
17773
+
17774
+ // Alt+Enter toggles the full width and Esc goes back; Esc in the dock focuses the terminal.
17775
+ function dockHeadKey(e) {
17776
+ const combo = ClaudeHub.comboOf(e);
17777
+ if (combo !== 'alt+w' && combo !== 'alt+Enter' && combo !== 'Escape') return false;
17778
+ const pane = dockActivePane();
17779
+ if (!pane) return false;
17780
+ if (combo === 'alt+w') {
17781
+ dockRailCommand(pane.id);
17782
+ focusTerminalPane();
17783
+ } else if (combo === 'alt+Enter') setDockExpanded(!dockExpanded);
17784
+ else if (combo === 'Escape' && dockExpanded) setDockExpanded(false);
17785
+ else if (combo === 'Escape') focusTerminalPane();
17786
+ else return false;
17787
+ return true;
17788
+ }
17789
+
17790
+ // Focus moving from the terminal's frame into a dock page leaves this document's activeElement on the
17791
+ // terminal frame and fires no event here, so the page posts its own focus (skills/addon/references/build.md).
17792
+ let dockFocusFrame = null;
17793
+
17794
+ const dockFrameFocused = () => !!dockFocusFrame?.isConnected && !dockFocusFrame.hidden;
17795
+
17796
+ function isDockFocused() {
17797
+ return dockFrameFocused() || !!document.activeElement?.closest('#dock-addon');
17798
+ }
17799
+
17800
+ function setDockFrameFocused(frame, on) {
17801
+ if (on) dockFocusFrame = frame;
17802
+ else if (dockFocusFrame === frame) dockFocusFrame = null;
17803
+ syncDockFocus();
17804
+ }
17805
+
17806
+ function syncDockFocus() {
17807
+ document.getElementById('dock-addon').classList.toggle('focused', dockFrameFocused());
17808
+ }
17809
+
17810
+ const dockFrameEls = () => [...dockFrames.values()].filter((v) => v.tagName === 'IFRAME');
17811
+
17812
+ window.addEventListener('message', (e) => {
17813
+ if (e.data?.type !== `${SHOW_MSG}focus`) return;
17814
+ const frame = dockFrameEls().find((f) => f.contentWindow === e.source);
17815
+ if (frame) setDockFrameFocused(frame, e.data.on === true);
17816
+ });
17817
+
17818
+ // A dock page gets the pane keys plus these (hub protocol §5.2). A page with no hub SDK cannot
17819
+ // forward the hub's own combos, so they go as claims too.
17820
+ const DOCK_CLAIMS = [{ altKey: true, key: 'Enter', code: '' }];
17821
+
17822
+ function comboClaims(combo) {
17823
+ const parts = combo.split('+');
17824
+ const key = parts.pop() || '+';
17825
+ const mods = Object.fromEntries(parts.filter(Boolean).map((m) => [`${m}Key`, true]));
17826
+ const code = /^[a-z]$/.test(key) ? `Key${key.toUpperCase()}` : /^[1-9]$/.test(key) ? `Digit${key}` : '';
17827
+ return [{ ...mods, key, code: '' }, ...(code ? [{ ...mods, key: 'Unidentified', code }] : [])];
17828
+ }
17829
+
17830
+ // Focus goes to the header, not the page: a frame on another origin would keep the keys.
17831
+ function focusDockHead() {
17832
+ document.querySelector('#dock-addon .dock-addon-head')?.focus();
17833
+ }
17834
+
17835
+ // Show and each add-on take the dock in turn; the one on screen hides it.
17836
+ function dockRailCommand(id) {
17837
+ const active = dockActivePane();
17838
+ if (id === 'show') {
17839
+ const showOn = dockShowOn(active);
17840
+ setDockAddon(null);
17841
+ if (showOn) showState.closed = true;
17842
+ else Object.assign(showState, { closed: false, collapsed: false });
17843
+ } else if (active?.id === id) {
17844
+ setDockAddon(null);
17845
+ if (showState.docked) showState.closed = true;
17846
+ } else setDockAddon(id);
17847
+ renderShow();
17848
+ }
17849
+
17850
+ function dockRailItems(items = dockItems()) {
17851
+ const ids = items.map((p) => p.id);
17852
+ return ids.length && showState.posts.length ? ['show', ...ids] : ids;
17853
+ }
17854
+
17855
+ // With nothing in the dock, Down starts at the first item and Up at the last.
17856
+ function stepDockRail(dir) {
17857
+ const items = dockRailItems();
17858
+ const cur = dockOnScreen();
17859
+ const i = items.indexOf(cur);
17860
+ const next = items[i < 0 ? (dir > 0 ? 0 : items.length - 1) : (i + dir + items.length) % items.length];
17861
+ if (!next || next === cur) return;
17862
+ dockRailCommand(next);
17863
+ if (next === 'show') focusShowBody();
17864
+ else focusDockHead();
17865
+ }
17866
+
17867
+ // The panel follows the pointer over the terminal; the terminal takes the new width on release.
17868
+ function startDockDrag(e, panel) {
17869
+ const edge = e.target.closest('.show-dock-edge');
17870
+ if (!edge || e.button !== 0) return;
17871
+ e.preventDefault();
17872
+ edge.setPointerCapture(e.pointerId);
17873
+ const hostW = dockHostWidth();
17874
+ const start = { x: e.clientX, w: panel.offsetWidth };
17875
+ let w = start.w;
17876
+ let raf = 0;
17877
+ document.body.classList.add('show-resizing');
17878
+ const move = (ev) => {
17879
+ w = showDockWidth(Math.round(start.w + start.x - ev.clientX), hostW);
17880
+ raf ||= requestAnimationFrame(() => {
17881
+ raf = 0;
17882
+ panel.style.setProperty('--show-dock-drag', `${w}px`);
17883
+ });
17884
+ };
17885
+ const up = () => {
17886
+ cancelAnimationFrame(raf);
17887
+ edge.removeEventListener('pointermove', move);
17888
+ edge.removeEventListener('pointerup', up);
17889
+ edge.removeEventListener('pointercancel', up);
17890
+ document.body.classList.remove('show-resizing');
17891
+ panel.style.removeProperty('--show-dock-drag');
17892
+ showDockW = w;
17893
+ store.writeJson(SHOW_DOCK_KEY, showDockW);
17894
+ fitDockWidth();
17895
+ };
17896
+ edge.addEventListener('pointermove', move);
17897
+ edge.addEventListener('pointerup', up);
17898
+ edge.addEventListener('pointercancel', up);
17899
+ }
17900
+
17901
+ function resetDockWidth() {
17902
+ showDockW = null;
17903
+ store.removeItem(SHOW_DOCK_KEY);
17904
+ fitDockWidth();
17905
+ }
17906
+
17907
+ function initDockRail() {
17908
+ document.getElementById('dock-rail').addEventListener('click', (e) => {
17909
+ const b = e.target.closest('[data-rail]');
17910
+ if (b) dockRailCommand(b.dataset.rail);
17911
+ });
17912
+ const panel = document.getElementById('dock-addon');
17913
+ panel.addEventListener('click', (e) => {
17914
+ const pane = dockActivePane();
17915
+ if (!pane) return;
17916
+ if (e.target.closest('[data-dock="close"]')) dockRailCommand(pane.id);
17917
+ else if (e.target.closest('[data-dock="expand"]')) setDockExpanded(!dockExpanded);
17918
+ else if (e.target.closest('[data-dock="remove"]')) removeDockItem(pane);
17919
+ else if (e.target.closest('[data-dock="reload"]')) {
17920
+ dockAddonUp.delete(pane.id);
17921
+ dropDockView(pane.id);
17922
+ renderDockRail();
17923
+ } else if (e.target.closest('[data-dock="open"], [data-pane-open]')) hub.openExternal(pane.target);
17924
+ });
17925
+ panel.addEventListener('pointerdown', (e) => startDockDrag(e, panel));
17926
+ panel.addEventListener('dblclick', (e) => {
17927
+ if (e.target.closest('.show-dock-edge')) resetDockWidth();
17928
+ });
17929
+ }
17930
+
17245
17931
  async function openShowInPane() {
17246
17932
  const sid = showState.sessionId;
17247
17933
  const target = showState.path;
@@ -17275,9 +17961,13 @@ function showCommand(cmd) {
17275
17961
  showState.idx = i;
17276
17962
  showState.collapsed = false;
17277
17963
  } else if (cmd === 'collapse') showState.collapsed = !showState.collapsed;
17278
- else if (cmd === 'expand') {
17279
- showState.expanded = !showState.expanded;
17964
+ else if (cmd === 'dock') {
17965
+ showState.docked = !showState.docked;
17280
17966
  showState.collapsed = false;
17967
+ } else if (cmd === 'expand') {
17968
+ showState.collapsed = false;
17969
+ if (showState.docked) return setDockExpanded(!dockExpanded);
17970
+ showState.expanded = !showState.expanded;
17281
17971
  } else if (cmd === 'close') showState.closed = true;
17282
17972
  else if (cmd === 'remove') {
17283
17973
  const id = showState.posts[showState.idx]?.id;
@@ -17361,7 +18051,12 @@ function initShowCard() {
17361
18051
  grip.addEventListener('pointerup', up);
17362
18052
  grip.addEventListener('pointercancel', up);
17363
18053
  });
18054
+ card.addEventListener('pointerdown', (e) => startDockDrag(e, card));
17364
18055
  card.addEventListener('dblclick', (e) => {
18056
+ if (e.target.closest('.show-dock-edge')) {
18057
+ resetDockWidth();
18058
+ return;
18059
+ }
17365
18060
  if (!e.target.closest('.show-grip')) return;
17366
18061
  showSize = null;
17367
18062
  store.removeItem(SHOW_SIZE_KEY);
@@ -17400,6 +18095,7 @@ const PLACEHOLDER_HINTS = {
17400
18095
  pick: 'pick a session to resume',
17401
18096
  dispatch: 'starting',
17402
18097
  new: 'waiting for your first message',
18098
+ prompted: 'starting',
17403
18099
  edit: 'press Enter in the terminal to start',
17404
18100
  };
17405
18101
 
@@ -17414,8 +18110,9 @@ function placeholderSession(id, spec) {
17414
18110
  placeholder: true,
17415
18111
  mode: spec.mode,
17416
18112
  edit: !!spec.edit,
18113
+ prompted: !!(spec.prompted || spec.prompt),
17417
18114
  name: spec.name || (spec.forkOf && forkLabel(spec.forkOf)) || PLACEHOLDER_NAMES[spec.mode] || PLACEHOLDER_NAMES.new,
17418
- dispatched: spec.mode === 'dispatch' ? { parent: spec.parent || null } : undefined,
18115
+ dispatched: spec.mode === 'dispatch' ? { parent: spec.parent || null, child: !!spec.child } : undefined,
17419
18116
  project: spec.cwd,
17420
18117
  worktree: spec.worktree ? { repo: spec.cwd, name: spec.worktree === true ? 'new' : spec.worktree } : null,
17421
18118
  modifiedAt: new Date(spec.startedAt).toISOString(),
@@ -17464,6 +18161,7 @@ async function loadDispatches() {
17464
18161
  worktree: r.worktree,
17465
18162
  group: r.group,
17466
18163
  parent: r.parent,
18164
+ child: r.child,
17467
18165
  startedAt: r.startedAt,
17468
18166
  });
17469
18167
  }
@@ -17507,13 +18205,26 @@ function renderPlaceholderCard(session, depth, hostProject) {
17507
18205
  ${
17508
18206
  trustWaiting.has(session.id)
17509
18207
  ? `<div class="session-waiting"><span class="agent-badge agent-badge-waiting" title="Waiting for user">${ICON_AGENT_WAITING}</span>trust this folder?</div>`
17510
- : `<div class="session-waiting"><span class="pulse"></span>${PLACEHOLDER_HINTS[session.edit ? 'edit' : session.mode] || PLACEHOLDER_HINTS.new}</div>`
18208
+ : `<div class="session-waiting"><span class="pulse"></span>${PLACEHOLDER_HINTS[session.edit ? 'edit' : session.prompted ? 'prompted' : session.mode] || PLACEHOLDER_HINTS.new}</div>`
17511
18209
  }
17512
18210
  </button>
17513
18211
  `;
17514
18212
  }
17515
18213
 
17516
18214
  // After a reload the form is gone, but the PTY still runs; its hello options come back from the server.
18215
+ function specFromTerminal(t) {
18216
+ return {
18217
+ cwd: t.cwd,
18218
+ name: t.name,
18219
+ worktree: t.worktree,
18220
+ mode: t.mode,
18221
+ edit: t.edit,
18222
+ forkOf: t.forkOf,
18223
+ prompted: t.prompted,
18224
+ startedAt: t.startedAt,
18225
+ };
18226
+ }
18227
+
17517
18228
  async function restorePendingSessions() {
17518
18229
  document.getElementById('new-session-btn').hidden = !terminalAvailable();
17519
18230
  const terminals = terminalAvailable() ? loadTerminals() : null;
@@ -17521,15 +18232,7 @@ async function restorePendingSessions() {
17521
18232
  try {
17522
18233
  for (const t of terminals ? await terminals : []) {
17523
18234
  if ((t.mode !== 'new' && t.mode !== 'pick') || newSpecs.has(t.id) || dispatchSpecs.has(t.id)) continue;
17524
- newSpecs.set(t.id, {
17525
- cwd: t.cwd,
17526
- name: t.name,
17527
- worktree: t.worktree,
17528
- mode: t.mode,
17529
- edit: t.edit,
17530
- forkOf: t.forkOf,
17531
- startedAt: t.startedAt,
17532
- });
18235
+ newSpecs.set(t.id, specFromTerminal(t));
17533
18236
  }
17534
18237
  } catch (_) {}
17535
18238
  // Sessions that were already running keep the collapse state saved before the reload.
@@ -18134,10 +18837,12 @@ initSessionGroupsDnd();
18134
18837
  initSessionPicker();
18135
18838
  initProjectPicker();
18136
18839
  initShowCard();
18840
+ initDockRail();
18137
18841
  initAsk();
18138
18842
  for (const k of readStoredList(ACTIVITY_FILTER_KEY)) activityFilter.add(k);
18139
18843
  initSidebarResize();
18140
18844
  applyModalZoom();
18845
+ applyShowZoom();
18141
18846
  loadModalFullscreen();
18142
18847
  initModalResize();
18143
18848
  loadPanelWidths();
@@ -18429,6 +19134,7 @@ hub.onActive((active) => {
18429
19134
  });
18430
19135
  hub.onStatus(pushTerminalClaims);
18431
19136
  hub.onSettings(applyHubSettings);
19137
+ hub.handle('session.open', (p) => p.session && openSessionFromOutside(p.session));
18432
19138
 
18433
19139
  // session.changed goes out once the switch settles, so arrowing through the list sends one event.
18434
19140
  let publishTimer = 0;