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/cli.js +54 -32
- package/install.js +37 -7
- package/package.json +1 -1
- package/plugin/.claude-plugin/marketplace.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +2 -2
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +26 -25
- package/public/app.js +231 -87
- package/public/index.html +4 -4
- package/public/style.css +213 -74
- package/server.js +81 -16
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
|
-
${
|
|
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
|
-
${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4976
|
-
|
|
4977
|
-
|
|
4978
|
-
|
|
4979
|
-
|
|
4980
|
-
|
|
4981
|
-
|
|
4982
|
-
|
|
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
|
-
|
|
5022
|
-
|
|
5023
|
-
|
|
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
|
|
5040
|
-
|
|
5041
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
5137
|
-
|
|
5138
|
-
${
|
|
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)}">×</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
|
-
<
|
|
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
|
-
|
|
5155
|
-
|
|
5156
|
-
|
|
5157
|
-
|
|
5158
|
-
|
|
5159
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
7335
|
+
const fwd = (key) => {
|
|
7211
7336
|
e.preventDefault();
|
|
7212
|
-
window.parent?.postMessage({ type: 'hub:keydown', key: e.
|
|
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.
|
|
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
|
-
<!--
|
|
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"
|
|
315
|
-
<div id="preview-modal-body" class="rendered-md"
|
|
316
|
-
<div id="preview-modal-meta"
|
|
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>
|