claude-code-kanban 4.14.0 → 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 =
@@ -2634,7 +2636,7 @@ function showAgentModal(agentId) {
2634
2636
  const modalNameLabel = agent.agentName ? ` · ${escapeHtml(agent.agentName)}` : '';
2635
2637
  title.innerHTML = `${statusDot} ${escapeHtml(agent.type || 'unknown')}${modalNameLabel}`;
2636
2638
 
2637
- const shortModel = agent.model ? agent.model.replace(/^claude-/, '').replace(/-\d{8}$/, '') : null;
2639
+ const shortModel = shortModelName(agent.model);
2638
2640
  const shortId = agent.agentId ? agent.agentId.slice(0, 8) : '';
2639
2641
  const chip = (label, value, opts = {}) => {
2640
2642
  const cls = opts.cls ? ` ${opts.cls}` : '';
@@ -2648,7 +2650,7 @@ function showAgentModal(agentId) {
2648
2650
  // toolUseResult), not on the agent-activity record — so read them from there.
2649
2651
  const agentMsg = currentMessages.find((m) => m.tool === 'Agent' && m.agentId === agentId);
2650
2652
  const usageStats = agentMsg?.agentUsageStats || null;
2651
- const fmtTok = (t) => (t >= 1000 ? `${(t / 1000).toFixed(1).replace(/\.0$/, '')}k` : `${t}`);
2653
+ const fmtTok = fmtTokens;
2652
2654
 
2653
2655
  const chips = [];
2654
2656
  if (agent.agentId) chips.push(chip('id', escapeHtml(shortId), { cls: 'agent-chip-mono', title: agent.agentId }));
@@ -2796,7 +2798,7 @@ function renderSessions() {
2796
2798
  ((!s.sharedTaskList && (s.pending > 0 || s.inProgress > 0)) ||
2797
2799
  s.hasActiveAgents ||
2798
2800
  s.hasWaitingForUser ||
2799
- s.hasRecentLog);
2801
+ s.hasRecentActivity);
2800
2802
  if (isActive) activeSessionIds.add(s.id);
2801
2803
  return isActive;
2802
2804
  });
@@ -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
5078
  }
5051
- if (!html) {
5052
- existing.remove();
5053
- return;
5054
- }
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
  }
@@ -5707,8 +5824,12 @@ function filterBySessions(value) {
5707
5824
  sessionFilter = value;
5708
5825
  if (value !== 'active') activityFilter.clear();
5709
5826
  updateUrl();
5827
+ // Instant feedback from cached data, then refetch — the cached list was fetched
5828
+ // with the previous filter's server params (e.g. filter=active), so "All Sessions"
5829
+ // needs a server round-trip to actually include inactive sessions.
5710
5830
  renderSessions();
5711
5831
  renderActivityChip();
5832
+ fetchSessions(false);
5712
5833
  }
5713
5834
 
5714
5835
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
@@ -5779,7 +5900,7 @@ document.addEventListener('click', (e) => {
5779
5900
  }
5780
5901
  });
5781
5902
 
