claude-code-kanban 4.16.0 → 4.18.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
@@ -1,13 +1,17 @@
1
1
  //#region STATE
2
+ // The baseline "nothing is filtered" state: what resetState() returns to, what updateUrl() omits
3
+ // from the query string, and what decides whether a control tints ember in the header summary.
4
+ const FILTER_DEFAULTS = { project: '__recent__', session: 'active', limit: '20' };
5
+
2
6
  let sessions = [];
3
7
  let currentSessionId = null;
4
8
  let currentTasks = [];
5
9
  let viewMode = 'session';
6
- let sessionFilter = 'active';
10
+ let sessionFilter = FILTER_DEFAULTS.session;
7
11
  // Only meaningful while sessionFilter === 'active' (filterBySessions clears it otherwise)
8
12
  const activityFilter = new Set(); // kinds: 'waiting' | 'active'
9
- let sessionLimit = '20';
10
- let filterProject = '__recent__'; // null = all, '__recent__' = last 24h, or project path
13
+ let sessionLimit = FILTER_DEFAULTS.limit;
14
+ let filterProject = FILTER_DEFAULTS.project; // null = all, '__recent__' = last 24h, or project path
11
15
  let recentProjects = new Set();
12
16
  let projectsCacheDirty = true;
13
17
  const collapsedProjectGroups = new Set();
@@ -75,9 +79,9 @@ function updateUrl() {
75
79
  if (viewMode === 'all') params.set('view', 'all');
76
80
  if (viewMode === 'project' && currentProjectPath) params.set('projectView', btoa(currentProjectPath));
77
81
  if (currentSessionId) params.set('session', currentSessionId);
78
- if (sessionFilter !== 'active') params.set('filter', sessionFilter);
79
- if (sessionLimit !== '20') params.set('limit', sessionLimit);
80
- if (filterProject && filterProject !== '__recent__') params.set('project', filterProject);
82
+ if (sessionFilter !== FILTER_DEFAULTS.session) params.set('filter', sessionFilter);
83
+ if (sessionLimit !== FILTER_DEFAULTS.limit) params.set('limit', sessionLimit);
84
+ if (filterProject && filterProject !== FILTER_DEFAULTS.project) params.set('project', filterProject);
81
85
  if (ownerFilter) params.set('owner', ownerFilter);
82
86
  if (searchQuery) params.set('search', searchQuery);
83
87
  if (messagePanelOpen) params.set('messages', '1');
@@ -94,6 +98,9 @@ function persistLastView() {
94
98
  view: viewMode,
95
99
  session: currentSessionId,
96
100
  projectPath: viewMode === 'project' ? currentProjectPath : null,
101
+ // The project filter also lives in the URL, but the hub recreates each iframe at the app's
102
+ // base URL on reload, so the query string alone doesn't survive a hub refresh.
103
+ project: filterProject,
97
104
  };
98
105
  localStorage.setItem(LAST_VIEW_KEY, JSON.stringify(data));
99
106
  } catch (_) {}
@@ -112,9 +119,9 @@ function resetState() {
112
119
  try {
113
120
  localStorage.removeItem(LAST_VIEW_KEY);
114
121
  } catch (_) {}
115
- sessionFilter = 'active';
116
- sessionLimit = '20';
117
- filterProject = '__recent__';
122
+ sessionFilter = FILTER_DEFAULTS.session;
123
+ sessionLimit = FILTER_DEFAULTS.limit;
124
+ filterProject = FILTER_DEFAULTS.project;
118
125
  ownerFilter = '';
119
126
  searchQuery = '';
120
127
  viewMode = 'all';
@@ -126,7 +133,7 @@ function resetState() {
126
133
  const searchInput = document.getElementById('search-input');
127
134
  if (searchInput) searchInput.value = '';
128
135
  document.getElementById('search-clear-btn')?.classList.remove('visible');
129
- loadPreferences();
136
+ renderFilterState();
130
137
  fetchSessions().then(() => showAllTasks());
131
138
  }
132
139
 
@@ -325,7 +332,7 @@ function renderActivityChip() {
325
332
  return `
326
333
  <button type="button"
327
334
  class="${classes}"
328
- onclick="setActivityFilter('${c.kind}')"
335
+ onclick="setActivityFilter('${escAttrJs(c.kind)}')"
329
336
  aria-pressed="${isOn ? 'true' : 'false'}"
330
337
  title="${escapeHtml(c.title + hint)}">
331
338
  <span class="activity-dot"></span>
@@ -984,7 +991,7 @@ function renderToolItem(m, i, compact) {
984
991
  // click all light up DURING the run, identical to post-completion.
985
992
  const agentLink =
986
993
  m.tool === 'Agent' && m.agentId
987
- ? ` <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>`
988
995
  : '';
989
996
  // Usage chip (tokens · tools · duration) on completed Agent rows — same stats a
990
997
  // background agent shows, sourced here from the foreground toolUseResult.
@@ -1369,7 +1376,7 @@ function getToolIcon(toolName) {
1369
1376
  const AGENT_LOG_ICON =
1370
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>';
1371
1378
  function agentLogButton(agentId) {
1372
- 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>`;
1373
1380
  }
1374
1381
 
1375
1382
  function getPinId(m) {
@@ -1691,7 +1698,7 @@ const linkSvg = (size) =>
1691
1698
  // Shared markup for an attachment image and the labeled section that holds a grid
1692
1699
  // of them — used by both user-pasted images and agent tool-result images.
1693
1700
  function attachImageTag(url, alt) {
1694
- 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" />`;
1695
1702
  }
1696
1703
  function attachImageSection(label, imgsHtml) {
1697
1704
  return imgsHtml
@@ -1729,7 +1736,7 @@ function renderUserAttachments(m) {
1729
1736
  return `<details class="user-attach-toolresult">
1730
1737
  <summary>Tool result <code>${escapeHtml(shortId)}</code></summary>
1731
1738
  <pre class="${TINTED_PRE_CLASS}" id="${expandId}">${preview}</pre>
1732
- <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>
1733
1740
  </details>`;
1734
1741
  })
1735
1742
  .join('');
@@ -1869,7 +1876,7 @@ function showMsgDetail(idx) {
1869
1876
  }
1870
1877
 
1871
1878
  function closeMsgDetailModal() {
1872
- resetModalFullscreen('msg-detail-modal');
1879
+ hideModalOverlay('msg-detail-modal');
1873
1880
  msgDetailFollowLatest = false;
1874
1881
  // Drop the message highlight on close, mirroring task-card behavior.
1875
1882
  msgHighlightDimmed = true;
@@ -1893,19 +1900,32 @@ function _setModalWidth(modal, slot, on, maxWidth, width) {
1893
1900
  }
1894
1901
  }
1895
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
+
1896
1910
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
1897
1911
  function toggleModalFullscreen(modalId) {
1898
- const modal = document.querySelector(`#${modalId} .modal`);
1899
- const isFs = modal.classList.toggle('fullscreen');
1900
- _setModalWidth(modal, 'Fs', isFs, '', '');
1901
- 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));
1915
+ }
1916
+
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
+ }
1902
1922
  }
