claude-code-kanban 4.14.1 → 4.17.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
 
@@ -1328,6 +1335,8 @@ const ICON_COPY =
1328
1335
  '<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
1336
  const ICON_CHECKMARK =
1330
1337
  '<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>';
1338
+ const ICON_PLAN =
1339
+ '<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
1340
  const ICON_AGENT_WAITING =
1332
1341
  '<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
1342
  const ICON_AGENT_ACTIVE =
@@ -2940,15 +2949,15 @@ function renderSessions() {
2940
2949
  <div class="session-progress">
2941
2950
  <span class="session-indicators">
2942
2951
  ${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
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>` : ''}
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
2953
  ${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>` : ''}
2954
+ ${renderLoopBadge(session)}
2950
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>` : ''}
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>` : ''}
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>` : ''}
2960
+ ${session.sharedTaskList ? `<span class="shared-tasklist-badge" title="Shared task list: ${escapeHtml(session.sharedTaskList)}">${linkSvg(12)}</span>` : ''}
2952
2961
  ${session.hasWaitingForUser ? `<span class="agent-badge agent-badge-waiting" title="Waiting for user">${ICON_AGENT_WAITING}</span>` : ''}
2953
2962
  ${session.hasRunningAgents && !session.hasWaitingForUser ? `<span class="agent-badge agent-badge-active" title="Agents running">${ICON_AGENT_ACTIVE}</span>` : ''}
2954
2963
  ${isLive || session.hasRunningAgents ? `<span class="pulse" title="${isLive ? 'Live' : 'Active agents'}"></span>` : ''}
@@ -4204,8 +4213,8 @@ function showStorageManager() {
4204
4213
  document.querySelectorAll('.storage-tab').forEach((t) => {
4205
4214
  t.classList.toggle('active', t.dataset.tab === 'sessions');
4206
4215
  });
4207
- _renderStorageTab();
4208
4216
  document.getElementById('storage-modal').classList.add('visible');
4217
+ _renderStorageTab();
4209
4218
  }
4210
4219
 
4211
4220
  function closeStorageManager() {
@@ -4221,6 +4230,8 @@ function switchStorageTab(tab) {
4221
4230
  }
4222
4231
 
4223
4232
  function _renderStorageTab() {
4233
+ const modal = document.getElementById('storage-modal');
4234
+ if (!modal?.classList.contains('visible')) return;
4224
4235
  const body = document.getElementById('storage-modal-body');
4225
4236
  const tab = document.querySelector('.storage-tab.active')?.dataset.tab || 'sessions';
4226
4237
  if (tab === 'sessions') body.innerHTML = _renderStorageSessions();
@@ -4513,19 +4524,12 @@ function _storagePreviewLinkedDoc(path) {
4513
4524
  }
4514
4525
 
4515
4526
  function _storageUnlinkDoc(sessionId, path) {
4516
- removeSessionPreviewPath(sessionId, path);
4517
- if (sessionId === _infoModalSessionId) refreshInfoModalLinkedDocs();
4518
- renderSessions();
4519
- _renderStorageTab();
4520
- _updateStorageTotal();
4527
+ setSessionDocLink(sessionId, path, true);
4521
4528
  }
4522
4529
 
4523
4530
  function _storageClearLinkedDocs(sessionId) {
4524
4531
  localStorage.removeItem(PREVIEW_STORAGE_PREFIX + sessionId);
4525
- if (sessionId === _infoModalSessionId) refreshInfoModalLinkedDocs();
4526
- renderSessions();
4527
- _renderStorageTab();
4528
- _updateStorageTotal();
4532
+ afterLinkedDocsChanged(sessionId);
4529
4533
  }
4530
4534
 
4531
4535
  function _findOrphanedKeys() {
@@ -4946,6 +4950,25 @@ function removeSessionPreviewPath(sessionId, filePath) {
4946
4950
  else localStorage.removeItem(PREVIEW_STORAGE_PREFIX + sessionId);
4947
4951
  }
4948
4952
 
4953
+ // Every surface that shows linked docs — info modal, session card badge, preview
4954
+ // toolbar toggle, storage manager — refreshes from one place, so a new mutator
4955
+ // can never forget one of them.
4956
+ // The one mutator: link/unlink plus the refresh, so no caller can do half of it.
4957
+ function setSessionDocLink(sessionId, filePath, unlink) {
4958
+ if (!sessionId || !filePath) return;
4959
+ if (unlink) removeSessionPreviewPath(sessionId, filePath);
4960
+ else addSessionPreviewPath(sessionId, filePath);
4961
+ afterLinkedDocsChanged(sessionId);
4962
+ }
4963
+
4964
+ function afterLinkedDocsChanged(sessionId) {
4965
+ if (_infoModalSessionId === sessionId) refreshInfoModalLinkedDocs();
4966
+ renderSessions();
4967
+ updatePreviewLinkBtn();
4968
+ _renderStorageTab();
4969
+ _updateStorageTotal();
4970
+ }
4971
+
4949
4972
  function splitFrontmatter(text) {
4950
4973
  const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
4951
4974
  if (!m) return { fm: null, body: text };
@@ -4967,27 +4990,50 @@ function renderFrontmatterBlock(fm) {
4967
4990
  return `<details class="preview-fm" open><summary>frontmatter</summary><div class="fm-grid">${rows}</div></details>`;
4968
4991
  }
4969
4992
 
4970
- function openPreviewModal(filePath, content) {
4993
+ // Previewed HTML is rendered as authored, not sanitized: the sandbox without
4994
+ // 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.
4996
+ function renderHtmlPreview(bodyEl, content) {
4997
+ bodyEl.innerHTML = '';
4998
+ const frame = document.createElement('iframe');
4999
+ frame.className = 'preview-html-frame';
5000
+ frame.setAttribute('sandbox', 'allow-scripts allow-popups');
5001
+ frame.setAttribute('referrerpolicy', 'no-referrer');
5002
+ frame.srcdoc = content;
5003
+ bodyEl.appendChild(frame);
5004
+ }
5005
+
5006
+ function bindPreviewRelativeLinks(bodyEl) {
5007
+ if (bodyEl.dataset.relLinkBound) return;
5008
+ bodyEl.addEventListener('click', (e) => {
5009
+ const a = e.target.closest('a[href]');
5010
+ if (!a) return;
5011
+ const href = a.getAttribute('href');
5012
+ if (!href || href.startsWith('#')) return;
5013
+ const isAbsoluteUrl = /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith('//');
5014
+ const isAbsolutePath = href.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(href);
5015
+ if (isAbsoluteUrl) return;
5016
+ const cleanHref = href.replace(/#.*$/, '');
5017
+ if (!isPreviewablePath(cleanHref)) return;
5018
+ e.preventDefault();
5019
+ openPreviewByPath(cleanHref, isAbsolutePath ? undefined : currentPreviewPath);
5020
+ });
5021
+ bodyEl.dataset.relLinkBound = '1';
5022
+ }
5023
+
5024
+ // `kind` comes from the server, which is the only place that decides what is previewable.
5025
+ function openPreviewModal(filePath, content, kind) {
4971
5026
  currentPreviewPath = filePath;
4972
5027
  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
5028
  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';
5029
+ const isHtml = kind === 'html';
5030
+ document.querySelector('#preview-modal .modal').classList.toggle('preview-html', isHtml);
5031
+ bindPreviewRelativeLinks(bodyEl);
5032
+ if (isHtml) {
5033
+ renderHtmlPreview(bodyEl, content);
5034
+ } else {
5035
+ const { fm, body } = splitFrontmatter(content);
5036
+ bodyEl.innerHTML = (fm ? renderFrontmatterBlock(fm) : '') + renderMarkdown(body);
4991
5037
  }
4992
5038
  document.getElementById('preview-modal-meta').textContent = filePath;
4993
5039
  document.getElementById('preview-modal').classList.add('visible');
@@ -5018,49 +5064,32 @@ function togglePreviewSessionLink() {
5018
5064
  showToast('Select a session first');
5019
5065
  return;
5020
5066
  }
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();
5067
+ const unlink = isPreviewLinkedToCurrentSession();
5068
+ setSessionDocLink(currentSessionId, currentPreviewPath, unlink);
5069
+ showToast(unlink ? 'Unlinked from session' : 'Linked to session');
5033
5070
  }
5034
5071
 
5035
5072
  function refreshInfoModalLinkedDocs() {
5036
5073
  const bodyEl = document.getElementById('team-modal-body');
5037
5074
  if (!bodyEl) return;
5038
5075
  const existing = bodyEl.querySelector('.linked-docs-section');
5039
- const html = renderLinkedDocsHtml(_infoModalSessionId);
5040
- if (!existing) {
5041
- if (!html) return;
5076
+ const wrap = document.createElement('div');
5077
+ wrap.innerHTML = renderLinkedDocsHtml(_infoModalSessionId);
5078
+ const node = wrap.firstElementChild;
5079
+ if (existing) {
5080
+ existing.replaceWith(node);
5081
+ } else {
5042
5082
  const planCard = bodyEl.querySelector('[data-plan-card]');
5043
- const wrap = document.createElement('div');
5044
- wrap.innerHTML = html;
5045
- const node = wrap.firstElementChild;
5046
5083
  if (planCard?.nextSibling) planCard.parentNode.insertBefore(node, planCard.nextSibling);
5047
5084
  else bodyEl.appendChild(node);
5048
- bindLinkedDocsHandlers(node, _infoModalSessionId);
5049
- return;
5050
- }
5051
- if (!html) {
5052
- existing.remove();
5053
- return;
5054
5085
  }
5055
- const wrap = document.createElement('div');
5056
- wrap.innerHTML = html;
5057
- const node = wrap.firstElementChild;
5058
- existing.replaceWith(node);
5059
5086
  bindLinkedDocsHandlers(node, _infoModalSessionId);
5060
5087
  }
5061
5088
 
5062
5089
  function closePreviewModal() {
5063
5090
  resetModalFullscreen('preview-modal');
5091
+ // Empty the body so an iframe preview is destroyed and its scripts/timers stop.
5092
+ document.getElementById('preview-modal-body').innerHTML = '';
5064
5093
  currentPreviewPath = null;
5065
5094
  }
5066
5095
 
@@ -5081,7 +5110,7 @@ async function openPreviewByPath(filePath, base) {
5081
5110
  return;
5082
5111
  }
5083
5112
  const data = await r.json();
5084
- openPreviewModal(data.path, data.content);
5113
+ openPreviewModal(data.path, data.content, data.kind);
5085
5114
  } catch {
5086
5115
  showToast('Failed to load preview');
5087
5116
  }
@@ -5108,7 +5137,7 @@ function handleSessionOpenEvent(data) {
5108
5137
  }
5109
5138
 
5110
5139
  async function handlePreviewOpenEvent(data) {
5111
- const { path: filePath, content, sessionId } = data;
5140
+ const { path: filePath, sessionId } = data;
5112
5141
  if (sessionId && sessionId !== currentSessionId) {
5113
5142
  if (sessions.find((s) => s.id === sessionId)) {
5114
5143
  await fetchTasks(sessionId);
@@ -5116,7 +5145,17 @@ async function handlePreviewOpenEvent(data) {
5116
5145
  showToast(`Preview received for unknown session ${sessionId.slice(0, 8)}`);
5117
5146
  }
5118
5147
  }
5119
- openPreviewModal(filePath, content);
5148
+ // The broadcast carries the path only — each tab fetches the document itself.
5149
+ openPreviewByPath(filePath);
5150
+ }
5151
+
5152
+ // Linked docs live in localStorage, so the CLI can only reach them through a tab:
5153
+ // the server broadcasts the resolved path and every tab applies it idempotently.
5154
+ function handleDocumentLinkEvent(data) {
5155
+ const { path: filePath, sessionId, unlink } = data;
5156
+ if (!filePath || !sessionId) return;
5157
+ setSessionDocLink(sessionId, filePath, unlink);
5158
+ showToast(`${unlink ? 'Unlinked' : 'Linked'} ${filePath.split(/[\\/]/).pop()}`);
5120
5159
  }
5121
5160
 
5122
5161
  function getSessionBaseDir(sessionId) {
@@ -5124,42 +5163,123 @@ function getSessionBaseDir(sessionId) {
5124
5163
  return s?.cwd || s?.project || '';
5125
5164
  }
5126
5165
 
5166
+ // Linked paths are stored as bare strings, so previewability is re-derived from the
5167
+ // extension on every render instead of being remembered alongside the path.
5168
+ function isPreviewablePath(p) {
5169
+ return /\.(md|markdown|html?)$/i.test(p);
5170
+ }
5171
+
5127
5172
  function renderLinkedDocsHtml(sessionId) {
5128
5173
  const paths = getSessionPreviewPaths(sessionId);
5129
- if (!paths.length) return '';
5130
5174
  const baseDir = getSessionBaseDir(sessionId);
5131
5175
  const items = paths
5132
- .map((p, i) => {
5176
+ .map((p) => {
5133
5177
  const name = p.split(/[\\/]/).pop();
5134
5178
  const rel = baseDir ? toRelativeIfUnder(p, baseDir) : null;
5179
+ const previewable = isPreviewablePath(p);
5180
+ const title = previewable ? p : `${p} — opens in editor`;
5135
5181
  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}
5182
+ const attr = escapeHtml(p);
5183
+ return `<li class="linked-doc-item${previewable ? '' : ' is-editor'}">
5184
+ <a href="#" class="linked-doc-link" data-path="${attr}" title="${escapeHtml(title)}">${escapeHtml(name)}</a>
5185
+ ${pathSpan}${previewable ? '' : '<span class="linked-doc-path">(editor)</span>'}
5186
+ <button type="button" class="linked-doc-remove" data-path="${attr}" title="Unlink" aria-label="Unlink ${escapeHtml(name)}">&times;</button>
5139
5187
  </li>`;
5140
5188
  })
5141
5189
  .join('');
5190
+ // Rendered even when empty — the add button has to stay reachable.
5191
+ const body = paths.length
5192
+ ? `<ul class="linked-doc-list">${items}</ul>`
5193
+ : '<div class="linked-docs-empty">No linked files yet</div>';
5142
5194
  return `<div class="linked-docs-section" style="margin-bottom:16px;font-size:12px;">
