claude-code-kanban 7.20.0 → 7.22.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
@@ -1939,6 +1939,8 @@ const ICON_AGENT_ACTIVE =
1939
1939
  const ICON_TERMINAL =
1940
1940
  '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>';
1941
1941
  const TERMINAL_TITLE = 'Running in a terminal here';
1942
+ const ICON_FORK =
1943
+ '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><path d="M18 9v2c0 .6-.4 1-1 1H7c-.6 0-1-.4-1-1V9"/><path d="M12 12v3"/></svg>';
1942
1944
  const ICON_DISPATCHED =
1943
1945
  '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"/><path d="m21.854 2.147-10.94 10.939"/></svg>';
1944
1946
  const ICON_CHAT =
@@ -2767,32 +2769,58 @@ function createResizeGrip({ onReset, ...drag }) {
2767
2769
  }
2768
2770
 
2769
2771
  const MODAL_ZOOM_KEY = 'modal-zoom';
2770
- const MODAL_ZOOM_MIN = 0.7;
2771
- const MODAL_ZOOM_MAX = 2.0;
2772
- 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);
2773
2777
 
2774
- function clampModalZoom(v) {
2775
- 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)}%`;
2776
2788
  }
2777
2789
 
2778
2790
  function modalZoomLabel() {
2779
- 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;
2780
2801
  }
2781
2802
 
2782
2803
  function applyModalZoom() {
2783
2804
  document.documentElement.style.setProperty('--modal-zoom', String(modalZoom));
2784
2805
  for (const el of document.querySelectorAll('.pane-zoom-val')) el.textContent = modalZoomLabel();
2785
- postToShowFrames(() => ({ type: `${SHOW_MSG}zoom`, z: modalZoom }));
2806
+ postToShowFrames(() => ({ type: `${SHOW_MSG}zoom`, z: modalZoom }), document.querySelectorAll(PANE_SHOW_FRAMES));
2786
2807
  }
2787
2808
 
2788
- // delta 0 resets to 100%
2789
2809
  function adjustModalZoom(delta) {
2790
- const next = delta === 0 ? 1 : clampModalZoom(Math.round((modalZoom + delta) * 10) / 10);
2791
- if (next === modalZoom && delta !== 0) return;
2792
- modalZoom = next;
2793
- store.setItem(MODAL_ZOOM_KEY, String(modalZoom));
2810
+ modalZoom = stepZoom(modalZoom, delta, MODAL_ZOOM_KEY, 'Text');
2794
2811
  applyModalZoom();
2795
- 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();
2796
2824
  }
2797
2825
 
2798
2826
  // The markup decides what scales: `.modal-zoomable` marks a modal's reading
@@ -2806,9 +2834,12 @@ function isAnyModalOpen() {
2806
2834
  return document.querySelector('.modal-overlay.visible') !== null;
2807
2835
  }
2808
2836
 
2809
- // An open modal owns the zoom keys even when it has nothing to scale.
2810
- function isZoomableOnScreen() {
2811
- 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;
2812
2843
  }
2813
2844
 
2814
2845
  const ZOOM_KEYS = { '+': 0.1, '=': 0.1, NumpadAdd: 0.1, '-': -0.1, _: -0.1, NumpadSubtract: -0.1, 0: 0, Numpad0: 0 };
@@ -3955,6 +3986,10 @@ function renderSessions() {
3955
3986
  const childDepth = new Map();
3956
3987
  const parentOf = new Map();
3957
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
+ }
3958
3993
  const nestChildren = (list) => {
3959
3994
  if (parentOf.size === 0) return list;
3960
3995
  const ids = new Set(list.map((s) => s.id));
@@ -3982,16 +4017,15 @@ function renderSessions() {
3982
4017
  return out;
3983
4018
  };
3984
4019
 
3985
- const renderSessionCard = (session) => {
3986
- if (session.placeholder) return renderPlaceholderCard(session, childDepth.get(session.id));
4020
+ const renderSessionCard = (session, hostProject = null) => {
4021
+ if (session.placeholder) return renderPlaceholderCard(session, childDepth.get(session.id), hostProject);
3987
4022
  const total = session.taskCount;
3988
4023
  const pct = (n) => Math.round((n / total) * 100);
3989
4024
  const isActive = session.id === currentSessionId && viewMode === 'session';
3990
4025
  const isLive = isSessionLive(session);
3991
4026
  const sessionName = sessionDisplayName(session);
3992
- const projectHtml = renderProjectIdentity(session);
4027
+ const projectHtml = renderProjectIdentity(session, hostProject);
3993
4028
 
3994
- const gitBranch = session.gitBranch ? escapeHtml(session.gitBranch) : null;
3995
4029
  const isTeam = session.isTeam;
3996
4030
  const memberCount = session.memberCount || 0;
3997
4031
 
@@ -4025,8 +4059,7 @@ function renderSessions() {
4025
4059
  <button onclick="openSession('${sid}')" draggable="true" data-session-id="${escapeHtml(session.id)}" class="session-item ${isActive ? 'active' : ''} ${sessionBarClass(session)} ${tempClass} ${idleClass} ${justFinishedClass}${childDepthClass(childDepth.get(session.id))}">
4026
4060
  <span class="session-pin-btn${pinClass}" onclick="event.stopPropagation();toggleSessionPin('${sid}')" title="${pinTitle} session">${pinState === 'sticky' ? SESSION_STAR_SVG : SESSION_PIN_SVG}</span>
4027
4061
  <div class="session-name">${escapeHtml(sessionName)}</div>
4028
- ${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
4029
- ${gitBranch ? `<div class="session-branch">${gitBranch}</div>` : ''}
4062
+ ${renderSessionIdentity(projectHtml, session.gitBranch)}
4030
4063
  ${metricsHtml ? `<div class="session-metrics">${metricsHtml}</div>` : ''}
4031
4064
  <div class="session-footer">
4032
4065
  <div class="session-time">${formatDate(session.modifiedAt)}</div>
@@ -4042,6 +4075,7 @@ function renderSessions() {
4042
4075
  ${session.planSourceSessionId ? `<span class="plan-indicator" title="Implements plan — click to reveal plan session" onclick="event.stopPropagation(); revealPlanSession('${escAttrJs(session.planSourceSessionId)}')">${ICON_PLAN}</span>` : ''}
4043
4076
  ${session.sharedTaskList ? `<span class="shared-tasklist-badge" title="Shared task list: ${escapeHtml(session.sharedTaskList)}">${linkSvg(12)}</span>` : ''}
4044
4077
  ${session.dispatched ? `<span class="dispatched-badge"${session.dispatched.parent ? ` onclick="event.stopPropagation(); revealSession('${escAttrJs(session.dispatched.parent)}')"` : ''} title="${escapeHtml(dispatchedTitle(session.dispatched))}">${ICON_DISPATCHED}</span>` : ''}
4078
+ ${session.forkedFromSessionId ? `<span class="fork-badge" onclick="event.stopPropagation(); revealSession('${escAttrJs(session.forkedFromSessionId)}')" title="Forked from ${escapeHtml(session.forkedFromSessionId)}, click to reveal">${ICON_FORK}</span>` : ''}
4045
4079
  ${runningTerminals.has(session.id) ? `<span class="terminal-badge" onclick="event.stopPropagation(); showSessionTerminal('${escAttrJs(session.id)}')" title="${TERMINAL_TITLE}">${ICON_TERMINAL}</span>` : ''}
4046
4080
  ${isWaitingSession(session) ? `<span class="agent-badge agent-badge-waiting" title="Waiting for user">${ICON_AGENT_WAITING}</span>` : ''}
4047
4081
  ${session.hasRunningAgents && !isWaitingSession(session) ? `<span class="agent-badge agent-badge-active" title="Agents running">${ICON_AGENT_ACTIVE}</span>` : ''}
@@ -4072,7 +4106,7 @@ function renderSessions() {
4072
4106
  SECTION_OUTSIDE,
4073
4107
  `Outside current filters ${countHtml(outside.rows)}`,
4074
4108
  false,
4075
- collapsedProjectGroups.has(SECTION_OUTSIDE) ? '' : outside.rows.map(renderSessionCard).join(''),
4109
+ collapsedProjectGroups.has(SECTION_OUTSIDE) ? '' : outside.rows.map((s) => renderSessionCard(s)).join(''),
4076
4110
  )
4077
4111
  : '';
4078
4112
 
@@ -4131,13 +4165,18 @@ function renderSessions() {
4131
4165
  return;
4132
4166
  }
4133
4167
 
4168
+ const nestedCards = (list) =>
4169
+ nestChildren(list)
4170
+ .map((s) => renderSessionCard(s))
4171
+ .join('');
4134
4172
  const groupPinned = store.getItem(GROUP_PINNED_KEY) !== 'false';
4135
4173
  const pinWeight = (s) => (isPlacedSticky(s.id) ? 2 : isInPinnedGroup(s) ? 1 : 0);
4136
4174
  const pinSort = (a, b) => pinWeight(b) - pinWeight(a);
4137
- const renderGroupSessions = (list, pinKey) => {
4138
- if (!groupPinned || pinnedSessionIds.size === 0) return nestChildren(list).map(renderSessionCard).join('');
4175
+ const renderGroupSessions = (list, pinKey, hostProject = null) => {
4176
+ const card = (s) => renderSessionCard(s, hostProject);
4177
+ if (!groupPinned || pinnedSessionIds.size === 0) return nestChildren(list).map(card).join('');
4139
4178
  const gPinned = nestChildren(list.filter(isInPinnedGroup));
4140
- if (gPinned.length === 0) return nestChildren(list).map(renderSessionCard).join('');
4179
+ if (gPinned.length === 0) return nestChildren(list).map(card).join('');
4141
4180
  const gUnpinned = nestChildren(list.filter((s) => !isInPinnedGroup(s)));
4142
4181
  const pinCollapsed = collapsedProjectGroups.has(pinKey);
4143
4182
  return (
@@ -4157,10 +4196,10 @@ function renderSessions() {
4157
4196
  '<div class="pinned-sub-items' +
4158
4197
  (pinCollapsed ? ' collapsed' : '') +
4159
4198
  '">' +
4160
- gPinned.map(renderSessionCard).join('') +
4199
+ gPinned.map(card).join('') +
4161
4200
  '</div>' +
4162
4201
  '</div>' +
4163
- gUnpinned.map(renderSessionCard).join('')
4202
+ gUnpinned.map(card).join('')
4164
4203
  );
4165
4204
  };
4166
4205
 
@@ -4192,7 +4231,7 @@ function renderSessions() {
4192
4231
  </div>
4193
4232
  <div class="project-group-breadcrumb${nestedCls}" data-full-path="${escapedPath}" title="Click to copy path">${breadcrumbHtml}</div>
4194
4233
  <div class="project-group-sessions${isCollapsed ? ' collapsed' : ''}${nestedCls}" data-project-path="${escapedPath}">
4195
- ${renderGroupSessions(projectSessions, pinKey(projectPath))}
4234
+ ${renderGroupSessions(projectSessions, pinKey(projectPath), projectPath)}
4196
4235
  </div>
4197
4236
  `;
4198
4237
  };
@@ -4250,13 +4289,14 @@ function renderSessions() {
4250
4289
  // Placed sessions follow the order the user dragged them into; a session that is here through
4251
4290
  // its project takes the project's slot.
4252
4291
  const slot = new Map(group.members.map((m, i) => [`${m.type}:${m.ref}`, i]));
4253
- 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
+ };
4254
4296
  const byMemberOrder = (a, b) => (groupPinned ? 0 : pinSort(a, b)) || slotOf(a) - slotOf(b);
4255
4297
  let body = '';
