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/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 +373 -113
- package/public/index.html +56 -31
- package/public/style.css +312 -82
- package/server.js +94 -19
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 =
|
|
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 =
|
|
10
|
-
let filterProject =
|
|
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 !==
|
|
79
|
-
if (sessionLimit !==
|
|
80
|
-
if (filterProject && 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 =
|
|
116
|
-
sessionLimit =
|
|
117
|
-
filterProject =
|
|
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
|
-
|
|
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
|
-
${
|
|
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
|
-
${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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';
|
|
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
|
-
|
|
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();
|
|
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
|
|
5040
|
-
|
|
5041
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
5137
|
-
|
|
5138
|
-
${
|
|
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)}">×</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
|
-
<
|
|
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
|
-
|
|
5155
|
-
|
|
5156
|
-
|
|
5157
|
-
|
|
5158
|
-
|
|
5159
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
7119
|
-
|
|
7120
|
-
|
|
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
|
-
|
|
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 ||
|
|
7187
|
-
sessionLimit = s.limit ||
|
|
7188
|
-
filterProject = s.project ||
|
|
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
|
-
|
|
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
|
-
|
|
7451
|
+
const fwd = (key) => {
|
|
7211
7452
|
e.preventDefault();
|
|
7212
|
-
window.parent?.postMessage({ type: 'hub:keydown', key: e.
|
|
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.
|
|
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
|