claude-code-kanban 4.24.0 → 4.26.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/README.md +4 -2
- package/cli.js +1 -1
- package/install.js +30 -10
- package/lib/approvals.js +34 -3
- package/lib/claude-dir.js +53 -0
- package/lib/open-editor.js +4 -1
- package/lib/parsers.js +118 -4
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/scripts/agent-spy.sh +2 -2
- package/plugin/plugins/claude-code-kanban/scripts/approval-gate.sh +58 -23
- package/plugin/plugins/claude-code-kanban/scripts/context-status.sh +2 -2
- package/plugin/plugins/claude-code-kanban/scripts/postman.js +2 -1
- package/public/app.js +428 -126
- package/public/index.html +21 -3
- package/public/style.css +216 -17
- package/server.js +141 -46
package/public/app.js
CHANGED
|
@@ -1,4 +1,23 @@
|
|
|
1
1
|
//#region STATE
|
|
2
|
+
// Set by the server in index.html: null for the default Claude dir, a per-dir prefix otherwise.
|
|
3
|
+
const STORAGE_NS = window.__STORAGE_NS__ ? `${window.__STORAGE_NS__}:` : '';
|
|
4
|
+
// Theme is hub-wide (echoed to every app via hub:theme), so it stays shared across config dirs.
|
|
5
|
+
const GLOBAL_KEYS = new Set(['theme', 'color-theme']);
|
|
6
|
+
const nsKey = (k) => (GLOBAL_KEYS.has(k) ? k : STORAGE_NS + k);
|
|
7
|
+
const store = {
|
|
8
|
+
keys() {
|
|
9
|
+
const out = [];
|
|
10
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
11
|
+
const k = localStorage.key(i);
|
|
12
|
+
if (k.startsWith(STORAGE_NS)) out.push(k.slice(STORAGE_NS.length));
|
|
13
|
+
}
|
|
14
|
+
return out;
|
|
15
|
+
},
|
|
16
|
+
getItem: (k) => localStorage.getItem(nsKey(k)),
|
|
17
|
+
setItem: (k, v) => localStorage.setItem(nsKey(k), v),
|
|
18
|
+
removeItem: (k) => localStorage.removeItem(nsKey(k)),
|
|
19
|
+
};
|
|
20
|
+
|
|
2
21
|
// The baseline "nothing is filtered" state: what resetState() returns to, what updateUrl() omits
|
|
3
22
|
// from the query string, and what decides whether a control tints ember in the header summary.
|
|
4
23
|
const FILTER_DEFAULTS = { project: '__recent__', session: 'active', limit: '20' };
|
|
@@ -11,6 +30,12 @@ let sessionFilter = FILTER_DEFAULTS.session;
|
|
|
11
30
|
// Only meaningful while sessionFilter === 'active' (filterBySessions clears it otherwise)
|
|
12
31
|
const activityFilter = new Set(); // kinds: 'waiting' | 'active'
|
|
13
32
|
let sessionLimit = FILTER_DEFAULTS.limit;
|
|
33
|
+
// Narrows the sidebar to the open session only. Kept out of FILTER_DEFAULTS/updateUrl because it
|
|
34
|
+
// is not a filter — it has no select, no summary token, and never reaches the session picker.
|
|
35
|
+
// Persisted in localStorage rather than the URL: the hub recreates each iframe at the app's base
|
|
36
|
+
// URL, so a query string would not survive a hub reload.
|
|
37
|
+
const ZEN_KEY = 'zenMode';
|
|
38
|
+
let zenMode = store.getItem(ZEN_KEY) === 'true';
|
|
14
39
|
let filterProject = FILTER_DEFAULTS.project; // null = all, '__recent__' = last 24h, or project path
|
|
15
40
|
let recentProjects = new Set();
|
|
16
41
|
let projectsCacheDirty = true;
|
|
@@ -72,9 +97,7 @@ function getUrlState() {
|
|
|
72
97
|
project: params.get('project'),
|
|
73
98
|
owner: params.get('owner'),
|
|
74
99
|
search: params.get('search'),
|
|
75
|
-
messages: params.has('messages')
|
|
76
|
-
? params.get('messages') === '1'
|
|
77
|
-
: localStorage.getItem('message-panel-open') === 'true',
|
|
100
|
+
messages: params.has('messages') ? params.get('messages') === '1' : store.getItem('message-panel-open') === 'true',
|
|
78
101
|
projectView: params.get('projectView'),
|
|
79
102
|
};
|
|
80
103
|
}
|
|
@@ -107,12 +130,12 @@ function persistLastView() {
|
|
|
107
130
|
// base URL on reload, so the query string alone doesn't survive a hub refresh.
|
|
108
131
|
project: filterProject,
|
|
109
132
|
};
|
|
110
|
-
|
|
133
|
+
store.setItem(LAST_VIEW_KEY, JSON.stringify(data));
|
|
111
134
|
} catch (_) {}
|
|
112
135
|
}
|
|
113
136
|
function loadLastView() {
|
|
114
137
|
try {
|
|
115
|
-
return JSON.parse(
|
|
138
|
+
return JSON.parse(store.getItem(LAST_VIEW_KEY)) || null;
|
|
116
139
|
} catch (_) {
|
|
117
140
|
return null;
|
|
118
141
|
}
|
|
@@ -122,11 +145,14 @@ function loadLastView() {
|
|
|
122
145
|
function resetState() {
|
|
123
146
|
history.replaceState(null, '', window.location.pathname);
|
|
124
147
|
try {
|
|
125
|
-
|
|
148
|
+
store.removeItem(LAST_VIEW_KEY);
|
|
126
149
|
} catch (_) {}
|
|
127
150
|
sessionFilter = FILTER_DEFAULTS.session;
|
|
128
151
|
sessionLimit = FILTER_DEFAULTS.limit;
|
|
129
152
|
filterProject = FILTER_DEFAULTS.project;
|
|
153
|
+
zenMode = false;
|
|
154
|
+
store.removeItem(ZEN_KEY);
|
|
155
|
+
renderZenState();
|
|
130
156
|
ownerFilter = '';
|
|
131
157
|
searchQuery = '';
|
|
132
158
|
viewMode = 'all';
|
|
@@ -176,7 +202,7 @@ async function fetchSessions(includeTasks = true) {
|
|
|
176
202
|
if (revealedStorageSessionId) allPinnedIds.add(revealedStorageSessionId);
|
|
177
203
|
// When server filters by activity, the focused session may not be active —
|
|
178
204
|
// include it in pinned so the server still returns it.
|
|
179
|
-
if (sessionFilter === 'active' && currentSessionId) allPinnedIds.add(currentSessionId);
|
|
205
|
+
if ((sessionFilter === 'active' || zenMode) && currentSessionId) allPinnedIds.add(currentSessionId);
|
|
180
206
|
const pinnedParam = allPinnedIds.size > 0 ? `&pinned=${[...allPinnedIds].join(',')}` : '';
|
|
181
207
|
const projectParam =
|
|
182
208
|
filterProject && filterProject !== '__recent__' ? `&project=${encodeURIComponent(filterProject)}` : '';
|
|
@@ -274,7 +300,7 @@ function toggleSection(containerId, chevronId) {
|
|
|
274
300
|
const chevron = document.getElementById(chevronId);
|
|
275
301
|
const collapsed = container.classList.toggle('collapsed');
|
|
276
302
|
chevron.classList.toggle('rotated', collapsed);
|
|
277
|
-
|
|
303
|
+
store.setItem(`${containerId}Collapsed`, collapsed);
|
|
278
304
|
}
|
|
279
305
|
|
|
280
306
|
function isWaitingSession(s) {
|
|
@@ -369,7 +395,7 @@ function setActivityFilter(kind) {
|
|
|
369
395
|
} else {
|
|
370
396
|
toggleActivityKind(kind);
|
|
371
397
|
}
|
|
372
|
-
|
|
398
|
+
store.setItem('activityFilter', JSON.stringify([...activityFilter]));
|
|
373
399
|
// active/waiting only make sense with the active session filter on
|
|
374
400
|
const targetFilter = activityFilter.size > 0 ? 'active' : sessionFilter;
|
|
375
401
|
if (targetFilter !== sessionFilter) {
|
|
@@ -476,6 +502,7 @@ async function fetchAgents(sessionId) {
|
|
|
476
502
|
lastWaitingHash = waitHash;
|
|
477
503
|
if (messagePanelOpen && currentMessages.length) renderMessages(currentMessages);
|
|
478
504
|
maybeFollowLatest();
|
|
505
|
+
maybeAutoOpenWaiting();
|
|
479
506
|
}
|
|
480
507
|
} catch (e) {
|
|
481
508
|
console.error('[fetchAgents]', e);
|
|
@@ -581,7 +608,7 @@ async function refreshProjectAgents() {
|
|
|
581
608
|
function toggleMessagePanel() {
|
|
582
609
|
const panel = document.getElementById('message-panel');
|
|
583
610
|
messagePanelOpen = !messagePanelOpen;
|
|
584
|
-
|
|
611
|
+
store.setItem('message-panel-open', messagePanelOpen);
|
|
585
612
|
panel.classList.toggle('visible', messagePanelOpen);
|
|
586
613
|
document.getElementById('message-toggle')?.classList.toggle('active', messagePanelOpen);
|
|
587
614
|
if (messagePanelOpen && currentSessionId) {
|
|
@@ -596,7 +623,7 @@ async function openSessionWithBookmarks(sessionId) {
|
|
|
596
623
|
if (!messagePanelOpen) {
|
|
597
624
|
const panel = document.getElementById('message-panel');
|
|
598
625
|
messagePanelOpen = true;
|
|
599
|
-
|
|
626
|
+
store.setItem('message-panel-open', 'true');
|
|
600
627
|
panel.classList.add('visible');
|
|
601
628
|
document.getElementById('message-toggle')?.classList.add('active');
|
|
602
629
|
}
|
|
@@ -687,11 +714,19 @@ function openLiveLatestMessage() {
|
|
|
687
714
|
}
|
|
688
715
|
}
|
|
689
716
|
|
|
717
|
+
const MSG_PAGE_LIMIT = 15;
|
|
718
|
+
|
|
719
|
+
async function fetchSessionMessagesPage(sessionId, { before } = {}) {
|
|
720
|
+
const qs = new URLSearchParams({ limit: MSG_PAGE_LIMIT });
|
|
721
|
+
if (before) qs.set('before', before);
|
|
722
|
+
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/messages?${qs}`);
|
|
723
|
+
return res.ok ? res.json() : null;
|
|
724
|
+
}
|
|
725
|
+
|
|
690
726
|
async function fetchMessages(sessionId) {
|
|
691
727
|
try {
|
|
692
|
-
const
|
|
693
|
-
if (!
|
|
694
|
-
const data = await res.json();
|
|
728
|
+
const data = await fetchSessionMessagesPage(sessionId);
|
|
729
|
+
if (!data) return;
|
|
695
730
|
let agentEnriched = false;
|
|
696
731
|
for (const m of data.messages) {
|
|
697
732
|
if (m.agentId && m.agentPrompt) {
|
|
@@ -742,10 +777,8 @@ async function loadOlderMessages() {
|
|
|
742
777
|
loader.textContent = 'Loading...';
|
|
743
778
|
container.prepend(loader);
|
|
744
779
|
try {
|
|
745
|
-
const
|
|
746
|
-
|
|
747
|
-
if (!res.ok) return;
|
|
748
|
-
const data = await res.json();
|
|
780
|
+
const data = await fetchSessionMessagesPage(currentSessionId, { before: currentMessages[0].timestamp });
|
|
781
|
+
if (!data) return;
|
|
749
782
|
msgHasMore = data.hasMore && data.messages.length > 0;
|
|
750
783
|
if (data.messages.length) {
|
|
751
784
|
loader.remove();
|
|
@@ -920,7 +953,12 @@ function buildAssistantPreview(text) {
|
|
|
920
953
|
return { md: out.join('\n').trim(), remainder };
|
|
921
954
|
}
|
|
922
955
|
|
|
956
|
+
// Set for the duration of a read-only renderMessageList call: the pin, agent-link and
|
|
957
|
+
// agent-log controls act on the live panel's state, so a display-only list omits them.
|
|
958
|
+
let msgRenderReadOnly = false;
|
|
959
|
+
|
|
923
960
|
function renderMsgPinBtn(m, i) {
|
|
961
|
+
if (msgRenderReadOnly) return '';
|
|
924
962
|
const pinned = isPinned(m);
|
|
925
963
|
return `<button class="msg-pin-btn${pinned ? ' pinned' : ''}" onclick="event.stopPropagation();togglePin(${i})" title="${pinned ? 'Unpin' : 'Pin'} message">${PIN_SVG}</button>`;
|
|
926
964
|
}
|
|
@@ -980,6 +1018,7 @@ function renderPinnedSection() {
|
|
|
980
1018
|
}
|
|
981
1019
|
|
|
982
1020
|
function resolveAgentLogBtn(m) {
|
|
1021
|
+
if (msgRenderReadOnly) return '';
|
|
983
1022
|
if (m.tool === 'Agent' && m.agentId) return agentLogButton(m.agentId);
|
|
984
1023
|
if (m.tool === 'SendMessage' && m.params?.to) {
|
|
985
1024
|
const recipient = currentAgents.find((a) => (a.type || a.name) === m.params.to);
|
|
@@ -998,7 +1037,7 @@ function renderToolItem(m, i, compact) {
|
|
|
998
1037
|
// from the live agent-activity files), so the ⇗ link, agent-log button, and modal
|
|
999
1038
|
// click all light up DURING the run, identical to post-completion.
|
|
1000
1039
|
const agentLink =
|
|
1001
|
-
m.tool === 'Agent' && m.agentId
|
|
1040
|
+
m.tool === 'Agent' && m.agentId && !msgRenderReadOnly
|
|
1002
1041
|
? ` <span class="msg-agent-link" title="View agent" onclick="event.stopPropagation();showAgentModal('${escAttrJs(m.agentId)}')">⇗</span>`
|
|
1003
1042
|
: '';
|
|
1004
1043
|
// Usage chip (tokens · tools · duration) on completed Agent rows — same stats a
|
|
@@ -1021,7 +1060,16 @@ function renderToolItem(m, i, compact) {
|
|
|
1021
1060
|
</div>`;
|
|
1022
1061
|
}
|
|
1023
1062
|
|
|
1024
|
-
function renderMessageList(messages) {
|
|
1063
|
+
function renderMessageList(messages, { readOnly = false } = {}) {
|
|
1064
|
+
msgRenderReadOnly = readOnly;
|
|
1065
|
+
try {
|
|
1066
|
+
return renderMessageListBody(messages);
|
|
1067
|
+
} finally {
|
|
1068
|
+
msgRenderReadOnly = false;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function renderMessageListBody(messages) {
|
|
1025
1073
|
const parts = [];
|
|
1026
1074
|
let i = 0;
|
|
1027
1075
|
while (i < messages.length) {
|
|
@@ -1479,14 +1527,14 @@ function getPinId(m) {
|
|
|
1479
1527
|
|
|
1480
1528
|
function loadPins(sessionId) {
|
|
1481
1529
|
try {
|
|
1482
|
-
return JSON.parse(
|
|
1530
|
+
return JSON.parse(store.getItem(`pinned-messages-${sessionId}`)) || [];
|
|
1483
1531
|
} catch {
|
|
1484
1532
|
return [];
|
|
1485
1533
|
}
|
|
1486
1534
|
}
|
|
1487
1535
|
|
|
1488
1536
|
function savePins(sessionId, pins) {
|
|
1489
|
-
|
|
1537
|
+
store.setItem(`pinned-messages-${sessionId}`, JSON.stringify(pins));
|
|
1490
1538
|
}
|
|
1491
1539
|
|
|
1492
1540
|
function isPinned(m) {
|
|
@@ -1652,7 +1700,7 @@ const deferredPinPlacement = new Set();
|
|
|
1652
1700
|
|
|
1653
1701
|
function loadPinnedSessions() {
|
|
1654
1702
|
try {
|
|
1655
|
-
return new Set(JSON.parse(
|
|
1703
|
+
return new Set(JSON.parse(store.getItem('pinned-sessions')) || []);
|
|
1656
1704
|
} catch {
|
|
1657
1705
|
return new Set();
|
|
1658
1706
|
}
|
|
@@ -1660,15 +1708,15 @@ function loadPinnedSessions() {
|
|
|
1660
1708
|
|
|
1661
1709
|
function loadStickySessions() {
|
|
1662
1710
|
try {
|
|
1663
|
-
return new Set(JSON.parse(
|
|
1711
|
+
return new Set(JSON.parse(store.getItem('sticky-sessions')) || []);
|
|
1664
1712
|
} catch {
|
|
1665
1713
|
return new Set();
|
|
1666
1714
|
}
|
|
1667
1715
|
}
|
|
1668
1716
|
|
|
1669
1717
|
function savePinnedSessions() {
|
|
1670
|
-
|
|
1671
|
-
|
|
1718
|
+
store.setItem('pinned-sessions', JSON.stringify([...pinnedSessionIds]));
|
|
1719
|
+
store.setItem('sticky-sessions', JSON.stringify([...stickySessionIds]));
|
|
1672
1720
|
}
|
|
1673
1721
|
|
|
1674
1722
|
// Mirror pin state to server so it can be queried by the CLI. UI remains source of truth for itself.
|
|
@@ -2022,12 +2070,12 @@ function _applyModalFullscreen(modalId, on) {
|
|
|
2022
2070
|
function toggleModalFullscreen(modalId) {
|
|
2023
2071
|
const on = !_modalEl(modalId).classList.contains('fullscreen');
|
|
2024
2072
|
_applyModalFullscreen(modalId, on);
|
|
2025
|
-
|
|
2073
|
+
store.setItem(`modal-fullscreen-${modalId}`, String(on));
|
|
2026
2074
|
}
|
|
2027
2075
|
|
|
2028
2076
|
function loadModalFullscreen() {
|
|
2029
2077
|
_forEachFullscreenModal((modalId) => {
|
|
2030
|
-
if (
|
|
2078
|
+
if (store.getItem(`modal-fullscreen-${modalId}`) === 'true') _applyModalFullscreen(modalId, true);
|
|
2031
2079
|
});
|
|
2032
2080
|
}
|
|
2033
2081
|
|
|
@@ -2064,8 +2112,8 @@ function initModalResize() {
|
|
|
2064
2112
|
_forEachFullscreenModal((modalId, modal) => {
|
|
2065
2113
|
const wKey = `modal-width-${modalId}`;
|
|
2066
2114
|
const hKey = `modal-height-${modalId}`;
|
|
2067
|
-
const savedW =
|
|
2068
|
-
const savedH =
|
|
2115
|
+
const savedW = store.getItem(wKey);
|
|
2116
|
+
const savedH = store.getItem(hKey);
|
|
2069
2117
|
if (savedW && savedH) {
|
|
2070
2118
|
_setModalUserSize(modal, savedW, savedH);
|
|
2071
2119
|
modal.classList.add('user-sized');
|
|
@@ -2099,16 +2147,16 @@ function initModalResize() {
|
|
|
2099
2147
|
},
|
|
2100
2148
|
onEnd() {
|
|
2101
2149
|
if (w && h) {
|
|
2102
|
-
|
|
2103
|
-
|
|
2150
|
+
store.setItem(wKey, `${w}px`);
|
|
2151
|
+
store.setItem(hKey, `${h}px`);
|
|
2104
2152
|
}
|
|
2105
2153
|
},
|
|
2106
2154
|
});
|
|
2107
2155
|
|
|
2108
2156
|
handle.addEventListener('dblclick', () => {
|
|
2109
2157
|
modal.classList.remove('user-sized');
|
|
2110
|
-
|
|
2111
|
-
|
|
2158
|
+
store.removeItem(wKey);
|
|
2159
|
+
store.removeItem(hKey);
|
|
2112
2160
|
});
|
|
2113
2161
|
});
|
|
2114
2162
|
}
|
|
@@ -2116,7 +2164,7 @@ function initModalResize() {
|
|
|
2116
2164
|
const MODAL_ZOOM_KEY = 'modal-zoom';
|
|
2117
2165
|
const MODAL_ZOOM_MIN = 0.7;
|
|
2118
2166
|
const MODAL_ZOOM_MAX = 2.0;
|
|
2119
|
-
let modalZoom = clampModalZoom(Number.parseFloat(
|
|
2167
|
+
let modalZoom = clampModalZoom(Number.parseFloat(store.getItem(MODAL_ZOOM_KEY)) || 1);
|
|
2120
2168
|
|
|
2121
2169
|
function clampModalZoom(v) {
|
|
2122
2170
|
return Math.min(MODAL_ZOOM_MAX, Math.max(MODAL_ZOOM_MIN, v));
|
|
@@ -2131,7 +2179,7 @@ function adjustModalZoom(delta) {
|
|
|
2131
2179
|
const next = delta === 0 ? 1 : clampModalZoom(Math.round((modalZoom + delta) * 10) / 10);
|
|
2132
2180
|
if (next === modalZoom && delta !== 0) return;
|
|
2133
2181
|
modalZoom = next;
|
|
2134
|
-
|
|
2182
|
+
store.setItem(MODAL_ZOOM_KEY, String(modalZoom));
|
|
2135
2183
|
applyModalZoom();
|
|
2136
2184
|
showToast(`Text ${Math.round(modalZoom * 100)}%`);
|
|
2137
2185
|
}
|
|
@@ -2143,6 +2191,10 @@ function isZoomableModalOpen() {
|
|
|
2143
2191
|
return document.querySelector('.modal-overlay.visible .modal-zoomable') !== null;
|
|
2144
2192
|
}
|
|
2145
2193
|
|
|
2194
|
+
function isAnyModalOpen() {
|
|
2195
|
+
return document.querySelector('.modal-overlay.visible') !== null;
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2146
2198
|
const ZOOM_KEYS = { '+': 0.1, '=': 0.1, NumpadAdd: 0.1, '-': -0.1, _: -0.1, NumpadSubtract: -0.1, 0: 0, Numpad0: 0 };
|
|
2147
2199
|
|
|
2148
2200
|
let _toastTimer = null;
|
|
@@ -2730,7 +2782,7 @@ function renderAgentFooter() {
|
|
|
2730
2782
|
footer.classList.add('visible');
|
|
2731
2783
|
label.textContent = `Agents Log (${visible.length})`;
|
|
2732
2784
|
|
|
2733
|
-
const collapsed =
|
|
2785
|
+
const collapsed = store.getItem('agentFooterCollapsed') === 'true';
|
|
2734
2786
|
footer.classList.toggle('collapsed', collapsed);
|
|
2735
2787
|
document.getElementById('agent-footer-toggle').innerHTML = collapsed ? '▴' : '▾';
|
|
2736
2788
|
|
|
@@ -2808,7 +2860,7 @@ function toggleAgentFooter() {
|
|
|
2808
2860
|
const footer = document.getElementById('agent-footer');
|
|
2809
2861
|
const collapsed = !footer.classList.contains('collapsed');
|
|
2810
2862
|
footer.classList.toggle('collapsed', collapsed);
|
|
2811
|
-
|
|
2863
|
+
store.setItem('agentFooterCollapsed', collapsed);
|
|
2812
2864
|
document.getElementById('agent-footer-toggle').innerHTML = collapsed ? '▴' : '▾';
|
|
2813
2865
|
}
|
|
2814
2866
|
|
|
@@ -2963,11 +3015,18 @@ let revealedStorageSessionId = null;
|
|
|
2963
3015
|
// Opens a session and scrolls the sidebar to it, refetching first when the id
|
|
2964
3016
|
// fell outside the session list the current filters asked for.
|
|
2965
3017
|
async function revealSession(id) {
|
|
2966
|
-
|
|
3018
|
+
let session = sessions.find((s) => s.id === id);
|
|
3019
|
+
if (!session) {
|
|
2967
3020
|
lastSessionsHash = '';
|
|
2968
3021
|
await fetchSessions();
|
|
3022
|
+
session = sessions.find((s) => s.id === id);
|
|
2969
3023
|
}
|
|
3024
|
+
const uncollapsed = session ? uncollapseFor(session) : false;
|
|
3025
|
+
if (uncollapsed) persistCollapsedGroups();
|
|
3026
|
+
expandSidebar();
|
|
2970
3027
|
await fetchTasks(id);
|
|
3028
|
+
// fetchTasks skips its render when the session and task hash are unchanged.
|
|
3029
|
+
if (uncollapsed) renderSessions();
|
|
2971
3030
|
const el = document.querySelector(`.session-item[data-session-id="${escSel(id)}"]`);
|
|
2972
3031
|
if (el) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
2973
3032
|
}
|
|
@@ -3122,20 +3181,32 @@ function getFilteredSessions() {
|
|
|
3122
3181
|
return filteredSessions;
|
|
3123
3182
|
}
|
|
3124
3183
|
|
|
3184
|
+
// A live refresh would replace the zen panel's link-a-file input mid-type, the same way it
|
|
3185
|
+
// would swallow a half-typed group name. Off zen there is no panel, so skip the DOM probe.
|
|
3186
|
+
function zenPanelIsEditing() {
|
|
3187
|
+
return zenMode && !!sessionsList.querySelector('.linked-doc-input');
|
|
3188
|
+
}
|
|
3189
|
+
|
|
3125
3190
|
function renderSessions() {
|
|
3126
3191
|
// Rebuilding the list under the pointer cancels an in-flight drop and would swallow a
|
|
3127
3192
|
// half-typed group name, and the SSE path can fire at any moment — defer instead.
|
|
3128
|
-
if (sgDrag || sgIsEditing()) return;
|
|
3193
|
+
if (sgDrag || sgIsEditing() || zenPanelIsEditing()) return;
|
|
3129
3194
|
// Update project dropdown
|
|
3130
3195
|
updateProjectDropdown();
|
|
3131
3196
|
|
|
3132
|
-
|
|
3197
|
+
// Zen narrows the rendered list only — the session picker keeps calling getFilteredSessions()
|
|
3198
|
+
// and still offers everything, or there would be no way to switch sessions without leaving zen.
|
|
3199
|
+
const zenSession = zenMode && currentSessionId ? sessions.find((s) => s.id === currentSessionId) : null;
|
|
3200
|
+
const filteredSessions = zenMode ? (zenSession ? [zenSession] : []) : getFilteredSessions();
|
|
3133
3201
|
|
|
3134
3202
|
if (filteredSessions.length === 0) {
|
|
3135
3203
|
let emptyMsg = 'No sessions found';
|
|
3136
3204
|
let emptyHint = 'Tasks appear when you use Claude Code';
|
|
3137
3205
|
|
|
3138
|
-
if (
|
|
3206
|
+
if (zenMode) {
|
|
3207
|
+
emptyMsg = 'Zen mode: no session open';
|
|
3208
|
+
emptyHint = 'Press Shift+Z to leave zen mode, then open a session';
|
|
3209
|
+
} else if (searchQuery) {
|
|
3139
3210
|
emptyMsg = `No results for "${searchQuery}"`;
|
|
3140
3211
|
emptyHint = 'Try a different search term or clear the search';
|
|
3141
3212
|
} else if (filterProject && sessionFilter === 'active') {
|
|
@@ -3164,9 +3235,7 @@ function renderSessions() {
|
|
|
3164
3235
|
const isActive = session.id === currentSessionId && viewMode === 'session';
|
|
3165
3236
|
const isLive = isSessionLive(session);
|
|
3166
3237
|
const sessionName = sessionDisplayName(session);
|
|
3167
|
-
const
|
|
3168
|
-
const primaryName = useGrouped ? sessionName : session.project ? session.project.split('/').pop() : sessionName;
|
|
3169
|
-
const secondaryName = useGrouped ? null : session.project ? sessionName : null;
|
|
3238
|
+
const projectHtml = renderProjectIdentity(session);
|
|
3170
3239
|
|
|
3171
3240
|
const gitBranch = session.gitBranch ? escapeHtml(session.gitBranch) : null;
|
|
3172
3241
|
const createdDisplay = session.createdAt ? formatDate(session.createdAt) : '';
|
|
@@ -3175,7 +3244,9 @@ function renderSessions() {
|
|
|
3175
3244
|
session.createdAt && createdDisplay !== modifiedDisplay
|
|
3176
3245
|
? `Created ${createdDisplay} · Modified ${modifiedDisplay}`
|
|
3177
3246
|
: modifiedDisplay;
|
|
3178
|
-
const tooltip = [session.id, timeDisplay, gitBranch ? `Branch: ${gitBranch}` : '']
|
|
3247
|
+
const tooltip = [session.id, session.project, timeDisplay, gitBranch ? `Branch: ${gitBranch}` : '']
|
|
3248
|
+
.filter(Boolean)
|
|
3249
|
+
.join(' | ');
|
|
3179
3250
|
const isTeam = session.isTeam;
|
|
3180
3251
|
const memberCount = session.memberCount || 0;
|
|
3181
3252
|
|
|
@@ -3183,17 +3254,18 @@ function renderSessions() {
|
|
|
3183
3254
|
const pinClass = pinState === 'sticky' ? ' sticky' : pinState === 'pinned' ? ' pinned' : '';
|
|
3184
3255
|
const pinTitle =
|
|
3185
3256
|
pinState === 'pinned' || pinState === 'sticky' ? 'Unpin session (.)' : 'Pin session (. · > sticky)';
|
|
3186
|
-
|
|
3257
|
+
// Zen renders the full context detail below the card, so the compact bar would just repeat it.
|
|
3258
|
+
const showCtx = !!session.contextStatus && !zenMode;
|
|
3187
3259
|
const linkedDocsCount = getSessionPreviewPaths(session.id).length;
|
|
3188
3260
|
const bookmarksCount = loadPins(session.id).length;
|
|
3189
|
-
const hasScratchpad = !!(
|
|
3261
|
+
const hasScratchpad = !!(store.getItem(_sessionScratchpadKey(session.id)) || '').trim();
|
|
3190
3262
|
const tempClass = session.hasRecentLog || session.inProgress || session.hasWaitingForUser ? 'warm' : 'stale';
|
|
3191
3263
|
const sid = escAttrJs(session.id);
|
|
3192
3264
|
return `
|
|
3193
3265
|
<button onclick="fetchTasks('${sid}')" draggable="true" data-session-id="${escapeHtml(session.id)}" class="session-item ${isActive ? 'active' : ''} ${session.hasWaitingForUser ? 'permission-pending' : ''} ${tempClass} ${showCtx ? 'has-context' : ''}" title="${escapeHtml(tooltip)}">
|
|
3194
3266
|
<span class="session-pin-btn${pinClass}" onclick="event.stopPropagation();toggleSessionPin('${sid}')" title="${pinTitle} session">${pinState === 'sticky' ? SESSION_STAR_SVG : SESSION_PIN_SVG}</span>
|
|
3195
|
-
<div class="session-name">${escapeHtml(
|
|
3196
|
-
${
|
|
3267
|
+
<div class="session-name">${escapeHtml(sessionName)}</div>
|
|
3268
|
+
${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
|
|
3197
3269
|
${gitBranch ? `<div class="session-branch">${gitBranch}</div>` : ''}
|
|
3198
3270
|
${session.planTitle ? `<div class="session-plan">${escapeHtml(session.planTitle)}</div>` : ''}
|
|
3199
3271
|
${renderGoalSubtitle(session)}
|
|
@@ -3222,7 +3294,22 @@ function renderSessions() {
|
|
|
3222
3294
|
`;
|
|
3223
3295
|
};
|
|
3224
3296
|
|
|
3225
|
-
|
|
3297
|
+
// Zen draws one card and its panel, so it returns before the grouping below — leaving it in
|
|
3298
|
+
// that chain would build the whole list only to discard it.
|
|
3299
|
+
if (zenMode) {
|
|
3300
|
+
sessionsList.innerHTML = `${renderSessionCard(zenSession)}
|
|
3301
|
+
<div class="zen-panel">
|
|
3302
|
+
${renderContextDetail(zenSession.contextStatus) || '<div class="zen-panel-empty">No context data for this session</div>'}
|
|
3303
|
+
${renderScratchpadRow(zenSession)}
|
|
3304
|
+
${renderLinkedDocsHtml(zenSession.id)}
|
|
3305
|
+
${renderArtifactsHtml(zenSession.id)}
|
|
3306
|
+
</div>`;
|
|
3307
|
+
bindLinkedDocsHandlers(sessionsList.querySelector('.linked-docs-section'), zenSession.id);
|
|
3308
|
+
ensureSessionArtifacts(zenSession.id);
|
|
3309
|
+
return;
|
|
3310
|
+
}
|
|
3311
|
+
|
|
3312
|
+
const groupPinned = store.getItem('groupPinnedSessions') !== 'false';
|
|
3226
3313
|
const pinWeight = (s) => (isPlacedSticky(s.id) ? 2 : isPlacedPinned(s.id) && !isSessionActive(s) ? 1 : 0);
|
|
3227
3314
|
const pinSort = (a, b) => pinWeight(b) - pinWeight(a);
|
|
3228
3315
|
const countHtml = (arr) => {
|
|
@@ -3296,7 +3383,7 @@ function renderSessions() {
|
|
|
3296
3383
|
</div>
|
|
3297
3384
|
<div class="project-group-breadcrumb${nestedCls}" data-full-path="${escapedPath}" title="Click to copy path">${breadcrumbHtml}</div>
|
|
3298
3385
|
<div class="project-group-sessions${isCollapsed ? ' collapsed' : ''}${nestedCls}" data-project-path="${escapedPath}">
|
|
3299
|
-
${renderGroupSessions(projectSessions,
|
|
3386
|
+
${renderGroupSessions(projectSessions, pinKey(projectPath))}
|
|
3300
3387
|
</div>
|
|
3301
3388
|
`;
|
|
3302
3389
|
};
|
|
@@ -3435,7 +3522,7 @@ function renderSessions() {
|
|
|
3435
3522
|
${countHtml(ungrouped)}
|
|
3436
3523
|
</div>
|
|
3437
3524
|
<div class="project-group-sessions${isCollapsed ? ' collapsed' : ''}">
|
|
3438
|
-
${renderGroupSessions(ungrouped, '
|
|
3525
|
+
${renderGroupSessions(ungrouped, pinKey('__ungrouped__'))}
|
|
3439
3526
|
</div>
|
|
3440
3527
|
`;
|
|
3441
3528
|
} else {
|
|
@@ -3500,7 +3587,7 @@ function renderSession() {
|
|
|
3500
3587
|
sessionTitle.textContent = displayName;
|
|
3501
3588
|
|
|
3502
3589
|
// Build meta text with project path and description
|
|
3503
|
-
const projectName = session.project ? session.project
|
|
3590
|
+
const projectName = session.project ? pathBasename(session.project) : null;
|
|
3504
3591
|
const metaParts = [`${currentTasks.length} tasks`];
|
|
3505
3592
|
if (projectName) {
|
|
3506
3593
|
metaParts.push(projectName);
|
|
@@ -3885,9 +3972,15 @@ function sgKey(id) {
|
|
|
3885
3972
|
return `__group_${id}__`;
|
|
3886
3973
|
}
|
|
3887
3974
|
|
|
3975
|
+
// Collapse key of a project's pinned sub-section. The ungrouped spelling predates this helper
|
|
3976
|
+
// and is kept so saved collapse state still matches.
|
|
3977
|
+
function pinKey(projectPath) {
|
|
3978
|
+
return projectPath === '__ungrouped__' ? '__pinned___ungrouped__' : `__pinned_${projectPath}__`;
|
|
3979
|
+
}
|
|
3980
|
+
|
|
3888
3981
|
function loadSessionGroups() {
|
|
3889
3982
|
try {
|
|
3890
|
-
const list = JSON.parse(
|
|
3983
|
+
const list = JSON.parse(store.getItem(SESSION_GROUPS_KEY) || 'null')?.groups;
|
|
3891
3984
|
sessionGroups = (Array.isArray(list) ? list : [])
|
|
3892
3985
|
.filter((g) => g && typeof g.id === 'string')
|
|
3893
3986
|
.map((g) => ({
|
|
@@ -3909,7 +4002,7 @@ function loadSessionGroups() {
|
|
|
3909
4002
|
|
|
3910
4003
|
function persistSessionGroups() {
|
|
3911
4004
|
try {
|
|
3912
|
-
|
|
4005
|
+
store.setItem(SESSION_GROUPS_KEY, JSON.stringify({ version: 1, groups: sessionGroups }));
|
|
3913
4006
|
} catch (_) {}
|
|
3914
4007
|
}
|
|
3915
4008
|
|
|
@@ -4571,7 +4664,7 @@ function setGroupCollapsed(header, collapsed) {
|
|
|
4571
4664
|
|
|
4572
4665
|
function persistCollapsedGroups() {
|
|
4573
4666
|
try {
|
|
4574
|
-
|
|
4667
|
+
store.setItem('collapsedGroups', JSON.stringify([...collapsedProjectGroups]));
|
|
4575
4668
|
} catch (_) {}
|
|
4576
4669
|
}
|
|
4577
4670
|
|
|
@@ -4590,20 +4683,37 @@ function expandActiveGroups({ onlyNew = false } = {}) {
|
|
|
4590
4683
|
if (!isSessionActive(s)) continue;
|
|
4591
4684
|
activeIds.add(s.id);
|
|
4592
4685
|
if (onlyNew && (!primed || prevActiveSessionIds.has(s.id))) continue;
|
|
4593
|
-
if (
|
|
4594
|
-
// A named group wrapping that project (or the session itself) would keep it hidden.
|
|
4595
|
-
const group = sgGroupForSession(s);
|
|
4596
|
-
if (group && collapsedProjectGroups.delete(sgKey(group.id))) changed = true;
|
|
4597
|
-
// Which section holds it depends on the view, so open every one rather than re-deriving it.
|
|
4598
|
-
for (const key of [SECTION_GROUPS, SECTION_PROJECTS, SECTION_SESSIONS]) {
|
|
4599
|
-
if (collapsedProjectGroups.delete(key)) changed = true;
|
|
4600
|
-
}
|
|
4686
|
+
if (uncollapseFor(s)) changed = true;
|
|
4601
4687
|
}
|
|
4602
4688
|
prevActiveSessionIds = activeIds;
|
|
4603
4689
|
if (changed) persistCollapsedGroups();
|
|
4604
4690
|
// renderSessions() re-renders headers/containers from the updated set
|
|
4605
4691
|
}
|
|
4606
4692
|
|
|
4693
|
+
// Drops every collapse key that could hide this session: its project, a named group wrapping
|
|
4694
|
+
// the project or the session itself, the pinned sub-section only when the card renders inside
|
|
4695
|
+
// it (pinned, not sticky, idle — see renderGroupSessions), and — since which section holds it
|
|
4696
|
+
// depends on the view — every section header. Returns whether anything changed.
|
|
4697
|
+
function uncollapseFor(session) {
|
|
4698
|
+
const project = session.project || '__ungrouped__';
|
|
4699
|
+
const group = sgGroupForSession(session);
|
|
4700
|
+
const inPinned = isPlacedPinned(session.id) && !isPlacedSticky(session.id) && !isSessionActive(session);
|
|
4701
|
+
const keys = [
|
|
4702
|
+
project,
|
|
4703
|
+
inPinned && pinKey(project),
|
|
4704
|
+
group && sgKey(group.id),
|
|
4705
|
+
group && inPinned && `__pinned_group_${group.id}__`,
|
|
4706
|
+
SECTION_GROUPS,
|
|
4707
|
+
SECTION_PROJECTS,
|
|
4708
|
+
SECTION_SESSIONS,
|
|
4709
|
+
];
|
|
4710
|
+
let changed = false;
|
|
4711
|
+
for (const key of keys) {
|
|
4712
|
+
if (key && collapsedProjectGroups.delete(key)) changed = true;
|
|
4713
|
+
}
|
|
4714
|
+
return changed;
|
|
4715
|
+
}
|
|
4716
|
+
|
|
4607
4717
|
function isGroupHeader(el) {
|
|
4608
4718
|
return el.matches(COLLAPSIBLE_HEADER_SELECTOR);
|
|
4609
4719
|
}
|
|
@@ -4667,16 +4777,12 @@ function activateSelectedSession(items) {
|
|
|
4667
4777
|
}
|
|
4668
4778
|
|
|
4669
4779
|
function setFocusZone(zone) {
|
|
4670
|
-
const sidebar = document.querySelector('.sidebar');
|
|
4671
4780
|
clearKbSelection();
|
|
4672
4781
|
clearTaskSelection();
|
|
4673
4782
|
|
|
4674
4783
|
focusZone = zone;
|
|
4675
4784
|
if (zone === 'sidebar') {
|
|
4676
|
-
|
|
4677
|
-
sidebar.classList.remove('collapsed');
|
|
4678
|
-
localStorage.setItem('sidebar-collapsed', false);
|
|
4679
|
-
}
|
|
4785
|
+
expandSidebar();
|
|
4680
4786
|
const items = getNavigableItems();
|
|
4681
4787
|
if (items.length > 0) {
|
|
4682
4788
|
const activeIdx = items.findIndex((el) => el.classList.contains('active'));
|
|
@@ -5132,6 +5238,7 @@ const SHORTCUT_PAIRS = [
|
|
|
5132
5238
|
{ keys: ['['], label: 'Toggle sidebar' },
|
|
5133
5239
|
{ keys: ['T'], label: 'Toggle theme' },
|
|
5134
5240
|
{ keys: ['Shift', 'S'], combo: true, label: 'Storage manager' },
|
|
5241
|
+
{ keys: ['Shift', 'Z'], combo: true, label: 'Zen mode (current session only)' },
|
|
5135
5242
|
{ keys: ['Ctrl', '+'], combo: true, label: 'Larger modal text' },
|
|
5136
5243
|
{ keys: ['Ctrl', '−'], combo: true, label: 'Smaller modal text' },
|
|
5137
5244
|
{ keys: ['Ctrl', '0'], combo: true, label: 'Reset modal text size' },
|
|
@@ -5260,7 +5367,7 @@ function showScratchpad(keyOverride) {
|
|
|
5260
5367
|
_scratchpadKeyOverride = keyOverride || null;
|
|
5261
5368
|
const key = _scratchpadKey();
|
|
5262
5369
|
if (!key) return;
|
|
5263
|
-
_scratchpadTextarea.value =
|
|
5370
|
+
_scratchpadTextarea.value = store.getItem(key) || '';
|
|
5264
5371
|
_scratchpadCharcount.textContent = `${_scratchpadTextarea.value.length} chars`;
|
|
5265
5372
|
_scratchpadModal.classList.add('visible');
|
|
5266
5373
|
_scratchpadTextarea.focus();
|
|
@@ -5280,12 +5387,12 @@ function saveScratchpad() {
|
|
|
5280
5387
|
const key = _scratchpadKey();
|
|
5281
5388
|
if (!key) return;
|
|
5282
5389
|
const val = _scratchpadTextarea.value;
|
|
5283
|
-
const had = !!(
|
|
5390
|
+
const had = !!(store.getItem(key) || '').trim();
|
|
5284
5391
|
const has = !!val.trim();
|
|
5285
5392
|
if (has) {
|
|
5286
|
-
|
|
5393
|
+
store.setItem(key, val);
|
|
5287
5394
|
} else {
|
|
5288
|
-
|
|
5395
|
+
store.removeItem(key);
|
|
5289
5396
|
}
|
|
5290
5397
|
if (had !== has && _isSessionScratchpadKey(key)) {
|
|
5291
5398
|
renderSessions();
|
|
@@ -5307,9 +5414,8 @@ _scratchpadTextarea.addEventListener('input', () => {
|
|
|
5307
5414
|
|
|
5308
5415
|
function _getStorageTotalSize() {
|
|
5309
5416
|
let bytes = 0;
|
|
5310
|
-
for (
|
|
5311
|
-
|
|
5312
|
-
bytes += k.length + localStorage.getItem(k).length;
|
|
5417
|
+
for (const k of store.keys()) {
|
|
5418
|
+
bytes += k.length + store.getItem(k).length;
|
|
5313
5419
|
}
|
|
5314
5420
|
return bytes * 2; // UTF-16
|
|
5315
5421
|
}
|
|
@@ -5346,7 +5452,7 @@ function _groupByProject(sessionIds) {
|
|
|
5346
5452
|
|
|
5347
5453
|
function _projectLabel(project) {
|
|
5348
5454
|
if (project === '(no project)') return '(no project)';
|
|
5349
|
-
return project
|
|
5455
|
+
return pathBasename(project);
|
|
5350
5456
|
}
|
|
5351
5457
|
|
|
5352
5458
|
function _renderProjectGroup(label, meta, innerHtml) {
|
|
@@ -5399,12 +5505,11 @@ function _renderStorageSessions() {
|
|
|
5399
5505
|
const pinnedIds = [...new Set([...pinnedSessionIds, ...stickySessionIds])];
|
|
5400
5506
|
|
|
5401
5507
|
const msgMap = new Map();
|
|
5402
|
-
for (
|
|
5403
|
-
const key = localStorage.key(i);
|
|
5508
|
+
for (const key of store.keys()) {
|
|
5404
5509
|
if (!key.startsWith('pinned-messages-')) continue;
|
|
5405
5510
|
const sid = key.slice('pinned-messages-'.length);
|
|
5406
5511
|
try {
|
|
5407
|
-
const pins = JSON.parse(
|
|
5512
|
+
const pins = JSON.parse(store.getItem(key)) || [];
|
|
5408
5513
|
if (pins.length) msgMap.set(sid, { pins, key });
|
|
5409
5514
|
} catch {}
|
|
5410
5515
|
}
|
|
@@ -5487,7 +5592,7 @@ function _storageUnpinSession(id) {
|
|
|
5487
5592
|
}
|
|
5488
5593
|
|
|
5489
5594
|
function _storageClearSessionPins(sessionId) {
|
|
5490
|
-
|
|
5595
|
+
store.removeItem(`pinned-messages-${sessionId}`);
|
|
5491
5596
|
if (currentSessionId === sessionId) {
|
|
5492
5597
|
currentPins = [];
|
|
5493
5598
|
const el = document.getElementById('message-panel-pinned');
|
|
@@ -5500,12 +5605,12 @@ function _storageClearSessionPins(sessionId) {
|
|
|
5500
5605
|
function _storageUnpinMessage(sessionId, pinId) {
|
|
5501
5606
|
const key = `pinned-messages-${sessionId}`;
|
|
5502
5607
|
try {
|
|
5503
|
-
const pins = JSON.parse(
|
|
5608
|
+
const pins = JSON.parse(store.getItem(key)) || [];
|
|
5504
5609
|
const idx = pins.findIndex((p) => p.id === pinId);
|
|
5505
5610
|
if (idx < 0) return;
|
|
5506
5611
|
pins.splice(idx, 1);
|
|
5507
|
-
if (pins.length)
|
|
5508
|
-
else
|
|
5612
|
+
if (pins.length) store.setItem(key, JSON.stringify(pins));
|
|
5613
|
+
else store.removeItem(key);
|
|
5509
5614
|
if (currentSessionId === sessionId) {
|
|
5510
5615
|
currentPins = pins;
|
|
5511
5616
|
const el = document.getElementById('message-panel-pinned');
|
|
@@ -5518,10 +5623,9 @@ function _storageUnpinMessage(sessionId, pinId) {
|
|
|
5518
5623
|
|
|
5519
5624
|
function _renderStorageScratchpads() {
|
|
5520
5625
|
const allItems = [];
|
|
5521
|
-
for (
|
|
5522
|
-
const key = localStorage.key(i);
|
|
5626
|
+
for (const key of store.keys()) {
|
|
5523
5627
|
if (!key.startsWith('scratchpad-')) continue;
|
|
5524
|
-
const val =
|
|
5628
|
+
const val = store.getItem(key) || '';
|
|
5525
5629
|
const isProject = key.startsWith('scratchpad-project:');
|
|
5526
5630
|
const id = isProject ? key.slice('scratchpad-project:'.length) : key.slice('scratchpad-'.length);
|
|
5527
5631
|
allItems.push({ key, id, isProject, chars: val.length });
|
|
@@ -5590,7 +5694,7 @@ function _storagePreviewPin(sessionId, pinId) {
|
|
|
5590
5694
|
closeStorageManager();
|
|
5591
5695
|
const key = `pinned-messages-${sessionId}`;
|
|
5592
5696
|
try {
|
|
5593
|
-
const pins = JSON.parse(
|
|
5697
|
+
const pins = JSON.parse(store.getItem(key)) || [];
|
|
5594
5698
|
const pin = pins.find((p) => p.id === pinId);
|
|
5595
5699
|
if (!pin) return;
|
|
5596
5700
|
document.getElementById('msg-detail-pin-btn').style.display = 'none';
|
|
@@ -5604,18 +5708,17 @@ function _storagePreviewPin(sessionId, pinId) {
|
|
|
5604
5708
|
}
|
|
5605
5709
|
|
|
5606
5710
|
function _storageDeleteScratchpad(key) {
|
|
5607
|
-
|
|
5711
|
+
store.removeItem(key);
|
|
5608
5712
|
_renderStorageTab();
|
|
5609
5713
|
_updateStorageTotal();
|
|
5610
5714
|
}
|
|
5611
5715
|
|
|
5612
5716
|
function _renderStorageLinkedDocs() {
|
|
5613
5717
|
const entries = [];
|
|
5614
|
-
for (
|
|
5615
|
-
const key = localStorage.key(i);
|
|
5718
|
+
for (const key of store.keys()) {
|
|
5616
5719
|
if (!key.startsWith(PREVIEW_STORAGE_PREFIX)) continue;
|
|
5617
5720
|
try {
|
|
5618
|
-
const arr = JSON.parse(
|
|
5721
|
+
const arr = JSON.parse(store.getItem(key)) || [];
|
|
5619
5722
|
if (Array.isArray(arr) && arr.length) {
|
|
5620
5723
|
entries.push({ sessionId: key.slice(PREVIEW_STORAGE_PREFIX.length), paths: arr });
|
|
5621
5724
|
}
|
|
@@ -5679,7 +5782,7 @@ function _storageUnlinkDoc(sessionId, path) {
|
|
|
5679
5782
|
}
|
|
5680
5783
|
|
|
5681
5784
|
function _storageClearLinkedDocs(sessionId) {
|
|
5682
|
-
|
|
5785
|
+
store.removeItem(PREVIEW_STORAGE_PREFIX + sessionId);
|
|
5683
5786
|
afterLinkedDocsChanged(sessionId);
|
|
5684
5787
|
}
|
|
5685
5788
|
|
|
@@ -5689,8 +5792,7 @@ function _findOrphanedKeys() {
|
|
|
5689
5792
|
const orphaned = [];
|
|
5690
5793
|
for (const id of pinnedSessionIds) if (!known.has(id)) orphaned.push(`__pinned__${id}`);
|
|
5691
5794
|
for (const id of stickySessionIds) if (!known.has(id)) orphaned.push(`__sticky__${id}`);
|
|
5692
|
-
for (
|
|
5693
|
-
const key = localStorage.key(i);
|
|
5795
|
+
for (const key of store.keys()) {
|
|
5694
5796
|
if (key.startsWith('pinned-messages-')) {
|
|
5695
5797
|
if (!known.has(key.slice('pinned-messages-'.length))) orphaned.push(key);
|
|
5696
5798
|
} else if (_isSessionScratchpadKey(key)) {
|
|
@@ -5725,7 +5827,7 @@ function cleanupOrphanedStorage() {
|
|
|
5725
5827
|
stickySessionIds.delete(key.slice('__sticky__'.length));
|
|
5726
5828
|
pinsChanged = true;
|
|
5727
5829
|
} else {
|
|
5728
|
-
|
|
5830
|
+
store.removeItem(key);
|
|
5729
5831
|
}
|
|
5730
5832
|
}
|
|
5731
5833
|
if (pinsChanged) savePinnedSessions();
|
|
@@ -5787,7 +5889,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
5787
5889
|
}
|
|
5788
5890
|
|
|
5789
5891
|
// Modal guard — only Escape, Shift+M, and msg-detail J/K navigation pass through
|
|
5790
|
-
if (
|
|
5892
|
+
if (isAnyModalOpen()) {
|
|
5791
5893
|
if (e.key === 'Escape') {
|
|
5792
5894
|
if (_scratchpadModal.classList.contains('visible')) {
|
|
5793
5895
|
closeScratchpad();
|
|
@@ -5872,6 +5974,11 @@ document.addEventListener('keydown', (e) => {
|
|
|
5872
5974
|
showStorageManager();
|
|
5873
5975
|
return;
|
|
5874
5976
|
}
|
|
5977
|
+
if (e.code === 'KeyZ' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
|
5978
|
+
e.preventDefault();
|
|
5979
|
+
toggleZenMode();
|
|
5980
|
+
return;
|
|
5981
|
+
}
|
|
5875
5982
|
// Ctrl+Shift+P is the browser's own; only the bare chord opens the picker.
|
|
5876
5983
|
if (e.code === 'KeyP' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
|
5877
5984
|
e.preventDefault();
|
|
@@ -6102,7 +6209,7 @@ let currentPreviewPath = null;
|
|
|
6102
6209
|
function getSessionPreviewPaths(sessionId) {
|
|
6103
6210
|
if (!sessionId) return [];
|
|
6104
6211
|
try {
|
|
6105
|
-
const raw =
|
|
6212
|
+
const raw = store.getItem(PREVIEW_STORAGE_PREFIX + sessionId);
|
|
6106
6213
|
const arr = raw ? JSON.parse(raw) : [];
|
|
6107
6214
|
return Array.isArray(arr) ? arr : [];
|
|
6108
6215
|
} catch {
|
|
@@ -6114,14 +6221,14 @@ function addSessionPreviewPath(sessionId, filePath) {
|
|
|
6114
6221
|
if (!sessionId || !filePath) return;
|
|
6115
6222
|
const paths = getSessionPreviewPaths(sessionId).filter((p) => p !== filePath);
|
|
6116
6223
|
paths.unshift(filePath);
|
|
6117
|
-
|
|
6224
|
+
store.setItem(PREVIEW_STORAGE_PREFIX + sessionId, JSON.stringify(paths.slice(0, 20)));
|
|
6118
6225
|
}
|
|
6119
6226
|
|
|
6120
6227
|
function removeSessionPreviewPath(sessionId, filePath) {
|
|
6121
6228
|
if (!sessionId) return;
|
|
6122
6229
|
const paths = getSessionPreviewPaths(sessionId).filter((p) => p !== filePath);
|
|
6123
|
-
if (paths.length)
|
|
6124
|
-
else
|
|
6230
|
+
if (paths.length) store.setItem(PREVIEW_STORAGE_PREFIX + sessionId, JSON.stringify(paths));
|
|
6231
|
+
else store.removeItem(PREVIEW_STORAGE_PREFIX + sessionId);
|
|
6125
6232
|
}
|
|
6126
6233
|
|
|
6127
6234
|
// Every surface that shows linked docs — info modal, session card badge, preview
|
|
@@ -6419,11 +6526,11 @@ function renderLinkedDocsHtml(sessionId) {
|
|
|
6419
6526
|
const body = paths.length
|
|
6420
6527
|
? `<ul class="linked-doc-list">${items}</ul>`
|
|
6421
6528
|
: '<div class="linked-docs-empty">No linked files yet</div>';
|
|
6422
|
-
return `<div class="linked-docs-section
|
|
6423
|
-
<div
|
|
6529
|
+
return `<div class="linked-docs-section panel-section">
|
|
6530
|
+
<div class="panel-section-header">
|
|
6424
6531
|
${linkSvg(12)}
|
|
6425
6532
|
<span>Linked documents</span>
|
|
6426
|
-
<span
|
|
6533
|
+
<span class="panel-section-count">${paths.length}</span>
|
|
6427
6534
|
<button type="button" class="linked-docs-add-btn" title="Link a file" aria-label="Link a file">+</button>
|
|
6428
6535
|
</div>
|
|
6429
6536
|
<div class="linked-doc-editor-slot"></div>
|
|
@@ -6431,6 +6538,63 @@ function renderLinkedDocsHtml(sessionId) {
|
|
|
6431
6538
|
</div>`;
|
|
6432
6539
|
}
|
|
6433
6540
|
|
|
6541
|
+
// Artifacts are read out of the transcript rather than stored with the session, so
|
|
6542
|
+
// the list arrives after the panel is already on screen and the slot — always
|
|
6543
|
+
// rendered, empty or not — is patched in place once it does.
|
|
6544
|
+
const artifactsBySession = new Map();
|
|
6545
|
+
const artifactsInFlight = new Set();
|
|
6546
|
+
|
|
6547
|
+
function artifactLabel(a) {
|
|
6548
|
+
if (a.title) return a.title;
|
|
6549
|
+
return a.path ? pathBasename(a.path).replace(/\.html?$/i, '') : 'Artifact';
|
|
6550
|
+
}
|
|
6551
|
+
|
|
6552
|
+
function artifactsInnerHtml(sessionId) {
|
|
6553
|
+
const list = artifactsBySession.get(sessionId) || [];
|
|
6554
|
+
if (!list.length) return '';
|
|
6555
|
+
const items = list
|
|
6556
|
+
.map(
|
|
6557
|
+
(a) => `<li class="artifact-item">
|
|
6558
|
+
<a class="artifact-link" href="${escapeHtml(a.url)}" target="_blank" rel="noopener" title="${escapeHtml(a.url)}">${escapeHtml(artifactLabel(a))}</a>
|
|
6559
|
+
<span class="row-actions artifact-actions">
|
|
6560
|
+
<button type="button" onclick="copyWithFeedback('${escAttrJs(a.url)}', this)" title="Copy link" aria-label="Copy artifact link">${ICON_COPY}</button>
|
|
6561
|
+
</span>
|
|
6562
|
+
</li>`,
|
|
6563
|
+
)
|
|
6564
|
+
.join('');
|
|
6565
|
+
return `<div class="panel-section-header">
|
|
6566
|
+
${ICON_OPEN_EXTERNAL}
|
|
6567
|
+
<span>Artifacts</span>
|
|
6568
|
+
<span class="panel-section-count">${list.length}</span>
|
|
6569
|
+
</div>
|
|
6570
|
+
<ul class="artifact-list">${items}</ul>`;
|
|
6571
|
+
}
|
|
6572
|
+
|
|
6573
|
+
function renderArtifactsHtml(sessionId) {
|
|
6574
|
+
return `<div class="artifacts-section panel-section" data-artifacts-for="${escapeHtml(sessionId)}">${artifactsInnerHtml(sessionId)}</div>`;
|
|
6575
|
+
}
|
|
6576
|
+
|
|
6577
|
+
// Always asks: the server holds the scan until the transcript grows, so a repeat
|
|
6578
|
+
// costs one stat, and an artifact published while the panel is open still appears.
|
|
6579
|
+
async function ensureSessionArtifacts(sessionId) {
|
|
6580
|
+
if (!sessionId || artifactsInFlight.has(sessionId)) return;
|
|
6581
|
+
artifactsInFlight.add(sessionId);
|
|
6582
|
+
try {
|
|
6583
|
+
const res = await fetch(`/api/sessions/${sessionId}/artifacts`);
|
|
6584
|
+
if (!res.ok) return;
|
|
6585
|
+
const data = await res.json();
|
|
6586
|
+
artifactsBySession.set(sessionId, data.artifacts || []);
|
|
6587
|
+
} catch (_) {
|
|
6588
|
+
return;
|
|
6589
|
+
} finally {
|
|
6590
|
+
artifactsInFlight.delete(sessionId);
|
|
6591
|
+
}
|
|
6592
|
+
const html = artifactsInnerHtml(sessionId);
|
|
6593
|
+
for (const slot of document.querySelectorAll(`[data-artifacts-for="${CSS.escape(sessionId)}"]`)) {
|
|
6594
|
+
slot.innerHTML = html;
|
|
6595
|
+
}
|
|
6596
|
+
}
|
|
6597
|
+
|
|
6434
6598
|
// One delegated listener per section: the list is re-rendered on every change, so
|
|
6435
6599
|
// per-row handlers would only be rebound each time anyway.
|
|
6436
6600
|
function bindLinkedDocsHandlers(container, sessionId) {
|
|
@@ -6504,6 +6668,9 @@ async function linkFileByPath(sessionId, raw, slot) {
|
|
|
6504
6668
|
fail(data.error || 'File not found');
|
|
6505
6669
|
return;
|
|
6506
6670
|
}
|
|
6671
|
+
// Close the editor before the refresh: a sidebar panel defers its re-render while an
|
|
6672
|
+
// input is open, so leaving it up would hide the file that was just linked.
|
|
6673
|
+
if (slot) slot.innerHTML = '';
|
|
6507
6674
|
setSessionDocLink(sessionId, data.path, false);
|
|
6508
6675
|
showToast('Linked to session', 'success');
|
|
6509
6676
|
} catch {
|
|
@@ -6827,6 +6994,19 @@ function maybeFollowLatest() {
|
|
|
6827
6994
|
}
|
|
6828
6995
|
}
|
|
6829
6996
|
|
|
6997
|
+
// A new answerable ask opens its modal on its own, but only onto an empty
|
|
6998
|
+
// screen: an open modal means the user is reading something, so the ask stays
|
|
6999
|
+
// on the card and the badge until they get to it. Each ask id is offered once,
|
|
7000
|
+
// so closing the modal does not bring it back on the next poll.
|
|
7001
|
+
let lastAutoOpenedWaitingId = null;
|
|
7002
|
+
function maybeAutoOpenWaiting() {
|
|
7003
|
+
if (!isWaitingAnswerable() || !isWaitingFresh()) return;
|
|
7004
|
+
if (currentWaiting.id === lastAutoOpenedWaitingId || isAnyModalOpen()) return;
|
|
7005
|
+
lastAutoOpenedWaitingId = currentWaiting.id;
|
|
7006
|
+
msgDetailFollowLatest = true;
|
|
7007
|
+
maybeFollowLatest();
|
|
7008
|
+
}
|
|
7009
|
+
|
|
6830
7010
|
function isWaitingFresh() {
|
|
6831
7011
|
if (!currentWaiting?.timestamp) return false;
|
|
6832
7012
|
return Date.now() - new Date(currentWaiting.timestamp).getTime() < WAITING_TTL_MS;
|
|
@@ -7030,6 +7210,24 @@ function sessionDisplayName(session) {
|
|
|
7030
7210
|
return SESSION_UUID_RE.test(raw) ? raw.slice(0, 8) : raw;
|
|
7031
7211
|
}
|
|
7032
7212
|
|
|
7213
|
+
// Project paths arrive in the OS spelling, so a Windows path is backslash-separated.
|
|
7214
|
+
function pathBasename(p) {
|
|
7215
|
+
return p.split(/[/\\]/).filter(Boolean).pop() || p;
|
|
7216
|
+
}
|
|
7217
|
+
|
|
7218
|
+
// `session.worktree` is resolved server-side from the `.git` pointer file, so it holds for a
|
|
7219
|
+
// worktree parked beside its repo as well as one under <repo>/.claude/worktrees/.
|
|
7220
|
+
function renderProjectIdentity(session) {
|
|
7221
|
+
if (!session.project) return null;
|
|
7222
|
+
const wt = session.worktree;
|
|
7223
|
+
if (!wt) return escapeHtml(pathBasename(session.project));
|
|
7224
|
+
const repo = pathBasename(wt.repo);
|
|
7225
|
+
// Worktrees beside the repo are conventionally named `<repo>-<branch>`, and repeating the
|
|
7226
|
+
// repo inside a chip that already sits next to it wastes the whole width of the card.
|
|
7227
|
+
const name = wt.name.startsWith(repo) ? wt.name.slice(repo.length).replace(/^[-._]/, '') || wt.name : wt.name;
|
|
7228
|
+
return `${escapeHtml(repo)}<span class="wt-chip" title="Worktree of ${escapeHtml(wt.repo)}">⑂ ${escapeHtml(name)}</span>`;
|
|
7229
|
+
}
|
|
7230
|
+
|
|
7033
7231
|
function formatDate(dateStr) {
|
|
7034
7232
|
const date = new Date(dateStr);
|
|
7035
7233
|
const now = new Date();
|
|
@@ -7246,7 +7444,7 @@ function renderMarkdown(text) {
|
|
|
7246
7444
|
}
|
|
7247
7445
|
|
|
7248
7446
|
function isLightTheme() {
|
|
7249
|
-
const saved =
|
|
7447
|
+
const saved = store.getItem('theme');
|
|
7250
7448
|
return (
|
|
7251
7449
|
document.body.classList.contains('light') || (!saved && window.matchMedia('(prefers-color-scheme: light)').matches)
|
|
7252
7450
|
);
|
|
@@ -7467,6 +7665,24 @@ function renderFilterState() {
|
|
|
7467
7665
|
document.getElementById('filter-menu-btn')?.classList.toggle('has-filters', short.length > 0);
|
|
7468
7666
|
}
|
|
7469
7667
|
|
|
7668
|
+
function toggleZenMode() {
|
|
7669
|
+
zenMode = !zenMode;
|
|
7670
|
+
store.setItem(ZEN_KEY, String(zenMode));
|
|
7671
|
+
renderZenState();
|
|
7672
|
+
if (zenMode) closeFilterMenu();
|
|
7673
|
+
renderSessions();
|
|
7674
|
+
// The open session may be outside the current server page/filter; refetch pins it in.
|
|
7675
|
+
if (zenMode) fetchSessions(false);
|
|
7676
|
+
}
|
|
7677
|
+
|
|
7678
|
+
function renderZenState() {
|
|
7679
|
+
const btn = document.getElementById('zen-mode-btn');
|
|
7680
|
+
if (!btn) return;
|
|
7681
|
+
btn.classList.toggle('active', zenMode);
|
|
7682
|
+
btn.setAttribute('aria-pressed', String(zenMode));
|
|
7683
|
+
btn.title = zenMode ? 'Zen mode on — show all sessions (Shift+Z)' : 'Zen mode: current session only (Shift+Z)';
|
|
7684
|
+
}
|
|
7685
|
+
|
|
7470
7686
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
7471
7687
|
function filterBySessions(value) {
|
|
7472
7688
|
sessionFilter = value;
|
|
@@ -7552,13 +7768,13 @@ document.addEventListener('click', (e) => {
|
|
|
7552
7768
|
|
|
7553
7769
|
if (e.target.closest('.pinned-ungroup-btn')) {
|
|
7554
7770
|
e.stopPropagation();
|
|
7555
|
-
|
|
7771
|
+
store.setItem('groupPinnedSessions', 'false');
|
|
7556
7772
|
renderSessions();
|
|
7557
7773
|
return;
|
|
7558
7774
|
}
|
|
7559
7775
|
|
|
7560
7776
|
if (e.target.closest('.pinned-regroup-banner')) {
|
|
7561
|
-
|
|
7777
|
+
store.setItem('groupPinnedSessions', 'true');
|
|
7562
7778
|
renderSessions();
|
|
7563
7779
|
return;
|
|
7564
7780
|
}
|
|
@@ -7672,11 +7888,11 @@ function toggleTheme() {
|
|
|
7672
7888
|
if (isCurrentlyLight) {
|
|
7673
7889
|
document.body.classList.remove('light');
|
|
7674
7890
|
document.body.classList.add('dark-forced');
|
|
7675
|
-
|
|
7891
|
+
store.setItem('theme', 'dark');
|
|
7676
7892
|
} else {
|
|
7677
7893
|
document.body.classList.add('light');
|
|
7678
7894
|
document.body.classList.remove('dark-forced');
|
|
7679
|
-
|
|
7895
|
+
store.setItem('theme', 'light');
|
|
7680
7896
|
}
|
|
7681
7897
|
updateThemeIcon();
|
|
7682
7898
|
updateThemeColor(!isCurrentlyLight);
|
|
@@ -7699,7 +7915,7 @@ function updateThemeIcon() {
|
|
|
7699
7915
|
}
|
|
7700
7916
|
|
|
7701
7917
|
function loadTheme() {
|
|
7702
|
-
const saved =
|
|
7918
|
+
const saved = store.getItem('theme');
|
|
7703
7919
|
if (saved === 'light') {
|
|
7704
7920
|
document.body.classList.add('light');
|
|
7705
7921
|
document.body.classList.remove('dark-forced');
|
|
@@ -7712,7 +7928,7 @@ function loadTheme() {
|
|
|
7712
7928
|
updateThemeColor(document.body.classList.contains('light'));
|
|
7713
7929
|
syncHljsTheme();
|
|
7714
7930
|
buildThemeMenu();
|
|
7715
|
-
const colorTheme =
|
|
7931
|
+
const colorTheme = store.getItem('color-theme');
|
|
7716
7932
|
if (colorTheme) document.body.dataset.colorTheme = colorTheme;
|
|
7717
7933
|
syncColorThemeSelect(colorTheme || 'ember');
|
|
7718
7934
|
}
|
|
@@ -7768,10 +7984,10 @@ function syncColorThemeSelect(id) {
|
|
|
7768
7984
|
function setColorTheme(id) {
|
|
7769
7985
|
if (!id || id === 'ember') {
|
|
7770
7986
|
delete document.body.dataset.colorTheme;
|
|
7771
|
-
|
|
7987
|
+
store.removeItem('color-theme');
|
|
7772
7988
|
} else {
|
|
7773
7989
|
document.body.dataset.colorTheme = id;
|
|
7774
|
-
|
|
7990
|
+
store.setItem('color-theme', id);
|
|
7775
7991
|
}
|
|
7776
7992
|
syncColorThemeSelect(id);
|
|
7777
7993
|
}
|
|
@@ -7779,10 +7995,17 @@ function setColorTheme(id) {
|
|
|
7779
7995
|
//#endregion
|
|
7780
7996
|
|
|
7781
7997
|
//#region SIDEBAR_LAYOUT
|
|
7998
|
+
function expandSidebar() {
|
|
7999
|
+
const sidebar = document.querySelector('.sidebar');
|
|
8000
|
+
if (!sidebar.classList.contains('collapsed')) return;
|
|
8001
|
+
sidebar.classList.remove('collapsed');
|
|
8002
|
+
store.setItem('sidebar-collapsed', false);
|
|
8003
|
+
}
|
|
8004
|
+
|
|
7782
8005
|
function toggleSidebar() {
|
|
7783
8006
|
const sidebar = document.querySelector('.sidebar');
|
|
7784
8007
|
const collapsed = sidebar.classList.toggle('collapsed');
|
|
7785
|
-
|
|
8008
|
+
store.setItem('sidebar-collapsed', collapsed);
|
|
7786
8009
|
if (collapsed) {
|
|
7787
8010
|
sidebar.style.width = '';
|
|
7788
8011
|
if (focusZone === 'sidebar') setFocusZone('board');
|
|
@@ -7794,10 +8017,10 @@ function toggleSidebar() {
|
|
|
7794
8017
|
|
|
7795
8018
|
function loadSidebarState() {
|
|
7796
8019
|
const sidebar = document.querySelector('.sidebar');
|
|
7797
|
-
if (
|
|
8020
|
+
if (store.getItem('sidebar-collapsed') === 'true') {
|
|
7798
8021
|
sidebar.classList.add('collapsed');
|
|
7799
8022
|
}
|
|
7800
|
-
const w =
|
|
8023
|
+
const w = store.getItem('sidebar-width');
|
|
7801
8024
|
if (w) {
|
|
7802
8025
|
sidebar.style.setProperty('--sidebar-width', w);
|
|
7803
8026
|
}
|
|
@@ -7852,7 +8075,7 @@ function initSidebarResize() {
|
|
|
7852
8075
|
},
|
|
7853
8076
|
onEnd() {
|
|
7854
8077
|
sidebar.classList.remove('resizing');
|
|
7855
|
-
|
|
8078
|
+
store.setItem('sidebar-width', sidebar.style.getPropertyValue('--sidebar-width'));
|
|
7856
8079
|
},
|
|
7857
8080
|
});
|
|
7858
8081
|
}
|
|
@@ -7873,7 +8096,7 @@ function initPanelResize(panelId, handleId, cssVar, storageKey) {
|
|
|
7873
8096
|
},
|
|
7874
8097
|
onEnd() {
|
|
7875
8098
|
panel.classList.remove('resizing');
|
|
7876
|
-
|
|
8099
|
+
store.setItem(storageKey, panel.style.getPropertyValue(cssVar));
|
|
7877
8100
|
},
|
|
7878
8101
|
});
|
|
7879
8102
|
}
|
|
@@ -7883,7 +8106,7 @@ function loadPanelWidths() {
|
|
|
7883
8106
|
['detail-panel', '--detail-panel-width'],
|
|
7884
8107
|
['message-panel', '--message-panel-width'],
|
|
7885
8108
|
].forEach(([id, cssVar]) => {
|
|
7886
|
-
const w =
|
|
8109
|
+
const w = store.getItem(`${id}-width`);
|
|
7887
8110
|
if (w) document.getElementById(id).style.setProperty(cssVar, w);
|
|
7888
8111
|
});
|
|
7889
8112
|
}
|
|
@@ -7901,6 +8124,7 @@ async function showSessionInfoModal(sessionId) {
|
|
|
7901
8124
|
_planSessionId = sessionId;
|
|
7902
8125
|
const cachedTasks = currentSessionId === sessionId ? currentTasks : [];
|
|
7903
8126
|
showInfoModal(session, null, cachedTasks, null, null);
|
|
8127
|
+
ensureSessionArtifacts(sessionId);
|
|
7904
8128
|
|
|
7905
8129
|
const rerender = (teamConfig, tasks, planContent, parentInfo) => {
|
|
7906
8130
|
if (_planSessionId !== sessionId) return; // user opened a different modal
|
|
@@ -7959,7 +8183,20 @@ function updateStickyBtnState() {
|
|
|
7959
8183
|
// carry information. The full path stays in the tooltip and the copy button.
|
|
7960
8184
|
function abbreviateScratchpadDir(dir) {
|
|
7961
8185
|
const sep = dir.includes('\\') ? '\\' : '/';
|
|
7962
|
-
return
|
|
8186
|
+
return dir.split(/[/\\]/).slice(-2).join(sep);
|
|
8187
|
+
}
|
|
8188
|
+
|
|
8189
|
+
function renderScratchpadRow(session) {
|
|
8190
|
+
const dir = session?.scratchpadDir;
|
|
8191
|
+
if (!dir) return '';
|
|
8192
|
+
return `<div class="zen-scratchpad">
|
|
8193
|
+
<span class="zen-scratchpad-label">Scratchpad</span>
|
|
8194
|
+
<span class="zen-scratchpad-path" title="${escapeHtml(dir)}">${escapeHtml(abbreviateScratchpadDir(dir))}</span>
|
|
8195
|
+
<span class="row-actions">
|
|
8196
|
+
<button onclick="copyWithFeedback('${escAttrJs(dir)}', this)" title="Copy">${ICON_COPY}</button>
|
|
8197
|
+
<button data-folder="${escapeHtml(dir)}" onclick="openFolderInEditor(this.dataset.folder)" title="Open in editor">${ICON_OPEN_EXTERNAL}</button>
|
|
8198
|
+
</span>
|
|
8199
|
+
</div>`;
|
|
7963
8200
|
}
|
|
7964
8201
|
|
|
7965
8202
|
function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
|
|
@@ -8074,6 +8311,7 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
|
|
|
8074
8311
|
}
|
|
8075
8312
|
|
|
8076
8313
|
html += renderLinkedDocsHtml(session.id);
|
|
8314
|
+
html += renderArtifactsHtml(session.id);
|
|
8077
8315
|
|
|
8078
8316
|
// Team info section
|
|
8079
8317
|
if (teamConfig) {
|
|
@@ -8202,20 +8440,80 @@ let _planSessionId = null;
|
|
|
8202
8440
|
let spSource = [];
|
|
8203
8441
|
let spRows = [];
|
|
8204
8442
|
let spIdx = 0;
|
|
8443
|
+
let spPeekTimer = null;
|
|
8444
|
+
let spPeekToken = 0;
|
|
8445
|
+
let spPeekedId = null;
|
|
8446
|
+
// Per-open cache: spSource is frozen while the picker is up, so a fetched log stays valid.
|
|
8447
|
+
const spPeekCache = new Map();
|
|
8205
8448
|
|
|
8206
8449
|
function openSessionPicker() {
|
|
8207
8450
|
const input = document.getElementById('session-picker-input');
|
|
8208
8451
|
input.value = '';
|
|
8209
8452
|
spSource = getFilteredSessions().sort((a, b) => Date.parse(b.modifiedAt) - Date.parse(a.modifiedAt));
|
|
8453
|
+
spPeekCache.clear();
|
|
8210
8454
|
document.getElementById('session-picker-modal').classList.add('visible');
|
|
8211
8455
|
renderSessionPicker();
|
|
8212
8456
|
input.focus();
|
|
8213
8457
|
}
|
|
8214
8458
|
|
|
8215
8459
|
function closeSessionPicker() {
|
|
8460
|
+
spSetPeeking(false);
|
|
8216
8461
|
hideModalOverlay('session-picker-modal');
|
|
8217
8462
|
}
|
|
8218
8463
|
|
|
8464
|
+
function spIsPeeking() {
|
|
8465
|
+
return _modalEl('session-picker-modal').classList.contains('peeking');
|
|
8466
|
+
}
|
|
8467
|
+
|
|
8468
|
+
function spSetPeeking(on) {
|
|
8469
|
+
clearTimeout(spPeekTimer);
|
|
8470
|
+
spPeekToken++;
|
|
8471
|
+
spPeekedId = null;
|
|
8472
|
+
_modalEl('session-picker-modal').classList.toggle('peeking', on);
|
|
8473
|
+
if (on) spSchedulePeek();
|
|
8474
|
+
else document.getElementById('session-picker-peek').innerHTML = '';
|
|
8475
|
+
}
|
|
8476
|
+
|
|
8477
|
+
function spSchedulePeek() {
|
|
8478
|
+
if (!spIsPeeking() || spRows[spIdx]?.id === spPeekedId) return;
|
|
8479
|
+
clearTimeout(spPeekTimer);
|
|
8480
|
+
spPeekTimer = setTimeout(spLoadPeek, 120);
|
|
8481
|
+
}
|
|
8482
|
+
|
|
8483
|
+
function spRenderPeek(session, messages, note) {
|
|
8484
|
+
const pane = document.getElementById('session-picker-peek');
|
|
8485
|
+
const body =
|
|
8486
|
+
messages === null
|
|
8487
|
+
? ''
|
|
8488
|
+
: messages.length
|
|
8489
|
+
? `<div class="sp-peek-items">${renderMessageList(messages, { readOnly: true })}</div>`
|
|
8490
|
+
: '<div class="msg-empty">No messages in this session</div>';
|
|
8491
|
+
pane.innerHTML = `<div class="sp-peek-title"><span>${escapeHtml(sessionDisplayName(session))}</span><span>${note}</span></div>${body}`;
|
|
8492
|
+
pane.scrollTop = pane.scrollHeight;
|
|
8493
|
+
}
|
|
8494
|
+
|
|
8495
|
+
async function spLoadPeek() {
|
|
8496
|
+
const session = spRows[spIdx];
|
|
8497
|
+
if (!session) {
|
|
8498
|
+
document.getElementById('session-picker-peek').innerHTML = '<div class="msg-empty">No session selected</div>';
|
|
8499
|
+
return;
|
|
8500
|
+
}
|
|
8501
|
+
spPeekedId = session.id;
|
|
8502
|
+
let messages = spPeekCache.get(session.id);
|
|
8503
|
+
if (!messages) {
|
|
8504
|
+
const token = ++spPeekToken;
|
|
8505
|
+
spRenderPeek(session, null, 'loading…');
|
|
8506
|
+
try {
|
|
8507
|
+
messages = (await fetchSessionMessagesPage(session.id))?.messages || [];
|
|
8508
|
+
} catch (_) {
|
|
8509
|
+
messages = [];
|
|
8510
|
+
}
|
|
8511
|
+
if (token !== spPeekToken) return;
|
|
8512
|
+
spPeekCache.set(session.id, messages);
|
|
8513
|
+
}
|
|
8514
|
+
spRenderPeek(session, messages, `last ${messages.length} entries`);
|
|
8515
|
+
}
|
|
8516
|
+
|
|
8219
8517
|
function spMatches(session, query) {
|
|
8220
8518
|
return (
|
|
8221
8519
|
fuzzyMatch(session.name, query) ||
|
|
@@ -8273,6 +8571,7 @@ function spSelect(idx) {
|
|
|
8273
8571
|
const row = list.children[spIdx];
|
|
8274
8572
|
row?.classList.add('selected');
|
|
8275
8573
|
row?.scrollIntoView({ block: 'nearest' });
|
|
8574
|
+
spSchedulePeek();
|
|
8276
8575
|
}
|
|
8277
8576
|
|
|
8278
8577
|
async function spOpen(idx) {
|
|
@@ -8291,6 +8590,7 @@ function initSessionPicker() {
|
|
|
8291
8590
|
else if (matchKey(e, 'ArrowDown') || (e.ctrlKey && e.key === 'n')) spSelect(spIdx + 1);
|
|
8292
8591
|
else if (matchKey(e, 'ArrowUp') || (e.ctrlKey && e.key === 'p')) spSelect(spIdx - 1);
|
|
8293
8592
|
else if (e.key === 'Enter') spOpen(spIdx);
|
|
8593
|
+
else if (e.ctrlKey && e.code === 'Space') spSetPeeking(!spIsPeeking());
|
|
8294
8594
|
else return;
|
|
8295
8595
|
e.preventDefault();
|
|
8296
8596
|
e.stopPropagation();
|
|
@@ -8892,7 +9192,7 @@ if ('serviceWorker' in navigator) {
|
|
|
8892
9192
|
|
|
8893
9193
|
//#region INIT
|
|
8894
9194
|
loadTheme();
|
|
8895
|
-
if (
|
|
9195
|
+
if (store.getItem('sessions-filtersCollapsed') === 'true') {
|
|
8896
9196
|
document.getElementById('sessions-filters').classList.add('collapsed');
|
|
8897
9197
|
document.getElementById('sessions-chevron').classList.add('rotated');
|
|
8898
9198
|
}
|
|
@@ -8932,7 +9232,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
8932
9232
|
|
|
8933
9233
|
loadSidebarState();
|
|
8934
9234
|
try {
|
|
8935
|
-
const cg = JSON.parse(
|
|
9235
|
+
const cg = JSON.parse(store.getItem('collapsedGroups') || '[]');
|
|
8936
9236
|
// biome-ignore lint/suspicious/useIterableCallbackReturn: forEach side-effect
|
|
8937
9237
|
cg.forEach((p) => collapsedProjectGroups.add(p));
|
|
8938
9238
|
} catch (_) {}
|
|
@@ -8940,7 +9240,7 @@ loadSessionGroups();
|
|
|
8940
9240
|
initSessionGroupsDnd();
|
|
8941
9241
|
initSessionPicker();
|
|
8942
9242
|
try {
|
|
8943
|
-
const af = JSON.parse(
|
|
9243
|
+
const af = JSON.parse(store.getItem('activityFilter') || '[]');
|
|
8944
9244
|
// biome-ignore lint/suspicious/useIterableCallbackReturn: forEach side-effect
|
|
8945
9245
|
af.forEach((k) => activityFilter.add(k));
|
|
8946
9246
|
} catch (_) {}
|
|
@@ -9081,6 +9381,7 @@ ownerFilter = urlState.owner || '';
|
|
|
9081
9381
|
searchQuery = urlState.search || '';
|
|
9082
9382
|
|
|
9083
9383
|
renderFilterState();
|
|
9384
|
+
renderZenState();
|
|
9084
9385
|
pinnedSessionIds = loadPinnedSessions();
|
|
9085
9386
|
stickySessionIds = loadStickySessions();
|
|
9086
9387
|
setupEventSource();
|
|
@@ -9173,8 +9474,9 @@ document.addEventListener('keydown', (e) => {
|
|
|
9173
9474
|
if (e.ctrlKey && e.altKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
|
|
9174
9475
|
fwd(e.key);
|
|
9175
9476
|
}
|
|
9176
|
-
//
|
|
9177
|
-
|
|
9477
|
+
// Own branch: the Alt+digit case below requires !ctrlKey. The hub owns the Ctrl+Alt+letter
|
|
9478
|
+
// keymap and ignores unbound letters.
|
|
9479
|
+
if (e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey && /^[a-z]$/i.test(e.key)) {
|
|
9178
9480
|
fwd(e.key);
|
|
9179
9481
|
}
|
|
9180
9482
|
if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey && /^[1-9]$/.test(e.key)) {
|