4256
4298
  if (flat) {
4257
- body = nestChildren([...bucket].sort(byMemberOrder))
4258
- .map(renderSessionCard)
4259
- .join('');
4299
+ body = nestedCards([...bucket].sort(byMemberOrder));
4260
4300
  } else {
4261
4301
  const loose = [];
4262
4302
  const byProject = new Map();
@@ -4310,7 +4350,7 @@ function renderSessions() {
4310
4350
  const groups = new Map();
4311
4351
  const ungrouped = [];
4312
4352
  for (const session of sgRest) {
4313
- const key = sessionProjectKey(session);
4353
+ const key = sessionProjectKey(placementOf(session));
4314
4354
  if (key) {
4315
4355
  if (!groups.has(key)) groups.set(key, []);
4316
4356
  groups.get(key).push(session);
@@ -4361,7 +4401,7 @@ function renderSessions() {
4361
4401
  </div>
4362
4402
  `;
4363
4403
  } else {
4364
- projectsHtml += ungrouped.map(renderSessionCard).join('');
4404
+ projectsHtml += nestedCards(ungrouped);
4365
4405
  }
4366
4406
  }
4367
4407
 
@@ -4371,8 +4411,7 @@ function renderSessions() {
4371
4411
  sessionsList.innerHTML = html + outsideHtml;
4372
4412
  } else {
4373
4413
  const sectioned = sessionGroups.length > 0;
4374
- const restHtml =
4375
- sectioned && collapsedProjectGroups.has(SECTION_SESSIONS) ? '' : sgRest.map(renderSessionCard).join('');
4414
+ const restHtml = sectioned && collapsedProjectGroups.has(SECTION_SESSIONS) ? '' : nestedCards(sgRest);
4376
4415
  const tail = sectioned ? sectionHtml(SECTION_SESSIONS, 'Sessions', true, restHtml, sgRest.length === 0) : restHtml;
4377
4416
  sessionsList.innerHTML = html + sgSectionHtml(true) + tail + outsideHtml;
4378
4417
  }
@@ -4444,7 +4483,7 @@ function renderSession() {
4444
4483
  const completed = currentTasks.filter((t) => t.status === 'completed').length;
4445
4484
  const percent = currentTasks.length > 0 ? Math.round((completed / currentTasks.length) * 100) : 0;
4446
4485
 
4447
- progressPercent.textContent = `${percent}%`;
4486
+ progressPercent.textContent = `${percent}% done`;
4448
4487
  progressBar.style.width = `${percent}%`;
4449
4488
  const hasInProgress = currentTasks.some((t) => t.status === 'in_progress');
4450
4489
  progressBar.classList.toggle('shimmer', hasInProgress && percent < 100);
@@ -4468,7 +4507,7 @@ function renderProjectView() {
4468
4507
  const completed = currentTasks.filter((t) => t.status === 'completed').length;
4469
4508
  const percent = currentTasks.length > 0 ? Math.round((completed / currentTasks.length) * 100) : 0;
4470
4509
 
4471
- progressPercent.textContent = `${percent}%`;
4510
+ progressPercent.textContent = `${percent}% done`;
4472
4511
  progressBar.style.width = `${percent}%`;
4473
4512
  const hasInProgress = currentTasks.some((t) => t.status === 'in_progress');
4474
4513
  progressBar.classList.toggle('shimmer', hasInProgress && percent < 100);
@@ -4987,6 +5026,12 @@ function sgGroupById(id) {
4987
5026
  // Matches MAX_DEPTH in lib/user-groups.js.
4988
5027
  const SG_MAX_DEPTH = 8;
4989
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
+
4990
5035
  function sgSubtreeIds(rootId) {
4991
5036
  const ids = new Set([rootId]);
4992
5037
  for (const id of ids) for (const g of sessionGroups) if (g.parent === id) ids.add(g.id);
@@ -5011,8 +5056,35 @@ function sgSessionParent(id) {
5011
5056
 
5012
5057
  // An individually placed session wins over the placement of its project.
5013
5058
  function sgGroupForSession(session) {
5014
- const project = sessionProjectKey(session);
5015
- 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;
5016
5088
  }
5017
5089
 
5018
5090
  function sgListFiltered() {
@@ -5058,8 +5130,9 @@ function sgDetach(type, ref) {
5058
5130
 
5059
5131
  // Where a session renders inside a group: the project block it was moved under, its own
5060
5132
  // project's block, or - when the user lifted it out - the group's own loose list.
5061
- function sgHostOf(group, session) {
5062
- if (!group || !session) return null;
5133
+ function sgHostOf(group, placed) {
5134
+ if (!group || !placed) return null;
5135
+ const session = placementOf(placed);
5063
5136
  const m = group.members.find((x) => x.type === 'session' && x.ref === session.id);
5064
5137
  const isMemberProject = (path) => !!path && group.members.some((x) => x.type === 'project' && x.ref === path);
5065
5138
  if (m?.under && isMemberProject(m.under)) return m.under;
@@ -5944,7 +6017,8 @@ function expandActiveGroups({ onlyNew = false } = {}) {
5944
6017
  // (a collapsed Pinned sub-section opens only for the open session, see syncPinPeek):
5945
6018
  // Groups for a grouped session, else Projects or Sessions, which depends on the view. Returns
5946
6019
  // whether anything changed.
5947
- function uncollapseFor(session) {
6020
+ function uncollapseFor(placed) {
6021
+ const session = placementOf(placed);
5948
6022
  const group = sgGroupForSession(session);
5949
6023
  const keys = [
5950
6024
  ...(group
@@ -5961,7 +6035,7 @@ function uncollapseFor(session) {
5961
6035
  function pinnedCollapseKeys(session) {
5962
6036
  if (!isInPinnedGroup(session)) return [];
5963
6037
  const group = sgGroupForSession(session);
5964
- if (!group) return [pinKey(sessionProjectKey(session) || '__ungrouped__')];
6038
+ if (!group) return [pinKey(sessionProjectKey(placementOf(session)) || '__ungrouped__')];
5965
6039
  const host = sgHostOf(group, session);
5966
6040
  return [host ? pinKey(host) : `__pinned_group_${group.id}__`];
5967
6041
  }
@@ -6512,7 +6586,7 @@ const SHORTCUT_TABS = [
6512
6586
  { keys: ['>'], label: 'Toggle sticky' },
6513
6587
  { keys: ['Ctrl', 'Shift', '.'], combo: true, label: 'Toggle sticky (from the terminal)' },
6514
6588
  { keys: ['Shift', 'F10'], combo: true, label: 'Session menu: move to group, fork (or Menu key)' },
6515
- { 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' },
6516
6590
  { keys: ['Ctrl', 'Shift', 'L'], combo: true, label: 'Toggle session log' },
6517
6591
  { keys: ['Ctrl', 'Shift', 'M'], combo: true, label: 'Open last message' },
6518
6592
  { keys: ['↑', '↓'], label: 'Previous / next message in detail' },
@@ -6550,6 +6624,7 @@ const SHORTCUT_TABS = [
6550
6624
  rows: [
6551
6625
  { keys: ['[', ']'], label: 'Previous / next post' },
6552
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' },
6553
6628
  { keys: ['Esc'], label: 'Back to the card, else collapse to the header' },
6554
6629
  { keys: ['Alt', 'W'], combo: true, label: 'Close until the next post' },
6555
6630
  { keys: ['Ctrl', 'D'], combo: true, label: 'Remove the post' },
@@ -7765,15 +7840,20 @@ document.addEventListener('keydown', (e) => {
7765
7840
  // the whole page. Sits above the text-field guard so it still works with the
7766
7841
  // caret in a field inside the modal.
7767
7842
  const zoom = zoomDelta(e);
7768
- if (zoom !== undefined && isZoomableOnScreen()) {
7843
+ const zoomTo = zoom !== undefined && zoomTarget();
7844
+ if (zoomTo) {
7769
7845
  e.preventDefault();
7770
- adjustModalZoom(zoom);
7846
+ zoomTo(zoom);
7771
7847
  return;
7772
7848
  }
7773
7849
  if (isShowCardFocused() && !isAnyModalOpen() && showCardKey(e)) {
7774
7850
  e.preventDefault();
7775
7851
  return;
7776
7852
  }
7853
+ if (isDockFocused() && !isAnyModalOpen() && dockHeadKey(e)) {
7854
+ e.preventDefault();
7855
+ return;
7856
+ }
7777
7857
 
7778
7858
  // Above the text-field guard: xterm's input is a textarea, and the toggle must work from it.
7779
7859
  const terminalPrompt = document.getElementById('terminal-prompt');
@@ -8121,7 +8201,9 @@ document.addEventListener('keydown', (e) => {
8121
8201
  }
8122
8202
  if (ClaudeHub.comboOf(e) === 'ctrl+d') {
8123
8203
  e.preventDefault();
8124
- dismissContextSession();
8204
+ const group = kbSelectedGroup();
8205
+ if (group) sgConfirmDelete(group);
8206
+ else dismissContextSession();
8125
8207
  return;
8126
8208
  }
8127
8209
  if (e.code === 'KeyC' && e.shiftKey && !e.altKey && !e.metaKey) {
@@ -8730,10 +8812,11 @@ const PANE_CLAIMS = [
8730
8812
  ...'123456789'.split('').map((n) => ({ ctrlKey: true, altKey: true, key: 'Unidentified', code: `Digit${n}` })),
8731
8813
  ];
8732
8814
 
8733
- // 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.
8734
8816
  const SHOW_CLAIMS = [
8735
8817
  ...['ctrlKey', 'metaKey'].map((mod) => ({ [mod]: true, key: 'd', code: '' })),
8736
8818
  { altKey: true, key: 'Enter', code: '' },
8819
+ { altKey: true, shiftKey: true, key: 'Enter', code: '' },
8737
8820
  ];
8738
8821
 
8739
8822
  // Claimed only while comments wait, so a previewed page keeps Ctrl+Enter otherwise.
@@ -9432,6 +9515,13 @@ function openFileInEditor(filePath) {
9432
9515
  postAndToast('/api/open-in-editor', { file: filePath }, 'in editor');
9433
9516
  }
9434
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
+
9435
9525
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
9436
9526
  function openPreviewInEditor() {
9437
9527
  if (!currentPreviewPath) return;
@@ -9447,7 +9537,7 @@ async function openPreviewByPath(filePath, base, onUnsupported, sessionId = prev
9447
9537
  try {
9448
9538
  const r = await api('/api/preview', { query: { path: filePath, base: base || null } });
9449
9539
  if (!r.ok) {
9450
- showToast('Preview file unavailable', 'error');
9540
+ showToast('Preview file unavailable', 'error', { label: 'Copy path', onClick: () => copyText(filePath, 'path') });
9451
9541
  return;
9452
9542
  }
9453
9543
  const data = await r.json();
@@ -9489,6 +9579,25 @@ function handleSessionOpenEvent(data) {
9489
9579
  fetchTasks(id);
9490
9580
  }
9491
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
+
9492
9601
  async function handlePreviewOpenEvent(data) {
9493
9602
  const { path: filePath, sessionId } = data;
9494
9603
  if (sessionId && sessionId !== currentSessionId) {
@@ -10290,6 +10399,10 @@ function setupEventSource() {
10290
10399
  handleSessionOpenEvent(data);
10291
10400
  }
10292
10401
 
10402
+ if (data.type === 'session:launched') {
10403
+ handleSessionLaunchedEvent(data);
10404
+ }
10405
+
10293
10406
  // Alone, the page would open a tab without a user gesture, which the browser blocks; the
10294
10407
  // CLI prints the link instead.
10295
10408
  if (data.type === 'session:cost' && hub.inHub) {
@@ -10308,6 +10421,15 @@ function setupEventSource() {
10308
10421
  applyPaneLayout(data.sessionId, data.layout);
10309
10422
  }
10310
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
+
10311
10433
  if (data.type === 'pane:file-changed') {
10312
10434
  onPaneFileChanged(data.path);
10313
10435
  }
@@ -10371,6 +10493,8 @@ const CONTEXT_COLORS = {
10371
10493
  red: 'var(--danger)',
10372
10494
  };
10373
10495
  const COST_THRESHOLDS = { green: 0.5, yellow: 2, orange: 5 };
10496
+ // Status colors as small text measure 3.7–3.9:1 on light surfaces; bars and dots keep the pure color.
10497
+ const textTone = (color) => `color-mix(in srgb, ${color} 70%, var(--text-primary))`;
10374
10498
  const MODEL_THRESHOLDS = [
10375
10499
  { match: /sonnet/i, yellow: 120000, orange: 200000, red: 300000 },
10376
10500
  { match: /haiku/i, yellow: 100000, orange: 130000, red: 150000 },
@@ -10477,7 +10601,7 @@ function renderContextBar(session) {
10477
10601
  ${renderMarkers(ctx.markers)}
10478
10602
  </div>
10479
10603
  <div class="context-bar-labels">
10480
- <span style="color:${color}">${Math.round(ctx.pct)}% (${used})</span>
10604
+ <span>ctx <span style="color:${textTone(color)}">${Math.round(ctx.pct)}% (${used})</span></span>
10481
10605
  <span>${Math.round(ctx.remaining)}% free</span>
10482
10606
  </div>
10483
10607
  </div>`;
@@ -10518,7 +10642,7 @@ function formatCacheLeft(expiresAt, expiredText) {
10518
10642
  if (left === 0) return { text: expiredText, color: 'var(--text-muted)' };
10519
10643
  const text = `${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`;
10520
10644
  const color = left < 120 ? CONTEXT_COLORS.red : left < 300 ? CONTEXT_COLORS.orange : CONTEXT_COLORS.green;
10521
- return { text, color };
10645
+ return { text, color: textTone(color) };
10522
10646
  }
10523
10647
 
10524
10648
  let cacheTimerInterval = null;
@@ -10587,7 +10711,7 @@ function renderContextDetail(session, { tokens = true } = {}) {
10587
10711
  </div>
10588
10712
  </div>
10589
10713
  <div class="detail-context-summary">
10590
- <span style="color:${color}">${Math.round(ctx.pct)}% used</span>
10714
+ <span style="color:${textTone(color)}">${Math.round(ctx.pct)}% used</span>
10591
10715
  <span${ctx.compacts ? ` title="Auto-compact window · model window ${escapeHtml(formatTokens(ctx.modelSize / 1000))}"` : ''}>${formatTokens(ctx.usedTokens / 1000)} / ${formatTokens(ctx.size / 1000)}</span>
10592
10716
  </div>`
10593
10717
  : '';
@@ -10599,11 +10723,11 @@ function renderContextDetail(session, { tokens = true } = {}) {
10599
10723
  <div class="detail-context-stats">
10600
10724
  ${usageRows}
10601
10725
  ${totalRows}
10602
- ${ctx ? `<div class="stat-item"><span class="stat-label">Cost</span><span class="stat-value" style="color:${getCostColor(cost.total_cost_usd)}">${formatCost(cost.total_cost_usd)}</span></div>` : ''}
10726
+ ${ctx ? `<div class="stat-item"><span class="stat-label">Cost</span><span class="stat-value" style="color:${textTone(getCostColor(cost.total_cost_usd))}">${formatCost(cost.total_cost_usd)}</span></div>` : ''}
10603
10727
  ${reply ? renderCacheTimer(reply) : ''}
10604
10728
  ${cost.total_duration_ms != null ? `<div class="stat-item"><span class="stat-label">Duration</span><span class="stat-value">${formatDuration(cost.total_duration_ms)}</span></div>` : ''}
10605
10729
  ${cost.total_api_duration_ms != null ? `<div class="stat-item"><span class="stat-label">API time</span><span class="stat-value">${formatDuration(cost.total_api_duration_ms)}</span></div>` : ''}
10606
- ${cost.total_lines_added != null ? `<div class="stat-item"><span class="stat-label">Lines</span><span class="stat-value"><span style="color:${CONTEXT_COLORS.green}">+${(cost.total_lines_added || 0).toLocaleString()}</span> / <span style="color:${CONTEXT_COLORS.red}">-${(cost.total_lines_removed || 0).toLocaleString()}</span></span></div>` : ''}
10730
+ ${cost.total_lines_added != null ? `<div class="stat-item"><span class="stat-label">Lines</span><span class="stat-value"><span style="color:${textTone(CONTEXT_COLORS.green)}">+${(cost.total_lines_added || 0).toLocaleString()}</span> / <span style="color:${textTone(CONTEXT_COLORS.red)}">-${(cost.total_lines_removed || 0).toLocaleString()}</span></span></div>` : ''}
10607
10731
  </div>
10608
10732
  </div>`;
10609
10733
  }
@@ -10876,8 +11000,8 @@ function showWaitingDetail() {
10876
11000
  // With actions on screen the form/buttons are the content — tuck the raw
10877
11001
  // JSON behind a toggle, but keep what the user is approving visible: for a
10878
11002
  // permission ask surface the command (or description) as a one-line summary
11003
+ let summaryHtml = '';
10879
11004
  if ((actionsHtml || footerHtml) && inputHtml) {
10880
- let summaryHtml = '';
10881
11005
  if (currentWaiting.kind === 'plan' && typeof params?.plan === 'string' && params.plan) {
10882
11006
  // A plan ask IS the plan — render it in full so it can be reviewed and
10883
11007
  // approved right here (follow mode included) instead of a JSON dump. The
@@ -10900,7 +11024,8 @@ function showWaitingDetail() {
10900
11024
  autoSizeModal(modal, body);
10901
11025
  setMsgDetailFollow(msgDetailFollowLatest);
10902
11026
  const overlay = document.getElementById('msg-detail-modal');
10903
- const meta = [formatDate(currentWaiting.timestamp), 'waiting'];
11027
+ // The permission card's header already shows how long the ask has waited.
11028
+ const meta = summaryHtml ? [] : [formatDate(currentWaiting.timestamp), 'waiting'];
10904
11029
  document.getElementById('msg-detail-meta').textContent = meta.join(' · ');
10905
11030
  currentPinDetailId = null;
10906
11031
  updateMsgDetailPinState();
@@ -10930,17 +11055,34 @@ function pathBasename(p) {
10930
11055
 
10931
11056
  // `session.worktree` is resolved server-side from the `.git` pointer file, so it holds for a
10932
11057
  // worktree parked beside its repo as well as one under <repo>/.claude/worktrees/.
10933
- function renderProjectIdentity(session) {
11058
+ // Under its own project group the card leaves the repo name out: the group header shows it.
11059
+ function renderProjectIdentity(session, hostProject) {
10934
11060
  if (!session.project) return null;
11061
+ const underHost = !!hostProject && sessionProjectKey(session) === hostProject;
10935
11062
  const wt = session.worktree;
10936
- if (!wt) return escapeHtml(pathBasename(session.project));
11063
+ if (!wt) {
11064
+ return underHost ? null : `<span class="session-project">${escapeHtml(pathBasename(session.project))}</span>`;
11065
+ }
10937
11066
  const repo = pathBasename(wt.repo);
10938
11067
  // Worktrees beside the repo are conventionally named `<repo>-<branch>`, and repeating the
10939
11068
  // repo inside a chip that already sits next to it wastes the whole width of the card.
10940
11069
  const name = wt.name.startsWith(repo) ? wt.name.slice(repo.length).replace(/^[-._]/, '') || wt.name : wt.name;
10941
- return `${escapeHtml(repo)}<span class="wt-chip" title="Worktree of ${escapeHtml(wt.repo)}">⑂ ${escapeHtml(name)}</span>`;
11070
+ const chip = `<span class="wt-chip" title="Worktree of ${escapeHtml(wt.repo)}">⑂ ${escapeHtml(name)}</span>`;
11071
+ return underHost ? chip : `<span class="session-project">${escapeHtml(repo)}</span>${chip}`;
11072
+ }
11073
+
11074
+ function renderSessionIdentity(projectHtml, gitBranch) {
11075
+ if (!projectHtml && !gitBranch) return '';
11076
+ const branch = gitBranch
11077
+ ? `<span class="session-branch" title="${escapeHtml(gitBranch)}">${escapeHtml(gitBranch)}</span>`
11078
+ : '';
11079
+ const sep = projectHtml && branch ? '<span class="agent-ident-sep">·</span>' : '';
11080
+ return `<div class="session-secondary">${projectHtml || ''}${sep}${branch}</div>`;
10942
11081
  }
10943
11082
 
11083
+ const SHORT_DATE = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' });
11084
+ const SHORT_DATE_YEAR = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
11085
+
10944
11086
  function formatDate(dateStr) {
10945
11087
  const date = new Date(dateStr);
10946
11088
  const now = new Date();
@@ -10949,7 +11091,8 @@ function formatDate(dateStr) {
10949
11091
  if (diff < JUST_NOW_MS) return 'just now';
10950
11092
  if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
10951
11093
  if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
10952
- return date.toLocaleDateString();
11094
+ if (diff < 7 * 86400000) return `${Math.floor(diff / 86400000)}d ago`;
11095
+ return (date.getFullYear() === now.getFullYear() ? SHORT_DATE : SHORT_DATE_YEAR).format(date);
10953
11096
  }
10954
11097
 
10955
11098
  // Terminal output reaches the transcript raw. A CSI-only pattern left OSC
@@ -11275,8 +11418,9 @@ function loadMermaid() {
11275
11418
  }
11276
11419
 
11277
11420
  let mermaidQueue = Promise.resolve();
11421
+ let mermaidSeq = 0;
11278
11422
 
11279
- // mermaid.run is async, and two runs that overlap draw diagrams over each other.
11423
+ // mermaid.render is async, and two renders that overlap draw diagrams over each other.
11280
11424
  function queueMermaid(work) {
11281
11425
  mermaidQueue = mermaidQueue.then(work).catch((err) => console.warn(err.message));
11282
11426
  }
@@ -11303,10 +11447,15 @@ function initMermaidBlocks(container) {
11303
11447
  return;
11304
11448
  }
11305
11449
  queueMermaid(async () => {
11306
- // One block per run, so a bad diagram shows its own error and the others still render.
11450
+ // One block per render, so a bad diagram shows its own error and the others still render.
11451
+ // render, not run: run measures labels in place, and under a modal's CSS zoom the measured
11452
+ // widths are off, so node boxes clip their text.
11307
11453
  for (const b of stampPending()) {
11308
11454
  try {
11309
- await mermaid.run({ nodes: [b] });
11455
+ const { svg, bindFunctions } = await mermaid.render(`mermaid-${++mermaidSeq}`, b.textContent.trim());
11456
+ b.innerHTML = svg;
11457
+ b.setAttribute('data-processed', 'true');
11458
+ bindFunctions?.(b);
11310
11459
  } catch (err) {
11311
11460
  showMermaidError(b, err);
11312
11461
  }
@@ -11621,8 +11770,7 @@ function toggleZenMode() {
11621
11770
  if (zenMode) zenMode = false;
11622
11771
  else if (zenGroup) zenGroup = null;
11623
11772
  else {
11624
- const header = focusZone === 'sidebar' ? sessionsList.querySelector('.session-group-header.kb-selected') : null;
11625
- zenGroup = header?.dataset.groupId || null;
11773
+ zenGroup = kbSelectedGroup()?.id || null;
11626
11774
  zenMode = !zenGroup;
11627
11775
  }
11628
11776
  store.setItem(ZEN_KEY, String(zenMode));
@@ -12363,8 +12511,8 @@ function updateDismissBtnState() {
12363
12511
  const btn = document.getElementById('session-info-dismiss-btn');
12364
12512
  if (!btn || !_infoModalSessionId) return;
12365
12513
  const isDismissed = dismissedSessionIds.has(_infoModalSessionId);
12366
- btn.textContent = isDismissed ? 'Restore' : 'Dismiss';
12367
- btn.title = isDismissed ? 'Restore — show in active list again' : 'Dismiss — hide from active list';
12514
+ btn.textContent = isDismissed ? 'Show session' : 'Hide session';
12515
+ btn.title = isDismissed ? 'Show in the active list again' : 'Hide from the active list';
12368
12516
  }
12369
12517
 
12370
12518
  let _planSessionId = null;
@@ -13351,8 +13499,10 @@ const PANE_EMPTY_LAYOUT = { panes: [] };
13351
13499
  const paneLayouts = new Map();
13352
13500
  let paneLayoutSid = null;
13353
13501
 
13354
- // Targets the server's probe found refusing framing, kept for the page's life; a reload asks again.
13355
- 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();
13356
13506
 
13357
13507
  // Every origin above a pane's frame. Firefox has no ancestorOrigins, so under the hub there it
13358
13508
  // names the board only; a refused frame then shows the browser's own error.
@@ -13361,7 +13511,7 @@ function paneAncestors() {
13361
13511
  }
13362
13512
 
13363
13513
  function paneFrameable(pane) {
13364
- return pane.kind !== 'url' || !paneRefused.has(pane.target);
13514
+ return pane.kind !== 'url' || paneFraming.get(pane.target) !== false;
13365
13515
  }
13366
13516
 
13367
13517
  // allow-scripts with allow-same-origin on the board's or the hub's origin would lift the sandbox.
@@ -13383,7 +13533,7 @@ function paneOwnOrigin(pane) {
13383
13533
  async function probePaneFraming(sid, pane) {
13384
13534
  const q = new URLSearchParams({ ancestors: paneAncestors().join(',') });
13385
13535
  const { frameable } = await paneRequest('GET', `${apiPath`/api/panes/${sid}/${pane.id}/framing`}?${q}`);
13386
- if (frameable === false) paneRefused.add(pane.target);
13536
+ if (typeof frameable === 'boolean') paneFraming.set(pane.target, frameable);
13387
13537
  return frameable;
13388
13538
  }
13389
13539
 
@@ -13400,6 +13550,7 @@ function applyPaneLayout(sid, layout) {
13400
13550
  if (targets.get(p.id) !== p.target) unmountPane(sid, p.id);
13401
13551
  }
13402
13552
  if (!targets.has(getActivePaneId(sid))) setActivePaneId(sid, 'board');
13553
+ if (sid === dockSid) renderDockRail();
13403
13554
  if (sid !== paneSessionId()) return;
13404
13555
  syncPanes();
13405
13556
  autoRevealLog(sid);
@@ -13425,20 +13576,20 @@ async function paneRequest(method, path, body) {
13425
13576
  }
13426
13577
  }
13427
13578
 
13428
- // 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`.
13429
13580
  const PANE_STATE_CAP = 20;
13430
13581
 
13431
- function loadPaneState(key) {
13432
- const map = tabStore.readJson(key);
13582
+ function loadPaneState(key, area = tabStore) {
13583
+ const map = area.readJson(key);
13433
13584
  return map && typeof map === 'object' ? map : {};
13434
13585
  }
13435
13586
 
13436
13587
  // Puts `sid` last, so the cap drops the least recent session. A null value removes it.
13437
- function savePaneState(key, map, sid, value) {
13588
+ function savePaneState(key, map, sid, value, area = tabStore) {
13438
13589
  delete map[sid];
13439
13590
  if (value != null) map[sid] = value;
13440
13591
  const capped = Object.fromEntries(Object.entries(map).slice(-PANE_STATE_CAP));
13441
- tabStore.writeJson(key, capped);
13592
+ area.writeJson(key, capped);
13442
13593
  return capped;
13443
13594
  }
13444
13595
 
@@ -13520,7 +13671,8 @@ function neighborPair(sid, id, pairs) {
13520
13671
  // tab, so grouping leaves the server's order alone; a drag saves the order on screen.
13521
13672
  const PANE_GROUPS_KEY = 'pane-groups';
13522
13673
  const PANE_GROUP_COLORS = ['blue', 'purple', 'teal', 'orange', 'yellow', 'pink', 'green', 'red'];
13523
- 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);
13524
13676
 
13525
13677
  // Tabs that are gone, or in an earlier group, drop out, and so do groups left empty.
13526
13678
  function getPaneGroups(sid) {
@@ -13538,7 +13690,8 @@ function getPaneGroups(sid) {
13538
13690
  }
13539
13691
 
13540
13692
  function setPaneGroups(sid, groups) {
13541
- 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);
13542
13695
  }
13543
13696
 
13544
13697
  // A pair is in the group of either side, and the board is in none.
@@ -13846,9 +13999,10 @@ function markPaneFrameFocus() {
13846
13999
  view?.classList.add('frame-focus');
13847
14000
  }
13848
14001
 
13849
- function paneCardHtml(title, text, button) {
13850
- const btn = button ? `<button class="btn btn-primary" data-pane-open>${escapeHtml(button)}</button>` : '';
13851
- 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>`;
13852
14006
  }
13853
14007
 
13854
14008
  // Returns the key of the view to show.
@@ -13877,32 +14031,37 @@ function mountPane(sid, pane) {
13877
14031
  return key;
13878
14032
  }
13879
14033
 
13880
- 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) {
13881
14036
  if (paneOwnOrigin(pane)) {
13882
- view.insertAdjacentHTML(
13883
- 'beforeend',
13884
- paneCardHtml('A pane cannot show the board or the hub', pane.target, 'Open in new tab'),
13885
- );
13886
- return;
14037
+ return paneCardHtml('A pane cannot show the board or the hub', pane.target, 'Open in new tab');
13887
14038
  }
13888
- if (!paneFrameable(pane)) {
13889
- view.insertAdjacentHTML(
13890
- 'beforeend',
13891
- paneCardHtml(
13892
- `${new URL(pane.target).host} does not allow framing`,
13893
- 'The site sends X-Frame-Options or a frame-ancestors policy. The pane keeps the link with the session.',
13894
- 'Open in new tab',
13895
- ),
13896
- );
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);
13897
14061
  return;
13898
14062
  }
13899
14063
  if (pane.kind === 'url') {
13900
- const frame = document.createElement('iframe');
13901
- frame.className = 'pane-frame pane-url-frame modal-zoomable';
13902
- frame.setAttribute('sandbox', PANE_URL_SANDBOX);
13903
- frame.setAttribute('referrerpolicy', 'no-referrer');
13904
- frame.title = pane.title;
13905
- frame.src = pane.target;
14064
+ const frame = paneUrlFrame(pane, 'pane-frame pane-url-frame modal-zoomable');
13906
14065
  frame.addEventListener('load', () => {
13907
14066
  view.classList.remove('keys-bridged');
13908
14067
  frame.hostKeysSig = null;
@@ -13926,7 +14085,7 @@ async function loadPaneView(sid, pane, view, scroll) {
13926
14085
  if (data?.kind === 'html') {
13927
14086
  // A show card's own zoom follows the text zoom (showBridge), so its frame is not .modal-zoomable.
13928
14087
  const [cls, doc] = pane.show
13929
- ? ['pane-frame show-pane-frame', showSrcdoc(data.content, scroll?.y)]
14088
+ ? ['pane-frame show-pane-frame', showSrcdoc(data.content, scroll?.y, modalZoom)]
13930
14089
  : ['pane-frame modal-zoomable themed-frame', themedSrcdoc(data.content)];
13931
14090
  const frame = createPreviewFrame(cls, doc);
13932
14091
  frame.title = pane.title;
@@ -14336,7 +14495,7 @@ async function reloadPane(id) {
14336
14495
  await reloadPaneView(sid, pane, view);
14337
14496
  } else {
14338
14497
  unmountPane(sid, pane.id);
14339
- paneRefused.delete(pane.target);
14498
+ paneFraming.delete(pane.target);
14340
14499
  syncPanes();
14341
14500
  }
14342
14501
  showToast('Pane reloaded', 'success');
@@ -14549,6 +14708,7 @@ function openPaneMenu(x, y, id) {
14549
14708
  : paneMenuItem('link', paneIsLinked(pane) ? 'Remove from linked documents' : 'Add to linked documents')) +
14550
14709
  paneMenuItem('rename', 'Rename') +
14551
14710
  paneMenuItem('reload', 'Reload') +
14711
+ (url ? paneMenuItem('dock', pane.dock ? 'Remove from the dock' : 'Dock beside the terminal') : '') +
14552
14712
  paneGroupMenuItems(pane.id) +
14553
14713
  paneMenuItem('close', 'Close pane'),
14554
14714
  );
@@ -14560,6 +14720,20 @@ function paneIsLinked(pane) {
14560
14720
  return getSessionPreviewPaths(paneSessionId()).some((p) => canonicalPath(p) === key);
14561
14721
  }
14562
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
+
14563
14737
  function togglePaneLink(id) {
14564
14738
  const pane = paneById(id);
14565
14739
  const sid = paneSessionId();
@@ -14591,6 +14765,7 @@ const PANE_MENU_ACTIONS = {
14591
14765
  link: togglePaneLink,
14592
14766
  rename: beginPaneRename,
14593
14767
  reload: reloadPane,
14768
+ dock: togglePaneDock,
14594
14769
  close: closePane,
14595
14770
  group: (id, to) => groupPaneTab(id, to || null),
14596
14771
  groupRename: beginPaneGroupRename,
@@ -15237,6 +15412,11 @@ function terminalShortcut(e, probe = false) {
15237
15412
  if (ctrlAlt && (e.code === 'KeyN' || e.code === 'KeyR') && terminalAvailable()) {
15238
15413
  return () => openNewSession(null, e.code === 'KeyR');
15239
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
+ }
15240
15420
  if (e.code !== 'Backquote' || e.metaKey) return null;
15241
15421
  if (ctrlAlt) return toggleShowFocus;
15242
15422
  if (e.ctrlKey && !e.altKey) return e.shiftKey ? openTerminalManager : toggleTerminal;
@@ -15280,14 +15460,29 @@ function toggleTerminalFocus() {
15280
15460
  }
15281
15461
 
15282
15462
  function toggleShowFocus() {
15283
- if (isShowCardFocused()) {
15463
+ if (isShowCardFocused() || isDockFocused()) {
15284
15464
  focusTerminalPane();
15285
15465
  return;
15286
15466
  }
15287
- if (!wantsTerminal() || !showState.posts.length) return;
15288
- if (showState.closed || showState.collapsed) {
15289
- showState.closed = false;
15290
- 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 }) });
15291
15486
  renderShow();
15292
15487
  }
15293
15488
  focusShowBody();
@@ -15297,6 +15492,8 @@ function toggleShowFocus() {
15297
15492
  function syncTerminal() {
15298
15493
  const btn = document.getElementById('terminal-toggle');
15299
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();
15300
15497
  const arrived = viewMode === 'session' ? currentSessionId : null;
15301
15498
  const view = `${arrived}:${on}`;
15302
15499
  // Only on a change of session or terminal state, so a panel opened by hand over the terminal stays.
@@ -15625,10 +15822,16 @@ const urlPaneFrames = () => [...paneViews.querySelectorAll('.pane-url-frame')];
15625
15822
  window.addEventListener('message', (e) => {
15626
15823
  const type = e.data?.type;
15627
15824
  if (type !== 'hub:hostKey' && type !== 'hub:hostReady') return;
15628
- 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);
15629
15827
  if (!frame || URL.parse(frame.src)?.origin !== e.origin) return;
15630
- if (type === 'hub:hostReady') frame.parentElement.classList.add('keys-bridged');
15631
- 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);
15632
15835
  });
15633
15836
 
15634
15837
  function pushTerminalClaims() {
@@ -15638,11 +15841,13 @@ function pushTerminalClaims() {
15638
15841
  terminalClaimsQueued = false;
15639
15842
  const bridged = bridgedFrameEls();
15640
15843
  const urlFrames = urlPaneFrames();
15641
- 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;
15642
15846
  const claims = keyClaims();
15643
15847
  const base = bridgeClaims(claims);
15644
15848
  sendBridgeClaimsAll(bridged, base);
15645
15849
  sendHostKeys(urlFrames, base);
15850
+ sendHostKeys(dockEls, { keys: [...base.keys, ...DOCK_CLAIMS, ...claims.forward.flatMap(comboClaims)] });
15646
15851
  const sig = JSON.stringify(claims);
15647
15852
  sendFrameClaims(kanbotState, claims, sig, postToKanbotFrame);
15648
15853
  sendFrameClaims(termFrame, claims, sig, terminalFrameSend);
@@ -15989,7 +16194,10 @@ function onTerminalMessage(sessionId, msg, tail = '') {
15989
16194
  if (msg.t === 'ready') {
15990
16195
  // The server has the prompt now; a later start of the same placeholder must not send it again.
15991
16196
  const spec = newSpecs.get(sessionId);
15992
- if (spec) spec.prompt = null;
16197
+ if (spec) {
16198
+ spec.prompted ||= !!spec.prompt;
16199
+ spec.prompt = null;
16200
+ }
15993
16201
  clearTerminalSlow();
15994
16202
  hideTerminalPrompt();
15995
16203
  setTerminalStatus('');
@@ -16472,7 +16680,7 @@ function setAsk(a) {
16472
16680
  if (a.content == null) {
16473
16681
  body.innerHTML = '<p class="ask-missing">The card file is gone. Skip, so the agent stops waiting.</p>';
16474
16682
  } else {
16475
- 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);
16476
16684
  frame.addEventListener('load', () => {
16477
16685
  if (askState.frame === frame && !askState.hidden) frame.focus();
16478
16686
  });
@@ -16602,6 +16810,11 @@ const SHOW_VIEW_KEY = 'cck-show-view-v1';
16602
16810
  const SHOW_MIN_W = 280;
16603
16811
  const SHOW_MIN_H = 120;
16604
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';
16605
16818
  const SHOW_SANDBOX = 'allow-scripts allow-forms';
16606
16819
  const SHOW_CSP =
16607
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'";
@@ -16655,6 +16868,9 @@ const SHOW_ICONS = {
16655
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"/>',
16656
16869
  close: '<path d="M18 6L6 18M6 6l12 12"/>',
16657
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"/>',
16658
16874
  };
16659
16875
 
16660
16876
  // PTY id → terminal id, from the terminal list. A terminal started after the last list read is
@@ -16668,6 +16884,7 @@ const showState = {
16668
16884
  closed: false,
16669
16885
  expanded: false,
16670
16886
  collapsed: false,
16887
+ docked: false,
16671
16888
  marked: new Set(),
16672
16889
  rendered: null,
16673
16890
  frame: null,
@@ -16681,12 +16898,31 @@ const showState = {
16681
16898
  path: null,
16682
16899
  };
16683
16900
  let showSize = store.readJson(SHOW_SIZE_KEY);
16684
- // 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.
16685
16903
  const showViews = store.readJson(SHOW_VIEW_KEY, {});
16686
16904
  // Older boards stored the string 'collapsed' or 'expanded'.
16687
16905
  for (const [tid, v] of Object.entries(showViews)) if (typeof v === 'string') showViews[tid] = { [v]: true };
16688
- const SHOW_VIEW_FLAGS = ['closed', 'collapsed', 'expanded'];
16906
+ const SHOW_VIEW_FLAGS = ['closed', 'collapsed', 'expanded', 'docked'];
16689
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;
16690
16926
 
16691
16927
  function showCardEl() {
16692
16928
  return document.getElementById('show-card');
@@ -16713,7 +16949,7 @@ function focusShowBody() {
16713
16949
  else showCardEl()?.querySelector('.show-body')?.focus();
16714
16950
  }
16715
16951
 
16716
- 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' };
16717
16953
 
16718
16954
  // The body keeps focus, because a render replaces an HTML card's frame.
16719
16955
  function showCardKey(e) {
@@ -16723,10 +16959,12 @@ function showCardKey(e) {
16723
16959
  return true;
16724
16960
  }
16725
16961
  const combo = ClaudeHub.comboOf(e);
16726
- const cmd = combo === 'Escape' ? (showState.expanded ? 'expand' : 'collapse') : SHOW_KEY_COMMANDS[combo];
16962
+ let cmd = SHOW_KEY_COMMANDS[combo];
16963
+ if (combo === 'Escape') cmd = showState.expanded && !showState.docked ? 'expand' : 'collapse';
16964
+ else if (cmd === 'expand' && showState.docked) cmd = null;
16727
16965
  if (cmd) {
16728
16966
  showCommand(cmd);
16729
- if (cmd === 'expand') focusShowBody();
16967
+ if (cmd === 'expand' || cmd === 'dock') focusShowBody();
16730
16968
  else focusTerminalPane();
16731
16969
  return true;
16732
16970
  }
@@ -16761,10 +16999,16 @@ function rememberTerminalIds(list) {
16761
16999
  for (const t of list) if (t.terminalId) terminalIds.set(t.id, t.terminalId);
16762
17000
  if (!terminalIds.size) return;
16763
17001
  const live = new Set(terminalIds.values());
16764
- const gone = Object.keys(showViews).filter((tid) => !live.has(tid));
17002
+ pruneTerminalMap(showViews, SHOW_VIEW_KEY, live);
17003
+ pruneTerminalMap(dockAddons, DOCK_ADDON_KEY, live);
17004
+ pruneTerminalMap(dockLast, DOCK_LAST_KEY, live);
17005
+ }
17006
+
17007
+ function pruneTerminalMap(map, key, live) {
17008
+ const gone = Object.keys(map).filter((tid) => !live.has(tid));
16765
17009
  if (!gone.length) return;
16766
- for (const tid of gone) delete showViews[tid];
16767
- store.writeJson(SHOW_VIEW_KEY, showViews);
17010
+ for (const tid of gone) delete map[tid];
17011
+ store.writeJson(key, map);
16768
17012
  }
16769
17013
 
16770
17014
  // A session resumed after its terminal ended runs under the same PTY id in a new terminal, with a
@@ -16872,16 +17116,29 @@ function onShowRemoved(d) {
16872
17116
  if (dropShowPost(d.id)) renderShow();
16873
17117
  }
16874
17118
 
17119
+ // `attr` names the data attribute that the header's click handler reads: show or dock.
17120
+ function showBtnHtml(attr, cmd, icon, title, disabled = false, pressed = null) {
17121
+ 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>`;
17122
+ }
17123
+
16875
17124
  function showHeaderHtml(p) {
16876
17125
  const n = showState.posts.length;
16877
17126
  const i = showState.idx;
16878
17127
  const key = p.key ? `<span class="show-sep">·</span><span class="show-key">${escapeHtml(p.key)}</span>` : '';
16879
- const btn = (cmd, icon, title, disabled = false) =>
16880
- `<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>`;
16881
- const { collapsed, expanded } = showState;
17128
+ const btn = (...args) => showBtnHtml('show', ...args);
17129
+ const { collapsed, expanded, docked } = showState;
17130
+ const dockTitle = docked ? 'Back to the overlay · Alt+Shift+Enter' : 'Dock beside the terminal · Alt+Shift+Enter';
17131
+ const dock = btn('dock', 'dock', dockTitle, false, docked);
17132
+ const expand = docked
17133
+ ? ''
17134
+ : btn(
17135
+ 'expand',
17136
+ expanded ? 'restore' : 'expand',
17137
+ expanded ? 'Back to the card · Alt+Enter or Esc' : 'Fill the terminal · Alt+Enter',
17138
+ );
16882
17139
  return `<span class="show-nav"><span class="show-title" title="${escapeHtml(p.title)}">${escapeHtml(p.title)}</span>${key}</span>
16883
17140
  <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>
16884
- <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>`;
17141
+ <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>`;
16885
17142
  }
16886
17143
 
16887
17144
  function showTicksHtml() {
@@ -16904,6 +17161,10 @@ function renderShow({ flash = false } = {}) {
16904
17161
  chip.hidden = !n || open;
16905
17162
  chip.querySelector('.show-reopen-n').textContent = `show · ${n}`;
16906
17163
  card.classList.toggle('open', open);
17164
+ const docked = open && showState.docked && !showState.collapsed;
17165
+ card.classList.toggle('docked', docked);
17166
+ if (flash && dockActivePane()) dockShowDot = true;
17167
+ renderDockRail();
16907
17168
  if (!open) {
16908
17169
  showState.rendered = null;
16909
17170
  showState.frame = null;
@@ -16915,12 +17176,13 @@ function renderShow({ flash = false } = {}) {
16915
17176
  }
16916
17177
  const p = showState.posts[showState.idx];
16917
17178
  showState.marked.delete(p.id);
16918
- card.classList.toggle('expanded', showState.expanded && !showState.collapsed);
17179
+ card.classList.toggle('expanded', showState.expanded && !showState.collapsed && !showState.docked);
16919
17180
  card.classList.toggle('collapsed', showState.collapsed);
16920
17181
  if (!card.firstChild) {
16921
17182
  card.innerHTML = `<header class="show-head"></header><div class="show-ticks"></div><div class="show-body" tabindex="-1"></div>
16922
17183
  <aside class="review-panel show-review" hidden></aside>
16923
- <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-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>
17185
+ <div class="show-dock-edge" title="Resize · double-click to reset"></div>`;
16924
17186
  }
16925
17187
  card.querySelector('.show-head').innerHTML = showHeaderHtml(p);
16926
17188
  card.querySelector('.show-ticks').innerHTML = showTicksHtml();
@@ -16958,10 +17220,10 @@ async function renderShowBody(p, samePost) {
16958
17220
  const restoreY = samePost && showState.frame ? showState.frameY : 0;
16959
17221
  showState.frameY = restoreY;
16960
17222
  if (samePost && showState.frame?.isConnected) {
16961
- showState.frame.srcdoc = showSrcdoc(data.content, restoreY);
17223
+ showState.frame.srcdoc = showSrcdoc(data.content, restoreY, showZoom);
16962
17224
  } else {
16963
17225
  showState.frameH = 0;
16964
- body.replaceChildren(createShowFrame(p.title, showSrcdoc(data.content, 0)));
17226
+ body.replaceChildren(createShowFrame(p.title, showSrcdoc(data.content, 0, showZoom)));
16965
17227
  }
16966
17228
  refocus();
16967
17229
  setShowReview(showReviewOpts(p, data, body, showState.frame));
@@ -16971,7 +17233,7 @@ async function renderShowBody(p, samePost) {
16971
17233
  showState.frame = null;
16972
17234
  const keepScroll = samePost ? body.scrollTop : 0;
16973
17235
  const md = document.createElement('div');
16974
- md.className = 'show-md modal-zoomable';
17236
+ md.className = 'show-md';
16975
17237
  if (!data) md.innerHTML = '<p class="show-gone">This post is gone.</p>';
16976
17238
  else if (data.waiting) md.innerHTML = '<p class="show-gone">Waiting for content.</p>';
16977
17239
  else renderPreviewContent(md, data.file || p.file || '', data.content ?? '', data.kind);
@@ -17058,9 +17320,9 @@ function withHeadFirst(content, head) {
17058
17320
  const FRAME_SCROLLBAR_CSS = ':where(html){scrollbar-width:thin;scrollbar-color:rgba(128,128,128,.45) transparent}';
17059
17321
 
17060
17322
  // A whole document gets the tokens and base styles first in its head, so its own styles win.
17061
- function showSrcdoc(content, restoreY, extraHead = '') {
17323
+ function showSrcdoc(content, restoreY, textZoom, extraHead = '') {
17062
17324
  const head = `<meta http-equiv="Content-Security-Policy" content="${SHOW_CSP}"><style>:root{${showTokenCss()}}${SHOW_FRAME_BASE}${FRAME_SCROLLBAR_CSS}</style>${extraHead}`;
17063
- const bridge = `<script>(${showBridge})(${Math.max(0, Math.round(restoreY) || 0)}, ${modalZoom});</script>${REVIEW_BRIDGE_TAG}`;
17325
+ const bridge = `<script>(${showBridge})(${Math.max(0, Math.round(restoreY) || 0)}, ${textZoom});</script>${REVIEW_BRIDGE_TAG}`;
17064
17326
  if (!/^\s*(<!doctype|<html)/i.test(content)) {
17065
17327
  return `<!doctype html><html><head><meta charset="utf-8">${head}</head><body>${content}${bridge}</body></html>`;
17066
17328
  }
@@ -17163,11 +17425,11 @@ new MutationObserver(() => {
17163
17425
  });
17164
17426
  }).observe(document.body, { attributes: true, attributeFilter: ['class', 'data-color-theme', 'style'] });
17165
17427
 
17428
+ const PANE_SHOW_FRAMES = '.show-pane-frame, .themed-frame, .ask-frame, .dock-addon-frame';
17429
+
17166
17430
  // Builds the message only when a frame is there, because showTokens forces a style recalc.
17167
- function postToShowFrames(makeMsg) {
17168
- const frames = [showState.frame, ...document.querySelectorAll('.show-pane-frame, .themed-frame, .ask-frame')].filter(
17169
- (f) => f?.contentWindow,
17170
- );
17431
+ function postToShowFrames(makeMsg, frames = [showState.frame, ...document.querySelectorAll(PANE_SHOW_FRAMES)]) {
17432
+ frames = [...frames].filter((f) => f?.contentWindow);
17171
17433
  if (!frames.length) return;
17172
17434
  const msg = makeMsg();
17173
17435
  if (!msg) return;
@@ -17185,16 +17447,16 @@ function showClampSize(size, w, h) {
17185
17447
 
17186
17448
  function layoutShow() {
17187
17449
  const card = showCardEl();
17188
- if (!card.classList.contains('open')) return;
17189
- const host = document.getElementById('terminal-host');
17190
- const w = host.clientWidth;
17191
- const h = host.clientHeight;
17192
- if (!w || !h) return;
17450
+ if (!card.classList.contains('open') || card.classList.contains('under-addon')) return;
17451
+ const w = dockHostWidth();
17452
+ const h = document.getElementById('terminal-host').clientHeight;
17453
+ if (w <= 0 || !h) return;
17193
17454
  const fit = showFitSize(w, h);
17194
17455
  card.style.setProperty('--show-fit-w', `${fit.w}px`);
17195
17456
  card.style.setProperty('--show-fit-h', `${fit.h}px`);
17196
17457
  card.style.setProperty('--show-term-font', `${currentTerminalFontSize()}px`);
17197
- const sized = !!showSize && !showState.expanded && !showState.collapsed;
17458
+ const docked = card.classList.contains('docked');
17459
+ const sized = !!showSize && !showState.expanded && !showState.collapsed && !docked;
17198
17460
  card.classList.toggle('sized', sized);
17199
17461
  if (sized) {
17200
17462
  const s = showClampSize(showSize, w, h);
@@ -17203,7 +17465,7 @@ function layoutShow() {
17203
17465
  }
17204
17466
  const frame = showState.frame;
17205
17467
  if (!frame) return;
17206
- const peek = !showState.expanded && !sized;
17468
+ const peek = !card.classList.contains('expanded') && !sized && !docked;
17207
17469
  const chrome =
17208
17470
  card.querySelector('.show-head').offsetHeight +
17209
17471
  card.querySelector('.show-ticks').offsetHeight +
@@ -17213,6 +17475,460 @@ function layoutShow() {
17213
17475
  frame.style.height = peek ? `${Math.min(showState.frameH || SHOW_MIN_H, fit.h - chrome)}px` : '';
17214
17476
  }
17215
17477
 
17478
+ // A docked card gets 40% of the host until dragged, and always leaves the terminal SHOW_DOCK_TERM_MIN.
17479
+ function showDockWidth(want, w) {
17480
+ return Math.max(SHOW_MIN_W, Math.min(want ?? Math.round(w * 0.4), w - SHOW_DOCK_TERM_MIN));
17481
+ }
17482
+
17483
+ // The terminal pane holds the width, because the terminal frame and the terminal bar read it too.
17484
+ // Each change refits the terminal and resizes its PTY, so a drag sets it only at the end.
17485
+ function setShowDockWidth(px) {
17486
+ const pane = document.getElementById('terminal-pane');
17487
+ const value = `${px}px`;
17488
+ if (pane.style.getPropertyValue('--show-dock-w') !== value) pane.style.setProperty('--show-dock-w', value);
17489
+ }
17490
+
17491
+ // The host's width less the rail, which is 0 wide while hidden.
17492
+ function dockHostWidth() {
17493
+ return document.getElementById('terminal-host').clientWidth - document.getElementById('dock-rail').offsetWidth;
17494
+ }
17495
+
17496
+ function fitDockWidth() {
17497
+ if (!document.getElementById('terminal-pane').classList.contains('show-docked')) return;
17498
+ const w = dockHostWidth();
17499
+ if (w > 0) setShowDockWidth(showDockWidth(showDockW, w));
17500
+ }
17501
+
17502
+ // A terminal with no posts has no session from the show list yet, so its PTY id stands in,
17503
+ // which is the session it started.
17504
+ function dockSessionId() {
17505
+ return showState.terminalId ? (showState.sessionId ?? currentSessionId) : null;
17506
+ }
17507
+
17508
+ function dockPanes(sid) {
17509
+ return sid ? paneLayout(sid).panes.filter((p) => p.dock) : [];
17510
+ }
17511
+
17512
+ function fillAddonUrl(url, values) {
17513
+ return url.replace(/\{([^{}]*)\}/g, (m, k) => (values[k] == null ? m : encodeURIComponent(values[k])));
17514
+ }
17515
+
17516
+ function addonValues(sid) {
17517
+ const s = sessions.find((x) => x.id === sid);
17518
+ const cwd = s?.cwd || s?.project || newSpecs.get(sid)?.cwd || '';
17519
+ return { sessionId: sid, cwd, project: s?.project || cwd };
17520
+ }
17521
+
17522
+ // The rail's items after the card: global add-ons, the session's, then its docked URL panes.
17523
+ // The same filled URL shows once. Until the list for a new session arrives, the global
17524
+ // entries of the last one stand, so their frames are kept when the URL stays the same.
17525
+ function dockItems(sid = dockSid) {
17526
+ if (!sid) return [];
17527
+ const list = dockAddonList;
17528
+ const own = list?.sid === sid ? list : null;
17529
+ const items = [];
17530
+ const seen = new Set();
17531
+ const push = (item) => {
17532
+ if (seen.has(item.target)) return;
17533
+ seen.add(item.target);
17534
+ items.push(item);
17535
+ };
17536
+ if (list && (list.global.length || own?.session.length)) {
17537
+ const values = addonValues(sid);
17538
+ const entries = [...list.global.map((a) => [a, true]), ...(own ? own.session.map((a) => [a, false]) : [])];
17539
+ for (const [a, global] of entries) {
17540
+ const target = fillAddonUrl(a.url, { ...a.params, ...own?.params[a.id], ...values });
17541
+ push({ id: a.id, kind: 'url', title: a.title, icon: a.icon, target, addon: true, global, run: !!a.run });
17542
+ }
17543
+ }
17544
+ for (const p of dockPanes(sid)) push(p);
17545
+ return items;
17546
+ }
17547
+
17548
+ async function fetchDockAddons(sid) {
17549
+ const list = await getJson(`${apiPath`/api/addons`}?session=${encodeURIComponent(sid)}`);
17550
+ if (!list || sid !== dockSid) return;
17551
+ dockAddonList = { sid, ...list };
17552
+ renderDockRail();
17553
+ }
17554
+
17555
+ function dockActivePane(items = dockItems()) {
17556
+ const id = showState.terminalId && dockAddons[showState.terminalId];
17557
+ return (id && items.find((p) => p.id === id)) || null;
17558
+ }
17559
+
17560
+ function dropDockView(id) {
17561
+ dockFrames.get(id)?.remove();
17562
+ dockFrames.delete(id);
17563
+ clearTimeout(dockHideTimers.get(id));
17564
+ dockHideTimers.delete(id);
17565
+ }
17566
+
17567
+ function dockShowOn(active) {
17568
+ return !active && showState.posts.length > 0 && !showState.closed;
17569
+ }
17570
+
17571
+ function dockOnScreen(active = dockActivePane()) {
17572
+ return active ? active.id : dockShowOn(active) ? 'show' : null;
17573
+ }
17574
+
17575
+ function setDockAddon(id) {
17576
+ const tid = showState.terminalId;
17577
+ if (!tid || (dockAddons[tid] ?? null) === id) return;
17578
+ if (id) dockAddons[tid] = id;
17579
+ else delete dockAddons[tid];
17580
+ store.writeJson(DOCK_ADDON_KEY, dockAddons);
17581
+ }
17582
+
17583
+ function dockRailButton(id, label, title, on, dot) {
17584
+ 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>`;
17585
+ }
17586
+
17587
+ // Runs from every renderShow, so it does no work past a class toggle until a session has add-ons.
17588
+ function renderDockRail() {
17589
+ const sid = dockSessionId();
17590
+ if (sid !== dockSid) {
17591
+ dockSid = sid;
17592
+ if (sid && !paneLayouts.has(sid)) fetchPaneLayout(sid);
17593
+ if (sid) fetchDockAddons(sid);
17594
+ }
17595
+ const addons = dockItems(sid);
17596
+ for (const [id, view] of dockFrames) {
17597
+ if (!addons.some((p) => p.id === id && p.target === view.dataset.target)) dropDockView(id);
17598
+ }
17599
+ const on = addons.length > 0;
17600
+ const termPane = document.getElementById('terminal-pane');
17601
+ const rail = document.getElementById('dock-rail');
17602
+ const card = showCardEl();
17603
+ const active = dockActivePane(addons);
17604
+ const onScreen = dockOnScreen(active);
17605
+ const tid = showState.terminalId;
17606
+ if (tid && onScreen && dockLast[tid] !== onScreen) {
17607
+ dockLast[tid] = onScreen;
17608
+ store.writeJson(DOCK_LAST_KEY, dockLast);
17609
+ }
17610
+ termPane.classList.toggle('dock-rail-on', on);
17611
+ rail.hidden = !on;
17612
+ card.classList.toggle('under-addon', !!active);
17613
+ const docked = !!active || card.classList.contains('docked');
17614
+ termPane.classList.toggle('show-docked', docked);
17615
+ if (on) document.getElementById('show-reopen').hidden = true;
17616
+ if (!active) dockShowDot = false;
17617
+ let html = '';
17618
+ if (on) {
17619
+ const n = showState.posts.length;
17620
+ if (n) html += dockRailButton('show', showIcon('card'), `Show · ${n}`, onScreen === 'show', dockShowDot);
17621
+ for (const p of addons) {
17622
+ html += dockRailButton(
17623
+ p.id,
17624
+ escapeHtml(p.icon || [...p.title][0]?.toUpperCase() || '?'),
17625
+ p.title,
17626
+ p === active,
17627
+ false,
17628
+ );
17629
+ }
17630
+ }
17631
+ if (html !== dockRailHtml) {
17632
+ dockRailHtml = html;
17633
+ rail.innerHTML = html;
17634
+ }
17635
+ const claimed = dockRailItems(addons).length > 1;
17636
+ if (claimed !== dockRailClaimed) {
17637
+ dockRailClaimed = claimed;
17638
+ pushTerminalClaims();
17639
+ }
17640
+ renderDockAddon(active);
17641
+ if (docked && !dockObserver) {
17642
+ dockObserver = new ResizeObserver(fitDockWidth);
17643
+ dockObserver.observe(document.getElementById('terminal-host'));
17644
+ } else if (!docked) {
17645
+ dockObserver?.disconnect();
17646
+ dockObserver = null;
17647
+ }
17648
+ fitDockWidth();
17649
+ }
17650
+
17651
+ function renderDockAddon(pane) {
17652
+ const panel = document.getElementById('dock-addon');
17653
+ panel.hidden = !pane;
17654
+ if (pane) {
17655
+ if (!panel.firstChild) {
17656
+ panel.innerHTML = `<header class="show-head dock-addon-head" tabindex="-1"><span class="show-nav"><span class="show-title"></span></span>
17657
+ <span class="show-actions">${showBtnHtml('dock', 'open', 'external', 'Open in a new tab')}
17658
+ ${showBtnHtml('dock', 'expand', 'expand', 'Fill the terminal · Alt+Enter')}
17659
+ ${showBtnHtml('dock', 'reload', 'reload', 'Reload')}
17660
+ ${showBtnHtml('dock', 'remove', 'clear', 'Remove from the rail')}
17661
+ ${showBtnHtml('dock', 'close', 'close', 'Hide the dock · Alt+W')}</span></header>
17662
+ <div class="dock-addon-body"></div><div class="show-dock-edge" title="Resize · double-click to reset"></div>`;
17663
+ }
17664
+ const title = panel.querySelector('.show-title');
17665
+ title.textContent = pane.title;
17666
+ title.title = pane.global ? `${pane.title} · every session` : pane.title;
17667
+ const open = panel.querySelector('[data-dock="open"]');
17668
+ open.title = `Open ${pane.target} in a new tab`;
17669
+ open.setAttribute('aria-label', open.title);
17670
+ panel.classList.toggle('expanded', dockExpanded);
17671
+ panel.style.setProperty('--show-term-font', `${currentTerminalFontSize()}px`);
17672
+ const expand = panel.querySelector('[data-dock="expand"]');
17673
+ const expandTitle = dockExpanded ? 'Back to the dock · Alt+Enter or Esc' : 'Fill the terminal · Alt+Enter';
17674
+ if (expand.title !== expandTitle) {
17675
+ expand.innerHTML = showIcon(dockExpanded ? 'restore' : 'expand');
17676
+ expand.title = expandTitle;
17677
+ expand.setAttribute('aria-label', expandTitle);
17678
+ }
17679
+ if (!dockFrames.has(pane.id)) {
17680
+ const view = createDockView(pane);
17681
+ panel.querySelector('.dock-addon-body').appendChild(view);
17682
+ dockFrames.set(pane.id, view);
17683
+ }
17684
+ }
17685
+ for (const [id, view] of dockFrames) {
17686
+ view.hidden = id !== pane?.id;
17687
+ if (!view.hidden) {
17688
+ clearTimeout(dockHideTimers.get(id));
17689
+ dockHideTimers.delete(id);
17690
+ } else if (!dockHideTimers.has(id))
17691
+ dockHideTimers.set(
17692
+ id,
17693
+ setTimeout(() => dropDockView(id), DOCK_UNLOAD_MS),
17694
+ );
17695
+ }
17696
+ syncDockFocus();
17697
+ }
17698
+
17699
+ function dockCardView(pane, html) {
17700
+ const view = document.createElement('div');
17701
+ view.className = 'dock-addon-view';
17702
+ view.dataset.target = pane.target;
17703
+ view.innerHTML = html;
17704
+ return view;
17705
+ }
17706
+
17707
+ function createDockView(pane) {
17708
+ const blocked = paneBlockedHtml(pane);
17709
+ if (blocked) return dockCardView(pane, blocked);
17710
+ if (pane.run && !dockAddonUp.has(pane.id)) return startDockAddon(pane);
17711
+ const frame = paneUrlFrame(pane, 'dock-addon-frame');
17712
+ frame.dataset.target = pane.target;
17713
+ frame.addEventListener('load', () => {
17714
+ postToShowFrames(() => ({ type: `${SHOW_MSG}tokens`, tokens: showTokens() }), [frame]);
17715
+ frame.hostKeysSig = null;
17716
+ pushTerminalClaims();
17717
+ });
17718
+ if (pane.addon || paneFraming.get(pane.target)) return frame;
17719
+ const sid = dockSid;
17720
+ probePaneFraming(sid, pane).then((frameable) => {
17721
+ if (frameable !== false || dockFrames.get(pane.id) !== frame) return;
17722
+ dropDockView(pane.id);
17723
+ if (sid === dockSid) renderDockRail();
17724
+ });
17725
+ return frame;
17726
+ }
17727
+
17728
+ // The board starts the page's server when its port does not answer (lib/addon-runner.js);
17729
+ // the frame comes once it does. Reload asks again.
17730
+ function startDockAddon(pane) {
17731
+ const view = dockCardView(
17732
+ pane,
17733
+ `<div class="pane-card"><div class="pane-card-box"><p>Starting ${escapeHtml(pane.title)}…</p></div></div>`,
17734
+ );
17735
+ paneRequest('POST', apiPath`/api/addons/${pane.id}/start`).then((r) => {
17736
+ if (dockFrames.get(pane.id) !== view) return;
17737
+ if (r.state === 'up' || r.state === 'none') {
17738
+ dockAddonUp.add(pane.id);
17739
+ dropDockView(pane.id);
17740
+ renderDockRail();
17741
+ return;
17742
+ }
17743
+ const log = r.log ? `<p>Log: <code>${escapeHtml(r.log)}</code></p>` : '';
17744
+ view.innerHTML = paneCardHtml(`${pane.title} did not start`, r.error || 'No answer', 'Retry', {
17745
+ attr: 'data-dock="reload"',
17746
+ extra: log,
17747
+ });
17748
+ });
17749
+ return view;
17750
+ }
17751
+
17752
+ async function removeDockItem(item) {
17753
+ if (!item.addon) return setPaneDock(dockSid, item.id, false);
17754
+ if (item.global) {
17755
+ const ok = await confirmModal({
17756
+ title: 'Remove this add-on from every session?',
17757
+ message: 'It was added with --global, so it is on the rail of each session.',
17758
+ chip: item.title,
17759
+ okLabel: 'Remove',
17760
+ });
17761
+ if (!ok) return;
17762
+ }
17763
+ const r = await paneRequest('DELETE', `${apiPath`/api/addons/${item.id}`}?session=${encodeURIComponent(dockSid)}`);
17764
+ if (r.error) showToast(r.error, 'error');
17765
+ }
17766
+
17767
+ // Page-wide, like the session log's full width. The panel covers the terminal above its prompt
17768
+ // rows, so the PTY keeps its size.
17769
+ function setDockExpanded(on) {
17770
+ dockExpanded = on;
17771
+ store.setItem(DOCK_EXPANDED_KEY, on ? '1' : '');
17772
+ renderDockAddon(dockActivePane());
17773
+ }
17774
+
17775
+ // Alt+Enter toggles the full width and Esc goes back; Esc in the dock focuses the terminal.
17776
+ function dockHeadKey(e) {
17777
+ const combo = ClaudeHub.comboOf(e);
17778
+ if (combo !== 'alt+w' && combo !== 'alt+Enter' && combo !== 'Escape') return false;
17779
+ const pane = dockActivePane();
17780
+ if (!pane) return false;
17781
+ if (combo === 'alt+w') {
17782
+ dockRailCommand(pane.id);
17783
+ focusTerminalPane();
17784
+ } else if (combo === 'alt+Enter') setDockExpanded(!dockExpanded);
17785
+ else if (combo === 'Escape' && dockExpanded) setDockExpanded(false);
17786
+ else if (combo === 'Escape') focusTerminalPane();
17787
+ else return false;
17788
+ return true;
17789
+ }
17790
+
17791
+ // Focus moving from the terminal's frame into a dock page leaves this document's activeElement on the
17792
+ // terminal frame and fires no event here, so the page posts its own focus (skills/addon/references/build.md).
17793
+ let dockFocusFrame = null;
17794
+
17795
+ const dockFrameFocused = () => !!dockFocusFrame?.isConnected && !dockFocusFrame.hidden;
17796
+
17797
+ function isDockFocused() {
17798
+ return dockFrameFocused() || !!document.activeElement?.closest('#dock-addon');
17799
+ }
17800
+
17801
+ function setDockFrameFocused(frame, on) {
17802
+ if (on) dockFocusFrame = frame;
17803
+ else if (dockFocusFrame === frame) dockFocusFrame = null;
17804
+ syncDockFocus();
17805
+ }
17806
+
17807
+ function syncDockFocus() {
17808
+ document.getElementById('dock-addon').classList.toggle('focused', dockFrameFocused());
17809
+ }
17810
+
17811
+ const dockFrameEls = () => [...dockFrames.values()].filter((v) => v.tagName === 'IFRAME');
17812
+
17813
+ window.addEventListener('message', (e) => {
17814
+ if (e.data?.type !== `${SHOW_MSG}focus`) return;
17815
+ const frame = dockFrameEls().find((f) => f.contentWindow === e.source);
17816
+ if (frame) setDockFrameFocused(frame, e.data.on === true);
17817
+ });
17818
+
17819
+ // A dock page gets the pane keys plus these (hub protocol §5.2). A page with no hub SDK cannot
17820
+ // forward the hub's own combos, so they go as claims too.
17821
+ const DOCK_CLAIMS = [{ altKey: true, key: 'Enter', code: '' }];
17822
+
17823
+ function comboClaims(combo) {
17824
+ const parts = combo.split('+');
17825
+ const key = parts.pop() || '+';
17826
+ const mods = Object.fromEntries(parts.filter(Boolean).map((m) => [`${m}Key`, true]));
17827
+ const code = /^[a-z]$/.test(key) ? `Key${key.toUpperCase()}` : /^[1-9]$/.test(key) ? `Digit${key}` : '';
17828
+ return [{ ...mods, key, code: '' }, ...(code ? [{ ...mods, key: 'Unidentified', code }] : [])];
17829
+ }
17830
+
17831
+ // Focus goes to the header, not the page: a frame on another origin would keep the keys.
17832
+ function focusDockHead() {
17833
+ document.querySelector('#dock-addon .dock-addon-head')?.focus();
17834
+ }
17835
+
17836
+ // Show and each add-on take the dock in turn; the one on screen hides it.
17837
+ function dockRailCommand(id) {
17838
+ const active = dockActivePane();
17839
+ if (id === 'show') {
17840
+ const showOn = dockShowOn(active);
17841
+ setDockAddon(null);
17842
+ if (showOn) showState.closed = true;
17843
+ else Object.assign(showState, { closed: false, collapsed: false });
17844
+ } else if (active?.id === id) {
17845
+ setDockAddon(null);
17846
+ if (showState.docked) showState.closed = true;
17847
+ } else setDockAddon(id);
17848
+ renderShow();
17849
+ }
17850
+
17851
+ function dockRailItems(items = dockItems()) {
17852
+ const ids = items.map((p) => p.id);
17853
+ return ids.length && showState.posts.length ? ['show', ...ids] : ids;
17854
+ }
17855
+
17856
+ // With nothing in the dock, Down starts at the first item and Up at the last.
17857
+ function stepDockRail(dir) {
17858
+ const items = dockRailItems();
17859
+ const cur = dockOnScreen();
17860
+ const i = items.indexOf(cur);
17861
+ const next = items[i < 0 ? (dir > 0 ? 0 : items.length - 1) : (i + dir + items.length) % items.length];
17862
+ if (!next || next === cur) return;
17863
+ dockRailCommand(next);
17864
+ if (next === 'show') focusShowBody();
17865
+ else focusDockHead();
17866
+ }
17867
+
17868
+ // The panel follows the pointer over the terminal; the terminal takes the new width on release.
17869
+ function startDockDrag(e, panel) {
17870
+ const edge = e.target.closest('.show-dock-edge');
17871
+ if (!edge || e.button !== 0) return;
17872
+ e.preventDefault();
17873
+ edge.setPointerCapture(e.pointerId);
17874
+ const hostW = dockHostWidth();
17875
+ const start = { x: e.clientX, w: panel.offsetWidth };
17876
+ let w = start.w;
17877
+ let raf = 0;
17878
+ document.body.classList.add('show-resizing');
17879
+ const move = (ev) => {
17880
+ w = showDockWidth(Math.round(start.w + start.x - ev.clientX), hostW);
17881
+ raf ||= requestAnimationFrame(() => {
17882
+ raf = 0;
17883
+ panel.style.setProperty('--show-dock-drag', `${w}px`);
17884
+ });
17885
+ };
17886
+ const up = () => {
17887
+ cancelAnimationFrame(raf);
17888
+ edge.removeEventListener('pointermove', move);
17889
+ edge.removeEventListener('pointerup', up);
17890
+ edge.removeEventListener('pointercancel', up);
17891
+ document.body.classList.remove('show-resizing');
17892
+ panel.style.removeProperty('--show-dock-drag');
17893
+ showDockW = w;
17894
+ store.writeJson(SHOW_DOCK_KEY, showDockW);
17895
+ fitDockWidth();
17896
+ };
17897
+ edge.addEventListener('pointermove', move);
17898
+ edge.addEventListener('pointerup', up);
17899
+ edge.addEventListener('pointercancel', up);
17900
+ }
17901
+
17902
+ function resetDockWidth() {
17903
+ showDockW = null;
17904
+ store.removeItem(SHOW_DOCK_KEY);
17905
+ fitDockWidth();
17906
+ }
17907
+
17908
+ function initDockRail() {
17909
+ document.getElementById('dock-rail').addEventListener('click', (e) => {
17910
+ const b = e.target.closest('[data-rail]');
17911
+ if (b) dockRailCommand(b.dataset.rail);
17912
+ });
17913
+ const panel = document.getElementById('dock-addon');
17914
+ panel.addEventListener('click', (e) => {
17915
+ const pane = dockActivePane();
17916
+ if (!pane) return;
17917
+ if (e.target.closest('[data-dock="close"]')) dockRailCommand(pane.id);
17918
+ else if (e.target.closest('[data-dock="expand"]')) setDockExpanded(!dockExpanded);
17919
+ else if (e.target.closest('[data-dock="remove"]')) removeDockItem(pane);
17920
+ else if (e.target.closest('[data-dock="reload"]')) {
17921
+ dockAddonUp.delete(pane.id);
17922
+ dropDockView(pane.id);
17923
+ renderDockRail();
17924
+ } else if (e.target.closest('[data-dock="open"], [data-pane-open]')) hub.openExternal(pane.target);
17925
+ });
17926
+ panel.addEventListener('pointerdown', (e) => startDockDrag(e, panel));
17927
+ panel.addEventListener('dblclick', (e) => {
17928
+ if (e.target.closest('.show-dock-edge')) resetDockWidth();
17929
+ });
17930
+ }
17931
+
17216
17932
  async function openShowInPane() {
17217
17933
  const sid = showState.sessionId;
17218
17934
  const target = showState.path;
@@ -17246,7 +17962,10 @@ function showCommand(cmd) {
17246
17962
  showState.idx = i;
17247
17963
  showState.collapsed = false;
17248
17964
  } else if (cmd === 'collapse') showState.collapsed = !showState.collapsed;
17249
- else if (cmd === 'expand') {
17965
+ else if (cmd === 'dock') {
17966
+ showState.docked = !showState.docked;
17967
+ showState.collapsed = false;
17968
+ } else if (cmd === 'expand') {
17250
17969
  showState.expanded = !showState.expanded;
17251
17970
  showState.collapsed = false;
17252
17971
  } else if (cmd === 'close') showState.closed = true;
@@ -17332,7 +18051,12 @@ function initShowCard() {
17332
18051
  grip.addEventListener('pointerup', up);
17333
18052
  grip.addEventListener('pointercancel', up);
17334
18053
  });
18054
+ card.addEventListener('pointerdown', (e) => startDockDrag(e, card));
17335
18055
  card.addEventListener('dblclick', (e) => {
18056
+ if (e.target.closest('.show-dock-edge')) {
18057
+ resetDockWidth();
18058
+ return;
18059
+ }
17336
18060
  if (!e.target.closest('.show-grip')) return;
17337
18061
  showSize = null;
17338
18062
  store.removeItem(SHOW_SIZE_KEY);
@@ -17371,6 +18095,7 @@ const PLACEHOLDER_HINTS = {
17371
18095
  pick: 'pick a session to resume',
17372
18096
  dispatch: 'starting',
17373
18097
  new: 'waiting for your first message',
18098
+ prompted: 'starting',
17374
18099
  edit: 'press Enter in the terminal to start',
17375
18100
  };
17376
18101
 
@@ -17385,8 +18110,9 @@ function placeholderSession(id, spec) {
17385
18110
  placeholder: true,
17386
18111
  mode: spec.mode,
17387
18112
  edit: !!spec.edit,
18113
+ prompted: !!(spec.prompted || spec.prompt),
17388
18114
  name: spec.name || (spec.forkOf && forkLabel(spec.forkOf)) || PLACEHOLDER_NAMES[spec.mode] || PLACEHOLDER_NAMES.new,
17389
- dispatched: spec.mode === 'dispatch' ? { parent: spec.parent || null } : undefined,
18115
+ dispatched: spec.mode === 'dispatch' ? { parent: spec.parent || null, child: !!spec.child } : undefined,
17390
18116
  project: spec.cwd,
17391
18117
  worktree: spec.worktree ? { repo: spec.cwd, name: spec.worktree === true ? 'new' : spec.worktree } : null,
17392
18118
  modifiedAt: new Date(spec.startedAt).toISOString(),
@@ -17435,6 +18161,7 @@ async function loadDispatches() {
17435
18161
  worktree: r.worktree,
17436
18162
  group: r.group,
17437
18163
  parent: r.parent,
18164
+ child: r.child,
17438
18165
  startedAt: r.startedAt,
17439
18166
  });
17440
18167
  }
@@ -17468,23 +18195,36 @@ function forgetPlaceholder(id) {
17468
18195
  return true;
17469
18196
  }
17470
18197
 
17471
- function renderPlaceholderCard(session, depth) {
18198
+ function renderPlaceholderCard(session, depth, hostProject) {
17472
18199
  const isActive = session.id === currentSessionId && viewMode === 'session';
17473
- const projectHtml = renderProjectIdentity(session);
18200
+ const projectHtml = renderProjectIdentity(session, hostProject);
17474
18201
  return `
17475
18202
  <button onclick="openSession('${escAttrJs(session.id)}')" data-session-id="${escapeHtml(session.id)}" class="session-item session-placeholder ${isActive ? 'active' : ''}${childDepthClass(depth)}" title="${escapeHtml(`${session.id} | ${session.project}`)}">
17476
18203
  <div class="session-name">${escapeHtml(session.name)}</div>
17477
- ${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
18204
+ ${renderSessionIdentity(projectHtml, null)}
17478
18205
  ${
17479
18206
  trustWaiting.has(session.id)
17480
18207
  ? `<div class="session-waiting"><span class="agent-badge agent-badge-waiting" title="Waiting for user">${ICON_AGENT_WAITING}</span>trust this folder?</div>`
17481
- : `<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>`
17482
18209
  }
17483
18210
  </button>
17484
18211
  `;
17485
18212
  }
17486
18213
 
17487
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
+
17488
18228
  async function restorePendingSessions() {
17489
18229
  document.getElementById('new-session-btn').hidden = !terminalAvailable();
17490
18230
  const terminals = terminalAvailable() ? loadTerminals() : null;
@@ -17492,15 +18232,7 @@ async function restorePendingSessions() {
17492
18232
  try {
17493
18233
  for (const t of terminals ? await terminals : []) {
17494
18234
  if ((t.mode !== 'new' && t.mode !== 'pick') || newSpecs.has(t.id) || dispatchSpecs.has(t.id)) continue;
17495
- newSpecs.set(t.id, {
17496
- cwd: t.cwd,
17497
- name: t.name,
17498
- worktree: t.worktree,
17499
- mode: t.mode,
17500
- edit: t.edit,
17501
- forkOf: t.forkOf,
17502
- startedAt: t.startedAt,
17503
- });
18235
+ newSpecs.set(t.id, specFromTerminal(t));
17504
18236
  }
17505
18237
  } catch (_) {}
17506
18238
  // Sessions that were already running keep the collapse state saved before the reload.
@@ -18105,10 +18837,12 @@ initSessionGroupsDnd();
18105
18837
  initSessionPicker();
18106
18838
  initProjectPicker();
18107
18839
  initShowCard();
18840
+ initDockRail();
18108
18841
  initAsk();
18109
18842
  for (const k of readStoredList(ACTIVITY_FILTER_KEY)) activityFilter.add(k);
18110
18843
  initSidebarResize();
18111
18844
  applyModalZoom();
18845
+ applyShowZoom();
18112
18846
  loadModalFullscreen();
18113
18847
  initModalResize();
18114
18848
  loadPanelWidths();
@@ -18187,7 +18921,13 @@ function makeLimitCell(label, bucket) {
18187
18921
  function makeLimitSpan(rl) {
18188
18922
  const span = document.createElement('span');
18189
18923
  span.className = 'footer-limits';
18190
- span.append(makeLimitCell('5h', rl?.five_hour), document.createTextNode(' · '), makeLimitCell('7d', rl?.seven_day));
18924
+ span.title = 'Plan usage: 5-hour and 7-day limits, used % (time until reset)';
18925
+ span.append(
18926
+ 'Usage ',
18927
+ makeLimitCell('5h', rl?.five_hour),
18928
+ document.createTextNode(' · '),
18929
+ makeLimitCell('7d', rl?.seven_day),
18930
+ );
18191
18931
  return span;
18192
18932
  }
18193
18933
  function renderSidebarFooter(rateLimits) {
@@ -18394,6 +19134,7 @@ hub.onActive((active) => {
18394
19134
  });
18395
19135
  hub.onStatus(pushTerminalClaims);
18396
19136
  hub.onSettings(applyHubSettings);
19137
+ hub.handle('session.open', (p) => p.session && openSessionFromOutside(p.session));
18397
19138
 
18398
19139
  // session.changed goes out once the switch settles, so arrowing through the list sends one event.
18399
19140
  let publishTimer = 0;