claude-code-kanban 4.17.0 → 4.19.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
@@ -332,7 +332,7 @@ function renderActivityChip() {
332
332
  return `
333
333
  <button type="button"
334
334
  class="${classes}"
335
- onclick="setActivityFilter('${c.kind}')"
335
+ onclick="setActivityFilter('${escAttrJs(c.kind)}')"
336
336
  aria-pressed="${isOn ? 'true' : 'false'}"
337
337
  title="${escapeHtml(c.title + hint)}">
338
338
  <span class="activity-dot"></span>
@@ -991,7 +991,7 @@ function renderToolItem(m, i, compact) {
991
991
  // click all light up DURING the run, identical to post-completion.
992
992
  const agentLink =
993
993
  m.tool === 'Agent' && m.agentId
994
- ? ` <span class="msg-agent-link" title="View agent" onclick="event.stopPropagation();showAgentModal('${escapeHtml(m.agentId)}')">⇗</span>`
994
+ ? ` <span class="msg-agent-link" title="View agent" onclick="event.stopPropagation();showAgentModal('${escAttrJs(m.agentId)}')">⇗</span>`
995
995
  : '';
996
996
  // Usage chip (tokens · tools · duration) on completed Agent rows — same stats a
997
997
  // background agent shows, sourced here from the foreground toolUseResult.
@@ -1376,7 +1376,7 @@ function getToolIcon(toolName) {
1376
1376
  const AGENT_LOG_ICON =
1377
1377
  '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>';
1378
1378
  function agentLogButton(agentId) {
1379
- return `<button class="msg-agent-log-btn" onclick="event.stopPropagation();viewAgentLog('${escapeHtml(agentId)}')" title="View agent log">${AGENT_LOG_ICON}</button>`;
1379
+ return `<button class="msg-agent-log-btn" onclick="event.stopPropagation();viewAgentLog('${escAttrJs(agentId)}')" title="View agent log">${AGENT_LOG_ICON}</button>`;
1380
1380
  }
1381
1381
 
1382
1382
  function getPinId(m) {
@@ -1698,7 +1698,7 @@ const linkSvg = (size) =>
1698
1698
  // Shared markup for an attachment image and the labeled section that holds a grid
1699
1699
  // of them — used by both user-pasted images and agent tool-result images.
1700
1700
  function attachImageTag(url, alt) {
1701
- return `<img src="${url}" loading="lazy" alt="${alt}" class="user-attach-image" />`;
1701
+ return `<img src="${escapeHtml(url)}" loading="lazy" alt="${escapeHtml(alt)}" class="user-attach-image" />`;
1702
1702
  }
1703
1703
  function attachImageSection(label, imgsHtml) {
1704
1704
  return imgsHtml
@@ -1736,7 +1736,7 @@ function renderUserAttachments(m) {
1736
1736
  return `<details class="user-attach-toolresult">
1737
1737
  <summary>Tool result <code>${escapeHtml(shortId)}</code></summary>
1738
1738
  <pre class="${TINTED_PRE_CLASS}" id="${expandId}">${preview}</pre>
1739
- <button type="button" class="tool-result-expand-btn" data-expand-id="${expandId}" data-tool-use-id="${safeId}" onclick="_toggleToolResultExpand(this)">Show full</button>
1739
+ <button type="button" class="tool-result-expand-btn" data-expand-id="${expandId}" data-tool-use-id="${escapeHtml(safeId)}" onclick="_toggleToolResultExpand(this)">Show full</button>
1740
1740
  </details>`;
1741
1741
  })
1742
1742
  .join('');
@@ -1876,7 +1876,7 @@ function showMsgDetail(idx) {
1876
1876
  }
1877
1877
 
1878
1878
  function closeMsgDetailModal() {
1879
- resetModalFullscreen('msg-detail-modal');
1879
+ hideModalOverlay('msg-detail-modal');
1880
1880
  msgDetailFollowLatest = false;
1881
1881
  // Drop the message highlight on close, mirroring task-card behavior.
1882
1882
  msgHighlightDimmed = true;
@@ -1900,19 +1900,32 @@ function _setModalWidth(modal, slot, on, maxWidth, width) {
1900
1900
  }
1901
1901
  }
1902
1902
 
1903
+ function _applyModalFullscreen(modalId, on) {
1904
+ const modal = document.querySelector(`#${modalId} .modal`);
1905
+ modal.classList.toggle('fullscreen', on);
1906
+ _setModalWidth(modal, 'Fs', on, '', '');
1907
+ updateFullscreenBtnIcon(`${modalId}-fullscreen-btn`, on);
1908
+ }
1909
+
1903
1910
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
1904
1911
  function toggleModalFullscreen(modalId) {
1905
- const modal = document.querySelector(`#${modalId} .modal`);
1906
- const isFs = modal.classList.toggle('fullscreen');
1907
- _setModalWidth(modal, 'Fs', isFs, '', '');
1908
- updateFullscreenBtnIcon(`${modalId}-fullscreen-btn`, isFs);
1912
+ const on = !document.querySelector(`#${modalId} .modal`).classList.contains('fullscreen');
1913
+ _applyModalFullscreen(modalId, on);
1914
+ localStorage.setItem(`modal-fullscreen-${modalId}`, String(on));
1909
1915
  }
1910
1916
 