5782
- // 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.
5783
5904
  function filterByProject(project) {
5784
5905
  filterProject = project || null;
5785
5906
  updateUrl();
@@ -5827,10 +5948,16 @@ async function updateProjectDropdown() {
5827
5948
 
5828
5949
  function renderProjectDropdown(dropdown, projects) {
5829
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;
5830
5957
  dropdown.innerHTML =
5831
5958
  '<option value="">All Projects</option>' +
5832
5959
  `<option value="__recent__"${recentSelected}>Recent (24h)</option>` +
5833
- projects
5960
+ list
5834
5961
  .map((p) => {
5835
5962
  const name = p.path.split(/[/\\]/).pop();
5836
5963
  const selected = p.path === filterProject ? ' selected' : '';
@@ -5922,7 +6049,7 @@ function buildThemeMenu() {
5922
6049
  const menu = document.getElementById('themeMenu');
5923
6050
  menu.innerHTML = COLOR_THEMES.map(
5924
6051
  ([id, label]) =>
5925
- `<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}"
5926
6053
  onclick="event.stopPropagation(); setColorTheme('${id}'); toggleThemeMenu()">
5927
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}
5928
6055
  </button>`,
@@ -6312,6 +6439,8 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
6312
6439
 
6313
6440
  const keyHandler = (e) => {
6314
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;
6315
6444
  if (document.getElementById('plan-modal').classList.contains('visible')) return;
6316
6445
  if (document.getElementById('tool-stats-modal').classList.contains('visible')) return;
6317
6446
  e.preventDefault();
@@ -6502,6 +6631,7 @@ function renderWorkflowBadge(session) {
6502
6631
 
6503
6632
  let _workflowSessionId = null;
6504
6633
  let _workflowHeaderId = null;
6634
+ let _workflowList = [];
6505
6635
 
6506
6636
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
6507
6637
  function showWorkflowModal(sessionId) {
@@ -6513,7 +6643,18 @@ function showWorkflowModal(sessionId) {
6513
6643
  fetch(`/api/sessions/${sessionId}/workflows`)
6514
6644
  .then((r) => (r.ok ? r.json() : { workflows: [] }))
6515
6645
  .catch(() => ({ workflows: [] }))
6516
- .then((data) => renderWorkflowList(data.workflows || []));
6646
+ .then((data) => {
6647
+ _workflowList = data.workflows || [];
6648
+ if (!_workflowList.length) {
6649
+ setWorkflowHeader('Workflows', null);
6650
+ body.innerHTML =
6651
+ '<div style="padding:24px;text-align:center;color:var(--text-secondary);">No workflow scripts for this session.</div>';
6652
+ } else if (_workflowList.length === 1) {
6653
+ showWorkflowRun(_workflowList[0].id);
6654
+ } else {
6655
+ renderWorkflowPicker(_workflowList);
6656
+ }
6657
+ });
6517
6658
  }
6518
6659
 
6519
6660
  function setWorkflowHeader(name, wfId) {
@@ -6527,44 +6668,118 @@ function setWorkflowHeader(name, wfId) {
6527
6668
  if (headerBtn) headerBtn.style.display = wfId ? '' : 'none';
6528
6669
  }
6529
6670
 
6530
- function renderWorkflowList(workflows) {
6531
- const body = document.getElementById('workflow-modal-body');
6532
- if (!workflows.length) {
6533
- setWorkflowHeader('Workflows', null);
6534
- body.innerHTML =
6535
- '<div style="padding:24px;text-align:center;color:var(--text-secondary);">No workflow scripts for this session.</div>';
6536
- return;
6537
- }
6538
- // Single workflow: promote its name to the header and open-button next to the close (X).
6539
- if (workflows.length === 1) {
6540
- const w = workflows[0];
6541
- setWorkflowHeader(w.name || w.id, w.id);
6542
- const when = w.modifiedAt ? formatDate(w.modifiedAt) : '';
6543
- body.innerHTML = `<div class="workflow-single">
6544
- <div class="workflow-row-meta"><code>${escapeHtml(w.id)}</code>${when ? ` · ${escapeHtml(when)}` : ''}</div>
6545
- <div class="workflow-row-code"></div>
6546
- </div>`;
6547
- loadWorkflowCode(w.id, body.querySelector('.workflow-row-code'));
6548
- return;
6549
- }
6550
- // Multiple workflows: generic header, collapsible list with per-row open icons.
6671
+ // Multiple workflows: pick one to drill into (we show a single run at a time).
6672
+ function renderWorkflowPicker(workflows) {
6551
6673
  setWorkflowHeader('Workflows', null);
6674
+ const body = document.getElementById('workflow-modal-body');
6552
6675
  body.innerHTML = workflows
6553
6676
  .map((w) => {
6554
6677
  const when = w.modifiedAt ? formatDate(w.modifiedAt) : '';
6555
- return `<div class="workflow-row" onclick="viewWorkflowScript('${escapeHtml(w.id)}', this)">
6678
+ return `<div class="workflow-row" onclick="showWorkflowRun('${escapeHtml(w.id)}')">
6556
6679
  <div class="workflow-row-head">
6557
6680
  <span class="workflow-row-name">${escapeHtml(w.name || w.id)}</span>
6558
6681
  <span class="workflow-row-id"><code>${escapeHtml(w.id)}</code></span>
6559
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>
6560
6683
  </div>
6684
+ ${w.description ? `<div class="workflow-row-desc">${escapeHtml(w.description)}</div>` : ''}
6561
6685
  ${when ? `<div class="workflow-row-meta">${escapeHtml(when)}</div>` : ''}
6562
- <div class="workflow-row-code"></div>
6563
6686
  </div>`;
6564
6687
  })
6565
6688
  .join('');
6566
6689
  }
6567
6690
 
6691
+ function showWorkflowRun(wfId) {
6692
+ const name = _workflowList.find((w) => w.id === wfId)?.name || wfId;
6693
+ setWorkflowHeader(name, wfId);
6694
+ const body = document.getElementById('workflow-modal-body');
6695
+ body.innerHTML = '<div style="padding:16px;color:var(--text-secondary);">Loading…</div>';
6696
+ fetch(`/api/sessions/${_workflowSessionId}/workflows/${encodeURIComponent(wfId)}/run`)
6697
+ .then((r) => (r.ok ? r.json() : null))
6698
+ .catch(() => null)
6699
+ .then((run) => {
6700
+ if (!run) {
6701
+ body.innerHTML = '<div style="padding:16px;color:var(--text-secondary);">Failed to load run state.</div>';
6702
+ return;
6703
+ }
6704
+ renderWorkflowRun(run);
6705
+ });
6706
+ }
6707
+
6708
+ function fmtTokens(t) {
6709
+ return t >= 1000 ? `${(t / 1000).toFixed(1).replace(/\.0$/, '')}k` : `${t}`;
6710
+ }
6711
+
6712
+ function shortModelName(model) {
6713
+ return model ? model.replace(/^claude-/, '').replace(/-\d{8}$/, '') : '';
6714
+ }
6715
+
6716
+ function renderWorkflowRun(run) {
6717
+ const body = document.getElementById('workflow-modal-body');
6718
+ const allDone = run.startedCount > 0 && run.doneCount >= run.startedCount;
6719
+ const wall =
6720
+ run.startedAt && run.stoppedAt ? formatDuration(new Date(run.stoppedAt) - new Date(run.startedAt)) : null;
6721
+
6722
+ const back =
6723
+ _workflowList.length > 1
6724
+ ? `<div class="wf-back" onclick="renderWorkflowPicker(_workflowList)">‹ All workflows</div>`
6725
+ : '';
6726
+
6727
+ const stats = [`${run.doneCount}/${run.startedCount} agents`, wall ? escapeHtml(wall) : null]
6728
+ .filter(Boolean)
6729
+ .join(' · ');
6730
+
6731
+ const desc = run.description ? `<div class="wf-run-desc">${escapeHtml(run.description)}</div>` : '';
6732
+
6733
+ const phases = (run.phases || [])
6734
+ .map(
6735
+ (p) => `<div class="wf-phase">
6736
+ <span class="wf-phase-mark${allDone ? ' done' : ''}">${allDone ? '✓' : ''}</span>
6737
+ <span class="wf-phase-title">${escapeHtml(p.title)}</span>
6738
+ ${p.detail ? `<span class="wf-phase-detail">${escapeHtml(p.detail)}</span>` : ''}
6739
+ </div>`,
6740
+ )
6741
+ .join('');
6742
+ const phasesBlock = phases ? `<div class="wf-section-title">Phases</div><div class="wf-phases">${phases}</div>` : '';
6743
+
6744
+ const agents = (run.agents || [])
6745
+ .map((a) => {
6746
+ const dur = a.durationMs != null ? formatDuration(a.durationMs) : '';
6747
+ const running = a.status !== 'done';
6748
+ return `<div class="wf-agent" onclick="showAgentModal('${escapeHtml(a.agentId)}')" title="${escapeHtml(a.agentId)}">
6749
+ <span class="wf-agent-type">${escapeHtml(a.type || 'agent')}</span>
6750
+ <span class="wf-agent-model">${escapeHtml(shortModelName(a.model))}</span>
6751
+ <span class="wf-agent-tok">${a.outputTokens ? `${fmtTokens(a.outputTokens)} tok` : ''}</span>
6752
+ <span class="wf-agent-dur">${escapeHtml(dur)}</span>
6753
+ <span class="wf-agent-status ${running ? 'running' : 'done'}">${running ? '●' : '✓'}</span>
6754
+ </div>`;
6755
+ })
6756
+ .join('');
6757
+ const agentsBlock = run.agents?.length
6758
+ ? `<div class="wf-section-title">Agents · ${run.agents.length}</div><div class="wf-agents">${agents}</div>`
6759
+ : '<div class="wf-section-title">Agents</div><div class="wf-empty">No agent runs recorded yet.</div>';
6760
+
6761
+ body.innerHTML = `<div class="workflow-run">
6762
+ ${back}
6763
+ ${desc}
6764
+ <div class="wf-run-stats">${stats} · <code>${escapeHtml(run.id)}</code></div>
6765
+ ${phasesBlock}
6766
+ ${agentsBlock}
6767
+ <details class="wf-source">
6768
+ <summary>Source</summary>
6769
+ <div class="workflow-row-code"></div>
6770
+ </details>
6771
+ </div>`;
6772
+
6773
+ const details = body.querySelector('.wf-source');
6774
+ details.addEventListener(
6775
+ 'toggle',
6776
+ () => {
6777
+ if (details.open) loadWorkflowCode(run.id, details.querySelector('.workflow-row-code'));
6778
+ },
6779
+ { once: true },
6780
+ );
6781
+ }
6782
+
6568
6783
  function loadWorkflowCode(wfId, codeEl) {
6569
6784
  if (!codeEl) return;
6570
6785
  codeEl.innerHTML = '<div style="padding:8px 0;color:var(--text-secondary);">Loading…</div>';
@@ -6586,19 +6801,6 @@ function loadWorkflowCode(wfId, codeEl) {
6586
6801
  });
6587
6802
  }
6588
6803
 
6589
- // biome-ignore lint/correctness/noUnusedVariables: used in HTML
6590
- function viewWorkflowScript(wfId, rowEl) {
6591
- const codeEl = rowEl.querySelector('.workflow-row-code');
6592
- if (!codeEl) return;
6593
- if (rowEl.classList.contains('expanded')) {
6594
- rowEl.classList.remove('expanded');
6595
- codeEl.innerHTML = '';
6596
- return;
6597
- }
6598
- rowEl.classList.add('expanded');
6599
- loadWorkflowCode(wfId, codeEl);
6600
- }
6601
-
6602
6804
  function openWorkflowInEditor(wfId) {
6603
6805
  postAndToast(`/api/sessions/${_workflowSessionId}/workflows/${encodeURIComponent(wfId)}/open`, {}, 'in editor');
6604
6806
  }
@@ -6611,6 +6813,7 @@ function openWorkflowHeaderScript() {
6611
6813
  function closeWorkflowModal() {
6612
6814
  document.getElementById('workflow-modal').classList.remove('visible');
6613
6815
  _workflowSessionId = null;
6816
+ _workflowList = [];
6614
6817
  setWorkflowHeader('Workflows', null);
6615
6818
  }
6616
6819
 
@@ -7111,6 +7314,9 @@ window.addEventListener('popstate', () => {
7111
7314
  ownerFilter = s.owner || '';
7112
7315
  searchQuery = s.search || '';
7113
7316
  loadPreferences();
7317
+ // fetchSessions derives query params from the globals set above — refetch so
7318
+ // back/forward across a filter change doesn't render a stale server-filtered list.
7319
+ fetchSessions(false);
7114
7320
  if (s.projectView) {
7115
7321
  try {
7116
7322
  fetchProjectView(atob(s.projectView));
@@ -7126,13 +7332,19 @@ window.addEventListener('popstate', () => {
7126
7332
  // #region HUB_INTEGRATION
7127
7333
  document.addEventListener('keydown', (e) => {
7128
7334
  if (!window.__HUB__?.enabled) return;
7129
- if (e.ctrlKey && e.altKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
7335
+ const fwd = (key) => {
7130
7336
  e.preventDefault();
7131
- 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);
7132
7345
  }
7133
7346
  if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey && /^[1-9]$/.test(e.key)) {
7134
- e.preventDefault();
7135
- window.parent?.postMessage({ type: 'hub:keydown', key: e.key }, '*');
7347
+ fwd(e.key);
7136
7348
  }
7137
7349
  });
7138
7350
 
@@ -7160,10 +7372,12 @@ window.hubNavigate = function hubNavigate(app, url) {
7160
7372
  window.parent?.postMessage({ type: 'hub:navigate', app, url }, '*');
7161
7373
  };
7162
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
+
7163
7378
  (function initHubTheme() {
7164
7379
  const getTheme = () => (document.body.classList.contains('light') ? 'light' : 'dark');
7165
7380
  const getColorTheme = () => document.body.dataset.colorTheme || 'ember';
7166
- const hubOrigin = () => (window.__HUB__?.url ? new URL(window.__HUB__.url).origin : null);
7167
7381
  // lastTheme/lastColorTheme are updated synchronously when applying a hub
7168
7382
  // message, so the (async) observer sees no diff and doesn't echo it back.
7169
7383
  let lastTheme = getTheme();
@@ -7193,4 +7407,15 @@ window.hubNavigate = function hubNavigate(app, url) {
7193
7407
  attributeFilter: ['class', 'data-color-theme'],
7194
7408
  });
7195
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
+ })();
7196
7421
  // #endregion HUB_INTEGRATION