1903
1923
 
1904
- function resetModalFullscreen(modalId) {
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) {
1905
1927
  const modal = document.getElementById(modalId);
1906
1928
  modal.classList.remove('visible');
1907
- modal.querySelector('.modal').classList.remove('fullscreen');
1908
- updateFullscreenBtnIcon(`${modalId}-fullscreen-btn`, false);
1909
1929
  return modal;
1910
1930
  }
1911
1931
 
@@ -1917,6 +1937,38 @@ function updateFullscreenBtnIcon(btnId, isFullscreen) {
1917
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>';
1918
1938
  }
1919
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
+
1920
1972
  let _toastTimer = null;
1921
1973
  let _manualRefreshing = false;
1922
1974
  //#endregion
@@ -2531,7 +2583,7 @@ function renderAgentFooter() {
2531
2583
  const agentColor = resolveNamedColor(a.color);
2532
2584
  const colorStyle = agentColor ? ` style="border-left:3px solid ${agentColor.color}"` : '';
2533
2585
  const selectedClass = a.agentId === currentAgentModalId ? ' selected' : '';
2534
- 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)}')">
2535
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>
2536
2588
  <div class="agent-status-row"><span class="agent-dot ${a.status}"></span><span class="agent-status">${statusText}</span></div>
2537
2589
  ${msgHtml}
@@ -2697,7 +2749,7 @@ function showAgentModal(agentId) {
2697
2749
  }
2698
2750
 
2699
2751
  function closeAgentModal() {
2700
- resetModalFullscreen('agent-modal');
2752
+ hideModalOverlay('agent-modal');
2701
2753
  currentAgentModalId = null;
2702
2754
  highlightSelectedAgent();
2703
2755
  }
@@ -2707,7 +2759,7 @@ function highlightSelectedAgent() {
2707
2759
  if (!content) return;
2708
2760
  for (const el of content.querySelectorAll('.agent-card.selected')) el.classList.remove('selected');
2709
2761
  if (currentAgentModalId == null) return;
2710
- const el = content.querySelector(`.agent-card[data-agent-id="${currentAgentModalId}"]`);
2762
+ const el = content.querySelector(`.agent-card[data-agent-id="${escSel(currentAgentModalId)}"]`);
2711
2763
  if (el) el.classList.add('selected');
2712
2764
  }
2713
2765
 
@@ -2931,9 +2983,10 @@ function renderSessions() {
2931
2983
  const bookmarksCount = loadPins(session.id).length;
2932
2984
  const hasScratchpad = !!(localStorage.getItem(_sessionScratchpadKey(session.id)) || '').trim();
2933
2985
  const tempClass = session.hasRecentLog || session.inProgress || session.hasWaitingForUser ? 'warm' : 'stale';
2986
+ const sid = escAttrJs(session.id);
2934
2987
  return `
2935
- <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}">
2936
- <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>
2937
2990
  <div class="session-name">${escapeHtml(primaryName)}</div>
2938
2991
  ${secondaryName ? `<div class="session-secondary">${escapeHtml(secondaryName)}</div>` : ''}
2939
2992
  ${gitBranch ? `<div class="session-branch">${gitBranch}</div>` : ''}
@@ -2942,14 +2995,14 @@ function renderSessions() {
2942
2995
  <div class="session-progress">
2943
2996
  <span class="session-indicators">
2944
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>` : ''}
2945
- ${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>` : ''}
2946
2999
  ${renderWorkflowBadge(session)}
2947
3000
  ${renderLoopBadge(session)}
2948
- ${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>` : ''}
2949
- ${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>` : ''}
2950
- ${linkedDocsCount > 0 ? `<span class="linked-docs-badge" onclick="event.stopPropagation(); showSessionInfoModal('${session.id}')" title="${linkedDocsCount} linked document${linkedDocsCount > 1 ? 's' : ''}">${linkSvg(10)}${linkedDocsCount}</span>` : ''}
2951
- ${session.hasPlan && !session.planSourceSessionId ? `<span class="plan-indicator" onclick="event.stopPropagation(); openPlanForSession('${session.id}')" title="View plan">${ICON_PLAN}</span>` : ''}
2952
- ${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>` : ''}
2953
3006
  ${session.sharedTaskList ? `<span class="shared-tasklist-badge" title="Shared task list: ${escapeHtml(session.sharedTaskList)}">${linkSvg(12)}</span>` : ''}