1911
- function resetModalFullscreen(modalId) {
1917
+ function loadModalFullscreen() {
1918
+ for (const btn of document.querySelectorAll('[id$="-fullscreen-btn"]')) {
1919
+ const modalId = btn.id.replace(/-fullscreen-btn$/, '');
1920
+ if (localStorage.getItem(`modal-fullscreen-${modalId}`) === 'true') _applyModalFullscreen(modalId, true);
1921
+ }
1922
+ }
1923
+
1924
+ // Hides the overlay only — the fullscreen state stays on the dialog, it is a
1925
+ // remembered preference so the next open comes back the way it was left.
1926
+ function hideModalOverlay(modalId) {
1912
1927
  const modal = document.getElementById(modalId);
1913
1928
  modal.classList.remove('visible');
1914
- modal.querySelector('.modal').classList.remove('fullscreen');
1915
- updateFullscreenBtnIcon(`${modalId}-fullscreen-btn`, false);
1916
1929
  return modal;
1917
1930
  }
1918
1931
 
@@ -1924,6 +1937,38 @@ function updateFullscreenBtnIcon(btnId, isFullscreen) {
1924
1937
  : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>';
1925
1938
  }
1926
1939
 
1940
+ const MODAL_ZOOM_KEY = 'modal-zoom';
1941
+ const MODAL_ZOOM_MIN = 0.7;
1942
+ const MODAL_ZOOM_MAX = 2.0;
1943
+ let modalZoom = clampModalZoom(Number.parseFloat(localStorage.getItem(MODAL_ZOOM_KEY)) || 1);
1944
+
1945
+ function clampModalZoom(v) {
1946
+ return Math.min(MODAL_ZOOM_MAX, Math.max(MODAL_ZOOM_MIN, v));
1947
+ }
1948
+
1949
+ function applyModalZoom() {
1950
+ document.documentElement.style.setProperty('--modal-zoom', String(modalZoom));
1951
+ }
1952
+
1953
+ // delta 0 resets to 100%
1954
+ function adjustModalZoom(delta) {
1955
+ const next = delta === 0 ? 1 : clampModalZoom(Math.round((modalZoom + delta) * 10) / 10);
1956
+ if (next === modalZoom && delta !== 0) return;
1957
+ modalZoom = next;
1958
+ localStorage.setItem(MODAL_ZOOM_KEY, String(modalZoom));
1959
+ applyModalZoom();
1960
+ showToast(`Text ${Math.round(modalZoom * 100)}%`);
1961
+ }
1962
+
1963
+ // The markup decides what scales: `.modal-zoomable` marks a modal's reading
1964
+ // surface, and style.css hangs the zoom on that same class. Confirm dialogs
1965
+ // carry no such body — nothing there is worth enlarging.
1966
+ function isZoomableModalOpen() {
1967
+ return document.querySelector('.modal-overlay.visible .modal-zoomable') !== null;
1968
+ }
1969
+
1970
+ const ZOOM_KEYS = { '+': 0.1, '=': 0.1, NumpadAdd: 0.1, '-': -0.1, _: -0.1, NumpadSubtract: -0.1, 0: 0, Numpad0: 0 };
1971
+
1927
1972
  let _toastTimer = null;
1928
1973
  let _manualRefreshing = false;
1929
1974
  //#endregion
@@ -2538,7 +2583,7 @@ function renderAgentFooter() {
2538
2583
  const agentColor = resolveNamedColor(a.color);
2539
2584
  const colorStyle = agentColor ? ` style="border-left:3px solid ${agentColor.color}"` : '';
2540
2585
  const selectedClass = a.agentId === currentAgentModalId ? ' selected' : '';
2541
- return `<div class="agent-card${selectedClass}" data-agent-id="${escapeHtml(a.agentId)}"${colorStyle} onclick="showAgentModal('${a.agentId}')">
2586
+ return `<div class="agent-card${selectedClass}" data-agent-id="${escapeHtml(a.agentId)}"${colorStyle} onclick="showAgentModal('${escAttrJs(a.agentId)}')">
2542
2587
  <div class="agent-type-row">${typeNs ? `<span class="agent-type-ns">${escapeHtml(typeNs)}</span>` : ''}<span class="agent-type-name">${escapeHtml(typeName)}</span>${nameBadgeHtml}</div>
2543
2588
  <div class="agent-status-row"><span class="agent-dot ${a.status}"></span><span class="agent-status">${statusText}</span></div>
2544
2589
  ${msgHtml}
@@ -2704,7 +2749,7 @@ function showAgentModal(agentId) {
2704
2749
  }
2705
2750
 
2706
2751
  function closeAgentModal() {
2707
- resetModalFullscreen('agent-modal');
2752
+ hideModalOverlay('agent-modal');
2708
2753
  currentAgentModalId = null;
2709
2754
  highlightSelectedAgent();
2710
2755
  }
@@ -2714,7 +2759,7 @@ function highlightSelectedAgent() {
2714
2759
  if (!content) return;
2715
2760
  for (const el of content.querySelectorAll('.agent-card.selected')) el.classList.remove('selected');
2716
2761
  if (currentAgentModalId == null) return;
2717
- const el = content.querySelector(`.agent-card[data-agent-id="${currentAgentModalId}"]`);
2762
+ const el = content.querySelector(`.agent-card[data-agent-id="${escSel(currentAgentModalId)}"]`);
2718
2763
  if (el) el.classList.add('selected');
2719
2764
  }
2720
2765
 
@@ -2938,9 +2983,10 @@ function renderSessions() {
2938
2983
  const bookmarksCount = loadPins(session.id).length;
2939
2984
  const hasScratchpad = !!(localStorage.getItem(_sessionScratchpadKey(session.id)) || '').trim();
2940
2985
  const tempClass = session.hasRecentLog || session.inProgress || session.hasWaitingForUser ? 'warm' : 'stale';
2986
+ const sid = escAttrJs(session.id);
2941
2987
  return `
2942
- <button onclick="fetchTasks('${session.id}')" data-session-id="${session.id}" class="session-item ${isActive ? 'active' : ''} ${session.hasWaitingForUser ? 'permission-pending' : ''} ${tempClass} ${showCtx ? 'has-context' : ''}" title="${tooltip}">
2943
- <span class="session-pin-btn${pinClass}" onclick="event.stopPropagation();toggleSessionPin('${escapeHtml(session.id)}')" title="${pinTitle} session">${pinState === 'sticky' ? SESSION_STAR_SVG : SESSION_PIN_SVG}</span>
2988
+ <button onclick="fetchTasks('${sid}')" data-session-id="${escapeHtml(session.id)}" class="session-item ${isActive ? 'active' : ''} ${session.hasWaitingForUser ? 'permission-pending' : ''} ${tempClass} ${showCtx ? 'has-context' : ''}" title="${escapeHtml(tooltip)}">
2989
+ <span class="session-pin-btn${pinClass}" onclick="event.stopPropagation();toggleSessionPin('${sid}')" title="${pinTitle} session">${pinState === 'sticky' ? SESSION_STAR_SVG : SESSION_PIN_SVG}</span>
2944
2990
  <div class="session-name">${escapeHtml(primaryName)}</div>
2945
2991
  ${secondaryName ? `<div class="session-secondary">${escapeHtml(secondaryName)}</div>` : ''}
2946
2992
  ${gitBranch ? `<div class="session-branch">${gitBranch}</div>` : ''}
@@ -2949,14 +2995,14 @@ function renderSessions() {
2949
2995
  <div class="session-progress">
2950
2996
  <span class="session-indicators">
2951
2997
  ${isTeam ? `<span class="team-badge" title="${memberCount} team members"><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="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>${memberCount}</span>` : ''}
2952
- ${isTeam || session.project || showCtx ? `<span class="team-info-btn" onclick="event.stopPropagation(); showSessionInfoModal('${session.id}')" title="View session info"><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="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg></span>` : ''}
2998
+ ${isTeam || session.project || showCtx ? `<span class="team-info-btn" onclick="event.stopPropagation(); showSessionInfoModal('${sid}')" title="View session info"><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="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg></span>` : ''}
2953
2999
  ${renderWorkflowBadge(session)}
2954
3000
  ${renderLoopBadge(session)}
2955
- ${hasScratchpad ? `<span class="scratchpad-badge" onclick="event.stopPropagation(); openSessionScratchpad('${session.id}')" title="Open scratchpad"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></span>` : ''}
2956
- ${bookmarksCount > 0 ? `<span class="bookmarks-badge" onclick="event.stopPropagation(); openSessionWithBookmarks('${session.id}')" title="${bookmarksCount} bookmarked message${bookmarksCount > 1 ? 's' : ''}"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>${bookmarksCount}</span>` : ''}
2957
- ${linkedDocsCount > 0 ? `<span class="linked-docs-badge" onclick="event.stopPropagation(); showSessionInfoModal('${session.id}')" title="${linkedDocsCount} linked document${linkedDocsCount > 1 ? 's' : ''}">${linkSvg(10)}${linkedDocsCount}</span>` : ''}
2958
- ${session.hasPlan && !session.planSourceSessionId ? `<span class="plan-indicator" onclick="event.stopPropagation(); openPlanForSession('${session.id}')" title="View plan">${ICON_PLAN}</span>` : ''}
2959
- ${session.planSourceSessionId ? `<span class="plan-indicator" title="Implements plan — click to reveal plan session" onclick="event.stopPropagation(); revealPlanSession('${escapeHtml(session.planSourceSessionId)}')">${ICON_PLAN}</span>` : ''}
3001
+ ${hasScratchpad ? `<span class="scratchpad-badge" onclick="event.stopPropagation(); openSessionScratchpad('${sid}')" title="Open scratchpad"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></span>` : ''}
3002
+ ${bookmarksCount > 0 ? `<span class="bookmarks-badge" onclick="event.stopPropagation(); openSessionWithBookmarks('${sid}')" title="${bookmarksCount} bookmarked message${bookmarksCount > 1 ? 's' : ''}"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>${bookmarksCount}</span>` : ''}
3003
+ ${linkedDocsCount > 0 ? `<span class="linked-docs-badge" onclick="event.stopPropagation(); showSessionInfoModal('${sid}')" title="${linkedDocsCount} linked document${linkedDocsCount > 1 ? 's' : ''}">${linkSvg(10)}${linkedDocsCount}</span>` : ''}
3004
+ ${session.hasPlan && !session.planSourceSessionId ? `<span class="plan-indicator" onclick="event.stopPropagation(); openPlanForSession('${sid}')" title="View plan">${ICON_PLAN}</span>` : ''}
3005
+ ${session.planSourceSessionId ? `<span class="plan-indicator" title="Implements plan — click to reveal plan session" onclick="event.stopPropagation(); revealPlanSession('${escAttrJs(session.planSourceSessionId)}')">${ICON_PLAN}</span>` : ''}
2960
3006
  ${session.sharedTaskList ? `<span class="shared-tasklist-badge" title="Shared task list: ${escapeHtml(session.sharedTaskList)}">${linkSvg(12)}</span>` : ''}
2961
3007
  ${session.hasWaitingForUser ? `<span class="agent-badge agent-badge-waiting" title="Waiting for user">${ICON_AGENT_WAITING}</span>` : ''}
2962
3008
  ${session.hasRunningAgents && !session.hasWaitingForUser ? `<span class="agent-badge agent-badge-active" title="Agents running">${ICON_AGENT_ACTIVE}</span>` : ''}
@@ -3205,9 +3251,9 @@ function renderTaskCard(task) {
3205
3251
  <div
3206
3252
  role="listitem"
3207
3253
  tabindex="0"
3208
- data-task-id="${task.id}"
3209
- data-session-id="${actualSessionId}"
3210
- onclick="showTaskDetail('${task.id}', '${actualSessionId}')"
3254
+ data-task-id="${escapeHtml(task.id)}"
3255
+ data-session-id="${escapeHtml(actualSessionId)}"
3256
+ onclick="showTaskDetail('${escAttrJs(task.id)}', '${escAttrJs(actualSessionId)}')"
3211
3257
  draggable="true"
3212
3258
  ondragstart="onCardDragStart(event)"
3213
3259
  ondragend="onCardDragEnd(event)"
@@ -3271,8 +3317,9 @@ function renderKanban() {
3271
3317
 
3272
3318
  if (selectedTaskId) {
3273
3319
  const card =
3274
- document.querySelector(`.task-card[data-task-id="${selectedTaskId}"][data-session-id="${selectedSessionId}"]`) ||
3275
- document.querySelector(`.task-card[data-task-id="${selectedTaskId}"]`);
3320
+ document.querySelector(
3321
+ `.task-card[data-task-id="${escSel(selectedTaskId)}"][data-session-id="${escSel(selectedSessionId)}"]`,
3322
+ ) || document.querySelector(`.task-card[data-task-id="${escSel(selectedTaskId)}"]`);
3276
3323
  if (card) {
3277
3324
  if (focusZone === 'board' && !taskHighlightDimmed) card.classList.add('selected');
3278
3325
  } else {
@@ -3364,8 +3411,8 @@ function selectTask(taskId, sessionId) {
3364
3411
  taskHighlightDimmed = false;
3365
3412
  if (!taskId) return;
3366
3413
  const card =
3367
- document.querySelector(`.task-card[data-task-id="${taskId}"][data-session-id="${sessionId}"]`) ||
3368
- document.querySelector(`.task-card[data-task-id="${taskId}"]`);
3414
+ document.querySelector(`.task-card[data-task-id="${escSel(taskId)}"][data-session-id="${escSel(sessionId)}"]`) ||
3415
+ document.querySelector(`.task-card[data-task-id="${escSel(taskId)}"]`);
3369
3416
  if (card) {
3370
3417
  card.classList.add('selected');
3371
3418
  card.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
@@ -3641,7 +3688,7 @@ function setFocusZone(zone) {
3641
3688
  }
3642
3689
  if (selectedTaskId) {
3643
3690
  const card = document.querySelector(
3644
- `.task-card[data-task-id="${selectedTaskId}"][data-session-id="${selectedSessionId}"]`,
3691
+ `.task-card[data-task-id="${escSel(selectedTaskId)}"][data-session-id="${escSel(selectedSessionId)}"]`,
3645
3692
  );
3646
3693
  if (card) card.classList.add('selected');
3647
3694
  } else {
@@ -3665,7 +3712,7 @@ function getAvailableTasksOptions(currentTaskId = null) {
3665
3712
  if (pending.length > 0) {
3666
3713
  options += '<optgroup label="Pending">';
3667
3714
  pending.forEach((t, _idx) => {
3668
- options += `<option value="${t.id}">#${t.id} - ${escapeHtml(t.subject)}</option>`;
3715
+ options += `<option value="${escapeHtml(t.id)}">#${t.id} - ${escapeHtml(t.subject)}</option>`;
3669
3716
  });
3670
3717
  options += '</optgroup>';
3671
3718
  }
@@ -3673,7 +3720,7 @@ function getAvailableTasksOptions(currentTaskId = null) {
3673
3720
  if (inProgress.length > 0) {
3674
3721
  options += '<optgroup label="In Progress">';
3675
3722
  inProgress.forEach((t, _idx) => {
3676
- options += `<option value="${t.id}">#${t.id} - ${escapeHtml(t.subject)}</option>`;
3723
+ options += `<option value="${escapeHtml(t.id)}">#${t.id} - ${escapeHtml(t.subject)}</option>`;
3677
3724
  });
3678
3725
  options += '</optgroup>';
3679
3726
  }
@@ -3681,7 +3728,7 @@ function getAvailableTasksOptions(currentTaskId = null) {
3681
3728
  if (completed.length > 0) {
3682
3729
  options += '<optgroup label="Completed">';
3683
3730
  completed.forEach((t, _idx) => {
3684
- options += `<option value="${t.id}">#${t.id} - ${escapeHtml(t.subject)}</option>`;
3731
+ options += `<option value="${escapeHtml(t.id)}">#${t.id} - ${escapeHtml(t.subject)}</option>`;
3685
3732
  });
3686
3733
  options += '</optgroup>';
3687
3734
  }
@@ -4188,11 +4235,6 @@ function _projectLabel(project) {
4188
4235
  return project.split(/[/\\]/).pop() || project;
4189
4236
  }
4190
4237
 
4191
- function _escapeForJsAttr(str) {
4192
- const jsEscaped = str.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n').replace(/\r/g, '\\r');
4193
- return escapeHtml(jsEscaped);
4194
- }
4195
-
4196
4238
  function _renderProjectGroup(label, meta, innerHtml) {
4197
4239
  return `<div class="storage-project-group">
4198
4240
  <div class="storage-project-header">
@@ -4260,7 +4302,7 @@ function _renderStorageSessions() {
4260
4302
  function renderMessageItems(id) {
4261
4303
  const g = msgMap.get(id);
4262
4304
  if (!g) return '';
4263
- const eid = escapeHtml(id);
4305
+ const eid = escAttrJs(id);
4264
4306
  const header = `<div class="storage-group-header" style="padding-left:12px;">
4265
4307
  <span>${g.pins.length} pinned message${g.pins.length > 1 ? 's' : ''}</span>
4266
4308
  <div class="storage-item-actions">
@@ -4271,8 +4313,8 @@ function _renderStorageSessions() {
4271
4313
  .map((p) => {
4272
4314
  const type = escapeHtml(p.type || '?');
4273
4315
  const text = escapeHtml((p.text || p.tool || p.agentType || '').slice(0, 60));
4274
- const pinId = _escapeForJsAttr(p.id || '');
4275
- const sid = _escapeForJsAttr(id);
4316
+ const pinId = escAttrJs(p.id || '');
4317
+ const sid = escAttrJs(id);
4276
4318
  return `<div class="storage-item storage-item-clickable" style="padding-left:24px;" onclick="_storagePreviewPin('${sid}','${pinId}')">
4277
4319
  <span class="storage-item-badge">${type}</span>
4278
4320
  <span class="storage-item-id">${text}</span>
@@ -4289,7 +4331,7 @@ function _renderStorageSessions() {
4289
4331
 
4290
4332
  function renderSessionItem({ id, session }) {
4291
4333
  const isPinned = isAnyPinned(id);
4292
- const eid = escapeHtml(id);
4334
+ const eid = escAttrJs(id);
4293
4335
  const actions = isPinned
4294
4336
  ? `<button onclick="_storageViewSession('${eid}')">View</button>
4295
4337
  <button class="danger" onclick="_storageUnpinSession('${eid}')">Unpin</button>`
@@ -4389,7 +4431,7 @@ function _renderStorageScratchpads() {
4389
4431
  const typeBadge = item.isProject
4390
4432
  ? '<span class="storage-item-badge">project</span>'
4391
4433
  : '<span class="storage-item-badge">session</span>';
4392
- const jsKey = _escapeForJsAttr(item.key);
4434
+ const jsKey = escAttrJs(item.key);
4393
4435
  const label = item.isProject ? escapeHtml(_projectLabel(item.id)) : _sessionLabel(session, item.id);
4394
4436
  return `<div class="storage-item">
4395
4437
  <span class="storage-item-id" title="${escapeHtml(item.id)}">${label}</span>
@@ -4478,8 +4520,8 @@ function _renderStorageLinkedDocs() {
4478
4520
 
4479
4521
  function renderDocRow(sessionId, p) {
4480
4522
  const name = p.split(/[\\/]/).pop();
4481
- const sid = _escapeForJsAttr(sessionId);
4482
- const jsPath = _escapeForJsAttr(p);
4523
+ const sid = escAttrJs(sessionId);
4524
+ const jsPath = escAttrJs(p);
4483
4525
  return `<div class="storage-item" style="padding-left:24px;">
4484
4526
  <span class="storage-item-id" title="${escapeHtml(p)}">${escapeHtml(name)}</span>
4485
4527
  <div class="storage-item-actions">
@@ -4492,7 +4534,7 @@ function _renderStorageLinkedDocs() {
4492
4534
  function renderSessionItem({ id, session }) {
4493
4535
  const entry = byId.get(id);
4494
4536
  if (!entry) return '';
4495
- const eid = escapeHtml(id);
4537
+ const eid = escAttrJs(id);
4496
4538
  const count = entry.paths.length;
4497
4539
  const header = `<div class="storage-group-header">
4498
4540
  <span>${_sessionLabel(session, id)} <span class="storage-item-badge">${count} doc${count > 1 ? 's' : ''}</span></span>
@@ -4618,6 +4660,18 @@ const MODAL_CLOSERS = {
4618
4660
  };
4619
4661
 
4620
4662
  document.addEventListener('keydown', (e) => {
4663
+ // Scale the open modal's reading surface instead of letting the browser zoom
4664
+ // the whole page. Sits above the text-field guard so it still works with the
4665
+ // caret in a field inside the modal.
4666
+ if ((e.ctrlKey || e.metaKey) && !e.altKey) {
4667
+ const delta = ZOOM_KEYS[e.key] ?? ZOOM_KEYS[e.code];
4668
+ if (delta !== undefined && isZoomableModalOpen()) {
4669
+ e.preventDefault();
4670
+ adjustModalZoom(delta);
4671
+ return;
4672
+ }
4673
+ }
4674
+
4621
4675
  if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') {
4622
4676
  return;
4623
4677
  }
@@ -4992,7 +5046,8 @@ function renderFrontmatterBlock(fm) {
4992
5046
 
4993
5047
  // Previewed HTML is rendered as authored, not sanitized: the sandbox without
4994
5048
  // allow-same-origin puts it on an opaque origin, so it cannot touch this app's
4995
- // storage, DOM or API. Sibling local assets don't load — srcdoc has no base URL.
5049
+ // storage, DOM or API. srcdoc has no base URL, so the server has already embedded
5050
+ // the document's local assets (lib/inline-assets.js); remote refs load normally.
4996
5051
  function renderHtmlPreview(bodyEl, content) {
4997
5052
  bodyEl.innerHTML = '';
4998
5053
  const frame = document.createElement('iframe');
@@ -5087,7 +5142,7 @@ function refreshInfoModalLinkedDocs() {
5087
5142
  }
5088
5143
 
5089
5144
  function closePreviewModal() {
5090
- resetModalFullscreen('preview-modal');
5145
+ hideModalOverlay('preview-modal');
5091
5146
  // Empty the body so an iframe preview is destroyed and its scripts/timers stop.
5092
5147
  document.getElementById('preview-modal-body').innerHTML = '';
5093
5148
  currentPreviewPath = null;
@@ -5663,10 +5718,36 @@ function stripTeammateWrapper(text) {
5663
5718
  return match ? match[1].trim() : text;
5664
5719
  }
5665
5720
 
5721
+ // The div.textContent -> innerHTML trick escapes only & < > : innerHTML serializes
5722
+ // for *text* context, where quotes are correctly left raw. That made every
5723
+ // attribute interpolation breakable with a bare " . It cannot be patched, so the
5724
+ // escaping is explicit here.
5725
+ const HTML_ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;', '`': '&#96;' };
5726
+
5666
5727
  function escapeHtml(text) {
5667
- const div = document.createElement('div');
5668
- div.textContent = text;
5669
- return div.innerHTML;
5728
+ if (text == null) return '';
5729
+ return String(text).replace(/[&<>"'`]/g, (c) => HTML_ESCAPES[c]);
5730
+ }
5731
+
5732
+ // For a value landing inside a quoted JS string inside an HTML attribute —
5733
+ // onclick="fn('${escAttrJs(x)}')". The browser HTML-decodes the attribute before
5734
+ // the JS parser sees it, so the JS escape must happen first and be escaped in turn.
5735
+ function escAttrJs(value) {
5736
+ const js = String(value == null ? '' : value)
5737
+ .replace(/\\/g, '\\\\')
5738
+ .replace(/'/g, "\\'")
5739
+ .replace(/\r/g, '\\r')
5740
+ .replace(/\n/g, '\\n');
5741
+ return escapeHtml(js);
5742
+ }
5743
+
5744
+ // For a value inside a double-quoted CSS attribute selector: [data-x="${escSel(v)}"].
5745
+ // escapeHtml is wrong here — the DOM holds the decoded value, so &quot; in the
5746
+ // selector matches nothing, and a bare " would end the selector string.
5747
+ function escSel(value) {
5748
+ return String(value == null ? '' : value)
5749
+ .replace(/\\/g, '\\\\')
5750
+ .replace(/"/g, '\\"');
5670
5751
  }
5671
5752
 
5672
5753
  function toRelativeIfUnder(filePath, baseDir) {
@@ -5744,7 +5825,7 @@ function renderAgentTabs(promptHtml, responseHtml, promptText, responseText) {
5744
5825
  const panelsHtml = panels
5745
5826
  .map(
5746
5827
  (p) =>
5747
- `<div class="agent-tab-panel${p.key === defaultTab ? ' active' : ''}" data-tab-group="${id}" data-tab-key="${p.key}"><div class="detail-desc rendered-md" style="font-size:13px;">${p.html}</div></div>`,
5828
+ `<div class="agent-tab-panel${p.key === defaultTab ? ' active' : ''}" data-tab-group="${id}" data-tab-key="${p.key}"><div class="detail-desc rendered-md">${p.html}</div></div>`,
5748
5829
  )
5749
5830
  .join('');
5750
5831
  return `<div class="agent-tabs">${tabsHtml}${copyBtnHtml}</div>${panelsHtml}`;
@@ -6372,6 +6453,14 @@ function updateStickyBtnState() {
6372
6453
  if (svg) svg.setAttribute('fill', isSticky ? 'currentColor' : 'none');
6373
6454
  }
6374
6455
 
6456
+ // Purely cosmetic: the tmp root is identical for every session and the encoded
6457
+ // project segment is already shown in the Path row, so only the last two segments
6458
+ // carry information. The full path stays in the tooltip and the copy button.
6459
+ function abbreviateScratchpadDir(dir) {
6460
+ const sep = dir.includes('\\') ? '\\' : '/';
6461
+ return ['$tmp', '…', ...dir.split(/[/\\]/).slice(-2)].join(sep);
6462
+ }
6463
+
6375
6464
  function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
6376
6465
  const modal = document.getElementById('team-modal');
6377
6466
  const titleEl = document.getElementById('team-modal-title');
@@ -6389,12 +6478,13 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
6389
6478
  let html = '';
6390
6479
 
6391
6480
  // Session & project details as compact key-value rows
6392
- // Each row: [label, displayValue, { openPath?, copyValue? }]
6481
+ // Each row: [label, value, { openPath?, abbrev? }] — `value` is authoritative
6482
+ // (tooltip + copy); `abbrev` only replaces the rendered text.
6393
6483
  const infoRows = [];
6394
6484
  infoRows.push(['Session', session.id, { openClaudeDir: true, openFile: session.jsonlPath }]);
6395
6485
  if (parentInfo?.parentSessionId) {
6396
6486
  infoRows.push([
6397
- 'Forked from',
6487
+ parentInfo.relation === 'compact' ? 'Continued from' : 'Forked from',
6398
6488
  parentInfo.parentSessionId,
6399
6489
  { openClaudeDir: true, openFile: parentInfo.parentJsonlPath, openSession: parentInfo.parentSessionId },
6400
6490
  ]);
@@ -6419,6 +6509,13 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
6419
6509
  if (session.tasksDir) {
6420
6510
  infoRows.push(['Tasks Dir', session.tasksDir, { openPath: session.tasksDir }]);
6421
6511
  }
6512
+ if (session.scratchpadDir) {
6513
+ infoRows.push([
6514
+ 'Scratchpad',
6515
+ session.scratchpadDir,
6516
+ { openPath: session.scratchpadDir, abbrev: abbreviateScratchpadDir(session.scratchpadDir) },
6517
+ ]);
6518
+ }
6422
6519
  if (session.sharedTaskList) {
6423
6520
  infoRows.push(['Shared Tasks', session.sharedTaskList]);
6424
6521
  }
@@ -6432,20 +6529,17 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
6432
6529
  'font-family: var(--mono); font-size: 12px; color: var(--text-primary); user-select: all; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;';
6433
6530
  html += `<div class="team-modal-meta info-grid">`;
6434
6531
  infoRows.forEach(([label, value, opts]) => {
6435
- const copyVal = escapeHtml(value).replace(/"/g, '&quot;');
6436
6532
  html += `<span style="font-weight: 500; color: var(--text-secondary); font-size: 12px; white-space: nowrap;">${label}</span>`;
6437
6533
  if (opts?.openSession) {
6438
- const sid = _escapeForJsAttr(escapeHtml(opts.openSession).replace(/"/g, '&quot;'));
6439
- html += `<span onclick="openSessionFromInfo('${sid}')" style="${clickableStyle}" title="Open session in app">${escapeHtml(value)}</span>`;
6534
+ html += `<span onclick="openSessionFromInfo('${escAttrJs(opts.openSession)}')" style="${clickableStyle}" title="Open session in app">${escapeHtml(value)}</span>`;
6440
6535
  } else {
6441
- html += `<span style="${plainStyle}" title="${copyVal}">${escapeHtml(value)}</span>`;
6536
+ html += `<span style="${plainStyle}" title="${escapeHtml(value)}">${escapeHtml(opts?.abbrev || value)}</span>`;
6442
6537
  }
6443
- const jsCopyVal = _escapeForJsAttr(copyVal);
6444
- const copyBtn = `<button onclick="copyWithFeedback('${jsCopyVal}', this)" title="Copy">${ICON_COPY}</button>`;
6538
+ const copyBtn = `<button onclick="copyWithFeedback('${escAttrJs(value)}', this)" title="Copy">${ICON_COPY}</button>`;
6445
6539
  let openBtn = '';
6446
6540
  if (opts?.openClaudeDir || opts?.openPath) {
6447
- const folder = opts.openClaudeDir ? '' : escapeHtml(opts.openPath).replace(/"/g, '&quot;');
6448
- const file = opts.openFile ? escapeHtml(opts.openFile).replace(/"/g, '&quot;') : '';
6541
+ const folder = opts.openClaudeDir ? '' : escapeHtml(opts.openPath);
6542
+ const file = opts.openFile ? escapeHtml(opts.openFile) : '';
6449
6543
  openBtn = `<button data-folder="${folder}" data-file="${file}" data-claude-dir="${opts.openClaudeDir ? '1' : ''}" onclick="openFolderInEditor(this.dataset.claudeDir ? undefined : this.dataset.folder, this.dataset.file || undefined)" title="Open in editor">${ICON_OPEN_EXTERNAL}</button>`;
6450
6544
  }
6451
6545
  html += `<span class="info-row-actions">${copyBtn}${openBtn}</span>`;
@@ -6534,7 +6628,9 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
6534
6628
  }
6535
6629
 
6536
6630
  bodyEl.innerHTML = html;
6537
- bindLinkedDocsHandlers(bodyEl, session.id);
6631
+ // Bind to the section, not bodyEl: bodyEl outlives the modal, so listeners on it
6632
+ // would stack up once per open and fire the click handler N times.
6633
+ bindLinkedDocsHandlers(bodyEl.querySelector('.linked-docs-section'), session.id);
6538
6634
  const alreadyVisible = modal.classList.contains('visible');
6539
6635
  _infoModalSessionId = session.id;
6540
6636
  updateStickyBtnState();
@@ -6709,7 +6805,7 @@ function renderGoalSubtitle(session) {
6709
6805
  // Only active (unmet) goals reach here — a met goal auto-clears. Clicking
6710
6806
  // opens the info modal (full text); stopPropagation so it doesn't also
6711
6807
  // trigger the card's fetchTasks.
6712
- return `<div class="session-goal" onclick="event.stopPropagation(); showSessionInfoModal('${session.id}')" title="${escapeHtml(g.condition)}"><span class="session-goal-icon">◎</span>${escapeHtml(short)}</div>`;
6808
+ return `<div class="session-goal" onclick="event.stopPropagation(); showSessionInfoModal('${escAttrJs(session.id)}')" title="${escapeHtml(g.condition)}"><span class="session-goal-icon">◎</span>${escapeHtml(short)}</div>`;
6713
6809
  }
6714
6810
 
6715
6811
  function renderLoopBadge(session) {
@@ -6721,7 +6817,7 @@ function renderLoopBadge(session) {
6721
6817
  const f = fmtLoopFireTime(li.latest.timestamp, li.latest.delaySeconds);
6722
6818
  if (f.abs) tip += ` — latest ${f.status === 'pending' ? 'fires' : 'fired'} ${f.rel} (${f.abs})`;
6723
6819
  }
6724
- return `<span class="loop-badge" onclick="event.stopPropagation(); showLoopModal('${session.id}')" title="${escapeHtml(tip)}"><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="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></span>`;
6820
+ return `<span class="loop-badge" onclick="event.stopPropagation(); showLoopModal('${escAttrJs(session.id)}')" title="${escapeHtml(tip)}"><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="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></span>`;
6725
6821
  }
6726
6822
 
6727
6823
  function closeLoopModal() {
@@ -6739,7 +6835,7 @@ function renderWorkflowBadge(session) {
6739
6835
  if (!session.hasWorkflow) return '';
6740
6836
  const n = session.workflowCount || 0;
6741
6837
  const tip = `${n} workflow${n === 1 ? '' : 's'}`;
6742
- return `<span class="workflow-badge" onclick="event.stopPropagation(); showWorkflowModal('${session.id}')" title="${escapeHtml(tip)}">${WORKFLOW_GEAR_SVG}</span>`;
6838
+ return `<span class="workflow-badge" onclick="event.stopPropagation(); showWorkflowModal('${escAttrJs(session.id)}')" title="${escapeHtml(tip)}">${WORKFLOW_GEAR_SVG}</span>`;
6743
6839
  }
6744
6840
 
6745
6841
  let _workflowSessionId = null;
@@ -6788,11 +6884,11 @@ function renderWorkflowPicker(workflows) {
6788
6884
  body.innerHTML = workflows
6789
6885
  .map((w) => {
6790
6886
  const when = w.modifiedAt ? formatDate(w.modifiedAt) : '';
6791
- return `<div class="workflow-row" onclick="showWorkflowRun('${escapeHtml(w.id)}')">
6887
+ return `<div class="workflow-row" onclick="showWorkflowRun('${escAttrJs(w.id)}')">
6792
6888
  <div class="workflow-row-head">
6793
6889
  <span class="workflow-row-name">${escapeHtml(w.name || w.id)}</span>
6794
6890
  <span class="workflow-row-id"><code>${escapeHtml(w.id)}</code></span>
6795
- <button class="icon-btn workflow-open-icon" aria-label="Open in editor" title="Open in editor" onclick="event.stopPropagation(); openWorkflowInEditor('${escapeHtml(w.id)}')">${ICON_OPEN_EDITOR}</button>
6891
+ <button class="icon-btn workflow-open-icon" aria-label="Open in editor" title="Open in editor" onclick="event.stopPropagation(); openWorkflowInEditor('${escAttrJs(w.id)}')">${ICON_OPEN_EDITOR}</button>
6796
6892
  </div>
6797
6893
  ${w.description ? `<div class="workflow-row-desc">${escapeHtml(w.description)}</div>` : ''}
6798
6894
  ${when ? `<div class="workflow-row-meta">${escapeHtml(when)}</div>` : ''}
@@ -6858,7 +6954,7 @@ function renderWorkflowRun(run) {
6858
6954
  .map((a) => {
6859
6955
  const dur = a.durationMs != null ? formatDuration(a.durationMs) : '';
6860
6956
  const running = a.status !== 'done';
6861
- return `<div class="wf-agent" onclick="showAgentModal('${escapeHtml(a.agentId)}')" title="${escapeHtml(a.agentId)}">
6957
+ return `<div class="wf-agent" onclick="showAgentModal('${escAttrJs(a.agentId)}')" title="${escapeHtml(a.agentId)}">
6862
6958
  <span class="wf-agent-type">${escapeHtml(a.type || 'agent')}</span>
6863
6959
  <span class="wf-agent-model">${escapeHtml(shortModelName(a.model))}</span>
6864
6960
  <span class="wf-agent-tok">${a.outputTokens ? `${fmtTokens(a.outputTokens)} tok` : ''}</span>
@@ -6960,7 +7056,7 @@ function openPlanModal() {
6960
7056
  }
6961
7057
 
6962
7058
  function closePlanModal() {
6963
- resetModalFullscreen('plan-modal');
7059
+ hideModalOverlay('plan-modal');
6964
7060
  }
6965
7061
 
6966
7062
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
@@ -7230,6 +7326,8 @@ try {
7230
7326
  af.forEach((k) => activityFilter.add(k));
7231
7327
  } catch (_) {}
7232
7328
  initSidebarResize();
7329
+ applyModalZoom();
7330
+ loadModalFullscreen();
7233
7331
  loadPanelWidths();
7234
7332
  initPanelResize('detail-panel', 'detail-panel-resize', '--detail-panel-width', 'detail-panel-width');
7235
7333
  initPanelResize('message-panel', 'message-panel-resize', '--message-panel-width', 'message-panel-width');
@@ -7450,7 +7548,7 @@ document.addEventListener('keydown', (e) => {
7450
7548
  if (!window.__HUB__?.enabled) return;
7451
7549
  const fwd = (key) => {
7452
7550
  e.preventDefault();
7453
- window.parent?.postMessage({ type: 'hub:keydown', key, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey }, '*');
7551
+ hubPost({ type: 'hub:keydown', key, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey });
7454
7552
  };
7455
7553
  if (e.ctrlKey && e.altKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
7456
7554
  fwd(e.key);
@@ -7480,17 +7578,24 @@ document.addEventListener('click', (e) => {
7480
7578
  if (url.protocol !== 'http:' && url.protocol !== 'https:') return;
7481
7579
  e.preventDefault();
7482
7580
  e.stopPropagation();
7483
- window.parent?.postMessage({ type: 'hub:openExternal', url: url.href }, '*');
7581
+ hubPost({ type: 'hub:openExternal', url: url.href });
7484
7582
  });
7485
7583
 
7486
7584
  window.hubNavigate = function hubNavigate(app, url) {
7487
7585
  if (!window.__HUB__?.enabled) return;
7488
- window.parent?.postMessage({ type: 'hub:navigate', app, url }, '*');
7586
+ hubPost({ type: 'hub:navigate', app, url });
7489
7587
  };
7490
7588
 
7491
7589
  // Hoisted out of initHubTheme so initHubProject can share it.
7492
7590
  const hubOrigin = () => (window.__HUB__?.url ? new URL(window.__HUB__.url).origin : null);
7493
7591
 
7592
+ // Every send is addressed to the hub explicitly. With targetOrigin '*' any page that
7593
+ // framed this app also received the forwarded keystrokes and navigation intents.
7594
+ function hubPost(message) {
7595
+ const origin = hubOrigin();
7596
+ if (origin) window.parent?.postMessage(message, origin);
7597
+ }
7598
+
7494
7599
  (function initHubTheme() {
7495
7600
  const getTheme = () => (document.body.classList.contains('light') ? 'light' : 'dark');
7496
7601
  const getColorTheme = () => document.body.dataset.colorTheme || 'ember';
@@ -7516,8 +7621,7 @@ const hubOrigin = () => (window.__HUB__?.url ? new URL(window.__HUB__.url).origi
7516
7621
  if (t === lastTheme && ct === lastColorTheme) return;
7517
7622
  lastTheme = t;
7518
7623
  lastColorTheme = ct;
7519
- const origin = hubOrigin();
7520
- if (origin) window.parent.postMessage({ type: 'hub:theme', theme: t, colorTheme: ct }, origin);
7624
+ hubPost({ type: 'hub:theme', theme: t, colorTheme: ct });
7521
7625
  }).observe(document.body, {
7522
7626
  attributes: true,
7523
7627
  attributeFilter: ['class', 'data-color-theme'],