claude-code-kanban 4.14.1 → 4.16.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
@@ -1328,6 +1328,8 @@ const ICON_COPY =
1328
1328
  '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
1329
1329
  const ICON_CHECKMARK =
1330
1330
  '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M20 6L9 17l-5-5"/></svg>';
1331
+ const ICON_PLAN =
1332
+ '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>';
1331
1333
  const ICON_AGENT_WAITING =
1332
1334
  '<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"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>';
1333
1335
  const ICON_AGENT_ACTIVE =
@@ -2940,15 +2942,15 @@ function renderSessions() {
2940
2942
  <div class="session-progress">
2941
2943
  <span class="session-indicators">
2942
2944
  ${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>` : ''}
2943
- ${session.sharedTaskList ? `<span class="shared-tasklist-badge" title="Shared task list: ${escapeHtml(session.sharedTaskList)}">${linkSvg(12)}</span>` : ''}
2944
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>` : ''}
2945
- ${session.hasPlan && !session.planSourceSessionId ? `<span class="plan-indicator" onclick="event.stopPropagation(); openPlanForSession('${session.id}')" title="View plan"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg></span>` : ''}
2946
- ${renderLoopBadge(session)}
2947
2946
  ${renderWorkflowBadge(session)}
2948
- ${linkedDocsCount > 0 ? `<span class="linked-docs-badge" onclick="event.stopPropagation(); showSessionInfoModal('${session.id}')" title="${linkedDocsCount} linked document${linkedDocsCount > 1 ? 's' : ''}">${linkSvg(10)}${linkedDocsCount}</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>` : ''}
2947
+ ${renderLoopBadge(session)}
2950
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>` : ''}
2951
- ${session.planSourceSessionId ? `<span class="plan-indicator" title="Implements plan — click to reveal plan session" onclick="event.stopPropagation(); revealPlanSession('${escapeHtml(session.planSourceSessionId)}')"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></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>` : ''}
2953
+ ${session.sharedTaskList ? `<span class="shared-tasklist-badge" title="Shared task list: ${escapeHtml(session.sharedTaskList)}">${linkSvg(12)}</span>` : ''}
2952
2954
  ${session.hasWaitingForUser ? `<span class="agent-badge agent-badge-waiting" title="Waiting for user">${ICON_AGENT_WAITING}</span>` : ''}
2953
2955
  ${session.hasRunningAgents && !session.hasWaitingForUser ? `<span class="agent-badge agent-badge-active" title="Agents running">${ICON_AGENT_ACTIVE}</span>` : ''}
2954
2956
  ${isLive || session.hasRunningAgents ? `<span class="pulse" title="${isLive ? 'Live' : 'Active agents'}"></span>` : ''}
@@ -4204,8 +4206,8 @@ function showStorageManager() {
4204
4206
  document.querySelectorAll('.storage-tab').forEach((t) => {
4205
4207
  t.classList.toggle('active', t.dataset.tab === 'sessions');
4206
4208
  });
4207
- _renderStorageTab();
4208
4209
  document.getElementById('storage-modal').classList.add('visible');
4210
+ _renderStorageTab();
4209
4211
  }
4210
4212
 
4211
4213
  function closeStorageManager() {
@@ -4221,6 +4223,8 @@ function switchStorageTab(tab) {
4221
4223
  }
4222
4224
 
4223
4225
  function _renderStorageTab() {
4226
+ const modal = document.getElementById('storage-modal');
4227
+ if (!modal?.classList.contains('visible')) return;
4224
4228
  const body = document.getElementById('storage-modal-body');
4225
4229
  const tab = document.querySelector('.storage-tab.active')?.dataset.tab || 'sessions';
4226
4230
  if (tab === 'sessions') body.innerHTML = _renderStorageSessions();
@@ -4513,19 +4517,12 @@ function _storagePreviewLinkedDoc(path) {
4513
4517
  }
4514
4518
 
4515
4519
  function _storageUnlinkDoc(sessionId, path) {
4516
- removeSessionPreviewPath(sessionId, path);
4517
- if (sessionId === _infoModalSessionId) refreshInfoModalLinkedDocs();
4518
- renderSessions();
4519
- _renderStorageTab();
4520
- _updateStorageTotal();
4520
+ setSessionDocLink(sessionId, path, true);
4521
4521
  }
4522
4522
 
4523
4523
  function _storageClearLinkedDocs(sessionId) {
4524
4524
  localStorage.removeItem(PREVIEW_STORAGE_PREFIX + sessionId);
4525
- if (sessionId === _infoModalSessionId) refreshInfoModalLinkedDocs();
4526
- renderSessions();
4527
- _renderStorageTab();
4528
- _updateStorageTotal();
4525
+ afterLinkedDocsChanged(sessionId);
4529
4526
  }
4530
4527
 
4531
4528
  function _findOrphanedKeys() {
@@ -4946,6 +4943,25 @@ function removeSessionPreviewPath(sessionId, filePath) {
4946
4943
  else localStorage.removeItem(PREVIEW_STORAGE_PREFIX + sessionId);
4947
4944
  }
4948
4945
 
4946
+ // Every surface that shows linked docs — info modal, session card badge, preview
4947
+ // toolbar toggle, storage manager — refreshes from one place, so a new mutator
4948
+ // can never forget one of them.
4949
+ // The one mutator: link/unlink plus the refresh, so no caller can do half of it.
4950
+ function setSessionDocLink(sessionId, filePath, unlink) {
4951
+ if (!sessionId || !filePath) return;
4952
+ if (unlink) removeSessionPreviewPath(sessionId, filePath);
4953
+ else addSessionPreviewPath(sessionId, filePath);
4954
+ afterLinkedDocsChanged(sessionId);
4955
+ }
4956
+
4957
+ function afterLinkedDocsChanged(sessionId) {
4958
+ if (_infoModalSessionId === sessionId) refreshInfoModalLinkedDocs();
4959
+ renderSessions();
4960
+ updatePreviewLinkBtn();
4961
+ _renderStorageTab();
4962
+ _updateStorageTotal();
4963
+ }
4964
+
4949
4965
  function splitFrontmatter(text) {
4950
4966
  const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
4951
4967
  if (!m) return { fm: null, body: text };
@@ -4967,27 +4983,50 @@ function renderFrontmatterBlock(fm) {
4967
4983
  return `<details class="preview-fm" open><summary>frontmatter</summary><div class="fm-grid">${rows}</div></details>`;
4968
4984
  }
4969
4985
 
4970
- function openPreviewModal(filePath, content) {
4986
+ // Previewed HTML is rendered as authored, not sanitized: the sandbox without
4987
+ // 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.
4989
+ function renderHtmlPreview(bodyEl, content) {
4990
+ bodyEl.innerHTML = '';
4991
+ const frame = document.createElement('iframe');
4992
+ frame.className = 'preview-html-frame';
4993
+ frame.setAttribute('sandbox', 'allow-scripts allow-popups');
4994
+ frame.setAttribute('referrerpolicy', 'no-referrer');
4995
+ frame.srcdoc = content;
4996
+ bodyEl.appendChild(frame);
4997
+ }
4998
+
4999
+ function bindPreviewRelativeLinks(bodyEl) {
5000
+ if (bodyEl.dataset.relLinkBound) return;
5001
+ bodyEl.addEventListener('click', (e) => {
5002
+ const a = e.target.closest('a[href]');
5003
+ if (!a) return;
5004
+ const href = a.getAttribute('href');
5005
+ if (!href || href.startsWith('#')) return;
5006
+ const isAbsoluteUrl = /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith('//');
5007
+ const isAbsolutePath = href.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(href);
5008
+ if (isAbsoluteUrl) return;
5009
+ const cleanHref = href.replace(/#.*$/, '');
5010
+ if (!isPreviewablePath(cleanHref)) return;
5011
+ e.preventDefault();
5012
+ openPreviewByPath(cleanHref, isAbsolutePath ? undefined : currentPreviewPath);
5013
+ });
5014
+ bodyEl.dataset.relLinkBound = '1';
5015
+ }
5016
+
5017
+ // `kind` comes from the server, which is the only place that decides what is previewable.
5018
+ function openPreviewModal(filePath, content, kind) {
4971
5019
  currentPreviewPath = filePath;
4972
5020
  document.getElementById('preview-modal-title').textContent = filePath.split(/[\\/]/).pop();
4973
- const { fm, body } = /\.(md|markdown)$/i.test(filePath) ? splitFrontmatter(content) : { fm: null, body: content };
4974
5021
  const bodyEl = document.getElementById('preview-modal-body');
4975
- bodyEl.innerHTML = (fm ? renderFrontmatterBlock(fm) : '') + renderMarkdown(body);
4976
- if (!bodyEl.dataset.relLinkBound) {
4977
- bodyEl.addEventListener('click', (e) => {
4978
- const a = e.target.closest('a[href]');
4979
- if (!a) return;
4980
- const href = a.getAttribute('href');
4981
- if (!href || href.startsWith('#')) return;
4982
- const isAbsoluteUrl = /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith('//');
4983
- const isAbsolutePath = href.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(href);
4984
- if (isAbsoluteUrl) return;
4985
- if (!/\.(md|markdown)(#.*)?$/i.test(href)) return;
4986
- e.preventDefault();
4987
- const cleanHref = href.replace(/#.*$/, '');
4988
- openPreviewByPath(cleanHref, isAbsolutePath ? undefined : currentPreviewPath);
4989
- });
4990
- bodyEl.dataset.relLinkBound = '1';
5022
+ const isHtml = kind === 'html';
5023
+ document.querySelector('#preview-modal .modal').classList.toggle('preview-html', isHtml);
5024
+ bindPreviewRelativeLinks(bodyEl);
5025
+ if (isHtml) {
5026
+ renderHtmlPreview(bodyEl, content);
5027
+ } else {
5028
+ const { fm, body } = splitFrontmatter(content);
5029
+ bodyEl.innerHTML = (fm ? renderFrontmatterBlock(fm) : '') + renderMarkdown(body);
4991
5030
  }
4992
5031
  document.getElementById('preview-modal-meta').textContent = filePath;
4993
5032
  document.getElementById('preview-modal').classList.add('visible');
@@ -5018,49 +5057,32 @@ function togglePreviewSessionLink() {
5018
5057
  showToast('Select a session first');
5019
5058
  return;
5020
5059
  }
5021
- if (isPreviewLinkedToCurrentSession()) {
5022
- removeSessionPreviewPath(currentSessionId, currentPreviewPath);
5023
- showToast('Unlinked from session');
5024
- } else {
5025
- addSessionPreviewPath(currentSessionId, currentPreviewPath);
5026
- showToast('Linked to session');
5027
- }
5028
- updatePreviewLinkBtn();
5029
- if (_infoModalSessionId === currentSessionId) {
5030
- refreshInfoModalLinkedDocs();
5031
- }
5032
- renderSessions();
5060
+ const unlink = isPreviewLinkedToCurrentSession();
5061
+ setSessionDocLink(currentSessionId, currentPreviewPath, unlink);
5062
+ showToast(unlink ? 'Unlinked from session' : 'Linked to session');
5033
5063
  }
5034
5064
 
5035
5065
  function refreshInfoModalLinkedDocs() {
5036
5066
  const bodyEl = document.getElementById('team-modal-body');
5037
5067
  if (!bodyEl) return;
5038
5068
  const existing = bodyEl.querySelector('.linked-docs-section');
5039
- const html = renderLinkedDocsHtml(_infoModalSessionId);
5040
- if (!existing) {
5041
- if (!html) return;
5069
+ const wrap = document.createElement('div');
5070
+ wrap.innerHTML = renderLinkedDocsHtml(_infoModalSessionId);
5071
+ const node = wrap.firstElementChild;
5072
+ if (existing) {
5073
+ existing.replaceWith(node);
5074
+ } else {
5042
5075
  const planCard = bodyEl.querySelector('[data-plan-card]');
5043
- const wrap = document.createElement('div');
5044
- wrap.innerHTML = html;
5045
- const node = wrap.firstElementChild;
5046
5076
  if (planCard?.nextSibling) planCard.parentNode.insertBefore(node, planCard.nextSibling);
5047
5077
  else bodyEl.appendChild(node);
5048
- bindLinkedDocsHandlers(node, _infoModalSessionId);
5049
- return;
5050
- }
5051
- if (!html) {
5052
- existing.remove();
5053
- return;
5054
5078
  }
5055
- const wrap = document.createElement('div');
5056
- wrap.innerHTML = html;
5057
- const node = wrap.firstElementChild;
5058
- existing.replaceWith(node);
5059
5079
  bindLinkedDocsHandlers(node, _infoModalSessionId);
5060
5080
  }
5061
5081
 
5062
5082
  function closePreviewModal() {
5063
5083
  resetModalFullscreen('preview-modal');
5084
+ // Empty the body so an iframe preview is destroyed and its scripts/timers stop.
5085
+ document.getElementById('preview-modal-body').innerHTML = '';
5064
5086
  currentPreviewPath = null;
5065
5087
  }
5066
5088
 
@@ -5081,7 +5103,7 @@ async function openPreviewByPath(filePath, base) {
5081
5103
  return;
5082
5104
  }
5083
5105
  const data = await r.json();
5084
- openPreviewModal(data.path, data.content);
5106
+ openPreviewModal(data.path, data.content, data.kind);
5085
5107
  } catch {
5086
5108
  showToast('Failed to load preview');
5087
5109
  }
@@ -5108,7 +5130,7 @@ function handleSessionOpenEvent(data) {
5108
5130
  }
5109
5131
 
5110
5132
  async function handlePreviewOpenEvent(data) {
5111
- const { path: filePath, content, sessionId } = data;
5133
+ const { path: filePath, sessionId } = data;
5112
5134
  if (sessionId && sessionId !== currentSessionId) {
5113
5135
  if (sessions.find((s) => s.id === sessionId)) {
5114
5136
  await fetchTasks(sessionId);
@@ -5116,7 +5138,17 @@ async function handlePreviewOpenEvent(data) {
5116
5138
  showToast(`Preview received for unknown session ${sessionId.slice(0, 8)}`);
5117
5139
  }
5118
5140
  }
5119
- openPreviewModal(filePath, content);
5141
+ // The broadcast carries the path only — each tab fetches the document itself.
5142
+ openPreviewByPath(filePath);
5143
+ }
5144
+
5145
+ // Linked docs live in localStorage, so the CLI can only reach them through a tab:
5146
+ // the server broadcasts the resolved path and every tab applies it idempotently.
5147
+ function handleDocumentLinkEvent(data) {
5148
+ const { path: filePath, sessionId, unlink } = data;
5149
+ if (!filePath || !sessionId) return;
5150
+ setSessionDocLink(sessionId, filePath, unlink);
5151
+ showToast(`${unlink ? 'Unlinked' : 'Linked'} ${filePath.split(/[\\/]/).pop()}`);
5120
5152
  }
5121
5153
 
5122
5154
  function getSessionBaseDir(sessionId) {
@@ -5124,42 +5156,123 @@ function getSessionBaseDir(sessionId) {
5124
5156
  return s?.cwd || s?.project || '';
5125
5157
  }
5126
5158
 
5159
+ // Linked paths are stored as bare strings, so previewability is re-derived from the
5160
+ // extension on every render instead of being remembered alongside the path.
5161
+ function isPreviewablePath(p) {
5162
+ return /\.(md|markdown|html?)$/i.test(p);
5163
+ }
5164
+
5127
5165
  function renderLinkedDocsHtml(sessionId) {
5128
5166
  const paths = getSessionPreviewPaths(sessionId);
5129
- if (!paths.length) return '';
5130
5167
  const baseDir = getSessionBaseDir(sessionId);
5131
5168
  const items = paths
5132
- .map((p, i) => {
5169
+ .map((p) => {
5133
5170
  const name = p.split(/[\\/]/).pop();
5134
5171
  const rel = baseDir ? toRelativeIfUnder(p, baseDir) : null;
5172
+ const previewable = isPreviewablePath(p);
5173
+ const title = previewable ? p : `${p} — opens in editor`;
5135
5174
  const pathSpan = rel ? `<span class="linked-doc-path" title="${escapeHtml(p)}">${escapeHtml(rel)}</span>` : '';
5136
- return `<li class="linked-doc-item">
5137
- <a href="#" class="linked-doc-link" data-idx="${i}" title="${escapeHtml(p)}">${escapeHtml(name)}</a>
5138
- ${pathSpan}
5175
+ const attr = escapeHtml(p);
5176
+ return `<li class="linked-doc-item${previewable ? '' : ' is-editor'}">
5177
+ <a href="#" class="linked-doc-link" data-path="${attr}" title="${escapeHtml(title)}">${escapeHtml(name)}</a>
5178
+ ${pathSpan}${previewable ? '' : '<span class="linked-doc-path">(editor)</span>'}
5179
+ <button type="button" class="linked-doc-remove" data-path="${attr}" title="Unlink" aria-label="Unlink ${escapeHtml(name)}">&times;</button>
5139
5180
  </li>`;
5140
5181
  })