5143
5195
  <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
5196
  ${linkSvg(12)}
5145
5197
  <span>Linked documents</span>
5146
5198
  <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>
5199
+ <button type="button" class="linked-docs-add-btn" title="Link a file" aria-label="Link a file">+</button>
5147
5200
  </div>
5148
- <ul class="linked-doc-list">${items}</ul>
5201
+ <div class="linked-doc-editor-slot"></div>
5202
+ ${body}
5149
5203
  </div>`;
5150
5204
  }
5151
5205
 
5206
+ // One delegated listener per section: the list is re-rendered on every change, so
5207
+ // per-row handlers would only be rebound each time anyway.
5152
5208
  function bindLinkedDocsHandlers(container, sessionId) {
5153
5209
  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) => {
5210
+ container.addEventListener('click', (e) => {
5211
+ const hit = e.target.closest('.linked-doc-link, .linked-doc-remove, .linked-docs-add-btn');
5212
+ if (!hit) return;
5213
+ e.preventDefault();
5214
+ if (hit.classList.contains('linked-docs-add-btn')) {
5215
+ startLinkedDocInput(container, sessionId);
5216
+ } else if (hit.classList.contains('linked-doc-remove')) {
5217
+ removeSessionPreviewPath(sessionId, hit.dataset.path);
5218
+ afterLinkedDocsChanged(sessionId);
5219
+ } else if (isPreviewablePath(hit.dataset.path)) {
5220
+ openPreviewByPath(hit.dataset.path, getSessionBaseDir(sessionId));
5221
+ } else {
5222
+ postAndToast('/api/open-in-editor', { file: hit.dataset.path }, 'in editor');
5223
+ }
5224
+ });
5225
+ }
5226
+
5227
+ function startLinkedDocInput(container, sessionId) {
5228
+ const slot = container.querySelector('.linked-doc-editor-slot');
5229
+ if (!slot) return;
5230
+ slot.innerHTML = `<input class="linked-doc-input" type="text" spellcheck="false" placeholder="Absolute path, or relative to the session cwd">
5231
+ <div class="linked-doc-hint">${escapeHtml(getSessionBaseDir(sessionId) || 'no session cwd — absolute paths only')}</div>
5232
+ <div class="linked-doc-error"></div>
5233
+ <div class="edit-actions">
5234
+ <button type="button" class="edit-cancel">Cancel</button>
5235
+ <button type="button" class="edit-save">Link</button>
5236
+ </div>`;
5237
+ const input = slot.querySelector('.linked-doc-input');
5238
+ const save = () => linkFileByPath(sessionId, input.value, slot);
5239
+ const cancel = () => {
5240
+ slot.innerHTML = '';
5241
+ };
5242
+ slot.querySelector('.edit-save').addEventListener('click', save);
5243
+ slot.querySelector('.edit-cancel').addEventListener('click', cancel);
5244
+ input.addEventListener('keydown', (e) => {
5245
+ if (e.key === 'Enter') {
5160
5246
  e.preventDefault();
5161
- openPreviewByPath(paths[+link.dataset.idx], base);
5162
- });
5247
+ save();
5248
+ } else if (e.key === 'Escape') {
5249
+ e.preventDefault();
5250
+ cancel();
5251
+ }
5252
+ });
5253
+ input.focus();
5254
+ }
5255
+
5256
+ async function linkFileByPath(sessionId, raw, slot) {
5257
+ // Paths pasted from Explorer or a shell often arrive quoted.
5258
+ const value = (raw || '').trim().replace(/^["']|["']$/g, '');
5259
+ // Reported inline so the input stays open and the path can be corrected in place.
5260
+ const fail = (msg) => {
5261
+ const err = slot?.querySelector('.linked-doc-error');
5262
+ if (err) err.textContent = msg;
5263
+ else showToast(msg, 'error');
5264
+ };
5265
+ if (!value) {
5266
+ fail('Enter a file path');
5267
+ return;
5268
+ }
5269
+ try {
5270
+ const qs = new URLSearchParams({ path: value });
5271
+ const base = getSessionBaseDir(sessionId);
5272
+ if (base) qs.set('base', base);
5273
+ const r = await fetch(`/api/file/resolve?${qs}`);
5274
+ const data = await r.json().catch(() => ({}));
5275
+ if (!r.ok) {
5276
+ fail(data.error || 'File not found');
5277
+ return;
5278
+ }
5279
+ setSessionDocLink(sessionId, data.path, false);
5280
+ showToast('Linked to session', 'success');
5281
+ } catch {
5282
+ fail('Failed to resolve file');
5163
5283
  }
5164
5284
  }
5165
5285
  //#endregion
@@ -5281,6 +5401,10 @@ function setupEventSource() {
5281
5401
  handlePreviewOpenEvent(data);
5282
5402
  }
5283
5403
 
5404
+ if (data.type === 'document:link') {
5405
+ handleDocumentLinkEvent(data);
5406
+ }
5407
+
5284
5408
  if (data.type === 'session:open') {
5285
5409
  handleSessionOpenEvent(data);
5286
5410
  }
@@ -5702,10 +5826,95 @@ function getOwnerColor(name) {
5702
5826
  //#endregion
5703
5827
 
5704
5828
  //#region FILTERS
5829
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
5830
+ function toggleFilterMenu(e) {
5831
+ // Stops the click reaching .section-header (which would collapse the section) *and*
5832
+ // document (which would immediately close the menu we are opening).
5833
+ e?.stopPropagation();
5834
+ if (closeFilterMenu()) return;
5835
+ document.getElementById('filter-menu').classList.add('open');
5836
+ document.getElementById('filter-menu-btn')?.setAttribute('aria-expanded', 'true');
5837
+ renderFilterState();
5838
+ document.addEventListener('click', closeFilterMenu, { once: true });
5839
+ // Capture phase: the global keydown handler bails out on SELECT targets, and every
5840
+ // focusable thing in this popover is a select — Escape would never reach it otherwise.
5841
+ document.addEventListener('keydown', filterMenuKeydown, true);
5842
+ document.getElementById('project-filter')?.focus();
5843
+ }
5844
+
5845
+ function filterMenuKeydown(e) {
5846
+ if (e.key !== 'Escape') return;
5847
+ e.stopPropagation();
5848
+ if (closeFilterMenu()) document.getElementById('filter-menu-btn')?.focus();
5849
+ }
5850
+
5851
+ function closeFilterMenu() {
5852
+ const menu = document.getElementById('filter-menu');
5853
+ if (!menu?.classList.contains('open')) return false;
5854
+ menu.classList.remove('open');
5855
+ document.getElementById('filter-menu-btn')?.setAttribute('aria-expanded', 'false');
5856
+ document.removeEventListener('keydown', filterMenuKeydown, true);
5857
+ return true;
5858
+ }
5859
+
5860
+ // One row per filter control: the select it mirrors, its value, whether it differs from the
5861
+ // default, and two label forms — terse tokens for the header strip (which has ~15 characters
5862
+ // before the sidebar's width ellipsises it) and long form for its tooltip.
5863
+ const FILTERS = [
5864
+ {
5865
+ id: 'project-filter',
5866
+ value: () => filterProject ?? '',
5867
+ active: () => filterProject !== FILTER_DEFAULTS.project,
5868
+ short: () => (filterProject ? filterProject.split(/[/\\]/).pop() : 'all proj'),
5869
+ long: () => (filterProject ? `project ${filterProject}` : 'all projects'),
5870
+ },
5871
+ {
5872
+ id: 'session-filter',
5873
+ value: () => sessionFilter,
5874
+ active: () => sessionFilter !== FILTER_DEFAULTS.session,
5875
+ short: () => 'inactive',
5876
+ long: () => 'including inactive sessions',
5877
+ },
5878
+ {
5879
+ id: 'session-limit',
5880
+ value: () => sessionLimit,
5881
+ active: () => sessionLimit !== FILTER_DEFAULTS.limit,
5882
+ short: () => (sessionLimit === 'all' ? 'n=∞' : `n=${sessionLimit}`),
5883
+ long: () => (sessionLimit === 'all' ? 'no session limit' : `showing ${sessionLimit}`),
5884
+ },
5885
+ ];
5886
+
5887
+ // Keeps the header (summary + funnel tint) and the popover's selects in sync with state.
5888
+ // Safe to call on every filter change — it only touches classes, values, and text.
5889
+ function renderFilterState() {
5890
+ const short = [];
5891
+ const long = [];
5892
+ for (const f of FILTERS) {
5893
+ const active = f.active();
5894
+ if (active) {
5895
+ short.push(f.short());
5896
+ long.push(f.long());
5897
+ }
5898
+ const el = document.getElementById(f.id);
5899
+ if (!el) continue;
5900
+ const value = String(f.value());
5901
+ if (el.value !== value) el.value = value;
5902
+ el.classList.toggle('non-default', active);
5903
+ }
5904
+
5905
+ const summary = document.getElementById('filter-summary');
5906
+ if (summary) {
5907
+ summary.textContent = short.join(' · ');
5908
+ summary.title = long.length ? `Active filters: ${long.join(', ')}` : '';
5909
+ }
5910
+ document.getElementById('filter-menu-btn')?.classList.toggle('has-filters', short.length > 0);
5911
+ }
5912
+
5705
5913
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
5706
5914
  function filterBySessions(value) {
5707
5915
  sessionFilter = value;
5708
5916
  if (value !== 'active') activityFilter.clear();
5917
+ renderFilterState();
5709
5918
  updateUrl();
5710
5919
  // Instant feedback from cached data, then refetch — the cached list was fetched
5711
5920
  // with the previous filter's server params (e.g. filter=active), so "All Sessions"
@@ -5718,6 +5927,7 @@ function filterBySessions(value) {
5718
5927
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
5719
5928
  function changeSessionLimit(value) {
5720
5929
  sessionLimit = value;
5930
+ renderFilterState();
5721
5931
  updateUrl();
5722
5932
  fetchSessions();
5723
5933
  }
@@ -5731,6 +5941,31 @@ function matchesProjectFilter(project) {
5731
5941
  //#endregion
5732
5942
 
5733
5943
  //#region EVENT_DELEGATION
5944
+ // Selecting text inside a dialog and releasing past its edge puts the mouseup on the
5945
+ // backdrop, so the synthesized click targets the overlay and the `onclick="close…()"`
5946
+ // on every .modal-overlay would dismiss it. Only a press that *started* on the
5947
+ // backdrop counts as a dismissal.
5948
+ let _overlayPressTarget = null;
5949
+ document.addEventListener(
5950
+ 'mousedown',
5951
+ (e) => {
5952
+ // Only an overlay is ever compared below, so don't hold a reference to any other node —
5953
+ // that would keep the last-pressed element (and a detached subtree, after a re-render)
5954
+ // alive until the next mousedown anywhere.
5955
+ _overlayPressTarget = e.target.classList?.contains('modal-overlay') ? e.target : null;
5956
+ },
5957
+ true,
5958
+ );
5959
+ document.addEventListener(
5960
+ 'click',
5961
+ (e) => {
5962
+ if (!e.target.classList?.contains('modal-overlay')) return;
5963
+ if (_overlayPressTarget === e.target) return;
5964
+ e.stopPropagation();
5965
+ },
5966
+ true,
5967
+ );
5968
+
5734
5969
  document.addEventListener('click', (e) => {
5735
5970
  const pathToggle = e.target.closest('[data-group-action="toggle-path"]');
5736
5971
  if (pathToggle) {
@@ -5783,9 +6018,10 @@ document.addEventListener('click', (e) => {
5783
6018
  }
5784
6019
  });
5785
6020
 
5786
- // biome-ignore lint/correctness/noUnusedVariables: used in HTML
6021
+ // Used in HTML; also called by the hub project shim, so no biome suppression is needed.
5787
6022
  function filterByProject(project) {
5788
6023
  filterProject = project || null;
6024
+ renderFilterState();
5789
6025
  updateUrl();
5790
6026
  fetchSessions(false);
5791
6027
  showAllTasks();
@@ -5831,16 +6067,24 @@ async function updateProjectDropdown() {
5831
6067
 
5832
6068
  function renderProjectDropdown(dropdown, projects) {
5833
6069
  const recentSelected = filterProject === '__recent__' ? ' selected' : '';
6070
+ // A hub-pushed project with no session history isn't in /api/projects, so synthesize its
6071
+ // option here rather than in the hub shim — this also survives the SSE re-render below.
6072
+ const list =
6073
+ filterProject && filterProject !== '__recent__' && !projects.some((p) => p.path === filterProject)
6074
+ ? [...projects, { path: filterProject, modifiedAt: null }]
6075
+ : projects;
5834
6076
  dropdown.innerHTML =
5835
6077
  '<option value="">All Projects</option>' +
5836
6078
  `<option value="__recent__"${recentSelected}>Recent (24h)</option>` +
5837
- projects
6079
+ list
5838
6080
  .map((p) => {
5839
6081
  const name = p.path.split(/[/\\]/).pop();
5840
6082
  const selected = p.path === filterProject ? ' selected' : '';
5841
6083
  return `<option value="${escapeHtml(p.path)}"${selected} title="${escapeHtml(p.path)}">${escapeHtml(name)}</option>`;
5842
6084
  })
5843
6085
  .join('');
6086
+ // The option list just changed under the select — re-assert tint and header summary.
6087
+ renderFilterState();
5844
6088
  }
5845
6089
 
5846
6090
  function updateThemeColor(isLight) {
@@ -5926,7 +6170,7 @@ function buildThemeMenu() {
5926
6170
  const menu = document.getElementById('themeMenu');
5927
6171
  menu.innerHTML = COLOR_THEMES.map(
5928
6172
  ([id, label]) =>
5929
- `<button type="button" class="theme-menu-item" data-theme-id="${id}"
6173
+ `<button type="button" class="theme-menu-item theme-swatch-${id}" data-theme-id="${id}"
5930
6174
  onclick="event.stopPropagation(); setColorTheme('${id}'); toggleThemeMenu()">
5931
6175
  <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
6176
  </button>`,
