claude-code-kanban 4.16.0 → 4.18.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/lib/contain.js +78 -0
- package/lib/inline-assets.js +238 -0
- package/lib/net-guard.js +267 -0
- package/lib/open-editor.js +267 -0
- package/lib/parsers.js +8 -1
- package/package.json +3 -3
- package/public/app.js +306 -102
- package/public/index.html +71 -38
- package/public/style.css +131 -10
- package/server.js +148 -55
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
|
|
|
@@ -325,7 +332,7 @@ function renderActivityChip() {
|
|
|
325
332
|
return `
|
|
326
333
|
<button type="button"
|
|
327
334
|
class="${classes}"
|
|
328
|
-
onclick="setActivityFilter('${c.kind}')"
|
|
335
|
+
onclick="setActivityFilter('${escAttrJs(c.kind)}')"
|
|
329
336
|
aria-pressed="${isOn ? 'true' : 'false'}"
|
|
330
337
|
title="${escapeHtml(c.title + hint)}">
|
|
331
338
|
<span class="activity-dot"></span>
|
|
@@ -984,7 +991,7 @@ function renderToolItem(m, i, compact) {
|
|
|
984
991
|
// click all light up DURING the run, identical to post-completion.
|
|
985
992
|
const agentLink =
|
|
986
993
|
m.tool === 'Agent' && m.agentId
|
|
987
|
-
? ` <span class="msg-agent-link" title="View agent" onclick="event.stopPropagation();showAgentModal('${
|
|
994
|
+
? ` <span class="msg-agent-link" title="View agent" onclick="event.stopPropagation();showAgentModal('${escAttrJs(m.agentId)}')">⇗</span>`
|
|
988
995
|
: '';
|
|
989
996
|
// Usage chip (tokens · tools · duration) on completed Agent rows — same stats a
|
|
990
997
|
// background agent shows, sourced here from the foreground toolUseResult.
|
|
@@ -1369,7 +1376,7 @@ function getToolIcon(toolName) {
|
|
|
1369
1376
|
const AGENT_LOG_ICON =
|
|
1370
1377
|
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>';
|
|
1371
1378
|
function agentLogButton(agentId) {
|
|
1372
|
-
return `<button class="msg-agent-log-btn" onclick="event.stopPropagation();viewAgentLog('${
|
|
1379
|
+
return `<button class="msg-agent-log-btn" onclick="event.stopPropagation();viewAgentLog('${escAttrJs(agentId)}')" title="View agent log">${AGENT_LOG_ICON}</button>`;
|
|
1373
1380
|
}
|
|
1374
1381
|
|
|
1375
1382
|
function getPinId(m) {
|
|
@@ -1691,7 +1698,7 @@ const linkSvg = (size) =>
|
|
|
1691
1698
|
// Shared markup for an attachment image and the labeled section that holds a grid
|
|
1692
1699
|
// of them — used by both user-pasted images and agent tool-result images.
|
|
1693
1700
|
function attachImageTag(url, alt) {
|
|
1694
|
-
return `<img src="${url}" loading="lazy" alt="${alt}" class="user-attach-image" />`;
|
|
1701
|
+
return `<img src="${escapeHtml(url)}" loading="lazy" alt="${escapeHtml(alt)}" class="user-attach-image" />`;
|
|
1695
1702
|
}
|
|
1696
1703
|
function attachImageSection(label, imgsHtml) {
|
|
1697
1704
|
return imgsHtml
|
|
@@ -1729,7 +1736,7 @@ function renderUserAttachments(m) {
|
|
|
1729
1736
|
return `<details class="user-attach-toolresult">
|
|
1730
1737
|
<summary>Tool result <code>${escapeHtml(shortId)}</code></summary>
|
|
1731
1738
|
<pre class="${TINTED_PRE_CLASS}" id="${expandId}">${preview}</pre>
|
|
1732
|
-
<button type="button" class="tool-result-expand-btn" data-expand-id="${expandId}" data-tool-use-id="${safeId}" onclick="_toggleToolResultExpand(this)">Show full</button>
|
|
1739
|
+
<button type="button" class="tool-result-expand-btn" data-expand-id="${expandId}" data-tool-use-id="${escapeHtml(safeId)}" onclick="_toggleToolResultExpand(this)">Show full</button>
|
|
1733
1740
|
</details>`;
|
|
1734
1741
|
})
|
|
1735
1742
|
.join('');
|
|
@@ -1869,7 +1876,7 @@ function showMsgDetail(idx) {
|
|
|
1869
1876
|
}
|
|
1870
1877
|
|
|
1871
1878
|
function closeMsgDetailModal() {
|
|
1872
|
-
|
|
1879
|
+
hideModalOverlay('msg-detail-modal');
|
|
1873
1880
|
msgDetailFollowLatest = false;
|
|
1874
1881
|
// Drop the message highlight on close, mirroring task-card behavior.
|
|
1875
1882
|
msgHighlightDimmed = true;
|
|
@@ -1893,19 +1900,32 @@ function _setModalWidth(modal, slot, on, maxWidth, width) {
|
|
|
1893
1900
|
}
|
|
1894
1901
|
}
|
|
1895
1902
|
|
|
1903
|
+
function _applyModalFullscreen(modalId, on) {
|
|
1904
|
+
const modal = document.querySelector(`#${modalId} .modal`);
|
|
1905
|
+
modal.classList.toggle('fullscreen', on);
|
|
1906
|
+
_setModalWidth(modal, 'Fs', on, '', '');
|
|
1907
|
+
updateFullscreenBtnIcon(`${modalId}-fullscreen-btn`, on);
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1896
1910
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
1897
1911
|
function toggleModalFullscreen(modalId) {
|
|
1898
|
-
const
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1912
|
+
const on = !document.querySelector(`#${modalId} .modal`).classList.contains('fullscreen');
|
|
1913
|
+
_applyModalFullscreen(modalId, on);
|
|
1914
|
+
localStorage.setItem(`modal-fullscreen-${modalId}`, String(on));
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
function loadModalFullscreen() {
|
|
1918
|
+
for (const btn of document.querySelectorAll('[id$="-fullscreen-btn"]')) {
|
|
1919
|
+
const modalId = btn.id.replace(/-fullscreen-btn$/, '');
|
|
1920
|
+
if (localStorage.getItem(`modal-fullscreen-${modalId}`) === 'true') _applyModalFullscreen(modalId, true);
|
|
1921
|
+
}
|
|
1902
1922
|
}
|
|
1903
1923
|
|
|
1904
|
-
|
|
1924
|
+
// Hides the overlay only — the fullscreen state stays on the dialog, it is a
|
|
1925
|
+
// remembered preference so the next open comes back the way it was left.
|
|
1926
|
+
function hideModalOverlay(modalId) {
|
|
1905
1927
|
const modal = document.getElementById(modalId);
|
|
1906
1928
|
modal.classList.remove('visible');
|
|
1907
|
-
modal.querySelector('.modal').classList.remove('fullscreen');
|
|
1908
|
-
updateFullscreenBtnIcon(`${modalId}-fullscreen-btn`, false);
|
|
1909
1929
|
return modal;
|
|
1910
1930
|
}
|
|
1911
1931
|
|
|
@@ -1917,6 +1937,38 @@ function updateFullscreenBtnIcon(btnId, isFullscreen) {
|
|
|
1917
1937
|
: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>';
|
|
1918
1938
|
}
|
|
1919
1939
|
|
|
1940
|
+
const MODAL_ZOOM_KEY = 'modal-zoom';
|
|
1941
|
+
const MODAL_ZOOM_MIN = 0.7;
|
|
1942
|
+
const MODAL_ZOOM_MAX = 2.0;
|
|
1943
|
+
let modalZoom = clampModalZoom(Number.parseFloat(localStorage.getItem(MODAL_ZOOM_KEY)) || 1);
|
|
1944
|
+
|
|
1945
|
+
function clampModalZoom(v) {
|
|
1946
|
+
return Math.min(MODAL_ZOOM_MAX, Math.max(MODAL_ZOOM_MIN, v));
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
function applyModalZoom() {
|
|
1950
|
+
document.documentElement.style.setProperty('--modal-zoom', String(modalZoom));
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
// delta 0 resets to 100%
|
|
1954
|
+
function adjustModalZoom(delta) {
|
|
1955
|
+
const next = delta === 0 ? 1 : clampModalZoom(Math.round((modalZoom + delta) * 10) / 10);
|
|
1956
|
+
if (next === modalZoom && delta !== 0) return;
|
|
1957
|
+
modalZoom = next;
|
|
1958
|
+
localStorage.setItem(MODAL_ZOOM_KEY, String(modalZoom));
|
|
1959
|
+
applyModalZoom();
|
|
1960
|
+
showToast(`Text ${Math.round(modalZoom * 100)}%`);
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
// The markup decides what scales: `.modal-zoomable` marks a modal's reading
|
|
1964
|
+
// surface, and style.css hangs the zoom on that same class. Confirm dialogs
|
|
1965
|
+
// carry no such body — nothing there is worth enlarging.
|
|
1966
|
+
function isZoomableModalOpen() {
|
|
1967
|
+
return document.querySelector('.modal-overlay.visible .modal-zoomable') !== null;
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
const ZOOM_KEYS = { '+': 0.1, '=': 0.1, NumpadAdd: 0.1, '-': -0.1, _: -0.1, NumpadSubtract: -0.1, 0: 0, Numpad0: 0 };
|
|
1971
|
+
|
|
1920
1972
|
let _toastTimer = null;
|
|
1921
1973
|
let _manualRefreshing = false;
|
|
1922
1974
|
//#endregion
|
|
@@ -2531,7 +2583,7 @@ function renderAgentFooter() {
|
|
|
2531
2583
|
const agentColor = resolveNamedColor(a.color);
|
|
2532
2584
|
const colorStyle = agentColor ? ` style="border-left:3px solid ${agentColor.color}"` : '';
|
|
2533
2585
|
const selectedClass = a.agentId === currentAgentModalId ? ' selected' : '';
|
|
2534
|
-
return `<div class="agent-card${selectedClass}" data-agent-id="${escapeHtml(a.agentId)}"${colorStyle} onclick="showAgentModal('${a.agentId}')">
|
|
2586
|
+
return `<div class="agent-card${selectedClass}" data-agent-id="${escapeHtml(a.agentId)}"${colorStyle} onclick="showAgentModal('${escAttrJs(a.agentId)}')">
|
|
2535
2587
|
<div class="agent-type-row">${typeNs ? `<span class="agent-type-ns">${escapeHtml(typeNs)}</span>` : ''}<span class="agent-type-name">${escapeHtml(typeName)}</span>${nameBadgeHtml}</div>
|
|
2536
2588
|
<div class="agent-status-row"><span class="agent-dot ${a.status}"></span><span class="agent-status">${statusText}</span></div>
|
|
2537
2589
|
${msgHtml}
|
|
@@ -2697,7 +2749,7 @@ function showAgentModal(agentId) {
|
|
|
2697
2749
|
}
|
|
2698
2750
|
|
|
2699
2751
|
function closeAgentModal() {
|
|
2700
|
-
|
|
2752
|
+
hideModalOverlay('agent-modal');
|
|
2701
2753
|
currentAgentModalId = null;
|
|
2702
2754
|
highlightSelectedAgent();
|
|
2703
2755
|
}
|
|
@@ -2707,7 +2759,7 @@ function highlightSelectedAgent() {
|
|
|
2707
2759
|
if (!content) return;
|
|
2708
2760
|
for (const el of content.querySelectorAll('.agent-card.selected')) el.classList.remove('selected');
|
|
2709
2761
|
if (currentAgentModalId == null) return;
|
|
2710
|
-
const el = content.querySelector(`.agent-card[data-agent-id="${currentAgentModalId}"]`);
|
|
2762
|
+
const el = content.querySelector(`.agent-card[data-agent-id="${escSel(currentAgentModalId)}"]`);
|
|
2711
2763
|
if (el) el.classList.add('selected');
|
|
2712
2764
|
}
|
|
2713
2765
|
|
|
@@ -2931,9 +2983,10 @@ function renderSessions() {
|
|
|
2931
2983
|
const bookmarksCount = loadPins(session.id).length;
|
|
2932
2984
|
const hasScratchpad = !!(localStorage.getItem(_sessionScratchpadKey(session.id)) || '').trim();
|
|
2933
2985
|
const tempClass = session.hasRecentLog || session.inProgress || session.hasWaitingForUser ? 'warm' : 'stale';
|
|
2986
|
+
const sid = escAttrJs(session.id);
|
|
2934
2987
|
return `
|
|
2935
|
-
<button onclick="fetchTasks('${
|
|
2936
|
-
<span class="session-pin-btn${pinClass}" onclick="event.stopPropagation();toggleSessionPin('${
|
|
2988
|
+
<button onclick="fetchTasks('${sid}')" data-session-id="${escapeHtml(session.id)}" class="session-item ${isActive ? 'active' : ''} ${session.hasWaitingForUser ? 'permission-pending' : ''} ${tempClass} ${showCtx ? 'has-context' : ''}" title="${escapeHtml(tooltip)}">
|
|
2989
|
+
<span class="session-pin-btn${pinClass}" onclick="event.stopPropagation();toggleSessionPin('${sid}')" title="${pinTitle} session">${pinState === 'sticky' ? SESSION_STAR_SVG : SESSION_PIN_SVG}</span>
|
|
2937
2990
|
<div class="session-name">${escapeHtml(primaryName)}</div>
|
|
2938
2991
|
${secondaryName ? `<div class="session-secondary">${escapeHtml(secondaryName)}</div>` : ''}
|
|
2939
2992
|
${gitBranch ? `<div class="session-branch">${gitBranch}</div>` : ''}
|
|
@@ -2942,14 +2995,14 @@ function renderSessions() {
|
|
|
2942
2995
|
<div class="session-progress">
|
|
2943
2996
|
<span class="session-indicators">
|
|
2944
2997
|
${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>` : ''}
|
|
2945
|
-
${isTeam || session.project || showCtx ? `<span class="team-info-btn" onclick="event.stopPropagation(); showSessionInfoModal('${
|
|
2998
|
+
${isTeam || session.project || showCtx ? `<span class="team-info-btn" onclick="event.stopPropagation(); showSessionInfoModal('${sid}')" 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>` : ''}
|
|
2946
2999
|
${renderWorkflowBadge(session)}
|
|
2947
3000
|
${renderLoopBadge(session)}
|
|
2948
|
-
${hasScratchpad ? `<span class="scratchpad-badge" onclick="event.stopPropagation(); openSessionScratchpad('${
|
|
2949
|
-
${bookmarksCount > 0 ? `<span class="bookmarks-badge" onclick="event.stopPropagation(); openSessionWithBookmarks('${
|
|
2950
|
-
${linkedDocsCount > 0 ? `<span class="linked-docs-badge" onclick="event.stopPropagation(); showSessionInfoModal('${
|
|
2951
|
-
${session.hasPlan && !session.planSourceSessionId ? `<span class="plan-indicator" onclick="event.stopPropagation(); openPlanForSession('${
|
|
2952
|
-
${session.planSourceSessionId ? `<span class="plan-indicator" title="Implements plan — click to reveal plan session" onclick="event.stopPropagation(); revealPlanSession('${
|
|
3001
|
+
${hasScratchpad ? `<span class="scratchpad-badge" onclick="event.stopPropagation(); openSessionScratchpad('${sid}')" 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>` : ''}
|
|
3002
|
+
${bookmarksCount > 0 ? `<span class="bookmarks-badge" onclick="event.stopPropagation(); openSessionWithBookmarks('${sid}')" 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>` : ''}
|
|
3003
|
+
${linkedDocsCount > 0 ? `<span class="linked-docs-badge" onclick="event.stopPropagation(); showSessionInfoModal('${sid}')" title="${linkedDocsCount} linked document${linkedDocsCount > 1 ? 's' : ''}">${linkSvg(10)}${linkedDocsCount}</span>` : ''}
|
|
3004
|
+
${session.hasPlan && !session.planSourceSessionId ? `<span class="plan-indicator" onclick="event.stopPropagation(); openPlanForSession('${sid}')" title="View plan">${ICON_PLAN}</span>` : ''}
|
|
3005
|
+
${session.planSourceSessionId ? `<span class="plan-indicator" title="Implements plan — click to reveal plan session" onclick="event.stopPropagation(); revealPlanSession('${escAttrJs(session.planSourceSessionId)}')">${ICON_PLAN}</span>` : ''}
|
|
2953
3006
|
${session.sharedTaskList ? `<span class="shared-tasklist-badge" title="Shared task list: ${escapeHtml(session.sharedTaskList)}">${linkSvg(12)}</span>` : ''}
|
|
2954
3007
|
${session.hasWaitingForUser ? `<span class="agent-badge agent-badge-waiting" title="Waiting for user">${ICON_AGENT_WAITING}</span>` : ''}
|
|
2955
3008
|
${session.hasRunningAgents && !session.hasWaitingForUser ? `<span class="agent-badge agent-badge-active" title="Agents running">${ICON_AGENT_ACTIVE}</span>` : ''}
|
|
@@ -3198,9 +3251,9 @@ function renderTaskCard(task) {
|
|
|
3198
3251
|
<div
|
|
3199
3252
|
role="listitem"
|
|
3200
3253
|
tabindex="0"
|
|
3201
|
-
data-task-id="${task.id}"
|
|
3202
|
-
data-session-id="${actualSessionId}"
|
|
3203
|
-
onclick="showTaskDetail('${task.id}', '${actualSessionId}')"
|
|
3254
|
+
data-task-id="${escapeHtml(task.id)}"
|
|
3255
|
+
data-session-id="${escapeHtml(actualSessionId)}"
|
|
3256
|
+
onclick="showTaskDetail('${escAttrJs(task.id)}', '${escAttrJs(actualSessionId)}')"
|
|
3204
3257
|
draggable="true"
|
|
3205
3258
|
ondragstart="onCardDragStart(event)"
|
|
3206
3259
|
ondragend="onCardDragEnd(event)"
|
|
@@ -3264,8 +3317,9 @@ function renderKanban() {
|
|
|
3264
3317
|
|
|
3265
3318
|
if (selectedTaskId) {
|
|
3266
3319
|
const card =
|
|
3267
|
-
document.querySelector(
|
|
3268
|
-
|
|
3320
|
+
document.querySelector(
|
|
3321
|
+
`.task-card[data-task-id="${escSel(selectedTaskId)}"][data-session-id="${escSel(selectedSessionId)}"]`,
|
|
3322
|
+
) || document.querySelector(`.task-card[data-task-id="${escSel(selectedTaskId)}"]`);
|
|
3269
3323
|
if (card) {
|
|
3270
3324
|
if (focusZone === 'board' && !taskHighlightDimmed) card.classList.add('selected');
|
|
3271
3325
|
} else {
|
|
@@ -3357,8 +3411,8 @@ function selectTask(taskId, sessionId) {
|
|
|
3357
3411
|
taskHighlightDimmed = false;
|
|
3358
3412
|
if (!taskId) return;
|
|
3359
3413
|
const card =
|
|
3360
|
-
document.querySelector(`.task-card[data-task-id="${taskId}"][data-session-id="${sessionId}"]`) ||
|
|
3361
|
-
document.querySelector(`.task-card[data-task-id="${taskId}"]`);
|
|
3414
|
+
document.querySelector(`.task-card[data-task-id="${escSel(taskId)}"][data-session-id="${escSel(sessionId)}"]`) ||
|
|
3415
|
+
document.querySelector(`.task-card[data-task-id="${escSel(taskId)}"]`);
|
|
3362
3416
|
if (card) {
|
|
3363
3417
|
card.classList.add('selected');
|
|
3364
3418
|
card.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
@@ -3634,7 +3688,7 @@ function setFocusZone(zone) {
|
|
|
3634
3688
|
}
|
|
3635
3689
|
if (selectedTaskId) {
|
|
3636
3690
|
const card = document.querySelector(
|
|
3637
|
-
`.task-card[data-task-id="${selectedTaskId}"][data-session-id="${selectedSessionId}"]`,
|
|
3691
|
+
`.task-card[data-task-id="${escSel(selectedTaskId)}"][data-session-id="${escSel(selectedSessionId)}"]`,
|
|
3638
3692
|
);
|
|
3639
3693
|
if (card) card.classList.add('selected');
|
|
3640
3694
|
} else {
|
|
@@ -3658,7 +3712,7 @@ function getAvailableTasksOptions(currentTaskId = null) {
|
|
|
3658
3712
|
if (pending.length > 0) {
|
|
3659
3713
|
options += '<optgroup label="Pending">';
|
|
3660
3714
|
pending.forEach((t, _idx) => {
|
|
3661
|
-
options += `<option value="${t.id}">#${t.id} - ${escapeHtml(t.subject)}</option>`;
|
|
3715
|
+
options += `<option value="${escapeHtml(t.id)}">#${t.id} - ${escapeHtml(t.subject)}</option>`;
|
|
3662
3716
|
});
|
|
3663
3717
|
options += '</optgroup>';
|
|
3664
3718
|
}
|
|
@@ -3666,7 +3720,7 @@ function getAvailableTasksOptions(currentTaskId = null) {
|
|
|
3666
3720
|
if (inProgress.length > 0) {
|
|
3667
3721
|
options += '<optgroup label="In Progress">';
|
|
3668
3722
|
inProgress.forEach((t, _idx) => {
|
|
3669
|
-
options += `<option value="${t.id}">#${t.id} - ${escapeHtml(t.subject)}</option>`;
|
|
3723
|
+
options += `<option value="${escapeHtml(t.id)}">#${t.id} - ${escapeHtml(t.subject)}</option>`;
|
|
3670
3724
|
});
|
|
3671
3725
|
options += '</optgroup>';
|
|
3672
3726
|
}
|
|
@@ -3674,7 +3728,7 @@ function getAvailableTasksOptions(currentTaskId = null) {
|
|
|
3674
3728
|
if (completed.length > 0) {
|
|
3675
3729
|
options += '<optgroup label="Completed">';
|
|
3676
3730
|
completed.forEach((t, _idx) => {
|
|
3677
|
-
options += `<option value="${t.id}">#${t.id} - ${escapeHtml(t.subject)}</option>`;
|
|
3731
|
+
options += `<option value="${escapeHtml(t.id)}">#${t.id} - ${escapeHtml(t.subject)}</option>`;
|
|
3678
3732
|
});
|
|
3679
3733
|
options += '</optgroup>';
|
|
3680
3734
|
}
|
|
@@ -4181,11 +4235,6 @@ function _projectLabel(project) {
|
|
|
4181
4235
|
return project.split(/[/\\]/).pop() || project;
|
|
4182
4236
|
}
|
|
4183
4237
|
|
|
4184
|
-
function _escapeForJsAttr(str) {
|
|
4185
|
-
const jsEscaped = str.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n').replace(/\r/g, '\\r');
|
|
4186
|
-
return escapeHtml(jsEscaped);
|
|
4187
|
-
}
|
|
4188
|
-
|
|
4189
4238
|
function _renderProjectGroup(label, meta, innerHtml) {
|
|
4190
4239
|
return `<div class="storage-project-group">
|
|
4191
4240
|
<div class="storage-project-header">
|
|
@@ -4253,7 +4302,7 @@ function _renderStorageSessions() {
|
|
|
4253
4302
|
function renderMessageItems(id) {
|
|
4254
4303
|
const g = msgMap.get(id);
|
|
4255
4304
|
if (!g) return '';
|
|
4256
|
-
const eid =
|
|
4305
|
+
const eid = escAttrJs(id);
|
|
4257
4306
|
const header = `<div class="storage-group-header" style="padding-left:12px;">
|
|
4258
4307
|
<span>${g.pins.length} pinned message${g.pins.length > 1 ? 's' : ''}</span>
|
|
4259
4308
|
<div class="storage-item-actions">
|
|
@@ -4264,8 +4313,8 @@ function _renderStorageSessions() {
|
|
|
4264
4313
|
.map((p) => {
|
|
4265
4314
|
const type = escapeHtml(p.type || '?');
|
|
4266
4315
|
const text = escapeHtml((p.text || p.tool || p.agentType || '').slice(0, 60));
|
|
4267
|
-
const pinId =
|
|
4268
|
-
const sid =
|
|
4316
|
+
const pinId = escAttrJs(p.id || '');
|
|
4317
|
+
const sid = escAttrJs(id);
|
|
4269
4318
|
return `<div class="storage-item storage-item-clickable" style="padding-left:24px;" onclick="_storagePreviewPin('${sid}','${pinId}')">
|
|
4270
4319
|
<span class="storage-item-badge">${type}</span>
|
|
4271
4320
|
<span class="storage-item-id">${text}</span>
|
|
@@ -4282,7 +4331,7 @@ function _renderStorageSessions() {
|
|
|
4282
4331
|
|
|
4283
4332
|
function renderSessionItem({ id, session }) {
|
|
4284
4333
|
const isPinned = isAnyPinned(id);
|
|
4285
|
-
const eid =
|
|
4334
|
+
const eid = escAttrJs(id);
|
|
4286
4335
|
const actions = isPinned
|
|
4287
4336
|
? `<button onclick="_storageViewSession('${eid}')">View</button>
|
|
4288
4337
|
<button class="danger" onclick="_storageUnpinSession('${eid}')">Unpin</button>`
|
|
@@ -4382,7 +4431,7 @@ function _renderStorageScratchpads() {
|
|
|
4382
4431
|
const typeBadge = item.isProject
|
|
4383
4432
|
? '<span class="storage-item-badge">project</span>'
|
|
4384
4433
|
: '<span class="storage-item-badge">session</span>';
|
|
4385
|
-
const jsKey =
|
|
4434
|
+
const jsKey = escAttrJs(item.key);
|
|
4386
4435
|
const label = item.isProject ? escapeHtml(_projectLabel(item.id)) : _sessionLabel(session, item.id);
|
|
4387
4436
|
return `<div class="storage-item">
|
|
4388
4437
|
<span class="storage-item-id" title="${escapeHtml(item.id)}">${label}</span>
|
|
@@ -4471,8 +4520,8 @@ function _renderStorageLinkedDocs() {
|
|
|
4471
4520
|
|
|
4472
4521
|
function renderDocRow(sessionId, p) {
|
|
4473
4522
|
const name = p.split(/[\\/]/).pop();
|
|
4474
|
-
const sid =
|
|
4475
|
-
const jsPath =
|
|
4523
|
+
const sid = escAttrJs(sessionId);
|
|
4524
|
+
const jsPath = escAttrJs(p);
|
|
4476
4525
|
return `<div class="storage-item" style="padding-left:24px;">
|
|
4477
4526
|
<span class="storage-item-id" title="${escapeHtml(p)}">${escapeHtml(name)}</span>
|
|
4478
4527
|
<div class="storage-item-actions">
|
|
@@ -4485,7 +4534,7 @@ function _renderStorageLinkedDocs() {
|
|
|
4485
4534
|
function renderSessionItem({ id, session }) {
|
|
4486
4535
|
const entry = byId.get(id);
|
|
4487
4536
|
if (!entry) return '';
|
|
4488
|
-
const eid =
|
|
4537
|
+
const eid = escAttrJs(id);
|
|
4489
4538
|
const count = entry.paths.length;
|
|
4490
4539
|
const header = `<div class="storage-group-header">
|
|
4491
4540
|
<span>${_sessionLabel(session, id)} <span class="storage-item-badge">${count} doc${count > 1 ? 's' : ''}</span></span>
|
|
@@ -4611,6 +4660,18 @@ const MODAL_CLOSERS = {
|
|
|
4611
4660
|
};
|
|
4612
4661
|
|
|
4613
4662
|
document.addEventListener('keydown', (e) => {
|
|
4663
|
+
// Scale the open modal's reading surface instead of letting the browser zoom
|
|
4664
|
+
// the whole page. Sits above the text-field guard so it still works with the
|
|
4665
|
+
// caret in a field inside the modal.
|
|
4666
|
+
if ((e.ctrlKey || e.metaKey) && !e.altKey) {
|
|
4667
|
+
const delta = ZOOM_KEYS[e.key] ?? ZOOM_KEYS[e.code];
|
|
4668
|
+
if (delta !== undefined && isZoomableModalOpen()) {
|
|
4669
|
+
e.preventDefault();
|
|
4670
|
+
adjustModalZoom(delta);
|
|
4671
|
+
return;
|
|
4672
|
+
}
|
|
4673
|
+
}
|
|
4674
|
+
|
|
4614
4675
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') {
|
|
4615
4676
|
return;
|
|
4616
4677
|
}
|
|
@@ -4985,7 +5046,8 @@ function renderFrontmatterBlock(fm) {
|
|
|
4985
5046
|
|
|
4986
5047
|
// Previewed HTML is rendered as authored, not sanitized: the sandbox without
|
|
4987
5048
|
// allow-same-origin puts it on an opaque origin, so it cannot touch this app's
|
|
4988
|
-
// storage, DOM or API.
|
|
5049
|
+
// storage, DOM or API. srcdoc has no base URL, so the server has already embedded
|
|
5050
|
+
// the document's local assets (lib/inline-assets.js); remote refs load normally.
|
|
4989
5051
|
function renderHtmlPreview(bodyEl, content) {
|
|
4990
5052
|
bodyEl.innerHTML = '';
|
|
4991
5053
|
const frame = document.createElement('iframe');
|
|
@@ -5080,7 +5142,7 @@ function refreshInfoModalLinkedDocs() {
|
|
|
5080
5142
|
}
|
|
5081
5143
|
|
|
5082
5144
|
function closePreviewModal() {
|
|
5083
|
-
|
|
5145
|
+
hideModalOverlay('preview-modal');
|
|
5084
5146
|
// Empty the body so an iframe preview is destroyed and its scripts/timers stop.
|
|
5085
5147
|
document.getElementById('preview-modal-body').innerHTML = '';
|
|
5086
5148
|
currentPreviewPath = null;
|
|
@@ -5656,10 +5718,36 @@ function stripTeammateWrapper(text) {
|
|
|
5656
5718
|
return match ? match[1].trim() : text;
|
|
5657
5719
|
}
|
|
5658
5720
|
|
|
5721
|
+
// The div.textContent -> innerHTML trick escapes only & < > : innerHTML serializes
|
|
5722
|
+
// for *text* context, where quotes are correctly left raw. That made every
|
|
5723
|
+
// attribute interpolation breakable with a bare " . It cannot be patched, so the
|
|
5724
|
+
// escaping is explicit here.
|
|
5725
|
+
const HTML_ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', '`': '`' };
|
|
5726
|
+
|
|
5659
5727
|
function escapeHtml(text) {
|
|
5660
|
-
|
|
5661
|
-
|
|
5662
|
-
|
|
5728
|
+
if (text == null) return '';
|
|
5729
|
+
return String(text).replace(/[&<>"'`]/g, (c) => HTML_ESCAPES[c]);
|
|
5730
|
+
}
|
|
5731
|
+
|
|
5732
|
+
// For a value landing inside a quoted JS string inside an HTML attribute —
|
|
5733
|
+
// onclick="fn('${escAttrJs(x)}')". The browser HTML-decodes the attribute before
|
|
5734
|
+
// the JS parser sees it, so the JS escape must happen first and be escaped in turn.
|
|
5735
|
+
function escAttrJs(value) {
|
|
5736
|
+
const js = String(value == null ? '' : value)
|
|
5737
|
+
.replace(/\\/g, '\\\\')
|
|
5738
|
+
.replace(/'/g, "\\'")
|
|
5739
|
+
.replace(/\r/g, '\\r')
|
|
5740
|
+
.replace(/\n/g, '\\n');
|
|
5741
|
+
return escapeHtml(js);
|
|
5742
|
+
}
|
|
5743
|
+
|
|
5744
|
+
// For a value inside a double-quoted CSS attribute selector: [data-x="${escSel(v)}"].
|
|
5745
|
+
// escapeHtml is wrong here — the DOM holds the decoded value, so " in the
|
|
5746
|
+
// selector matches nothing, and a bare " would end the selector string.
|
|
5747
|
+
function escSel(value) {
|
|
5748
|
+
return String(value == null ? '' : value)
|
|
5749
|
+
.replace(/\\/g, '\\\\')
|
|
5750
|
+
.replace(/"/g, '\\"');
|
|
5663
5751
|
}
|
|
5664
5752
|
|
|
5665
5753
|
function toRelativeIfUnder(filePath, baseDir) {
|
|
@@ -5737,7 +5825,7 @@ function renderAgentTabs(promptHtml, responseHtml, promptText, responseText) {
|
|
|
5737
5825
|
const panelsHtml = panels
|
|
5738
5826
|
.map(
|
|
5739
5827
|
(p) =>
|
|
5740
|
-
`<div class="agent-tab-panel${p.key === defaultTab ? ' active' : ''}" data-tab-group="${id}" data-tab-key="${p.key}"><div class="detail-desc rendered-md"
|
|
5828
|
+
`<div class="agent-tab-panel${p.key === defaultTab ? ' active' : ''}" data-tab-group="${id}" data-tab-key="${p.key}"><div class="detail-desc rendered-md">${p.html}</div></div>`,
|
|
5741
5829
|
)
|
|
5742
5830
|
.join('');
|
|
5743
5831
|
return `<div class="agent-tabs">${tabsHtml}${copyBtnHtml}</div>${panelsHtml}`;
|
|
@@ -5819,10 +5907,95 @@ function getOwnerColor(name) {
|
|
|
5819
5907
|
//#endregion
|
|
5820
5908
|
|
|
5821
5909
|
//#region FILTERS
|
|
5910
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
5911
|
+
function toggleFilterMenu(e) {
|
|
5912
|
+
// Stops the click reaching .section-header (which would collapse the section) *and*
|
|
5913
|
+
// document (which would immediately close the menu we are opening).
|
|
5914
|
+
e?.stopPropagation();
|
|
5915
|
+
if (closeFilterMenu()) return;
|
|
5916
|
+
document.getElementById('filter-menu').classList.add('open');
|
|
5917
|
+
document.getElementById('filter-menu-btn')?.setAttribute('aria-expanded', 'true');
|
|
5918
|
+
renderFilterState();
|
|
5919
|
+
document.addEventListener('click', closeFilterMenu, { once: true });
|
|
5920
|
+
// Capture phase: the global keydown handler bails out on SELECT targets, and every
|
|
5921
|
+
// focusable thing in this popover is a select — Escape would never reach it otherwise.
|
|
5922
|
+
document.addEventListener('keydown', filterMenuKeydown, true);
|
|
5923
|
+
document.getElementById('project-filter')?.focus();
|
|
5924
|
+
}
|
|
5925
|
+
|
|
5926
|
+
function filterMenuKeydown(e) {
|
|
5927
|
+
if (e.key !== 'Escape') return;
|
|
5928
|
+
e.stopPropagation();
|
|
5929
|
+
if (closeFilterMenu()) document.getElementById('filter-menu-btn')?.focus();
|
|
5930
|
+
}
|
|
5931
|
+
|
|
5932
|
+
function closeFilterMenu() {
|
|
5933
|
+
const menu = document.getElementById('filter-menu');
|
|
5934
|
+
if (!menu?.classList.contains('open')) return false;
|
|
5935
|
+
menu.classList.remove('open');
|
|
5936
|
+
document.getElementById('filter-menu-btn')?.setAttribute('aria-expanded', 'false');
|
|
5937
|
+
document.removeEventListener('keydown', filterMenuKeydown, true);
|
|
5938
|
+
return true;
|
|
5939
|
+
}
|
|
5940
|
+
|
|
5941
|
+
// One row per filter control: the select it mirrors, its value, whether it differs from the
|
|
5942
|
+
// default, and two label forms — terse tokens for the header strip (which has ~15 characters
|
|
5943
|
+
// before the sidebar's width ellipsises it) and long form for its tooltip.
|
|
5944
|
+
const FILTERS = [
|
|
5945
|
+
{
|
|
5946
|
+
id: 'project-filter',
|
|
5947
|
+
value: () => filterProject ?? '',
|
|
5948
|
+
active: () => filterProject !== FILTER_DEFAULTS.project,
|
|
5949
|
+
short: () => (filterProject ? filterProject.split(/[/\\]/).pop() : 'all proj'),
|
|
5950
|
+
long: () => (filterProject ? `project ${filterProject}` : 'all projects'),
|
|
5951
|
+
},
|
|
5952
|
+
{
|
|
5953
|
+
id: 'session-filter',
|
|
5954
|
+
value: () => sessionFilter,
|
|
5955
|
+
active: () => sessionFilter !== FILTER_DEFAULTS.session,
|
|
5956
|
+
short: () => 'inactive',
|
|
5957
|
+
long: () => 'including inactive sessions',
|
|
5958
|
+
},
|
|
5959
|
+
{
|
|
5960
|
+
id: 'session-limit',
|
|
5961
|
+
value: () => sessionLimit,
|
|
5962
|
+
active: () => sessionLimit !== FILTER_DEFAULTS.limit,
|
|
5963
|
+
short: () => (sessionLimit === 'all' ? 'n=∞' : `n=${sessionLimit}`),
|
|
5964
|
+
long: () => (sessionLimit === 'all' ? 'no session limit' : `showing ${sessionLimit}`),
|
|
5965
|
+
},
|
|
5966
|
+
];
|
|
5967
|
+
|
|
5968
|
+
// Keeps the header (summary + funnel tint) and the popover's selects in sync with state.
|
|
5969
|
+
// Safe to call on every filter change — it only touches classes, values, and text.
|
|
5970
|
+
function renderFilterState() {
|
|
5971
|
+
const short = [];
|
|
5972
|
+
const long = [];
|
|
5973
|
+
for (const f of FILTERS) {
|
|
5974
|
+
const active = f.active();
|
|
5975
|
+
if (active) {
|
|
5976
|
+
short.push(f.short());
|
|
5977
|
+
long.push(f.long());
|
|
5978
|
+
}
|
|
5979
|
+
const el = document.getElementById(f.id);
|
|
5980
|
+
if (!el) continue;
|
|
5981
|
+
const value = String(f.value());
|
|
5982
|
+
if (el.value !== value) el.value = value;
|
|
5983
|
+
el.classList.toggle('non-default', active);
|
|
5984
|
+
}
|
|
5985
|
+
|
|
5986
|
+
const summary = document.getElementById('filter-summary');
|
|
5987
|
+
if (summary) {
|
|
5988
|
+
summary.textContent = short.join(' · ');
|
|
5989
|
+
summary.title = long.length ? `Active filters: ${long.join(', ')}` : '';
|
|
5990
|
+
}
|
|
5991
|
+
document.getElementById('filter-menu-btn')?.classList.toggle('has-filters', short.length > 0);
|
|
5992
|
+
}
|
|
5993
|
+
|
|
5822
5994
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
5823
5995
|
function filterBySessions(value) {
|
|
5824
5996
|
sessionFilter = value;
|
|
5825
5997
|
if (value !== 'active') activityFilter.clear();
|
|
5998
|
+
renderFilterState();
|
|
5826
5999
|
updateUrl();
|
|
5827
6000
|
// Instant feedback from cached data, then refetch — the cached list was fetched
|
|
5828
6001
|
// with the previous filter's server params (e.g. filter=active), so "All Sessions"
|
|
@@ -5835,6 +6008,7 @@ function filterBySessions(value) {
|
|
|
5835
6008
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
5836
6009
|
function changeSessionLimit(value) {
|
|
5837
6010
|
sessionLimit = value;
|
|
6011
|
+
renderFilterState();
|
|
5838
6012
|
updateUrl();
|
|
5839
6013
|
fetchSessions();
|
|
5840
6014
|
}
|
|
@@ -5848,6 +6022,31 @@ function matchesProjectFilter(project) {
|
|
|
5848
6022
|
//#endregion
|
|
5849
6023
|
|
|
5850
6024
|
//#region EVENT_DELEGATION
|
|
6025
|
+
// Selecting text inside a dialog and releasing past its edge puts the mouseup on the
|
|
6026
|
+
// backdrop, so the synthesized click targets the overlay and the `onclick="close…()"`
|
|
6027
|
+
// on every .modal-overlay would dismiss it. Only a press that *started* on the
|
|
6028
|
+
// backdrop counts as a dismissal.
|
|
6029
|
+
let _overlayPressTarget = null;
|
|
6030
|
+
document.addEventListener(
|
|
6031
|
+
'mousedown',
|
|
6032
|
+
(e) => {
|
|
6033
|
+
// Only an overlay is ever compared below, so don't hold a reference to any other node —
|
|
6034
|
+
// that would keep the last-pressed element (and a detached subtree, after a re-render)
|
|
6035
|
+
// alive until the next mousedown anywhere.
|
|
6036
|
+
_overlayPressTarget = e.target.classList?.contains('modal-overlay') ? e.target : null;
|
|
6037
|
+
},
|
|
6038
|
+
true,
|
|
6039
|
+
);
|
|
6040
|
+
document.addEventListener(
|
|
6041
|
+
'click',
|
|
6042
|
+
(e) => {
|
|
6043
|
+
if (!e.target.classList?.contains('modal-overlay')) return;
|
|
6044
|
+
if (_overlayPressTarget === e.target) return;
|
|
6045
|
+
e.stopPropagation();
|
|
6046
|
+
},
|
|
6047
|
+
true,
|
|
6048
|
+
);
|
|
6049
|
+
|
|
5851
6050
|
document.addEventListener('click', (e) => {
|
|
5852
6051
|
const pathToggle = e.target.closest('[data-group-action="toggle-path"]');
|
|
5853
6052
|
if (pathToggle) {
|
|
@@ -5903,6 +6102,7 @@ document.addEventListener('click', (e) => {
|
|
|
5903
6102
|
// Used in HTML; also called by the hub project shim, so no biome suppression is needed.
|
|
5904
6103
|
function filterByProject(project) {
|
|
5905
6104
|
filterProject = project || null;
|
|
6105
|
+
renderFilterState();
|
|
5906
6106
|
updateUrl();
|
|
5907
6107
|
fetchSessions(false);
|
|
5908
6108
|
showAllTasks();
|
|
@@ -5964,6 +6164,8 @@ function renderProjectDropdown(dropdown, projects) {
|
|
|
5964
6164
|
return `<option value="${escapeHtml(p.path)}"${selected} title="${escapeHtml(p.path)}">${escapeHtml(name)}</option>`;
|
|
5965
6165
|
})
|
|
5966
6166
|
.join('');
|
|
6167
|
+
// The option list just changed under the select — re-assert tint and header summary.
|
|
6168
|
+
renderFilterState();
|
|
5967
6169
|
}
|
|
5968
6170
|
|
|
5969
6171
|
function updateThemeColor(isLight) {
|
|
@@ -6187,14 +6389,6 @@ function loadPanelWidths() {
|
|
|
6187
6389
|
|
|
6188
6390
|
//#endregion
|
|
6189
6391
|
|
|
6190
|
-
//#region PREFERENCES
|
|
6191
|
-
function loadPreferences() {
|
|
6192
|
-
document.getElementById('session-filter').value = sessionFilter;
|
|
6193
|
-
document.getElementById('session-limit').value = sessionLimit;
|
|
6194
|
-
}
|
|
6195
|
-
|
|
6196
|
-
//#endregion
|
|
6197
|
-
|
|
6198
6392
|
//#region SESSION_INFO
|
|
6199
6393
|
async function showSessionInfoModal(sessionId) {
|
|
6200
6394
|
const session = sessions.find((s) => s.id === sessionId);
|
|
@@ -6281,7 +6475,7 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
|
|
|
6281
6475
|
infoRows.push(['Session', session.id, { openClaudeDir: true, openFile: session.jsonlPath }]);
|
|
6282
6476
|
if (parentInfo?.parentSessionId) {
|
|
6283
6477
|
infoRows.push([
|
|
6284
|
-
'Forked from',
|
|
6478
|
+
parentInfo.relation === 'compact' ? 'Continued from' : 'Forked from',
|
|
6285
6479
|
parentInfo.parentSessionId,
|
|
6286
6480
|
{ openClaudeDir: true, openFile: parentInfo.parentJsonlPath, openSession: parentInfo.parentSessionId },
|
|
6287
6481
|
]);
|
|
@@ -6319,20 +6513,17 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
|
|
|
6319
6513
|
'font-family: var(--mono); font-size: 12px; color: var(--text-primary); user-select: all; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;';
|
|
6320
6514
|
html += `<div class="team-modal-meta info-grid">`;
|
|
6321
6515
|
infoRows.forEach(([label, value, opts]) => {
|
|
6322
|
-
const copyVal = escapeHtml(value).replace(/"/g, '"');
|
|
6323
6516
|
html += `<span style="font-weight: 500; color: var(--text-secondary); font-size: 12px; white-space: nowrap;">${label}</span>`;
|
|
6324
6517
|
if (opts?.openSession) {
|
|
6325
|
-
|
|
6326
|
-
html += `<span onclick="openSessionFromInfo('${sid}')" style="${clickableStyle}" title="Open session in app">${escapeHtml(value)}</span>`;
|
|
6518
|
+
html += `<span onclick="openSessionFromInfo('${escAttrJs(opts.openSession)}')" style="${clickableStyle}" title="Open session in app">${escapeHtml(value)}</span>`;
|
|
6327
6519
|
} else {
|
|
6328
|
-
html += `<span style="${plainStyle}" title="${
|
|
6520
|
+
html += `<span style="${plainStyle}" title="${escapeHtml(value)}">${escapeHtml(value)}</span>`;
|
|
6329
6521
|
}
|
|
6330
|
-
const
|
|
6331
|
-
const copyBtn = `<button onclick="copyWithFeedback('${jsCopyVal}', this)" title="Copy">${ICON_COPY}</button>`;
|
|
6522
|
+
const copyBtn = `<button onclick="copyWithFeedback('${escAttrJs(value)}', this)" title="Copy">${ICON_COPY}</button>`;
|
|
6332
6523
|
let openBtn = '';
|
|
6333
6524
|
if (opts?.openClaudeDir || opts?.openPath) {
|
|
6334
|
-
const folder = opts.openClaudeDir ? '' : escapeHtml(opts.openPath)
|
|
6335
|
-
const file = opts.openFile ? escapeHtml(opts.openFile)
|
|
6525
|
+
const folder = opts.openClaudeDir ? '' : escapeHtml(opts.openPath);
|
|
6526
|
+
const file = opts.openFile ? escapeHtml(opts.openFile) : '';
|
|
6336
6527
|
openBtn = `<button data-folder="${folder}" data-file="${file}" data-claude-dir="${opts.openClaudeDir ? '1' : ''}" onclick="openFolderInEditor(this.dataset.claudeDir ? undefined : this.dataset.folder, this.dataset.file || undefined)" title="Open in editor">${ICON_OPEN_EXTERNAL}</button>`;
|
|
6337
6528
|
}
|
|
6338
6529
|
html += `<span class="info-row-actions">${copyBtn}${openBtn}</span>`;
|
|
@@ -6421,7 +6612,9 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
|
|
|
6421
6612
|
}
|
|
6422
6613
|
|
|
6423
6614
|
bodyEl.innerHTML = html;
|
|
6424
|
-
|
|
6615
|
+
// Bind to the section, not bodyEl: bodyEl outlives the modal, so listeners on it
|
|
6616
|
+
// would stack up once per open and fire the click handler N times.
|
|
6617
|
+
bindLinkedDocsHandlers(bodyEl.querySelector('.linked-docs-section'), session.id);
|
|
6425
6618
|
const alreadyVisible = modal.classList.contains('visible');
|
|
6426
6619
|
_infoModalSessionId = session.id;
|
|
6427
6620
|
updateStickyBtnState();
|
|
@@ -6596,7 +6789,7 @@ function renderGoalSubtitle(session) {
|
|
|
6596
6789
|
// Only active (unmet) goals reach here — a met goal auto-clears. Clicking
|
|
6597
6790
|
// opens the info modal (full text); stopPropagation so it doesn't also
|
|
6598
6791
|
// trigger the card's fetchTasks.
|
|
6599
|
-
return `<div class="session-goal" onclick="event.stopPropagation(); showSessionInfoModal('${session.id}')" title="${escapeHtml(g.condition)}"><span class="session-goal-icon">◎</span>${escapeHtml(short)}</div>`;
|
|
6792
|
+
return `<div class="session-goal" onclick="event.stopPropagation(); showSessionInfoModal('${escAttrJs(session.id)}')" title="${escapeHtml(g.condition)}"><span class="session-goal-icon">◎</span>${escapeHtml(short)}</div>`;
|
|
6600
6793
|
}
|
|
6601
6794
|
|
|
6602
6795
|
function renderLoopBadge(session) {
|
|
@@ -6608,7 +6801,7 @@ function renderLoopBadge(session) {
|
|
|
6608
6801
|
const f = fmtLoopFireTime(li.latest.timestamp, li.latest.delaySeconds);
|
|
6609
6802
|
if (f.abs) tip += ` — latest ${f.status === 'pending' ? 'fires' : 'fired'} ${f.rel} (${f.abs})`;
|
|
6610
6803
|
}
|
|
6611
|
-
return `<span class="loop-badge" onclick="event.stopPropagation(); showLoopModal('${session.id}')" title="${escapeHtml(tip)}"><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"/><polyline points="12 6 12 12 16 14"/></svg></span>`;
|
|
6804
|
+
return `<span class="loop-badge" onclick="event.stopPropagation(); showLoopModal('${escAttrJs(session.id)}')" title="${escapeHtml(tip)}"><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"/><polyline points="12 6 12 12 16 14"/></svg></span>`;
|
|
6612
6805
|
}
|
|
6613
6806
|
|
|
6614
6807
|
function closeLoopModal() {
|
|
@@ -6626,7 +6819,7 @@ function renderWorkflowBadge(session) {
|
|
|
6626
6819
|
if (!session.hasWorkflow) return '';
|
|
6627
6820
|
const n = session.workflowCount || 0;
|
|
6628
6821
|
const tip = `${n} workflow${n === 1 ? '' : 's'}`;
|
|
6629
|
-
return `<span class="workflow-badge" onclick="event.stopPropagation(); showWorkflowModal('${session.id}')" title="${escapeHtml(tip)}">${WORKFLOW_GEAR_SVG}</span>`;
|
|
6822
|
+
return `<span class="workflow-badge" onclick="event.stopPropagation(); showWorkflowModal('${escAttrJs(session.id)}')" title="${escapeHtml(tip)}">${WORKFLOW_GEAR_SVG}</span>`;
|
|
6630
6823
|
}
|
|
6631
6824
|
|
|
6632
6825
|
let _workflowSessionId = null;
|
|
@@ -6675,11 +6868,11 @@ function renderWorkflowPicker(workflows) {
|
|
|
6675
6868
|
body.innerHTML = workflows
|
|
6676
6869
|
.map((w) => {
|
|
6677
6870
|
const when = w.modifiedAt ? formatDate(w.modifiedAt) : '';
|
|
6678
|
-
return `<div class="workflow-row" onclick="showWorkflowRun('${
|
|
6871
|
+
return `<div class="workflow-row" onclick="showWorkflowRun('${escAttrJs(w.id)}')">
|
|
6679
6872
|
<div class="workflow-row-head">
|
|
6680
6873
|
<span class="workflow-row-name">${escapeHtml(w.name || w.id)}</span>
|
|
6681
6874
|
<span class="workflow-row-id"><code>${escapeHtml(w.id)}</code></span>
|
|
6682
|
-
<button class="icon-btn workflow-open-icon" aria-label="Open in editor" title="Open in editor" onclick="event.stopPropagation(); openWorkflowInEditor('${
|
|
6875
|
+
<button class="icon-btn workflow-open-icon" aria-label="Open in editor" title="Open in editor" onclick="event.stopPropagation(); openWorkflowInEditor('${escAttrJs(w.id)}')">${ICON_OPEN_EDITOR}</button>
|
|
6683
6876
|
</div>
|
|
6684
6877
|
${w.description ? `<div class="workflow-row-desc">${escapeHtml(w.description)}</div>` : ''}
|
|
6685
6878
|
${when ? `<div class="workflow-row-meta">${escapeHtml(when)}</div>` : ''}
|
|
@@ -6745,7 +6938,7 @@ function renderWorkflowRun(run) {
|
|
|
6745
6938
|
.map((a) => {
|
|
6746
6939
|
const dur = a.durationMs != null ? formatDuration(a.durationMs) : '';
|
|
6747
6940
|
const running = a.status !== 'done';
|
|
6748
|
-
return `<div class="wf-agent" onclick="showAgentModal('${
|
|
6941
|
+
return `<div class="wf-agent" onclick="showAgentModal('${escAttrJs(a.agentId)}')" title="${escapeHtml(a.agentId)}">
|
|
6749
6942
|
<span class="wf-agent-type">${escapeHtml(a.type || 'agent')}</span>
|
|
6750
6943
|
<span class="wf-agent-model">${escapeHtml(shortModelName(a.model))}</span>
|
|
6751
6944
|
<span class="wf-agent-tok">${a.outputTokens ? `${fmtTokens(a.outputTokens)} tok` : ''}</span>
|
|
@@ -6847,7 +7040,7 @@ function openPlanModal() {
|
|
|
6847
7040
|
}
|
|
6848
7041
|
|
|
6849
7042
|
function closePlanModal() {
|
|
6850
|
-
|
|
7043
|
+
hideModalOverlay('plan-modal');
|
|
6851
7044
|
}
|
|
6852
7045
|
|
|
6853
7046
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
@@ -7117,6 +7310,8 @@ try {
|
|
|
7117
7310
|
af.forEach((k) => activityFilter.add(k));
|
|
7118
7311
|
} catch (_) {}
|
|
7119
7312
|
initSidebarResize();
|
|
7313
|
+
applyModalZoom();
|
|
7314
|
+
loadModalFullscreen();
|
|
7120
7315
|
loadPanelWidths();
|
|
7121
7316
|
initPanelResize('detail-panel', 'detail-panel-resize', '--detail-panel-width', 'detail-panel-width');
|
|
7122
7317
|
initPanelResize('message-panel', 'message-panel-resize', '--message-panel-width', 'message-panel-width');
|
|
@@ -7240,13 +7435,16 @@ fetch('/api/version')
|
|
|
7240
7435
|
.catch(() => {});
|
|
7241
7436
|
|
|
7242
7437
|
const urlState = getUrlState();
|
|
7243
|
-
|
|
7244
|
-
|
|
7245
|
-
|
|
7438
|
+
const lastView = loadLastView();
|
|
7439
|
+
sessionFilter = urlState.filter || FILTER_DEFAULTS.session;
|
|
7440
|
+
sessionLimit = urlState.limit || FILTER_DEFAULTS.limit;
|
|
7441
|
+
// The URL wins; the persisted view only fills in when it carries a project key, so 'all' (null)
|
|
7442
|
+
// restores as 'all' and pre-existing blobs without the key still default to '__recent__'.
|
|
7443
|
+
filterProject = urlState.project || (lastView && 'project' in lastView ? lastView.project : FILTER_DEFAULTS.project);
|
|
7246
7444
|
ownerFilter = urlState.owner || '';
|
|
7247
7445
|
searchQuery = urlState.search || '';
|
|
7248
7446
|
|
|
7249
|
-
|
|
7447
|
+
renderFilterState();
|
|
7250
7448
|
pinnedSessionIds = loadPinnedSessions();
|
|
7251
7449
|
stickySessionIds = loadStickySessions();
|
|
7252
7450
|
setupEventSource();
|
|
@@ -7308,12 +7506,12 @@ Promise.all([
|
|
|
7308
7506
|
|
|
7309
7507
|
window.addEventListener('popstate', () => {
|
|
7310
7508
|
const s = getUrlState();
|
|
7311
|
-
sessionFilter = s.filter ||
|
|
7312
|
-
sessionLimit = s.limit ||
|
|
7313
|
-
filterProject = s.project ||
|
|
7509
|
+
sessionFilter = s.filter || FILTER_DEFAULTS.session;
|
|
7510
|
+
sessionLimit = s.limit || FILTER_DEFAULTS.limit;
|
|
7511
|
+
filterProject = s.project || FILTER_DEFAULTS.project;
|
|
7314
7512
|
ownerFilter = s.owner || '';
|
|
7315
7513
|
searchQuery = s.search || '';
|
|
7316
|
-
|
|
7514
|
+
renderFilterState();
|
|
7317
7515
|
// fetchSessions derives query params from the globals set above — refetch so
|
|
7318
7516
|
// back/forward across a filter change doesn't render a stale server-filtered list.
|
|
7319
7517
|
fetchSessions(false);
|
|
@@ -7334,7 +7532,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
7334
7532
|
if (!window.__HUB__?.enabled) return;
|
|
7335
7533
|
const fwd = (key) => {
|
|
7336
7534
|
e.preventDefault();
|
|
7337
|
-
|
|
7535
|
+
hubPost({ type: 'hub:keydown', key, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey });
|
|
7338
7536
|
};
|
|
7339
7537
|
if (e.ctrlKey && e.altKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
|
|
7340
7538
|
fwd(e.key);
|
|
@@ -7364,17 +7562,24 @@ document.addEventListener('click', (e) => {
|
|
|
7364
7562
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') return;
|
|
7365
7563
|
e.preventDefault();
|
|
7366
7564
|
e.stopPropagation();
|
|
7367
|
-
|
|
7565
|
+
hubPost({ type: 'hub:openExternal', url: url.href });
|
|
7368
7566
|
});
|
|
7369
7567
|
|
|
7370
7568
|
window.hubNavigate = function hubNavigate(app, url) {
|
|
7371
7569
|
if (!window.__HUB__?.enabled) return;
|
|
7372
|
-
|
|
7570
|
+
hubPost({ type: 'hub:navigate', app, url });
|
|
7373
7571
|
};
|
|
7374
7572
|
|
|
7375
7573
|
// Hoisted out of initHubTheme so initHubProject can share it.
|
|
7376
7574
|
const hubOrigin = () => (window.__HUB__?.url ? new URL(window.__HUB__.url).origin : null);
|
|
7377
7575
|
|
|
7576
|
+
// Every send is addressed to the hub explicitly. With targetOrigin '*' any page that
|
|
7577
|
+
// framed this app also received the forwarded keystrokes and navigation intents.
|
|
7578
|
+
function hubPost(message) {
|
|
7579
|
+
const origin = hubOrigin();
|
|
7580
|
+
if (origin) window.parent?.postMessage(message, origin);
|
|
7581
|
+
}
|
|
7582
|
+
|
|
7378
7583
|
(function initHubTheme() {
|
|
7379
7584
|
const getTheme = () => (document.body.classList.contains('light') ? 'light' : 'dark');
|
|
7380
7585
|
const getColorTheme = () => document.body.dataset.colorTheme || 'ember';
|
|
@@ -7400,8 +7605,7 @@ const hubOrigin = () => (window.__HUB__?.url ? new URL(window.__HUB__.url).origi
|
|
|
7400
7605
|
if (t === lastTheme && ct === lastColorTheme) return;
|
|
7401
7606
|
lastTheme = t;
|
|
7402
7607
|
lastColorTheme = ct;
|
|
7403
|
-
|
|
7404
|
-
if (origin) window.parent.postMessage({ type: 'hub:theme', theme: t, colorTheme: ct }, origin);
|
|
7608
|
+
hubPost({ type: 'hub:theme', theme: t, colorTheme: ct });
|
|
7405
7609
|
}).observe(document.body, {
|
|
7406
7610
|
attributes: true,
|
|
7407
7611
|
attributeFilter: ['class', 'data-color-theme'],
|