5141
5182
  .join('');
5183
+ // Rendered even when empty — the add button has to stay reachable.
5184
+ const body = paths.length
5185
+ ? `<ul class="linked-doc-list">${items}</ul>`
5186
+ : '<div class="linked-docs-empty">No linked files yet</div>';
5142
5187
  return `<div class="linked-docs-section" style="margin-bottom:16px;font-size:12px;">
5143
5188
  <div style="font-size:11px;font-weight:500;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px;display:flex;align-items:center;gap:6px;">
5144
5189
  ${linkSvg(12)}
5145
5190
  <span>Linked documents</span>
5146
5191
  <span style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:10px;padding:0 6px;font-size:10px;color:var(--text-secondary);">${paths.length}</span>
5192
+ <button type="button" class="linked-docs-add-btn" title="Link a file" aria-label="Link a file">+</button>
5147
5193
  </div>
5148
- <ul class="linked-doc-list">${items}</ul>
5194
+ <div class="linked-doc-editor-slot"></div>
5195
+ ${body}
5149
5196
  </div>`;
5150
5197
  }
5151
5198
 
5199
+ // One delegated listener per section: the list is re-rendered on every change, so
5200
+ // per-row handlers would only be rebound each time anyway.
5152
5201
  function bindLinkedDocsHandlers(container, sessionId) {
5153
5202
  if (!container) return;
5154
- const links = container.querySelectorAll('.linked-doc-link');
5155
- if (!links.length) return;
5156
- const paths = getSessionPreviewPaths(sessionId);
5157
- const base = getSessionBaseDir(sessionId);
5158
- for (const link of links) {
5159
- link.addEventListener('click', (e) => {
5203
+ container.addEventListener('click', (e) => {
5204
+ const hit = e.target.closest('.linked-doc-link, .linked-doc-remove, .linked-docs-add-btn');
5205
+ if (!hit) return;
5206
+ e.preventDefault();
5207
+ if (hit.classList.contains('linked-docs-add-btn')) {
5208
+ startLinkedDocInput(container, sessionId);
5209
+ } else if (hit.classList.contains('linked-doc-remove')) {
5210
+ removeSessionPreviewPath(sessionId, hit.dataset.path);
5211
+ afterLinkedDocsChanged(sessionId);
5212
+ } else if (isPreviewablePath(hit.dataset.path)) {
5213
+ openPreviewByPath(hit.dataset.path, getSessionBaseDir(sessionId));
5214
+ } else {
5215
+ postAndToast('/api/open-in-editor', { file: hit.dataset.path }, 'in editor');
5216
+ }
5217
+ });
5218
+ }
5219
+
5220
+ function startLinkedDocInput(container, sessionId) {
5221
+ const slot = container.querySelector('.linked-doc-editor-slot');
5222
+ if (!slot) return;
5223
+ slot.innerHTML = `<input class="linked-doc-input" type="text" spellcheck="false" placeholder="Absolute path, or relative to the session cwd">
5224
+ <div class="linked-doc-hint">${escapeHtml(getSessionBaseDir(sessionId) || 'no session cwd — absolute paths only')}</div>
5225
+ <div class="linked-doc-error"></div>
5226
+ <div class="edit-actions">
5227
+ <button type="button" class="edit-cancel">Cancel</button>
5228
+ <button type="button" class="edit-save">Link</button>
5229
+ </div>`;
5230
+ const input = slot.querySelector('.linked-doc-input');
5231
+ const save = () => linkFileByPath(sessionId, input.value, slot);
5232
+ const cancel = () => {
5233
+ slot.innerHTML = '';
5234
+ };
5235
+ slot.querySelector('.edit-save').addEventListener('click', save);
5236
+ slot.querySelector('.edit-cancel').addEventListener('click', cancel);
5237
+ input.addEventListener('keydown', (e) => {
5238
+ if (e.key === 'Enter') {
5160
5239
  e.preventDefault();
5161
- openPreviewByPath(paths[+link.dataset.idx], base);
5162
- });
5240
+ save();
5241
+ } else if (e.key === 'Escape') {
5242
+ e.preventDefault();
5243
+ cancel();
5244
+ }
5245
+ });
5246
+ input.focus();
5247
+ }
5248
+
5249
+ async function linkFileByPath(sessionId, raw, slot) {
5250
+ // Paths pasted from Explorer or a shell often arrive quoted.
5251
+ const value = (raw || '').trim().replace(/^["']|["']$/g, '');
5252
+ // Reported inline so the input stays open and the path can be corrected in place.
5253
+ const fail = (msg) => {
5254
+ const err = slot?.querySelector('.linked-doc-error');
5255
+ if (err) err.textContent = msg;
5256
+ else showToast(msg, 'error');
5257
+ };
5258
+ if (!value) {
5259
+ fail('Enter a file path');
5260
+ return;
5261
+ }
5262
+ try {
5263
+ const qs = new URLSearchParams({ path: value });
5264
+ const base = getSessionBaseDir(sessionId);
5265
+ if (base) qs.set('base', base);
5266
+ const r = await fetch(`/api/file/resolve?${qs}`);
5267
+ const data = await r.json().catch(() => ({}));
5268
+ if (!r.ok) {
5269
+ fail(data.error || 'File not found');
5270
+ return;
5271
+ }
5272
+ setSessionDocLink(sessionId, data.path, false);
5273
+ showToast('Linked to session', 'success');
5274
+ } catch {
5275
+ fail('Failed to resolve file');
5163
5276
  }
5164
5277
  }
5165
5278
  //#endregion
@@ -5281,6 +5394,10 @@ function setupEventSource() {
5281
5394
  handlePreviewOpenEvent(data);
5282
5395
  }
5283
5396
 
5397
+ if (data.type === 'document:link') {
5398
+ handleDocumentLinkEvent(data);
5399
+ }
5400
+
5284
5401
  if (data.type === 'session:open') {
5285
5402
  handleSessionOpenEvent(data);
5286
5403
  }
@@ -5783,7 +5900,7 @@ document.addEventListener('click', (e) => {
5783
5900
  }
5784
5901
  });
5785
5902
 
5786
- // biome-ignore lint/correctness/noUnusedVariables: used in HTML
5903
+ // Used in HTML; also called by the hub project shim, so no biome suppression is needed.
5787
5904
  function filterByProject(project) {
5788
5905
  filterProject = project || null;
5789
5906
  updateUrl();
@@ -5831,10 +5948,16 @@ async function updateProjectDropdown() {
5831
5948
 
5832
5949
  function renderProjectDropdown(dropdown, projects) {
5833
5950
  const recentSelected = filterProject === '__recent__' ? ' selected' : '';
5951
+ // A hub-pushed project with no session history isn't in /api/projects, so synthesize its
5952
+ // option here rather than in the hub shim — this also survives the SSE re-render below.
5953
+ const list =
5954
+ filterProject && filterProject !== '__recent__' && !projects.some((p) => p.path === filterProject)
5955
+ ? [...projects, { path: filterProject, modifiedAt: null }]
5956
+ : projects;
5834
5957
  dropdown.innerHTML =
5835
5958
  '<option value="">All Projects</option>' +
5836
5959
  `<option value="__recent__"${recentSelected}>Recent (24h)</option>` +
5837
- projects
5960
+ list
5838
5961
  .map((p) => {
5839
5962
  const name = p.path.split(/[/\\]/).pop();
5840
5963
  const selected = p.path === filterProject ? ' selected' : '';
@@ -5926,7 +6049,7 @@ function buildThemeMenu() {
5926
6049
  const menu = document.getElementById('themeMenu');
5927
6050
  menu.innerHTML = COLOR_THEMES.map(
5928
6051
  ([id, label]) =>
5929
- `<button type="button" class="theme-menu-item" data-theme-id="${id}"
6052
+ `<button type="button" class="theme-menu-item theme-swatch-${id}" data-theme-id="${id}"
5930
6053
  onclick="event.stopPropagation(); setColorTheme('${id}'); toggleThemeMenu()">
5931
6054
  <span class="theme-swatch theme-swatch-${id}"><i class="sw-bg"></i><i class="sw-accent"></i><i class="sw-ink"></i></span>${label}
5932
6055
  </button>`,
@@ -6316,6 +6439,8 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
6316
6439
 
6317
6440
  const keyHandler = (e) => {
6318
6441
  if (e.key === 'Escape') {
6442
+ // An inline editor inside the modal owns Escape first — it reverts itself.
6443
+ if (['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)) return;
6319
6444
  if (document.getElementById('plan-modal').classList.contains('visible')) return;
6320
6445
  if (document.getElementById('tool-stats-modal').classList.contains('visible')) return;
6321
6446
  e.preventDefault();
@@ -7207,13 +7332,19 @@ window.addEventListener('popstate', () => {
7207
7332
  // #region HUB_INTEGRATION
7208
7333
  document.addEventListener('keydown', (e) => {
7209
7334
  if (!window.__HUB__?.enabled) return;
7210
- if (e.ctrlKey && e.altKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
7335
+ const fwd = (key) => {
7211
7336
  e.preventDefault();
7212
- window.parent?.postMessage({ type: 'hub:keydown', key: e.key }, '*');
7337
+ window.parent?.postMessage({ type: 'hub:keydown', key, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey }, '*');
7338
+ };
7339
+ if (e.ctrlKey && e.altKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
7340
+ fwd(e.key);
7341
+ }
7342
+ // Its own branch: the Alt+digit case below requires !ctrlKey.
7343
+ if (e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey && e.key.toLowerCase() === 'p') {
7344
+ fwd(e.key);
7213
7345
  }
7214
7346
  if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey && /^[1-9]$/.test(e.key)) {
7215
- e.preventDefault();
7216
- window.parent?.postMessage({ type: 'hub:keydown', key: e.key }, '*');
7347
+ fwd(e.key);
7217
7348
  }
7218
7349
  });
7219
7350
 
@@ -7241,10 +7372,12 @@ window.hubNavigate = function hubNavigate(app, url) {
7241
7372
  window.parent?.postMessage({ type: 'hub:navigate', app, url }, '*');
7242
7373
  };
7243
7374
 
7375
+ // Hoisted out of initHubTheme so initHubProject can share it.
7376
+ const hubOrigin = () => (window.__HUB__?.url ? new URL(window.__HUB__.url).origin : null);
7377
+
7244
7378
  (function initHubTheme() {
7245
7379
  const getTheme = () => (document.body.classList.contains('light') ? 'light' : 'dark');
7246
7380
  const getColorTheme = () => document.body.dataset.colorTheme || 'ember';
7247
- const hubOrigin = () => (window.__HUB__?.url ? new URL(window.__HUB__.url).origin : null);
7248
7381
  // lastTheme/lastColorTheme are updated synchronously when applying a hub
7249
7382
  // message, so the (async) observer sees no diff and doesn't echo it back.
7250
7383
  let lastTheme = getTheme();
@@ -7274,4 +7407,15 @@ window.hubNavigate = function hubNavigate(app, url) {
7274
7407
  attributeFilter: ['class', 'data-color-theme'],
7275
7408
  });
7276
7409
  })();
7410
+
7411
+ (function initHubProject() {
7412
+ window.addEventListener('message', (e) => {
7413
+ if (e.source !== window.parent || e.origin !== hubOrigin()) return;
7414
+ if (e.data?.type !== 'hub:project') return;
7415
+ const dirPath = e.data.project;
7416
+ if (typeof dirPath !== 'string' || !dirPath || dirPath === filterProject) return;
7417
+ filterByProject(dirPath);
7418
+ updateProjectDropdown();
7419
+ });
7420
+ })();
7277
7421
  // #endregion HUB_INTEGRATION
package/public/index.html CHANGED
@@ -291,7 +291,7 @@
291
291
  </div>
292
292
  </div>
293
293
 
294
- <!-- Markdown Preview Modal -->
294
+ <!-- File Preview Modal (markdown rendered inline, HTML in a sandboxed iframe) -->
295
295
  <div id="preview-modal" class="modal-overlay" style="z-index:10002;" onclick="closePreviewModal()">
296
296
  <div class="modal preview-modal-dialog" onclick="event.stopPropagation()">
297
297
  <div class="modal-header">
@@ -311,9 +311,9 @@
311
311
  </button>
312
312
  </div>
313
313
  </div>
314
- <div class="modal-body" style="flex: 0 1 auto; min-height: 0; display: flex; flex-direction: column; overflow: hidden;">
315
- <div id="preview-modal-body" class="rendered-md" style="word-break: break-word; font-size: 13px; line-height: 1.6; overflow-y: auto; flex: 0 1 auto; min-height: 0; padding-right: 8px;"></div>
316
- <div id="preview-modal-meta" style="margin-top: 12px; font-size: 11px; color: var(--text-muted); flex-shrink: 0;"></div>
314
+ <div class="modal-body">
315
+ <div id="preview-modal-body" class="rendered-md"></div>
316
+ <div id="preview-modal-meta"></div>
317
317
  </div>
318
318
  </div>
319
319
  </div>