@@ -6064,14 +6308,6 @@ function loadPanelWidths() {
6064
6308
 
6065
6309
  //#endregion
6066
6310
 
6067
- //#region PREFERENCES
6068
- function loadPreferences() {
6069
- document.getElementById('session-filter').value = sessionFilter;
6070
- document.getElementById('session-limit').value = sessionLimit;
6071
- }
6072
-
6073
- //#endregion
6074
-
6075
6311
  //#region SESSION_INFO
6076
6312
  async function showSessionInfoModal(sessionId) {
6077
6313
  const session = sessions.find((s) => s.id === sessionId);
@@ -6316,6 +6552,8 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
6316
6552
 
6317
6553
  const keyHandler = (e) => {
6318
6554
  if (e.key === 'Escape') {
6555
+ // An inline editor inside the modal owns Escape first — it reverts itself.
6556
+ if (['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)) return;
6319
6557
  if (document.getElementById('plan-modal').classList.contains('visible')) return;
6320
6558
  if (document.getElementById('tool-stats-modal').classList.contains('visible')) return;
6321
6559
  e.preventDefault();
@@ -7115,13 +7353,16 @@ fetch('/api/version')
7115
7353
  .catch(() => {});
7116
7354
 
7117
7355
  const urlState = getUrlState();
7118
- sessionFilter = urlState.filter || 'active';
7119
- sessionLimit = urlState.limit || '20';
7120
- filterProject = urlState.project || '__recent__';
7356
+ const lastView = loadLastView();
7357
+ sessionFilter = urlState.filter || FILTER_DEFAULTS.session;
7358
+ sessionLimit = urlState.limit || FILTER_DEFAULTS.limit;
7359
+ // The URL wins; the persisted view only fills in when it carries a project key, so 'all' (null)
7360
+ // restores as 'all' and pre-existing blobs without the key still default to '__recent__'.
7361
+ filterProject = urlState.project || (lastView && 'project' in lastView ? lastView.project : FILTER_DEFAULTS.project);
7121
7362
  ownerFilter = urlState.owner || '';
7122
7363
  searchQuery = urlState.search || '';
7123
7364
 
7124
- loadPreferences();
7365
+ renderFilterState();
7125
7366
  pinnedSessionIds = loadPinnedSessions();
7126
7367
  stickySessionIds = loadStickySessions();
7127
7368
  setupEventSource();
@@ -7183,12 +7424,12 @@ Promise.all([
7183
7424
 
7184
7425
  window.addEventListener('popstate', () => {
7185
7426
  const s = getUrlState();
7186
- sessionFilter = s.filter || 'active';
7187
- sessionLimit = s.limit || '20';
7188
- filterProject = s.project || '__recent__';
7427
+ sessionFilter = s.filter || FILTER_DEFAULTS.session;
7428
+ sessionLimit = s.limit || FILTER_DEFAULTS.limit;
7429
+ filterProject = s.project || FILTER_DEFAULTS.project;
7189
7430
  ownerFilter = s.owner || '';
7190
7431
  searchQuery = s.search || '';
7191
- loadPreferences();
7432
+ renderFilterState();
7192
7433
  // fetchSessions derives query params from the globals set above — refetch so
7193
7434
  // back/forward across a filter change doesn't render a stale server-filtered list.
7194
7435
  fetchSessions(false);
@@ -7207,13 +7448,19 @@ window.addEventListener('popstate', () => {
7207
7448
  // #region HUB_INTEGRATION
7208
7449
  document.addEventListener('keydown', (e) => {
7209
7450
  if (!window.__HUB__?.enabled) return;
7210
- if (e.ctrlKey && e.altKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
7451
+ const fwd = (key) => {
7211
7452
  e.preventDefault();
7212
- window.parent?.postMessage({ type: 'hub:keydown', key: e.key }, '*');
7453
+ window.parent?.postMessage({ type: 'hub:keydown', key, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey }, '*');
7454
+ };
7455
+ if (e.ctrlKey && e.altKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
7456
+ fwd(e.key);
7457
+ }
7458
+ // Its own branch: the Alt+digit case below requires !ctrlKey.
7459
+ if (e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey && e.key.toLowerCase() === 'p') {
7460
+ fwd(e.key);
7213
7461
  }
7214
7462
  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 }, '*');
7463
+ fwd(e.key);
7217
7464
  }
7218
7465
  });
7219
7466
 
@@ -7241,10 +7488,12 @@ window.hubNavigate = function hubNavigate(app, url) {
7241
7488
  window.parent?.postMessage({ type: 'hub:navigate', app, url }, '*');
7242
7489
  };
7243
7490
 
7491
+ // Hoisted out of initHubTheme so initHubProject can share it.
7492
+ const hubOrigin = () => (window.__HUB__?.url ? new URL(window.__HUB__.url).origin : null);
7493
+
7244
7494
  (function initHubTheme() {
7245
7495
  const getTheme = () => (document.body.classList.contains('light') ? 'light' : 'dark');
7246
7496
  const getColorTheme = () => document.body.dataset.colorTheme || 'ember';
7247
- const hubOrigin = () => (window.__HUB__?.url ? new URL(window.__HUB__.url).origin : null);
7248
7497
  // lastTheme/lastColorTheme are updated synchronously when applying a hub
7249
7498
  // message, so the (async) observer sees no diff and doesn't echo it back.
7250
7499
  let lastTheme = getTheme();
@@ -7274,4 +7523,15 @@ window.hubNavigate = function hubNavigate(app, url) {
7274
7523
  attributeFilter: ['class', 'data-color-theme'],
7275
7524
  });
7276
7525
  })();
7526
+
7527
+ (function initHubProject() {
7528
+ window.addEventListener('message', (e) => {
7529
+ if (e.source !== window.parent || e.origin !== hubOrigin()) return;
7530
+ if (e.data?.type !== 'hub:project') return;
7531
+ const dirPath = e.data.project;
7532
+ if (typeof dirPath !== 'string' || !dirPath || dirPath === filterProject) return;
7533
+ filterByProject(dirPath);
7534
+ updateProjectDropdown();
7535
+ });
7536
+ })();
7277
7537
  // #endregion HUB_INTEGRATION