2954
3007
  ${session.hasWaitingForUser ? `<span class="agent-badge agent-badge-waiting" title="Waiting for user">${ICON_AGENT_WAITING}</span>` : ''}
2955
3008
  ${session.hasRunningAgents && !session.hasWaitingForUser ? `<span class="agent-badge agent-badge-active" title="Agents running">${ICON_AGENT_ACTIVE}</span>` : ''}
@@ -3198,9 +3251,9 @@ function renderTaskCard(task) {
3198
3251
  <div
3199
3252
  role="listitem"
3200
3253
  tabindex="0"
3201
- data-task-id="${task.id}"
3202
- data-session-id="${actualSessionId}"
3203
- 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)}')"
3204
3257
  draggable="true"
3205
3258
  ondragstart="onCardDragStart(event)"
3206
3259
  ondragend="onCardDragEnd(event)"
@@ -3264,8 +3317,9 @@ function renderKanban() {
3264
3317
 
3265
3318
  if (selectedTaskId) {
3266
3319
  const card =
3267
- document.querySelector(`.task-card[data-task-id="${selectedTaskId}"][data-session-id="${selectedSessionId}"]`) ||
3268
- 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)}"]`);
3269
3323
  if (card) {
3270
3324
  if (focusZone === 'board' && !taskHighlightDimmed) card.classList.add('selected');
3271
3325
  } else {
@@ -3357,8 +3411,8 @@ function selectTask(taskId, sessionId) {
3357
3411
  taskHighlightDimmed = false;
3358
3412
  if (!taskId) return;
3359
3413
  const card =
3360
- document.querySelector(`.task-card[data-task-id="${taskId}"][data-session-id="${sessionId}"]`) ||
3361
- 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)}"]`);
3362
3416
  if (card) {
3363
3417
  card.classList.add('selected');
3364
3418
  card.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
@@ -3634,7 +3688,7 @@ function setFocusZone(zone) {
3634
3688
  }
3635
3689
  if (selectedTaskId) {
3636
3690
  const card = document.querySelector(
3637
- `.task-card[data-task-id="${selectedTaskId}"][data-session-id="${selectedSessionId}"]`,
3691
+ `.task-card[data-task-id="${escSel(selectedTaskId)}"][data-session-id="${escSel(selectedSessionId)}"]`,
3638
3692
  );
3639
3693
  if (card) card.classList.add('selected');
3640
3694
  } else {
@@ -3658,7 +3712,7 @@ function getAvailableTasksOptions(currentTaskId = null) {
3658
3712
  if (pending.length > 0) {
3659
3713
  options += '<optgroup label="Pending">';
3660
3714
  pending.forEach((t, _idx) => {
3661
- options += `<option value="${t.id}">#${t.id} - ${escapeHtml(t.subject)}</option>`;
3715
+ options += `<option value="${escapeHtml(t.id)}">#${t.id} - ${escapeHtml(t.subject)}</option>`;
3662
3716
  });
3663
3717
  options += '</optgroup>';
3664
3718
  }
@@ -3666,7 +3720,7 @@ function getAvailableTasksOptions(currentTaskId = null) {
3666
3720
  if (inProgress.length > 0) {
3667
3721
  options += '<optgroup label="In Progress">';
3668
3722
  inProgress.forEach((t, _idx) => {
3669
- options += `<option value="${t.id}">#${t.id} - ${escapeHtml(t.subject)}</option>`;
3723
+ options += `<option value="${escapeHtml(t.id)}">#${t.id} - ${escapeHtml(t.subject)}</option>`;
3670
3724
  });
3671
3725
  options += '</optgroup>';
3672
3726
  }
@@ -3674,7 +3728,7 @@ function getAvailableTasksOptions(currentTaskId = null) {
3674
3728
  if (completed.length > 0) {
3675
3729
  options += '<optgroup label="Completed">';
3676
3730
  completed.forEach((t, _idx) => {
3677
- options += `<option value="${t.id}">#${t.id} - ${escapeHtml(t.subject)}</option>`;
3731
+ options += `<option value="${escapeHtml(t.id)}">#${t.id} - ${escapeHtml(t.subject)}</option>`;
3678
3732
  });
3679
3733
  options += '</optgroup>';
3680
3734
  }
@@ -4181,11 +4235,6 @@ function _projectLabel(project) {
4181
4235
  return project.split(/[/\\]/).pop() || project;
4182
4236
  }
4183
4237
 
4184
- function _escapeForJsAttr(str) {
4185
- const jsEscaped = str.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n').replace(/\r/g, '\\r');
4186
- return escapeHtml(jsEscaped);
4187
- }
4188
-
4189
4238
  function _renderProjectGroup(label, meta, innerHtml) {
4190
4239
  return `<div class="storage-project-group">
4191
4240
  <div class="storage-project-header">
@@ -4253,7 +4302,7 @@ function _renderStorageSessions() {
4253
4302
  function renderMessageItems(id) {
4254
4303
  const g = msgMap.get(id);
4255
4304
  if (!g) return '';
4256
- const eid = escapeHtml(id);
4305
+ const eid = escAttrJs(id);
4257
4306
  const header = `<div class="storage-group-header" style="padding-left:12px;">
4258
4307
  <span>${g.pins.length} pinned message${g.pins.length > 1 ? 's' : ''}</span>
4259
4308
  <div class="storage-item-actions">
@@ -4264,8 +4313,8 @@ function _renderStorageSessions() {
4264
4313
  .map((p) => {
4265
4314
  const type = escapeHtml(p.type || '?');
4266
4315
  const text = escapeHtml((p.text || p.tool || p.agentType || '').slice(0, 60));
4267
- const pinId = _escapeForJsAttr(p.id || '');
4268
- const sid = _escapeForJsAttr(id);
4316
+ const pinId = escAttrJs(p.id || '');
4317
+ const sid = escAttrJs(id);
4269
4318
  return `<div class="storage-item storage-item-clickable" style="padding-left:24px;" onclick="_storagePreviewPin('${sid}','${pinId}')">
4270
4319
  <span class="storage-item-badge">${type}</span>
4271
4320
  <span class="storage-item-id">${text}</span>
@@ -4282,7 +4331,7 @@ function _renderStorageSessions() {
4282
4331
 
4283
4332
  function renderSessionItem({ id, session }) {
4284
4333
  const isPinned = isAnyPinned(id);
4285
- const eid = escapeHtml(id);
4334
+ const eid = escAttrJs(id);
4286
4335
  const actions = isPinned
4287
4336
  ? `<button onclick="_storageViewSession('${eid}')">View</button>
4288
4337
  <button class="danger" onclick="_storageUnpinSession('${eid}')">Unpin</button>`
@@ -4382,7 +4431,7 @@ function _renderStorageScratchpads() {
4382
4431
  const typeBadge = item.isProject
4383
4432
  ? '<span class="storage-item-badge">project</span>'
4384
4433
  : '<span class="storage-item-badge">session</span>';
4385
- const jsKey = _escapeForJsAttr(item.key);
4434
+ const jsKey = escAttrJs(item.key);
4386
4435
  const label = item.isProject ? escapeHtml(_projectLabel(item.id)) : _sessionLabel(session, item.id);
4387
4436
  return `<div class="storage-item">
4388
4437
  <span class="storage-item-id" title="${escapeHtml(item.id)}">${label}</span>
@@ -4471,8 +4520,8 @@ function _renderStorageLinkedDocs() {
4471
4520
 
4472
4521
  function renderDocRow(sessionId, p) {
4473
4522
  const name = p.split(/[\\/]/).pop();
4474
- const sid = _escapeForJsAttr(sessionId);
4475
- const jsPath = _escapeForJsAttr(p);
4523
+ const sid = escAttrJs(sessionId);
4524
+ const jsPath = escAttrJs(p);
4476
4525
  return `<div class="storage-item" style="padding-left:24px;">
4477
4526
  <span class="storage-item-id" title="${escapeHtml(p)}">${escapeHtml(name)}</span>
4478
4527
  <div class="storage-item-actions">
@@ -4485,7 +4534,7 @@ function _renderStorageLinkedDocs() {
4485
4534
  function renderSessionItem({ id, session }) {
4486
4535
  const entry = byId.get(id);
4487
4536
  if (!entry) return '';
4488
- const eid = escapeHtml(id);
4537
+ const eid = escAttrJs(id);
4489
4538
  const count = entry.paths.length;
4490
4539
  const header = `<div class="storage-group-header">
4491
4540
  <span>${_sessionLabel(session, id)} <span class="storage-item-badge">${count} doc${count > 1 ? 's' : ''}</span></span>
@@ -4611,6 +4660,18 @@ const MODAL_CLOSERS = {
4611
4660
  };
4612
4661
 
4613
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
+
4614
4675
  if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') {
4615
4676
  return;
4616
4677
  }
@@ -4985,7 +5046,8 @@ function renderFrontmatterBlock(fm) {
4985
5046
 
4986
5047
  // Previewed HTML is rendered as authored, not sanitized: the sandbox without
4987
5048
  // allow-same-origin puts it on an opaque origin, so it cannot touch this app's
4988
- // 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.
4989
5051
  function renderHtmlPreview(bodyEl, content) {
4990
5052
  bodyEl.innerHTML = '';
4991
5053
  const frame = document.createElement('iframe');
@@ -5080,7 +5142,7 @@ function refreshInfoModalLinkedDocs() {
5080
5142
  }
5081
5143
 
5082
5144
  function closePreviewModal() {
5083
- resetModalFullscreen('preview-modal');
5145
+ hideModalOverlay('preview-modal');
5084
5146
  // Empty the body so an iframe preview is destroyed and its scripts/timers stop.
5085
5147
  document.getElementById('preview-modal-body').innerHTML = '';
5086
5148
  currentPreviewPath = null;
@@ -5656,10 +5718,36 @@ function stripTeammateWrapper(text) {
5656
5718
  return match ? match[1].trim() : text;
5657
5719
  }
5658
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
+
5659
5727
  function escapeHtml(text) {
5660
- const div = document.createElement('div');
5661
- div.textContent = text;
5662
- 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, '\\"');
5663
5751
  }
5664
5752
 
5665
5753
  function toRelativeIfUnder(filePath, baseDir) {
@@ -5737,7 +5825,7 @@ function renderAgentTabs(promptHtml, responseHtml, promptText, responseText) {
5737
5825
  const panelsHtml = panels
5738
5826
  .map(
5739
5827
  (p) =>
5740
- `<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>`,
5741
5829
  )
5742
5830
  .join('');
5743
5831
  return `<div class="agent-tabs">${tabsHtml}${copyBtnHtml}</div>${panelsHtml}`;
@@ -5819,10 +5907,95 @@ function getOwnerColor(name) {
5819
5907
  //#endregion
5820
5908
 
5821
5909
  //#region FILTERS
5910
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
5911
+ function toggleFilterMenu(e) {
5912
+ // Stops the click reaching .section-header (which would collapse the section) *and*
5913
+ // document (which would immediately close the menu we are opening).
5914
+ e?.stopPropagation();
5915
+ if (closeFilterMenu()) return;
5916
+ document.getElementById('filter-menu').classList.add('open');
5917
+ document.getElementById('filter-menu-btn')?.setAttribute('aria-expanded', 'true');
5918
+ renderFilterState();
5919
+ document.addEventListener('click', closeFilterMenu, { once: true });
5920
+ // Capture phase: the global keydown handler bails out on SELECT targets, and every
5921
+ // focusable thing in this popover is a select — Escape would never reach it otherwise.
5922
+ document.addEventListener('keydown', filterMenuKeydown, true);
5923
+ document.getElementById('project-filter')?.focus();
5924
+ }
5925
+
5926
+ function filterMenuKeydown(e) {
5927
+ if (e.key !== 'Escape') return;
5928
+ e.stopPropagation();
5929
+ if (closeFilterMenu()) document.getElementById('filter-menu-btn')?.focus();
5930
+ }
5931
+
5932
+ function closeFilterMenu() {
5933
+ const menu = document.getElementById('filter-menu');
5934
+ if (!menu?.classList.contains('open')) return false;
5935
+ menu.classList.remove('open');
5936
+ document.getElementById('filter-menu-btn')?.setAttribute('aria-expanded', 'false');
5937
+ document.removeEventListener('keydown', filterMenuKeydown, true);
5938
+ return true;
5939
+ }
5940
+
5941
+ // One row per filter control: the select it mirrors, its value, whether it differs from the
5942
+ // default, and two label forms — terse tokens for the header strip (which has ~15 characters
5943
+ // before the sidebar's width ellipsises it) and long form for its tooltip.
5944
+ const FILTERS = [
5945
+ {
5946
+ id: 'project-filter',
5947
+ value: () => filterProject ?? '',
5948
+ active: () => filterProject !== FILTER_DEFAULTS.project,
5949
+ short: () => (filterProject ? filterProject.split(/[/\\]/).pop() : 'all proj'),
5950
+ long: () => (filterProject ? `project ${filterProject}` : 'all projects'),
5951
+ },
5952
+ {
5953
+ id: 'session-filter',
5954
+ value: () => sessionFilter,
5955
+ active: () => sessionFilter !== FILTER_DEFAULTS.session,
5956
+ short: () => 'inactive',
5957
+ long: () => 'including inactive sessions',
5958
+ },
5959
+ {
5960
+ id: 'session-limit',
5961
+ value: () => sessionLimit,
5962
+ active: () => sessionLimit !== FILTER_DEFAULTS.limit,
5963
+ short: () => (sessionLimit === 'all' ? 'n=∞' : `n=${sessionLimit}`),
5964
+ long: () => (sessionLimit === 'all' ? 'no session limit' : `showing ${sessionLimit}`),
5965
+ },
5966
+ ];
5967
+
5968
+ // Keeps the header (summary + funnel tint) and the popover's selects in sync with state.
5969
+ // Safe to call on every filter change — it only touches classes, values, and text.
5970
+ function renderFilterState() {
5971
+ const short = [];
5972
+ const long = [];
5973
+ for (const f of FILTERS) {
5974
+ const active = f.active();
5975
+ if (active) {
5976
+ short.push(f.short());
5977
+ long.push(f.long());
5978
+ }
5979
+ const el = document.getElementById(f.id);
5980
+ if (!el) continue;
5981
+ const value = String(f.value());
5982
+ if (el.value !== value) el.value = value;
5983
+ el.classList.toggle('non-default', active);
5984
+ }
5985
+
5986
+ const summary = document.getElementById('filter-summary');
5987
+ if (summary) {
5988
+ summary.textContent = short.join(' · ');
5989
+ summary.title = long.length ? `Active filters: ${long.join(', ')}` : '';
5990
+ }
5991
+ document.getElementById('filter-menu-btn')?.classList.toggle('has-filters', short.length > 0);
5992
+ }
5993
+
5822
5994
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
5823
5995
  function filterBySessions(value) {
5824
5996
  sessionFilter = value;
5825
5997
  if (value !== 'active') activityFilter.clear();
5998
+ renderFilterState();
5826
5999
  updateUrl();
5827
6000
  // Instant feedback from cached data, then refetch — the cached list was fetched
5828
6001
  // with the previous filter's server params (e.g. filter=active), so "All Sessions"
@@ -5835,6 +6008,7 @@ function filterBySessions(value) {
5835
6008
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
5836
6009
  function changeSessionLimit(value) {
5837
6010
  sessionLimit = value;
6011
+ renderFilterState();
5838
6012
  updateUrl();
5839
6013
  fetchSessions();
5840
6014
  }
@@ -5848,6 +6022,31 @@ function matchesProjectFilter(project) {
5848
6022
  //#endregion
5849
6023
 
5850
6024
  //#region EVENT_DELEGATION
6025
+ // Selecting text inside a dialog and releasing past its edge puts the mouseup on the
6026
+ // backdrop, so the synthesized click targets the overlay and the `onclick="close…()"`
6027
+ // on every .modal-overlay would dismiss it. Only a press that *started* on the
6028
+ // backdrop counts as a dismissal.
6029
+ let _overlayPressTarget = null;
6030
+ document.addEventListener(
6031
+ 'mousedown',
6032
+ (e) => {
6033
+ // Only an overlay is ever compared below, so don't hold a reference to any other node —
6034
+ // that would keep the last-pressed element (and a detached subtree, after a re-render)
6035
+ // alive until the next mousedown anywhere.
6036
+ _overlayPressTarget = e.target.classList?.contains('modal-overlay') ? e.target : null;
6037
+ },
6038
+ true,
6039
+ );
6040
+ document.addEventListener(
6041
+ 'click',
6042
+ (e) => {
6043
+ if (!e.target.classList?.contains('modal-overlay')) return;
6044
+ if (_overlayPressTarget === e.target) return;
6045
+ e.stopPropagation();
6046
+ },
6047
+ true,
6048
+ );
6049
+
5851
6050
  document.addEventListener('click', (e) => {
5852
6051
  const pathToggle = e.target.closest('[data-group-action="toggle-path"]');
5853
6052
  if (pathToggle) {
@@ -5903,6 +6102,7 @@ document.addEventListener('click', (e) => {
5903
6102
  // Used in HTML; also called by the hub project shim, so no biome suppression is needed.
5904
6103
  function filterByProject(project) {
5905
6104
  filterProject = project || null;
6105
+ renderFilterState();
5906
6106
  updateUrl();
5907
6107
  fetchSessions(false);
5908
6108
  showAllTasks();
@@ -5964,6 +6164,8 @@ function renderProjectDropdown(dropdown, projects) {
5964
6164
  return `<option value="${escapeHtml(p.path)}"${selected} title="${escapeHtml(p.path)}">${escapeHtml(name)}</option>`;
5965
6165
  })
5966
6166
  .join('');
6167
+ // The option list just changed under the select — re-assert tint and header summary.
6168
+ renderFilterState();
5967
6169
  }
5968
6170
 
5969
6171
  function updateThemeColor(isLight) {
@@ -6187,14 +6389,6 @@ function loadPanelWidths() {
6187
6389
 
6188
6390
  //#endregion
6189
6391
 
6190
- //#region PREFERENCES
6191
- function loadPreferences() {
6192
- document.getElementById('session-filter').value = sessionFilter;
6193
- document.getElementById('session-limit').value = sessionLimit;
6194
- }
6195
-
6196
- //#endregion
6197
-
6198
6392
  //#region SESSION_INFO
6199
6393
  async function showSessionInfoModal(sessionId) {
6200
6394
  const session = sessions.find((s) => s.id === sessionId);
@@ -6281,7 +6475,7 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
6281
6475
  infoRows.push(['Session', session.id, { openClaudeDir: true, openFile: session.jsonlPath }]);
6282
6476
  if (parentInfo?.parentSessionId) {
6283
6477
  infoRows.push([
6284
- 'Forked from',
6478
+ parentInfo.relation === 'compact' ? 'Continued from' : 'Forked from',
6285
6479
  parentInfo.parentSessionId,
6286
6480
  { openClaudeDir: true, openFile: parentInfo.parentJsonlPath, openSession: parentInfo.parentSessionId },
6287
6481
  ]);
@@ -6319,20 +6513,17 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
6319
6513
  'font-family: var(--mono); font-size: 12px; color: var(--text-primary); user-select: all; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;';
6320
6514
  html += `<div class="team-modal-meta info-grid">`;
6321
6515
  infoRows.forEach(([label, value, opts]) => {
6322
- const copyVal = escapeHtml(value).replace(/"/g, '&quot;');
6323
6516
  html += `<span style="font-weight: 500; color: var(--text-secondary); font-size: 12px; white-space: nowrap;">${label}</span>`;
6324
6517
  if (opts?.openSession) {
6325
- const sid = _escapeForJsAttr(escapeHtml(opts.openSession).replace(/"/g, '&quot;'));
6326
- html += `<span onclick="openSessionFromInfo('${sid}')" style="${clickableStyle}" title="Open session in app">${escapeHtml(value)}</span>`;
6518
+ html += `<span onclick="openSessionFromInfo('${escAttrJs(opts.openSession)}')" style="${clickableStyle}" title="Open session in app">${escapeHtml(value)}</span>`;
6327
6519
  } else {
6328
- html += `<span style="${plainStyle}" title="${copyVal}">${escapeHtml(value)}</span>`;
6520
+ html += `<span style="${plainStyle}" title="${escapeHtml(value)}">${escapeHtml(value)}</span>`;
6329
6521
  }
6330
- const jsCopyVal = _escapeForJsAttr(copyVal);
6331
- const copyBtn = `<button onclick="copyWithFeedback('${jsCopyVal}', this)" title="Copy">${ICON_COPY}</button>`;
6522
+ const copyBtn = `<button onclick="copyWithFeedback('${escAttrJs(value)}', this)" title="Copy">${ICON_COPY}</button>`;
6332
6523
  let openBtn = '';
6333
6524
  if (opts?.openClaudeDir || opts?.openPath) {
6334
- const folder = opts.openClaudeDir ? '' : escapeHtml(opts.openPath).replace(/"/g, '&quot;');
6335
- const file = opts.openFile ? escapeHtml(opts.openFile).replace(/"/g, '&quot;') : '';
6525
+ const folder = opts.openClaudeDir ? '' : escapeHtml(opts.openPath);
6526
+ const file = opts.openFile ? escapeHtml(opts.openFile) : '';
6336
6527
  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>`;
6337
6528
  }
6338
6529
  html += `<span class="info-row-actions">${copyBtn}${openBtn}</span>`;
@@ -6421,7 +6612,9 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
6421
6612
  }
6422
6613
 
6423
6614
  bodyEl.innerHTML = html;
6424
- bindLinkedDocsHandlers(bodyEl, session.id);
6615
+ // Bind to the section, not bodyEl: bodyEl outlives the modal, so listeners on it
6616
+ // would stack up once per open and fire the click handler N times.
6617
+ bindLinkedDocsHandlers(bodyEl.querySelector('.linked-docs-section'), session.id);
6425
6618
  const alreadyVisible = modal.classList.contains('visible');
6426
6619
  _infoModalSessionId = session.id;
6427
6620
  updateStickyBtnState();
@@ -6596,7 +6789,7 @@ function renderGoalSubtitle(session) {
6596
6789
  // Only active (unmet) goals reach here — a met goal auto-clears. Clicking
6597
6790
  // opens the info modal (full text); stopPropagation so it doesn't also
6598
6791
  // trigger the card's fetchTasks.
6599
- return `<div class="session-goal" onclick="event.stopPropagation(); showSessionInfoModal('${session.id}')" title="${escapeHtml(g.condition)}"><span class="session-goal-icon">◎</span>${escapeHtml(short)}</div>`;
6792
+ 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>`;
6600
6793
  }
6601
6794
 
6602
6795
  function renderLoopBadge(session) {
@@ -6608,7 +6801,7 @@ function renderLoopBadge(session) {
6608
6801
  const f = fmtLoopFireTime(li.latest.timestamp, li.latest.delaySeconds);
6609
6802
  if (f.abs) tip += ` — latest ${f.status === 'pending' ? 'fires' : 'fired'} ${f.rel} (${f.abs})`;
6610
6803
  }
6611
- 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>`;
6804
+ 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>`;
6612
6805
  }
6613
6806
 
6614
6807
  function closeLoopModal() {
@@ -6626,7 +6819,7 @@ function renderWorkflowBadge(session) {
6626
6819
  if (!session.hasWorkflow) return '';
6627
6820
  const n = session.workflowCount || 0;
6628
6821
  const tip = `${n} workflow${n === 1 ? '' : 's'}`;
6629
- return `<span class="workflow-badge" onclick="event.stopPropagation(); showWorkflowModal('${session.id}')" title="${escapeHtml(tip)}">${WORKFLOW_GEAR_SVG}</span>`;
6822
+ return `<span class="workflow-badge" onclick="event.stopPropagation(); showWorkflowModal('${escAttrJs(session.id)}')" title="${escapeHtml(tip)}">${WORKFLOW_GEAR_SVG}</span>`;
6630
6823
  }
6631
6824
 
6632
6825
  let _workflowSessionId = null;
@@ -6675,11 +6868,11 @@ function renderWorkflowPicker(workflows) {
6675
6868
  body.innerHTML = workflows
6676
6869
  .map((w) => {
6677
6870
  const when = w.modifiedAt ? formatDate(w.modifiedAt) : '';
6678
- return `<div class="workflow-row" onclick="showWorkflowRun('${escapeHtml(w.id)}')">
6871
+ return `<div class="workflow-row" onclick="showWorkflowRun('${escAttrJs(w.id)}')">
6679
6872
  <div class="workflow-row-head">
6680
6873
  <span class="workflow-row-name">${escapeHtml(w.name || w.id)}</span>
6681
6874
  <span class="workflow-row-id"><code>${escapeHtml(w.id)}</code></span>
6682
- <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>
6875
+ <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>
6683
6876
  </div>
6684
6877
  ${w.description ? `<div class="workflow-row-desc">${escapeHtml(w.description)}</div>` : ''}
6685
6878
  ${when ? `<div class="workflow-row-meta">${escapeHtml(when)}</div>` : ''}
@@ -6745,7 +6938,7 @@ function renderWorkflowRun(run) {
6745
6938
  .map((a) => {
6746
6939
  const dur = a.durationMs != null ? formatDuration(a.durationMs) : '';
6747
6940
  const running = a.status !== 'done';
6748
- return `<div class="wf-agent" onclick="showAgentModal('${escapeHtml(a.agentId)}')" title="${escapeHtml(a.agentId)}">
6941
+ return `<div class="wf-agent" onclick="showAgentModal('${escAttrJs(a.agentId)}')" title="${escapeHtml(a.agentId)}">
6749
6942
  <span class="wf-agent-type">${escapeHtml(a.type || 'agent')}</span>
6750
6943
  <span class="wf-agent-model">${escapeHtml(shortModelName(a.model))}</span>
6751
6944
  <span class="wf-agent-tok">${a.outputTokens ? `${fmtTokens(a.outputTokens)} tok` : ''}</span>
@@ -6847,7 +7040,7 @@ function openPlanModal() {
6847
7040
  }
6848
7041
 
6849
7042
  function closePlanModal() {
6850
- resetModalFullscreen('plan-modal');
7043
+ hideModalOverlay('plan-modal');
6851
7044
  }
6852
7045
 
6853
7046
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
@@ -7117,6 +7310,8 @@ try {
7117
7310
  af.forEach((k) => activityFilter.add(k));
7118
7311
  } catch (_) {}
7119
7312
  initSidebarResize();
7313
+ applyModalZoom();
7314
+ loadModalFullscreen();
7120
7315
  loadPanelWidths();
7121
7316
  initPanelResize('detail-panel', 'detail-panel-resize', '--detail-panel-width', 'detail-panel-width');
7122
7317
  initPanelResize('message-panel', 'message-panel-resize', '--message-panel-width', 'message-panel-width');
@@ -7240,13 +7435,16 @@ fetch('/api/version')
7240
7435
  .catch(() => {});
7241
7436
 
7242
7437
  const urlState = getUrlState();
7243
- sessionFilter = urlState.filter || 'active';
7244
- sessionLimit = urlState.limit || '20';
7245
- filterProject = urlState.project || '__recent__';
7438
+ const lastView = loadLastView();
7439
+ sessionFilter = urlState.filter || FILTER_DEFAULTS.session;
7440
+ sessionLimit = urlState.limit || FILTER_DEFAULTS.limit;
7441
+ // The URL wins; the persisted view only fills in when it carries a project key, so 'all' (null)
7442
+ // restores as 'all' and pre-existing blobs without the key still default to '__recent__'.
7443
+ filterProject = urlState.project || (lastView && 'project' in lastView ? lastView.project : FILTER_DEFAULTS.project);
7246
7444
  ownerFilter = urlState.owner || '';
7247
7445
  searchQuery = urlState.search || '';
7248
7446
 
7249
- loadPreferences();
7447
+ renderFilterState();
7250
7448
  pinnedSessionIds = loadPinnedSessions();
7251
7449
  stickySessionIds = loadStickySessions();
7252
7450
  setupEventSource();
@@ -7308,12 +7506,12 @@ Promise.all([
7308
7506
 
7309
7507
  window.addEventListener('popstate', () => {
7310
7508
  const s = getUrlState();
7311
- sessionFilter = s.filter || 'active';
7312
- sessionLimit = s.limit || '20';
7313
- filterProject = s.project || '__recent__';
7509
+ sessionFilter = s.filter || FILTER_DEFAULTS.session;
7510
+ sessionLimit = s.limit || FILTER_DEFAULTS.limit;
7511
+ filterProject = s.project || FILTER_DEFAULTS.project;
7314
7512
  ownerFilter = s.owner || '';
7315
7513
  searchQuery = s.search || '';
7316
- loadPreferences();
7514
+ renderFilterState();
7317
7515
  // fetchSessions derives query params from the globals set above — refetch so
7318
7516
  // back/forward across a filter change doesn't render a stale server-filtered list.
7319
7517
  fetchSessions(false);
@@ -7334,7 +7532,7 @@ document.addEventListener('keydown', (e) => {
7334
7532
  if (!window.__HUB__?.enabled) return;
7335
7533
  const fwd = (key) => {
7336
7534
  e.preventDefault();
7337
- window.parent?.postMessage({ type: 'hub:keydown', key, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey }, '*');
7535
+ hubPost({ type: 'hub:keydown', key, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey });
7338
7536
  };
7339
7537
  if (e.ctrlKey && e.altKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
7340
7538
  fwd(e.key);
@@ -7364,17 +7562,24 @@ document.addEventListener('click', (e) => {
7364
7562
  if (url.protocol !== 'http:' && url.protocol !== 'https:') return;
7365
7563
  e.preventDefault();
7366
7564
  e.stopPropagation();
7367
- window.parent?.postMessage({ type: 'hub:openExternal', url: url.href }, '*');
7565
+ hubPost({ type: 'hub:openExternal', url: url.href });
7368
7566
  });
7369
7567
 
7370
7568
  window.hubNavigate = function hubNavigate(app, url) {
7371
7569
  if (!window.__HUB__?.enabled) return;
7372
- window.parent?.postMessage({ type: 'hub:navigate', app, url }, '*');
7570
+ hubPost({ type: 'hub:navigate', app, url });
7373
7571
  };
7374
7572
 
7375
7573
  // Hoisted out of initHubTheme so initHubProject can share it.
7376
7574
  const hubOrigin = () => (window.__HUB__?.url ? new URL(window.__HUB__.url).origin : null);
7377
7575
 
7576
+ // Every send is addressed to the hub explicitly. With targetOrigin '*' any page that
7577
+ // framed this app also received the forwarded keystrokes and navigation intents.
7578
+ function hubPost(message) {
7579
+ const origin = hubOrigin();
7580
+ if (origin) window.parent?.postMessage(message, origin);
7581
+ }
7582
+
7378
7583
  (function initHubTheme() {
7379
7584
  const getTheme = () => (document.body.classList.contains('light') ? 'light' : 'dark');
7380
7585
  const getColorTheme = () => document.body.dataset.colorTheme || 'ember';
@@ -7400,8 +7605,7 @@ const hubOrigin = () => (window.__HUB__?.url ? new URL(window.__HUB__.url).origi
7400
7605
  if (t === lastTheme && ct === lastColorTheme) return;
7401
7606
  lastTheme = t;
7402
7607
  lastColorTheme = ct;
7403
- const origin = hubOrigin();
7404
- if (origin) window.parent.postMessage({ type: 'hub:theme', theme: t, colorTheme: ct }, origin);
7608
+ hubPost({ type: 'hub:theme', theme: t, colorTheme: ct });
7405
7609
  }).observe(document.body, {
7406
7610
  attributes: true,
7407
7611
  attributeFilter: ['class', 'data-color-theme'],