claude-code-kanban 4.23.2 → 4.25.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 -1
- package/cli.js +1 -1
- package/install.js +14 -9
- package/lib/claude-dir.js +53 -0
- package/lib/open-editor.js +4 -1
- package/lib/parsers.js +54 -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 +6 -6
- 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 +589 -186
- package/public/index.html +15 -132
- package/public/style.css +312 -6
- package/server.js +14 -23
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' };
|
|
@@ -15,6 +34,9 @@ let filterProject = FILTER_DEFAULTS.project; // null = all, '__recent__' = last
|
|
|
15
34
|
let recentProjects = new Set();
|
|
16
35
|
let projectsCacheDirty = true;
|
|
17
36
|
const collapsedProjectGroups = new Set();
|
|
37
|
+
const SECTION_GROUPS = '__section_groups__';
|
|
38
|
+
const SECTION_PROJECTS = '__section_projects__';
|
|
39
|
+
const SECTION_SESSIONS = '__section_sessions__';
|
|
18
40
|
let stableGroupOrder = []; // cached project path order to prevent jumping
|
|
19
41
|
let sessionGroups = []; // user-named groups: [{id, name, color, members:[{type,ref}]}]
|
|
20
42
|
let sgDrag = null; // in-flight sidebar drag: {kind:'session'|'project'|'group', ref}
|
|
@@ -69,9 +91,7 @@ function getUrlState() {
|
|
|
69
91
|
project: params.get('project'),
|
|
70
92
|
owner: params.get('owner'),
|
|
71
93
|
search: params.get('search'),
|
|
72
|
-
messages: params.has('messages')
|
|
73
|
-
? params.get('messages') === '1'
|
|
74
|
-
: localStorage.getItem('message-panel-open') === 'true',
|
|
94
|
+
messages: params.has('messages') ? params.get('messages') === '1' : store.getItem('message-panel-open') === 'true',
|
|
75
95
|
projectView: params.get('projectView'),
|
|
76
96
|
};
|
|
77
97
|
}
|
|
@@ -104,12 +124,12 @@ function persistLastView() {
|
|
|
104
124
|
// base URL on reload, so the query string alone doesn't survive a hub refresh.
|
|
105
125
|
project: filterProject,
|
|
106
126
|
};
|
|
107
|
-
|
|
127
|
+
store.setItem(LAST_VIEW_KEY, JSON.stringify(data));
|
|
108
128
|
} catch (_) {}
|
|
109
129
|
}
|
|
110
130
|
function loadLastView() {
|
|
111
131
|
try {
|
|
112
|
-
return JSON.parse(
|
|
132
|
+
return JSON.parse(store.getItem(LAST_VIEW_KEY)) || null;
|
|
113
133
|
} catch (_) {
|
|
114
134
|
return null;
|
|
115
135
|
}
|
|
@@ -119,7 +139,7 @@ function loadLastView() {
|
|
|
119
139
|
function resetState() {
|
|
120
140
|
history.replaceState(null, '', window.location.pathname);
|
|
121
141
|
try {
|
|
122
|
-
|
|
142
|
+
store.removeItem(LAST_VIEW_KEY);
|
|
123
143
|
} catch (_) {}
|
|
124
144
|
sessionFilter = FILTER_DEFAULTS.session;
|
|
125
145
|
sessionLimit = FILTER_DEFAULTS.limit;
|
|
@@ -271,7 +291,7 @@ function toggleSection(containerId, chevronId) {
|
|
|
271
291
|
const chevron = document.getElementById(chevronId);
|
|
272
292
|
const collapsed = container.classList.toggle('collapsed');
|
|
273
293
|
chevron.classList.toggle('rotated', collapsed);
|
|
274
|
-
|
|
294
|
+
store.setItem(`${containerId}Collapsed`, collapsed);
|
|
275
295
|
}
|
|
276
296
|
|
|
277
297
|
function isWaitingSession(s) {
|
|
@@ -280,6 +300,11 @@ function isWaitingSession(s) {
|
|
|
280
300
|
function isActiveSession(s) {
|
|
281
301
|
return !s.hasWaitingForUser && (s.inProgress > 0 || s.hasRecentLog || s.hasRunningAgents);
|
|
282
302
|
}
|
|
303
|
+
// hasRecentLog carries the server's registry-idle correction: an open-but-idle
|
|
304
|
+
// terminal keeps touching its JSONL, so mtime alone is not proof of activity.
|
|
305
|
+
function isSessionLive(s) {
|
|
306
|
+
return !!s.hasRecentLog && Date.now() - Date.parse(s.modifiedAt) <= LIVE_INDICATOR_MS;
|
|
307
|
+
}
|
|
283
308
|
|
|
284
309
|
const ACTIVITY_PREDICATES = {
|
|
285
310
|
waiting: isWaitingSession,
|
|
@@ -361,7 +386,7 @@ function setActivityFilter(kind) {
|
|
|
361
386
|
} else {
|
|
362
387
|
toggleActivityKind(kind);
|
|
363
388
|
}
|
|
364
|
-
|
|
389
|
+
store.setItem('activityFilter', JSON.stringify([...activityFilter]));
|
|
365
390
|
// active/waiting only make sense with the active session filter on
|
|
366
391
|
const targetFilter = activityFilter.size > 0 ? 'active' : sessionFilter;
|
|
367
392
|
if (targetFilter !== sessionFilter) {
|
|
@@ -573,7 +598,7 @@ async function refreshProjectAgents() {
|
|
|
573
598
|
function toggleMessagePanel() {
|
|
574
599
|
const panel = document.getElementById('message-panel');
|
|
575
600
|
messagePanelOpen = !messagePanelOpen;
|
|
576
|
-
|
|
601
|
+
store.setItem('message-panel-open', messagePanelOpen);
|
|
577
602
|
panel.classList.toggle('visible', messagePanelOpen);
|
|
578
603
|
document.getElementById('message-toggle')?.classList.toggle('active', messagePanelOpen);
|
|
579
604
|
if (messagePanelOpen && currentSessionId) {
|
|
@@ -588,7 +613,7 @@ async function openSessionWithBookmarks(sessionId) {
|
|
|
588
613
|
if (!messagePanelOpen) {
|
|
589
614
|
const panel = document.getElementById('message-panel');
|
|
590
615
|
messagePanelOpen = true;
|
|
591
|
-
|
|
616
|
+
store.setItem('message-panel-open', 'true');
|
|
592
617
|
panel.classList.add('visible');
|
|
593
618
|
document.getElementById('message-toggle')?.classList.add('active');
|
|
594
619
|
}
|
|
@@ -679,11 +704,19 @@ function openLiveLatestMessage() {
|
|
|
679
704
|
}
|
|
680
705
|
}
|
|
681
706
|
|
|
707
|
+
const MSG_PAGE_LIMIT = 15;
|
|
708
|
+
|
|
709
|
+
async function fetchSessionMessagesPage(sessionId, { before } = {}) {
|
|
710
|
+
const qs = new URLSearchParams({ limit: MSG_PAGE_LIMIT });
|
|
711
|
+
if (before) qs.set('before', before);
|
|
712
|
+
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/messages?${qs}`);
|
|
713
|
+
return res.ok ? res.json() : null;
|
|
714
|
+
}
|
|
715
|
+
|
|
682
716
|
async function fetchMessages(sessionId) {
|
|
683
717
|
try {
|
|
684
|
-
const
|
|
685
|
-
if (!
|
|
686
|
-
const data = await res.json();
|
|
718
|
+
const data = await fetchSessionMessagesPage(sessionId);
|
|
719
|
+
if (!data) return;
|
|
687
720
|
let agentEnriched = false;
|
|
688
721
|
for (const m of data.messages) {
|
|
689
722
|
if (m.agentId && m.agentPrompt) {
|
|
@@ -734,10 +767,8 @@ async function loadOlderMessages() {
|
|
|
734
767
|
loader.textContent = 'Loading...';
|
|
735
768
|
container.prepend(loader);
|
|
736
769
|
try {
|
|
737
|
-
const
|
|
738
|
-
|
|
739
|
-
if (!res.ok) return;
|
|
740
|
-
const data = await res.json();
|
|
770
|
+
const data = await fetchSessionMessagesPage(currentSessionId, { before: currentMessages[0].timestamp });
|
|
771
|
+
if (!data) return;
|
|
741
772
|
msgHasMore = data.hasMore && data.messages.length > 0;
|
|
742
773
|
if (data.messages.length) {
|
|
743
774
|
loader.remove();
|
|
@@ -912,7 +943,12 @@ function buildAssistantPreview(text) {
|
|
|
912
943
|
return { md: out.join('\n').trim(), remainder };
|
|
913
944
|
}
|
|
914
945
|
|
|
946
|
+
// Set for the duration of a read-only renderMessageList call: the pin, agent-link and
|
|
947
|
+
// agent-log controls act on the live panel's state, so a display-only list omits them.
|
|
948
|
+
let msgRenderReadOnly = false;
|
|
949
|
+
|
|
915
950
|
function renderMsgPinBtn(m, i) {
|
|
951
|
+
if (msgRenderReadOnly) return '';
|
|
916
952
|
const pinned = isPinned(m);
|
|
917
953
|
return `<button class="msg-pin-btn${pinned ? ' pinned' : ''}" onclick="event.stopPropagation();togglePin(${i})" title="${pinned ? 'Unpin' : 'Pin'} message">${PIN_SVG}</button>`;
|
|
918
954
|
}
|
|
@@ -972,6 +1008,7 @@ function renderPinnedSection() {
|
|
|
972
1008
|
}
|
|
973
1009
|
|
|
974
1010
|
function resolveAgentLogBtn(m) {
|
|
1011
|
+
if (msgRenderReadOnly) return '';
|
|
975
1012
|
if (m.tool === 'Agent' && m.agentId) return agentLogButton(m.agentId);
|
|
976
1013
|
if (m.tool === 'SendMessage' && m.params?.to) {
|
|
977
1014
|
const recipient = currentAgents.find((a) => (a.type || a.name) === m.params.to);
|
|
@@ -990,7 +1027,7 @@ function renderToolItem(m, i, compact) {
|
|
|
990
1027
|
// from the live agent-activity files), so the ⇗ link, agent-log button, and modal
|
|
991
1028
|
// click all light up DURING the run, identical to post-completion.
|
|
992
1029
|
const agentLink =
|
|
993
|
-
m.tool === 'Agent' && m.agentId
|
|
1030
|
+
m.tool === 'Agent' && m.agentId && !msgRenderReadOnly
|
|
994
1031
|
? ` <span class="msg-agent-link" title="View agent" onclick="event.stopPropagation();showAgentModal('${escAttrJs(m.agentId)}')">⇗</span>`
|
|
995
1032
|
: '';
|
|
996
1033
|
// Usage chip (tokens · tools · duration) on completed Agent rows — same stats a
|
|
@@ -1013,7 +1050,16 @@ function renderToolItem(m, i, compact) {
|
|
|
1013
1050
|
</div>`;
|
|
1014
1051
|
}
|
|
1015
1052
|
|
|
1016
|
-
function renderMessageList(messages) {
|
|
1053
|
+
function renderMessageList(messages, { readOnly = false } = {}) {
|
|
1054
|
+
msgRenderReadOnly = readOnly;
|
|
1055
|
+
try {
|
|
1056
|
+
return renderMessageListBody(messages);
|
|
1057
|
+
} finally {
|
|
1058
|
+
msgRenderReadOnly = false;
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
function renderMessageListBody(messages) {
|
|
1017
1063
|
const parts = [];
|
|
1018
1064
|
let i = 0;
|
|
1019
1065
|
while (i < messages.length) {
|
|
@@ -1471,14 +1517,14 @@ function getPinId(m) {
|
|
|
1471
1517
|
|
|
1472
1518
|
function loadPins(sessionId) {
|
|
1473
1519
|
try {
|
|
1474
|
-
return JSON.parse(
|
|
1520
|
+
return JSON.parse(store.getItem(`pinned-messages-${sessionId}`)) || [];
|
|
1475
1521
|
} catch {
|
|
1476
1522
|
return [];
|
|
1477
1523
|
}
|
|
1478
1524
|
}
|
|
1479
1525
|
|
|
1480
1526
|
function savePins(sessionId, pins) {
|
|
1481
|
-
|
|
1527
|
+
store.setItem(`pinned-messages-${sessionId}`, JSON.stringify(pins));
|
|
1482
1528
|
}
|
|
1483
1529
|
|
|
1484
1530
|
function isPinned(m) {
|
|
@@ -1644,7 +1690,7 @@ const deferredPinPlacement = new Set();
|
|
|
1644
1690
|
|
|
1645
1691
|
function loadPinnedSessions() {
|
|
1646
1692
|
try {
|
|
1647
|
-
return new Set(JSON.parse(
|
|
1693
|
+
return new Set(JSON.parse(store.getItem('pinned-sessions')) || []);
|
|
1648
1694
|
} catch {
|
|
1649
1695
|
return new Set();
|
|
1650
1696
|
}
|
|
@@ -1652,15 +1698,15 @@ function loadPinnedSessions() {
|
|
|
1652
1698
|
|
|
1653
1699
|
function loadStickySessions() {
|
|
1654
1700
|
try {
|
|
1655
|
-
return new Set(JSON.parse(
|
|
1701
|
+
return new Set(JSON.parse(store.getItem('sticky-sessions')) || []);
|
|
1656
1702
|
} catch {
|
|
1657
1703
|
return new Set();
|
|
1658
1704
|
}
|
|
1659
1705
|
}
|
|
1660
1706
|
|
|
1661
1707
|
function savePinnedSessions() {
|
|
1662
|
-
|
|
1663
|
-
|
|
1708
|
+
store.setItem('pinned-sessions', JSON.stringify([...pinnedSessionIds]));
|
|
1709
|
+
store.setItem('sticky-sessions', JSON.stringify([...stickySessionIds]));
|
|
1664
1710
|
}
|
|
1665
1711
|
|
|
1666
1712
|
// Mirror pin state to server so it can be queried by the CLI. UI remains source of truth for itself.
|
|
@@ -2014,12 +2060,12 @@ function _applyModalFullscreen(modalId, on) {
|
|
|
2014
2060
|
function toggleModalFullscreen(modalId) {
|
|
2015
2061
|
const on = !_modalEl(modalId).classList.contains('fullscreen');
|
|
2016
2062
|
_applyModalFullscreen(modalId, on);
|
|
2017
|
-
|
|
2063
|
+
store.setItem(`modal-fullscreen-${modalId}`, String(on));
|
|
2018
2064
|
}
|
|
2019
2065
|
|
|
2020
2066
|
function loadModalFullscreen() {
|
|
2021
2067
|
_forEachFullscreenModal((modalId) => {
|
|
2022
|
-
if (
|
|
2068
|
+
if (store.getItem(`modal-fullscreen-${modalId}`) === 'true') _applyModalFullscreen(modalId, true);
|
|
2023
2069
|
});
|
|
2024
2070
|
}
|
|
2025
2071
|
|
|
@@ -2056,8 +2102,8 @@ function initModalResize() {
|
|
|
2056
2102
|
_forEachFullscreenModal((modalId, modal) => {
|
|
2057
2103
|
const wKey = `modal-width-${modalId}`;
|
|
2058
2104
|
const hKey = `modal-height-${modalId}`;
|
|
2059
|
-
const savedW =
|
|
2060
|
-
const savedH =
|
|
2105
|
+
const savedW = store.getItem(wKey);
|
|
2106
|
+
const savedH = store.getItem(hKey);
|
|
2061
2107
|
if (savedW && savedH) {
|
|
2062
2108
|
_setModalUserSize(modal, savedW, savedH);
|
|
2063
2109
|
modal.classList.add('user-sized');
|
|
@@ -2091,16 +2137,16 @@ function initModalResize() {
|
|
|
2091
2137
|
},
|
|
2092
2138
|
onEnd() {
|
|
2093
2139
|
if (w && h) {
|
|
2094
|
-
|
|
2095
|
-
|
|
2140
|
+
store.setItem(wKey, `${w}px`);
|
|
2141
|
+
store.setItem(hKey, `${h}px`);
|
|
2096
2142
|
}
|
|
2097
2143
|
},
|
|
2098
2144
|
});
|
|
2099
2145
|
|
|
2100
2146
|
handle.addEventListener('dblclick', () => {
|
|
2101
2147
|
modal.classList.remove('user-sized');
|
|
2102
|
-
|
|
2103
|
-
|
|
2148
|
+
store.removeItem(wKey);
|
|
2149
|
+
store.removeItem(hKey);
|
|
2104
2150
|
});
|
|
2105
2151
|
});
|
|
2106
2152
|
}
|
|
@@ -2108,7 +2154,7 @@ function initModalResize() {
|
|
|
2108
2154
|
const MODAL_ZOOM_KEY = 'modal-zoom';
|
|
2109
2155
|
const MODAL_ZOOM_MIN = 0.7;
|
|
2110
2156
|
const MODAL_ZOOM_MAX = 2.0;
|
|
2111
|
-
let modalZoom = clampModalZoom(Number.parseFloat(
|
|
2157
|
+
let modalZoom = clampModalZoom(Number.parseFloat(store.getItem(MODAL_ZOOM_KEY)) || 1);
|
|
2112
2158
|
|
|
2113
2159
|
function clampModalZoom(v) {
|
|
2114
2160
|
return Math.min(MODAL_ZOOM_MAX, Math.max(MODAL_ZOOM_MIN, v));
|
|
@@ -2123,7 +2169,7 @@ function adjustModalZoom(delta) {
|
|
|
2123
2169
|
const next = delta === 0 ? 1 : clampModalZoom(Math.round((modalZoom + delta) * 10) / 10);
|
|
2124
2170
|
if (next === modalZoom && delta !== 0) return;
|
|
2125
2171
|
modalZoom = next;
|
|
2126
|
-
|
|
2172
|
+
store.setItem(MODAL_ZOOM_KEY, String(modalZoom));
|
|
2127
2173
|
applyModalZoom();
|
|
2128
2174
|
showToast(`Text ${Math.round(modalZoom * 100)}%`);
|
|
2129
2175
|
}
|
|
@@ -2722,7 +2768,7 @@ function renderAgentFooter() {
|
|
|
2722
2768
|
footer.classList.add('visible');
|
|
2723
2769
|
label.textContent = `Agents Log (${visible.length})`;
|
|
2724
2770
|
|
|
2725
|
-
const collapsed =
|
|
2771
|
+
const collapsed = store.getItem('agentFooterCollapsed') === 'true';
|
|
2726
2772
|
footer.classList.toggle('collapsed', collapsed);
|
|
2727
2773
|
document.getElementById('agent-footer-toggle').innerHTML = collapsed ? '▴' : '▾';
|
|
2728
2774
|
|
|
@@ -2800,7 +2846,7 @@ function toggleAgentFooter() {
|
|
|
2800
2846
|
const footer = document.getElementById('agent-footer');
|
|
2801
2847
|
const collapsed = !footer.classList.contains('collapsed');
|
|
2802
2848
|
footer.classList.toggle('collapsed', collapsed);
|
|
2803
|
-
|
|
2849
|
+
store.setItem('agentFooterCollapsed', collapsed);
|
|
2804
2850
|
document.getElementById('agent-footer-toggle').innerHTML = collapsed ? '▴' : '▾';
|
|
2805
2851
|
}
|
|
2806
2852
|
|
|
@@ -2951,6 +2997,26 @@ function highlightSelectedAgent() {
|
|
|
2951
2997
|
//#region RENDERING
|
|
2952
2998
|
let revealedPlanSessionId = null;
|
|
2953
2999
|
let revealedStorageSessionId = null;
|
|
3000
|
+
|
|
3001
|
+
// Opens a session and scrolls the sidebar to it, refetching first when the id
|
|
3002
|
+
// fell outside the session list the current filters asked for.
|
|
3003
|
+
async function revealSession(id) {
|
|
3004
|
+
let session = sessions.find((s) => s.id === id);
|
|
3005
|
+
if (!session) {
|
|
3006
|
+
lastSessionsHash = '';
|
|
3007
|
+
await fetchSessions();
|
|
3008
|
+
session = sessions.find((s) => s.id === id);
|
|
3009
|
+
}
|
|
3010
|
+
const uncollapsed = session ? uncollapseFor(session) : false;
|
|
3011
|
+
if (uncollapsed) persistCollapsedGroups();
|
|
3012
|
+
expandSidebar();
|
|
3013
|
+
await fetchTasks(id);
|
|
3014
|
+
// fetchTasks skips its render when the session and task hash are unchanged.
|
|
3015
|
+
if (uncollapsed) renderSessions();
|
|
3016
|
+
const el = document.querySelector(`.session-item[data-session-id="${escSel(id)}"]`);
|
|
3017
|
+
if (el) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
3018
|
+
}
|
|
3019
|
+
|
|
2954
3020
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
2955
3021
|
async function revealPlanSession(planSessionId) {
|
|
2956
3022
|
if (revealedPlanSessionId === planSessionId) {
|
|
@@ -2959,13 +3025,7 @@ async function revealPlanSession(planSessionId) {
|
|
|
2959
3025
|
return;
|
|
2960
3026
|
}
|
|
2961
3027
|
revealedPlanSessionId = planSessionId;
|
|
2962
|
-
|
|
2963
|
-
lastSessionsHash = '';
|
|
2964
|
-
await fetchSessions();
|
|
2965
|
-
}
|
|
2966
|
-
await fetchTasks(planSessionId);
|
|
2967
|
-
const el = document.querySelector(`.session-item[data-session-id="${CSS.escape(planSessionId)}"]`);
|
|
2968
|
-
if (el) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
3028
|
+
await revealSession(planSessionId);
|
|
2969
3029
|
}
|
|
2970
3030
|
|
|
2971
3031
|
async function showAllTasks() {
|
|
@@ -3017,15 +3077,10 @@ function renderAllTasks() {
|
|
|
3017
3077
|
renderKanban();
|
|
3018
3078
|
}
|
|
3019
3079
|
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
// Update project dropdown
|
|
3025
|
-
updateProjectDropdown();
|
|
3026
|
-
|
|
3027
|
-
// Filter pipeline: active filter → force-include revealed/current (non-pinned) sessions →
|
|
3028
|
-
// project filter → search filter → ensure pinned/sticky sessions are always included
|
|
3080
|
+
// Filter pipeline: active filter → force-include revealed/current (non-pinned) sessions →
|
|
3081
|
+
// project filter → search filter → ensure pinned/sticky sessions are always included.
|
|
3082
|
+
// Pure over module state, so the session picker can ask for the same list without a render.
|
|
3083
|
+
function getFilteredSessions() {
|
|
3029
3084
|
let filteredSessions = sessions;
|
|
3030
3085
|
if (sessionFilter === 'active') {
|
|
3031
3086
|
const activeSessionIds = new Set();
|
|
@@ -3109,6 +3164,18 @@ function renderSessions() {
|
|
|
3109
3164
|
if (missingPinned.length) filteredSessions = [...missingPinned, ...filteredSessions];
|
|
3110
3165
|
}
|
|
3111
3166
|
|
|
3167
|
+
return filteredSessions;
|
|
3168
|
+
}
|
|
3169
|
+
|
|
3170
|
+
function renderSessions() {
|
|
3171
|
+
// Rebuilding the list under the pointer cancels an in-flight drop and would swallow a
|
|
3172
|
+
// half-typed group name, and the SSE path can fire at any moment — defer instead.
|
|
3173
|
+
if (sgDrag || sgIsEditing()) return;
|
|
3174
|
+
// Update project dropdown
|
|
3175
|
+
updateProjectDropdown();
|
|
3176
|
+
|
|
3177
|
+
const filteredSessions = getFilteredSessions();
|
|
3178
|
+
|
|
3112
3179
|
if (filteredSessions.length === 0) {
|
|
3113
3180
|
let emptyMsg = 'No sessions found';
|
|
3114
3181
|
let emptyHint = 'Tasks appear when you use Claude Code';
|
|
@@ -3140,13 +3207,8 @@ function renderSessions() {
|
|
|
3140
3207
|
const total = session.taskCount;
|
|
3141
3208
|
const percent = total > 0 ? Math.round((session.completed / total) * 100) : 0;
|
|
3142
3209
|
const isActive = session.id === currentSessionId && viewMode === 'session';
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
const isLive = session.hasRecentLog && Date.now() - Date.parse(session.modifiedAt) <= LIVE_INDICATOR_MS;
|
|
3146
|
-
const rawName = session.name || session.id;
|
|
3147
|
-
const sessionName = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(rawName)
|
|
3148
|
-
? rawName.slice(0, 8)
|
|
3149
|
-
: rawName;
|
|
3210
|
+
const isLive = isSessionLive(session);
|
|
3211
|
+
const sessionName = sessionDisplayName(session);
|
|
3150
3212
|
const useGrouped = sessionFilter === 'active' && session.project;
|
|
3151
3213
|
const primaryName = useGrouped ? sessionName : session.project ? session.project.split('/').pop() : sessionName;
|
|
3152
3214
|
const secondaryName = useGrouped ? null : session.project ? sessionName : null;
|
|
@@ -3169,7 +3231,7 @@ function renderSessions() {
|
|
|
3169
3231
|
const showCtx = !!session.contextStatus;
|
|
3170
3232
|
const linkedDocsCount = getSessionPreviewPaths(session.id).length;
|
|
3171
3233
|
const bookmarksCount = loadPins(session.id).length;
|
|
3172
|
-
const hasScratchpad = !!(
|
|
3234
|
+
const hasScratchpad = !!(store.getItem(_sessionScratchpadKey(session.id)) || '').trim();
|
|
3173
3235
|
const tempClass = session.hasRecentLog || session.inProgress || session.hasWaitingForUser ? 'warm' : 'stale';
|
|
3174
3236
|
const sid = escAttrJs(session.id);
|
|
3175
3237
|
return `
|
|
@@ -3205,13 +3267,22 @@ function renderSessions() {
|
|
|
3205
3267
|
`;
|
|
3206
3268
|
};
|
|
3207
3269
|
|
|
3208
|
-
const groupPinned =
|
|
3270
|
+
const groupPinned = store.getItem('groupPinnedSessions') !== 'false';
|
|
3209
3271
|
const pinWeight = (s) => (isPlacedSticky(s.id) ? 2 : isPlacedPinned(s.id) && !isSessionActive(s) ? 1 : 0);
|
|
3210
3272
|
const pinSort = (a, b) => pinWeight(b) - pinWeight(a);
|
|
3211
3273
|
const countHtml = (arr) => {
|
|
3212
3274
|
const active = arr.reduce((n, s) => n + (isSessionActive(s) ? 1 : 0), 0);
|
|
3213
3275
|
return `<span class="group-count" title="${active} active / ${arr.length} total">${active > 0 ? `<span class="group-count-active">${active}</span><span class="group-count-sep">/</span>` : ''}${arr.length}</span>`;
|
|
3214
3276
|
};
|
|
3277
|
+
// `ungroup` marks the label as the drop target for pulling an item out of a named group.
|
|
3278
|
+
const sectionHtml = (key, text, ungroup, body) => {
|
|
3279
|
+
const collapsed = collapsedProjectGroups.has(key);
|
|
3280
|
+
const escPath = escapeHtml(key);
|
|
3281
|
+
const title = ungroup ? 'Click to collapse — drop here to remove from a group' : 'Click to collapse';
|
|
3282
|
+
return `<div class="sg-section-label sg-section-toggle${ungroup ? ' sg-ungroup-zone' : ''}${collapsed ? ' collapsed' : ''}" data-group-path="${escPath}" title="${escapeHtml(title)}">${groupChevronSvg(10)}<span>${text}</span></div>
|
|
3283
|
+
<div class="sg-section-body${collapsed ? ' collapsed' : ''}" data-group-path="${escPath}">${body}</div>`;
|
|
3284
|
+
};
|
|
3285
|
+
|
|
3215
3286
|
const renderGroupSessions = (sessions, pinKey) => {
|
|
3216
3287
|
if (!groupPinned || pinnedSessionIds.size === 0) return sessions.map(renderSessionCard).join('');
|
|
3217
3288
|
const gPinned = sessions.filter((s) => isPlacedPinned(s.id) && !isPlacedSticky(s.id));
|
|
@@ -3270,7 +3341,7 @@ function renderSessions() {
|
|
|
3270
3341
|
</div>
|
|
3271
3342
|
<div class="project-group-breadcrumb${nestedCls}" data-full-path="${escapedPath}" title="Click to copy path">${breadcrumbHtml}</div>
|
|
3272
3343
|
<div class="project-group-sessions${isCollapsed ? ' collapsed' : ''}${nestedCls}" data-project-path="${escapedPath}">
|
|
3273
|
-
${renderGroupSessions(projectSessions,
|
|
3344
|
+
${renderGroupSessions(projectSessions, pinKey(projectPath))}
|
|
3274
3345
|
</div>
|
|
3275
3346
|
`;
|
|
3276
3347
|
};
|
|
@@ -3298,7 +3369,7 @@ function renderSessions() {
|
|
|
3298
3369
|
// flat = the "All sessions" view, which has no project sub-blocks.
|
|
3299
3370
|
const sgSectionHtml = (flat) => {
|
|
3300
3371
|
if (sessionGroups.length === 0) return '';
|
|
3301
|
-
let out = '
|
|
3372
|
+
let out = '';
|
|
3302
3373
|
for (const group of sessionGroups) {
|
|
3303
3374
|
const bucket = sgBuckets.get(group.id) || [];
|
|
3304
3375
|
if (bucket.length === 0 && sgFiltering) continue;
|
|
@@ -3341,7 +3412,8 @@ function renderSessions() {
|
|
|
3341
3412
|
out += sgHeaderHtml(group, countHtml(bucket));
|
|
3342
3413
|
out += `<div class="session-group-sessions${collapsed ? ' collapsed' : ''}" data-group-id="${escapeHtml(group.id)}">${body}</div>`;
|
|
3343
3414
|
}
|
|
3344
|
-
return
|
|
3415
|
+
if (!out) return '';
|
|
3416
|
+
return sectionHtml(SECTION_GROUPS, 'Groups', false, out);
|
|
3345
3417
|
};
|
|
3346
3418
|
|
|
3347
3419
|
let html = '';
|
|
@@ -3389,37 +3461,42 @@ function renderSessions() {
|
|
|
3389
3461
|
stableGroupOrder = [...keptOrder, ...newPaths];
|
|
3390
3462
|
const sortedGroups = stableGroupOrder.filter((p) => groups.has(p)).map((p) => [p, groups.get(p)]);
|
|
3391
3463
|
|
|
3392
|
-
//
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3464
|
+
// The section header is rendered even when nothing is left under it: it is the drop target
|
|
3465
|
+
// for ungrouping. Its body is skipped while collapsed — nothing would be visible, and this is
|
|
3466
|
+
// the whole project list, re-parsed on every live refresh.
|
|
3467
|
+
const sectioned = sessionGroups.length > 0;
|
|
3468
|
+
let projectsHtml = '';
|
|
3469
|
+
if (!sectioned || !collapsedProjectGroups.has(SECTION_PROJECTS)) {
|
|
3470
|
+
for (const [projectPath, projectSessions] of sortedGroups) {
|
|
3471
|
+
projectsHtml += projectBlock(projectPath, projectSessions, false);
|
|
3472
|
+
}
|
|
3399
3473
|
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3474
|
+
if (ungrouped.length > 0 && sortedGroups.length > 0) {
|
|
3475
|
+
const isCollapsed = collapsedProjectGroups.has('__ungrouped__');
|
|
3476
|
+
projectsHtml += `
|
|
3403
3477
|
<div class="project-group-header${isCollapsed ? ' collapsed' : ''}" data-group-path="__ungrouped__">
|
|
3404
3478
|
${groupChevronSvg()}
|
|
3405
3479
|
<span class="group-name">Ungrouped</span>
|
|
3406
3480
|
${countHtml(ungrouped)}
|
|
3407
3481
|
</div>
|
|
3408
3482
|
<div class="project-group-sessions${isCollapsed ? ' collapsed' : ''}">
|
|
3409
|
-
${renderGroupSessions(ungrouped, '
|
|
3483
|
+
${renderGroupSessions(ungrouped, pinKey('__ungrouped__'))}
|
|
3410
3484
|
</div>
|
|
3411
3485
|
`;
|
|
3412
|
-
|
|
3413
|
-
|
|
3486
|
+
} else {
|
|
3487
|
+
projectsHtml += ungrouped.map(renderSessionCard).join('');
|
|
3488
|
+
}
|
|
3414
3489
|
}
|
|
3415
3490
|
|
|
3491
|
+
html += sectioned ? sectionHtml(SECTION_PROJECTS, 'Projects', true, projectsHtml) : projectsHtml;
|
|
3492
|
+
|
|
3416
3493
|
sessionsList.innerHTML = html;
|
|
3417
3494
|
} else {
|
|
3418
|
-
const
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
sessionsList.innerHTML = html + sgSectionHtml(true) +
|
|
3495
|
+
const sectioned = sessionGroups.length > 0;
|
|
3496
|
+
const restHtml =
|
|
3497
|
+
sectioned && collapsedProjectGroups.has(SECTION_SESSIONS) ? '' : sgRest.map(renderSessionCard).join('');
|
|
3498
|
+
const tail = sectioned ? sectionHtml(SECTION_SESSIONS, 'Sessions', true, restHtml) : restHtml;
|
|
3499
|
+
sessionsList.innerHTML = html + sgSectionHtml(true) + tail;
|
|
3423
3500
|
}
|
|
3424
3501
|
|
|
3425
3502
|
const navItems = getNavigableItems();
|
|
@@ -3853,9 +3930,15 @@ function sgKey(id) {
|
|
|
3853
3930
|
return `__group_${id}__`;
|
|
3854
3931
|
}
|
|
3855
3932
|
|
|
3933
|
+
// Collapse key of a project's pinned sub-section. The ungrouped spelling predates this helper
|
|
3934
|
+
// and is kept so saved collapse state still matches.
|
|
3935
|
+
function pinKey(projectPath) {
|
|
3936
|
+
return projectPath === '__ungrouped__' ? '__pinned___ungrouped__' : `__pinned_${projectPath}__`;
|
|
3937
|
+
}
|
|
3938
|
+
|
|
3856
3939
|
function loadSessionGroups() {
|
|
3857
3940
|
try {
|
|
3858
|
-
const list = JSON.parse(
|
|
3941
|
+
const list = JSON.parse(store.getItem(SESSION_GROUPS_KEY) || 'null')?.groups;
|
|
3859
3942
|
sessionGroups = (Array.isArray(list) ? list : [])
|
|
3860
3943
|
.filter((g) => g && typeof g.id === 'string')
|
|
3861
3944
|
.map((g) => ({
|
|
@@ -3877,7 +3960,7 @@ function loadSessionGroups() {
|
|
|
3877
3960
|
|
|
3878
3961
|
function persistSessionGroups() {
|
|
3879
3962
|
try {
|
|
3880
|
-
|
|
3963
|
+
store.setItem(SESSION_GROUPS_KEY, JSON.stringify({ version: 1, groups: sessionGroups }));
|
|
3881
3964
|
} catch (_) {}
|
|
3882
3965
|
}
|
|
3883
3966
|
|
|
@@ -4414,14 +4497,24 @@ function getKbId(el) {
|
|
|
4414
4497
|
return el.dataset.sessionId || el.dataset.groupPath || null;
|
|
4415
4498
|
}
|
|
4416
4499
|
|
|
4500
|
+
// Every collapsible header class and the body class it opens. Order matters only in that a
|
|
4501
|
+
// header carries exactly one of these.
|
|
4502
|
+
const COLLAPSIBLE_BODY_CLASS = {
|
|
4503
|
+
'pinned-sub-header': 'pinned-sub-items',
|
|
4504
|
+
'session-group-header': 'session-group-sessions',
|
|
4505
|
+
'sg-section-toggle': 'sg-section-body',
|
|
4506
|
+
'project-group-header': 'project-group-sessions',
|
|
4507
|
+
};
|
|
4508
|
+
const COLLAPSIBLE_HEADER_SELECTOR = Object.keys(COLLAPSIBLE_BODY_CLASS)
|
|
4509
|
+
.map((c) => `.${c}`)
|
|
4510
|
+
.join(', ');
|
|
4511
|
+
|
|
4417
4512
|
function getGroupSessionsContainer(header) {
|
|
4418
|
-
const cls = header.classList.contains(
|
|
4419
|
-
|
|
4420
|
-
: header.classList.contains('session-group-header')
|
|
4421
|
-
? 'session-group-sessions'
|
|
4422
|
-
: 'project-group-sessions';
|
|
4513
|
+
const cls = Object.keys(COLLAPSIBLE_BODY_CLASS).find((c) => header.classList.contains(c));
|
|
4514
|
+
if (!cls) return null;
|
|
4423
4515
|
let el = header.nextElementSibling;
|
|
4424
|
-
|
|
4516
|
+
const bodyCls = COLLAPSIBLE_BODY_CLASS[cls];
|
|
4517
|
+
while (el && !el.classList.contains(bodyCls)) el = el.nextElementSibling;
|
|
4425
4518
|
return el;
|
|
4426
4519
|
}
|
|
4427
4520
|
|
|
@@ -4448,16 +4541,27 @@ function getNavigableItems() {
|
|
|
4448
4541
|
}
|
|
4449
4542
|
}
|
|
4450
4543
|
};
|
|
4451
|
-
|
|
4452
|
-
|
|
4453
|
-
|
|
4454
|
-
|
|
4455
|
-
|
|
4544
|
+
// A section label sits next to its body div rather than wrapping it, so the top level walks
|
|
4545
|
+
// into the body to reach the blocks inside.
|
|
4546
|
+
const walkTopLevel = (children) => {
|
|
4547
|
+
for (const el of children) {
|
|
4548
|
+
if (
|
|
4549
|
+
el.classList.contains('project-group-header') ||
|
|
4550
|
+
el.classList.contains('session-group-header') ||
|
|
4551
|
+
el.classList.contains('sg-section-toggle')
|
|
4552
|
+
) {
|
|
4553
|
+
items.push(el);
|
|
4554
|
+
if (!el.classList.contains('sg-section-toggle') && !collapsedProjectGroups.has(el.dataset.groupPath)) {
|
|
4555
|
+
walkGroupContainer(getGroupSessionsContainer(el));
|
|
4556
|
+
}
|
|
4557
|
+
} else if (el.classList.contains('session-item')) {
|
|
4558
|
+
items.push(el);
|
|
4559
|
+
} else if (el.classList.contains('sg-section-body') && !el.classList.contains('collapsed')) {
|
|
4560
|
+
walkTopLevel(el.children);
|
|
4456
4561
|
}
|
|
4457
|
-
} else if (el.classList.contains('session-item')) {
|
|
4458
|
-
items.push(el);
|
|
4459
4562
|
}
|
|
4460
|
-
}
|
|
4563
|
+
};
|
|
4564
|
+
walkTopLevel(sessionsList.children);
|
|
4461
4565
|
return items;
|
|
4462
4566
|
}
|
|
4463
4567
|
|
|
@@ -4518,7 +4622,7 @@ function setGroupCollapsed(header, collapsed) {
|
|
|
4518
4622
|
|
|
4519
4623
|
function persistCollapsedGroups() {
|
|
4520
4624
|
try {
|
|
4521
|
-
|
|
4625
|
+
store.setItem('collapsedGroups', JSON.stringify([...collapsedProjectGroups]));
|
|
4522
4626
|
} catch (_) {}
|
|
4523
4627
|
}
|
|
4524
4628
|
|
|
@@ -4537,22 +4641,37 @@ function expandActiveGroups({ onlyNew = false } = {}) {
|
|
|
4537
4641
|
if (!isSessionActive(s)) continue;
|
|
4538
4642
|
activeIds.add(s.id);
|
|
4539
4643
|
if (onlyNew && (!primed || prevActiveSessionIds.has(s.id))) continue;
|
|
4540
|
-
if (
|
|
4541
|
-
// A named group wrapping that project (or the session itself) would keep it hidden.
|
|
4542
|
-
const group = sgGroupForSession(s);
|
|
4543
|
-
if (group && collapsedProjectGroups.delete(sgKey(group.id))) changed = true;
|
|
4644
|
+
if (uncollapseFor(s)) changed = true;
|
|
4544
4645
|
}
|
|
4545
4646
|
prevActiveSessionIds = activeIds;
|
|
4546
4647
|
if (changed) persistCollapsedGroups();
|
|
4547
4648
|
// renderSessions() re-renders headers/containers from the updated set
|
|
4548
4649
|
}
|
|
4549
4650
|
|
|
4651
|
+
// Drops every collapse key that could hide this session: its project, a named group wrapping
|
|
4652
|
+
// the project or the session itself, the pinned sub-sections of both, and — since which section
|
|
4653
|
+
// holds it depends on the view — every section header. Returns whether anything changed.
|
|
4654
|
+
function uncollapseFor(session) {
|
|
4655
|
+
const project = session.project || '__ungrouped__';
|
|
4656
|
+
const group = sgGroupForSession(session);
|
|
4657
|
+
const keys = [
|
|
4658
|
+
project,
|
|
4659
|
+
pinKey(project),
|
|
4660
|
+
group && sgKey(group.id),
|
|
4661
|
+
group && `__pinned_group_${group.id}__`,
|
|
4662
|
+
SECTION_GROUPS,
|
|
4663
|
+
SECTION_PROJECTS,
|
|
4664
|
+
SECTION_SESSIONS,
|
|
4665
|
+
];
|
|
4666
|
+
let changed = false;
|
|
4667
|
+
for (const key of keys) {
|
|
4668
|
+
if (key && collapsedProjectGroups.delete(key)) changed = true;
|
|
4669
|
+
}
|
|
4670
|
+
return changed;
|
|
4671
|
+
}
|
|
4672
|
+
|
|
4550
4673
|
function isGroupHeader(el) {
|
|
4551
|
-
return (
|
|
4552
|
-
el.classList.contains('project-group-header') ||
|
|
4553
|
-
el.classList.contains('pinned-sub-header') ||
|
|
4554
|
-
el.classList.contains('session-group-header')
|
|
4555
|
-
);
|
|
4674
|
+
return el.matches(COLLAPSIBLE_HEADER_SELECTOR);
|
|
4556
4675
|
}
|
|
4557
4676
|
|
|
4558
4677
|
function findParentHeader(el) {
|
|
@@ -4614,16 +4733,12 @@ function activateSelectedSession(items) {
|
|
|
4614
4733
|
}
|
|
4615
4734
|
|
|
4616
4735
|
function setFocusZone(zone) {
|
|
4617
|
-
const sidebar = document.querySelector('.sidebar');
|
|
4618
4736
|
clearKbSelection();
|
|
4619
4737
|
clearTaskSelection();
|
|
4620
4738
|
|
|
4621
4739
|
focusZone = zone;
|
|
4622
4740
|
if (zone === 'sidebar') {
|
|
4623
|
-
|
|
4624
|
-
sidebar.classList.remove('collapsed');
|
|
4625
|
-
localStorage.setItem('sidebar-collapsed', false);
|
|
4626
|
-
}
|
|
4741
|
+
expandSidebar();
|
|
4627
4742
|
const items = getNavigableItems();
|
|
4628
4743
|
if (items.length > 0) {
|
|
4629
4744
|
const activeIdx = items.findIndex((el) => el.classList.contains('active'));
|
|
@@ -5030,19 +5145,122 @@ async function confirmDelete() {
|
|
|
5030
5145
|
//#endregion
|
|
5031
5146
|
|
|
5032
5147
|
//#region HELP
|
|
5148
|
+
// Each entry pairs a left and a right group onto the same grid rows, so their
|
|
5149
|
+
// headings sit level and the shorter group just leaves empty rows.
|
|
5150
|
+
// `combo` joins the keys with a plus (a chord) instead of listing them as
|
|
5151
|
+
// alternatives; `hub` marks rows that only work inside Claude Code Hub.
|
|
5152
|
+
const SHORTCUT_PAIRS = [
|
|
5153
|
+
[
|
|
5154
|
+
{
|
|
5155
|
+
title: 'Navigate',
|
|
5156
|
+
rows: [
|
|
5157
|
+
{ keys: ['J', '↓'], label: 'Next item' },
|
|
5158
|
+
{ keys: ['K', '↑'], label: 'Previous item' },
|
|
5159
|
+
{ keys: ['H', '←'], label: 'Left column / collapse group' },
|
|
5160
|
+
{ keys: ['L', '→'], label: 'Right column / expand group' },
|
|
5161
|
+
{ keys: ['Tab'], label: 'Switch sidebar ↔ board' },
|
|
5162
|
+
{ keys: ['Enter', 'Space'], label: 'Open selected item' },
|
|
5163
|
+
],
|
|
5164
|
+
},
|
|
5165
|
+
{
|
|
5166
|
+
title: 'Session',
|
|
5167
|
+
rows: [
|
|
5168
|
+
{ keys: ['P'], label: 'Open plan' },
|
|
5169
|
+
{ keys: ['Shift', 'P'], combo: true, label: 'Session picker' },
|
|
5170
|
+
{ keys: ['I'], label: 'Session info' },
|
|
5171
|
+
{ keys: ['.'], label: 'Pin / unpin' },
|
|
5172
|
+
{ keys: ['>'], label: 'Toggle sticky' },
|
|
5173
|
+
{ keys: ['Ctrl', 'D'], combo: true, label: 'Dismiss session' },
|
|
5174
|
+
{ keys: ['Shift', 'L'], combo: true, label: 'Toggle session log' },
|
|
5175
|
+
{ keys: ['Shift', 'M'], combo: true, label: 'Open last message' },
|
|
5176
|
+
{ keys: ['J', 'K'], label: 'Previous / next message in detail' },
|
|
5177
|
+
],
|
|
5178
|
+
},
|
|
5179
|
+
],
|
|
5180
|
+
[
|
|
5181
|
+
{
|
|
5182
|
+
title: 'Board',
|
|
5183
|
+
rows: [
|
|
5184
|
+
{ keys: ['Enter'], label: 'Toggle task detail panel' },
|
|
5185
|
+
{ keys: ['D'], label: 'Delete selected task' },
|
|
5186
|
+
{ keys: ['N'], label: 'Toggle scratchpad' },
|
|
5187
|
+
{ keys: ['R'], label: 'Refresh data' },
|
|
5188
|
+
{ keys: ['Esc'], label: 'Close panel / clear selection' },
|
|
5189
|
+
],
|
|
5190
|
+
},
|
|
5191
|
+
{
|
|
5192
|
+
title: 'View',
|
|
5193
|
+
rows: [
|
|
5194
|
+
{ keys: ['['], label: 'Toggle sidebar' },
|
|
5195
|
+
{ keys: ['T'], label: 'Toggle theme' },
|
|
5196
|
+
{ keys: ['Shift', 'S'], combo: true, label: 'Storage manager' },
|
|
5197
|
+
{ keys: ['Ctrl', '+'], combo: true, label: 'Larger modal text' },
|
|
5198
|
+
{ keys: ['Ctrl', '−'], combo: true, label: 'Smaller modal text' },
|
|
5199
|
+
{ keys: ['Ctrl', '0'], combo: true, label: 'Reset modal text size' },
|
|
5200
|
+
{ keys: ['?'], label: 'Show this help' },
|
|
5201
|
+
],
|
|
5202
|
+
},
|
|
5203
|
+
],
|
|
5204
|
+
[
|
|
5205
|
+
{
|
|
5206
|
+
title: 'Copy',
|
|
5207
|
+
rows: [
|
|
5208
|
+
{ keys: ['Shift', 'C'], combo: true, label: 'Session id' },
|
|
5209
|
+
{ keys: ['Ctrl', 'Shift', 'C'], combo: true, label: 'Resume command (claude -r <id>)' },
|
|
5210
|
+
],
|
|
5211
|
+
},
|
|
5212
|
+
{
|
|
5213
|
+
title: 'Hub',
|
|
5214
|
+
hub: true,
|
|
5215
|
+
rows: [
|
|
5216
|
+
{ keys: ['M'], label: 'Jump to marketplace' },
|
|
5217
|
+
{ keys: ['$'], label: 'Jump to cost' },
|
|
5218
|
+
{ keys: ['Ctrl', 'M'], combo: true, label: 'Jump to memory' },
|
|
5219
|
+
{ keys: ['Ctrl', 'Alt', '←/→'], combo: true, label: 'Previous / next hub app' },
|
|
5220
|
+
{ keys: ['Alt', '1…9'], combo: true, label: 'Jump to hub app by number' },
|
|
5221
|
+
{ keys: ['Ctrl', 'Alt', 'P'], combo: true, label: 'Project picker' },
|
|
5222
|
+
],
|
|
5223
|
+
},
|
|
5224
|
+
],
|
|
5225
|
+
];
|
|
5226
|
+
|
|
5227
|
+
const EMPTY_GROUP = { title: '', rows: [] };
|
|
5228
|
+
|
|
5229
|
+
// Interleaves each pair's rows left-then-right so CSS grid auto-placement lands
|
|
5230
|
+
// them on shared row tracks (see .shortcuts in style.css).
|
|
5231
|
+
function buildHelpShortcuts() {
|
|
5232
|
+
const cells = [];
|
|
5233
|
+
SHORTCUT_PAIRS.forEach(([left, right = EMPTY_GROUP], pair) => {
|
|
5234
|
+
const first = pair === 0 ? ' sc-first' : '';
|
|
5235
|
+
const sides = [
|
|
5236
|
+
[left, 'sc-l'],
|
|
5237
|
+
[right, 'sc-r'],
|
|
5238
|
+
];
|
|
5239
|
+
const head = (g, side) =>
|
|
5240
|
+
g.title ? `<div class="${escapeHtml(`sc-group ${side}${first}${g.hub ? ' sc-hub' : ''}`)}">${g.title}</div>` : '';
|
|
5241
|
+
cells.push(sides.map(([g, side]) => head(g, side)).join(''));
|
|
5242
|
+
for (let i = 0; i < Math.max(left.rows.length, right.rows.length); i++) {
|
|
5243
|
+
for (const [group, side] of sides) {
|
|
5244
|
+
const row = group.rows[i];
|
|
5245
|
+
if (!row) continue;
|
|
5246
|
+
const sep = row.combo ? '<span class="sc-plus">+</span>' : '<span class="sc-or">/</span>';
|
|
5247
|
+
const keys = row.keys.map((k) => `<kbd>${escapeHtml(k)}</kbd>`).join(sep);
|
|
5248
|
+
const cls = side + (group.hub ? ' sc-hub' : '');
|
|
5249
|
+
cells.push(
|
|
5250
|
+
`<dt class="${escapeHtml(cls)}">${keys}</dt><dd class="${escapeHtml(cls)}">${escapeHtml(row.label)}</dd>`,
|
|
5251
|
+
);
|
|
5252
|
+
}
|
|
5253
|
+
}
|
|
5254
|
+
});
|
|
5255
|
+
return cells.join('');
|
|
5256
|
+
}
|
|
5257
|
+
|
|
5033
5258
|
function showHelpModal() {
|
|
5034
5259
|
const modal = document.getElementById('help-modal');
|
|
5260
|
+
const list = document.getElementById('help-shortcuts');
|
|
5261
|
+
if (!list.childElementCount) list.innerHTML = buildHelpShortcuts();
|
|
5262
|
+
list.classList.toggle('sc-standalone', !window.__HUB__?.enabled);
|
|
5035
5263
|
modal.classList.add('visible');
|
|
5036
|
-
|
|
5037
|
-
// Handle keyboard shortcuts
|
|
5038
|
-
const keyHandler = (e) => {
|
|
5039
|
-
if (e.key === 'Escape' || e.key === '?') {
|
|
5040
|
-
e.preventDefault();
|
|
5041
|
-
closeHelpModal();
|
|
5042
|
-
document.removeEventListener('keydown', keyHandler);
|
|
5043
|
-
}
|
|
5044
|
-
};
|
|
5045
|
-
document.addEventListener('keydown', keyHandler);
|
|
5046
5264
|
}
|
|
5047
5265
|
|
|
5048
5266
|
function closeHelpModal() {
|
|
@@ -5104,7 +5322,7 @@ function showScratchpad(keyOverride) {
|
|
|
5104
5322
|
_scratchpadKeyOverride = keyOverride || null;
|
|
5105
5323
|
const key = _scratchpadKey();
|
|
5106
5324
|
if (!key) return;
|
|
5107
|
-
_scratchpadTextarea.value =
|
|
5325
|
+
_scratchpadTextarea.value = store.getItem(key) || '';
|
|
5108
5326
|
_scratchpadCharcount.textContent = `${_scratchpadTextarea.value.length} chars`;
|
|
5109
5327
|
_scratchpadModal.classList.add('visible');
|
|
5110
5328
|
_scratchpadTextarea.focus();
|
|
@@ -5124,12 +5342,12 @@ function saveScratchpad() {
|
|
|
5124
5342
|
const key = _scratchpadKey();
|
|
5125
5343
|
if (!key) return;
|
|
5126
5344
|
const val = _scratchpadTextarea.value;
|
|
5127
|
-
const had = !!(
|
|
5345
|
+
const had = !!(store.getItem(key) || '').trim();
|
|
5128
5346
|
const has = !!val.trim();
|
|
5129
5347
|
if (has) {
|
|
5130
|
-
|
|
5348
|
+
store.setItem(key, val);
|
|
5131
5349
|
} else {
|
|
5132
|
-
|
|
5350
|
+
store.removeItem(key);
|
|
5133
5351
|
}
|
|
5134
5352
|
if (had !== has && _isSessionScratchpadKey(key)) {
|
|
5135
5353
|
renderSessions();
|
|
@@ -5151,9 +5369,8 @@ _scratchpadTextarea.addEventListener('input', () => {
|
|
|
5151
5369
|
|
|
5152
5370
|
function _getStorageTotalSize() {
|
|
5153
5371
|
let bytes = 0;
|
|
5154
|
-
for (
|
|
5155
|
-
|
|
5156
|
-
bytes += k.length + localStorage.getItem(k).length;
|
|
5372
|
+
for (const k of store.keys()) {
|
|
5373
|
+
bytes += k.length + store.getItem(k).length;
|
|
5157
5374
|
}
|
|
5158
5375
|
return bytes * 2; // UTF-16
|
|
5159
5376
|
}
|
|
@@ -5243,12 +5460,11 @@ function _renderStorageSessions() {
|
|
|
5243
5460
|
const pinnedIds = [...new Set([...pinnedSessionIds, ...stickySessionIds])];
|
|
5244
5461
|
|
|
5245
5462
|
const msgMap = new Map();
|
|
5246
|
-
for (
|
|
5247
|
-
const key = localStorage.key(i);
|
|
5463
|
+
for (const key of store.keys()) {
|
|
5248
5464
|
if (!key.startsWith('pinned-messages-')) continue;
|
|
5249
5465
|
const sid = key.slice('pinned-messages-'.length);
|
|
5250
5466
|
try {
|
|
5251
|
-
const pins = JSON.parse(
|
|
5467
|
+
const pins = JSON.parse(store.getItem(key)) || [];
|
|
5252
5468
|
if (pins.length) msgMap.set(sid, { pins, key });
|
|
5253
5469
|
} catch {}
|
|
5254
5470
|
}
|
|
@@ -5318,13 +5534,7 @@ function _renderStorageSessions() {
|
|
|
5318
5534
|
async function _storageViewSession(id) {
|
|
5319
5535
|
closeStorageManager();
|
|
5320
5536
|
revealedStorageSessionId = id;
|
|
5321
|
-
|
|
5322
|
-
lastSessionsHash = '';
|
|
5323
|
-
await fetchSessions();
|
|
5324
|
-
}
|
|
5325
|
-
await fetchTasks(id);
|
|
5326
|
-
const el = document.querySelector(`.session-item[data-session-id="${CSS.escape(id)}"]`);
|
|
5327
|
-
if (el) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
5537
|
+
await revealSession(id);
|
|
5328
5538
|
}
|
|
5329
5539
|
|
|
5330
5540
|
function _storageUnpinSession(id) {
|
|
@@ -5337,7 +5547,7 @@ function _storageUnpinSession(id) {
|
|
|
5337
5547
|
}
|
|
5338
5548
|
|
|
5339
5549
|
function _storageClearSessionPins(sessionId) {
|
|
5340
|
-
|
|
5550
|
+
store.removeItem(`pinned-messages-${sessionId}`);
|
|
5341
5551
|
if (currentSessionId === sessionId) {
|
|
5342
5552
|
currentPins = [];
|
|
5343
5553
|
const el = document.getElementById('message-panel-pinned');
|
|
@@ -5350,12 +5560,12 @@ function _storageClearSessionPins(sessionId) {
|
|
|
5350
5560
|
function _storageUnpinMessage(sessionId, pinId) {
|
|
5351
5561
|
const key = `pinned-messages-${sessionId}`;
|
|
5352
5562
|
try {
|
|
5353
|
-
const pins = JSON.parse(
|
|
5563
|
+
const pins = JSON.parse(store.getItem(key)) || [];
|
|
5354
5564
|
const idx = pins.findIndex((p) => p.id === pinId);
|
|
5355
5565
|
if (idx < 0) return;
|
|
5356
5566
|
pins.splice(idx, 1);
|
|
5357
|
-
if (pins.length)
|
|
5358
|
-
else
|
|
5567
|
+
if (pins.length) store.setItem(key, JSON.stringify(pins));
|
|
5568
|
+
else store.removeItem(key);
|
|
5359
5569
|
if (currentSessionId === sessionId) {
|
|
5360
5570
|
currentPins = pins;
|
|
5361
5571
|
const el = document.getElementById('message-panel-pinned');
|
|
@@ -5368,10 +5578,9 @@ function _storageUnpinMessage(sessionId, pinId) {
|
|
|
5368
5578
|
|
|
5369
5579
|
function _renderStorageScratchpads() {
|
|
5370
5580
|
const allItems = [];
|
|
5371
|
-
for (
|
|
5372
|
-
const key = localStorage.key(i);
|
|
5581
|
+
for (const key of store.keys()) {
|
|
5373
5582
|
if (!key.startsWith('scratchpad-')) continue;
|
|
5374
|
-
const val =
|
|
5583
|
+
const val = store.getItem(key) || '';
|
|
5375
5584
|
const isProject = key.startsWith('scratchpad-project:');
|
|
5376
5585
|
const id = isProject ? key.slice('scratchpad-project:'.length) : key.slice('scratchpad-'.length);
|
|
5377
5586
|
allItems.push({ key, id, isProject, chars: val.length });
|
|
@@ -5440,7 +5649,7 @@ function _storagePreviewPin(sessionId, pinId) {
|
|
|
5440
5649
|
closeStorageManager();
|
|
5441
5650
|
const key = `pinned-messages-${sessionId}`;
|
|
5442
5651
|
try {
|
|
5443
|
-
const pins = JSON.parse(
|
|
5652
|
+
const pins = JSON.parse(store.getItem(key)) || [];
|
|
5444
5653
|
const pin = pins.find((p) => p.id === pinId);
|
|
5445
5654
|
if (!pin) return;
|
|
5446
5655
|
document.getElementById('msg-detail-pin-btn').style.display = 'none';
|
|
@@ -5454,18 +5663,17 @@ function _storagePreviewPin(sessionId, pinId) {
|
|
|
5454
5663
|
}
|
|
5455
5664
|
|
|
5456
5665
|
function _storageDeleteScratchpad(key) {
|
|
5457
|
-
|
|
5666
|
+
store.removeItem(key);
|
|
5458
5667
|
_renderStorageTab();
|
|
5459
5668
|
_updateStorageTotal();
|
|
5460
5669
|
}
|
|
5461
5670
|
|
|
5462
5671
|
function _renderStorageLinkedDocs() {
|
|
5463
5672
|
const entries = [];
|
|
5464
|
-
for (
|
|
5465
|
-
const key = localStorage.key(i);
|
|
5673
|
+
for (const key of store.keys()) {
|
|
5466
5674
|
if (!key.startsWith(PREVIEW_STORAGE_PREFIX)) continue;
|
|
5467
5675
|
try {
|
|
5468
|
-
const arr = JSON.parse(
|
|
5676
|
+
const arr = JSON.parse(store.getItem(key)) || [];
|
|
5469
5677
|
if (Array.isArray(arr) && arr.length) {
|
|
5470
5678
|
entries.push({ sessionId: key.slice(PREVIEW_STORAGE_PREFIX.length), paths: arr });
|
|
5471
5679
|
}
|
|
@@ -5529,7 +5737,7 @@ function _storageUnlinkDoc(sessionId, path) {
|
|
|
5529
5737
|
}
|
|
5530
5738
|
|
|
5531
5739
|
function _storageClearLinkedDocs(sessionId) {
|
|
5532
|
-
|
|
5740
|
+
store.removeItem(PREVIEW_STORAGE_PREFIX + sessionId);
|
|
5533
5741
|
afterLinkedDocsChanged(sessionId);
|
|
5534
5742
|
}
|
|
5535
5743
|
|
|
@@ -5539,8 +5747,7 @@ function _findOrphanedKeys() {
|
|
|
5539
5747
|
const orphaned = [];
|
|
5540
5748
|
for (const id of pinnedSessionIds) if (!known.has(id)) orphaned.push(`__pinned__${id}`);
|
|
5541
5749
|
for (const id of stickySessionIds) if (!known.has(id)) orphaned.push(`__sticky__${id}`);
|
|
5542
|
-
for (
|
|
5543
|
-
const key = localStorage.key(i);
|
|
5750
|
+
for (const key of store.keys()) {
|
|
5544
5751
|
if (key.startsWith('pinned-messages-')) {
|
|
5545
5752
|
if (!known.has(key.slice('pinned-messages-'.length))) orphaned.push(key);
|
|
5546
5753
|
} else if (_isSessionScratchpadKey(key)) {
|
|
@@ -5575,7 +5782,7 @@ function cleanupOrphanedStorage() {
|
|
|
5575
5782
|
stickySessionIds.delete(key.slice('__sticky__'.length));
|
|
5576
5783
|
pinsChanged = true;
|
|
5577
5784
|
} else {
|
|
5578
|
-
|
|
5785
|
+
store.removeItem(key);
|
|
5579
5786
|
}
|
|
5580
5787
|
}
|
|
5581
5788
|
if (pinsChanged) savePinnedSessions();
|
|
@@ -5616,6 +5823,7 @@ const MODAL_CLOSERS = {
|
|
|
5616
5823
|
'team-modal': () => closeTeamModal(),
|
|
5617
5824
|
'agent-modal': () => closeAgentModal(),
|
|
5618
5825
|
'help-modal': () => closeHelpModal(),
|
|
5826
|
+
'session-picker-modal': () => closeSessionPicker(),
|
|
5619
5827
|
};
|
|
5620
5828
|
|
|
5621
5829
|
document.addEventListener('keydown', (e) => {
|
|
@@ -5649,6 +5857,9 @@ document.addEventListener('keydown', (e) => {
|
|
|
5649
5857
|
if (close) close();
|
|
5650
5858
|
else document.getElementById(topId).classList.remove('visible');
|
|
5651
5859
|
e.stopImmediatePropagation();
|
|
5860
|
+
} else if (e.key === '?' && document.getElementById('help-modal').classList.contains('visible')) {
|
|
5861
|
+
e.preventDefault();
|
|
5862
|
+
closeHelpModal();
|
|
5652
5863
|
} else if (
|
|
5653
5864
|
e.code === 'KeyM' &&
|
|
5654
5865
|
e.shiftKey &&
|
|
@@ -5718,6 +5929,12 @@ document.addEventListener('keydown', (e) => {
|
|
|
5718
5929
|
showStorageManager();
|
|
5719
5930
|
return;
|
|
5720
5931
|
}
|
|
5932
|
+
// Ctrl+Shift+P is the browser's own; only the bare chord opens the picker.
|
|
5933
|
+
if (e.code === 'KeyP' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
|
5934
|
+
e.preventDefault();
|
|
5935
|
+
openSessionPicker();
|
|
5936
|
+
return;
|
|
5937
|
+
}
|
|
5721
5938
|
if (e.key === '.' || e.key === '>') {
|
|
5722
5939
|
const sid = sessionsList.querySelector('.kb-selected')?.dataset.sessionId || currentSessionId;
|
|
5723
5940
|
if (sid) {
|
|
@@ -5892,16 +6109,18 @@ document.addEventListener('keydown', (e) => {
|
|
|
5892
6109
|
}
|
|
5893
6110
|
return;
|
|
5894
6111
|
}
|
|
5895
|
-
if (e.code === 'KeyC' && e.shiftKey) {
|
|
6112
|
+
if (e.code === 'KeyC' && e.shiftKey && !e.altKey && !e.metaKey) {
|
|
5896
6113
|
e.preventDefault();
|
|
5897
6114
|
if (!contextSid) {
|
|
5898
6115
|
showToast('No session selected');
|
|
5899
6116
|
return;
|
|
5900
6117
|
}
|
|
6118
|
+
const text = e.ctrlKey ? `claude -r ${contextSid}` : contextSid;
|
|
6119
|
+
const label = e.ctrlKey ? 'resume command' : 'session id';
|
|
5901
6120
|
navigator.clipboard
|
|
5902
|
-
.writeText(
|
|
5903
|
-
.then(() => showToast(`Copied
|
|
5904
|
-
.catch(() => showToast(
|
|
6121
|
+
.writeText(text)
|
|
6122
|
+
.then(() => showToast(`Copied ${label}: ${contextSid.slice(0, 8)}`, 'success'))
|
|
6123
|
+
.catch(() => showToast(`Failed to copy ${label}`));
|
|
5905
6124
|
return;
|
|
5906
6125
|
}
|
|
5907
6126
|
if (matchKey(e, 'KeyR')) {
|
|
@@ -5940,7 +6159,7 @@ let currentPreviewPath = null;
|
|
|
5940
6159
|
function getSessionPreviewPaths(sessionId) {
|
|
5941
6160
|
if (!sessionId) return [];
|
|
5942
6161
|
try {
|
|
5943
|
-
const raw =
|
|
6162
|
+
const raw = store.getItem(PREVIEW_STORAGE_PREFIX + sessionId);
|
|
5944
6163
|
const arr = raw ? JSON.parse(raw) : [];
|
|
5945
6164
|
return Array.isArray(arr) ? arr : [];
|
|
5946
6165
|
} catch {
|
|
@@ -5952,14 +6171,14 @@ function addSessionPreviewPath(sessionId, filePath) {
|
|
|
5952
6171
|
if (!sessionId || !filePath) return;
|
|
5953
6172
|
const paths = getSessionPreviewPaths(sessionId).filter((p) => p !== filePath);
|
|
5954
6173
|
paths.unshift(filePath);
|
|
5955
|
-
|
|
6174
|
+
store.setItem(PREVIEW_STORAGE_PREFIX + sessionId, JSON.stringify(paths.slice(0, 20)));
|
|
5956
6175
|
}
|
|
5957
6176
|
|
|
5958
6177
|
function removeSessionPreviewPath(sessionId, filePath) {
|
|
5959
6178
|
if (!sessionId) return;
|
|
5960
6179
|
const paths = getSessionPreviewPaths(sessionId).filter((p) => p !== filePath);
|
|
5961
|
-
if (paths.length)
|
|
5962
|
-
else
|
|
6180
|
+
if (paths.length) store.setItem(PREVIEW_STORAGE_PREFIX + sessionId, JSON.stringify(paths));
|
|
6181
|
+
else store.removeItem(PREVIEW_STORAGE_PREFIX + sessionId);
|
|
5963
6182
|
}
|
|
5964
6183
|
|
|
5965
6184
|
// Every surface that shows linked docs — info modal, session card badge, preview
|
|
@@ -6861,6 +7080,13 @@ function isSessionActive(s) {
|
|
|
6861
7080
|
return s.hasRecentLog || s.inProgress > 0 || s.hasActiveAgents || s.hasWaitingForUser;
|
|
6862
7081
|
}
|
|
6863
7082
|
|
|
7083
|
+
const SESSION_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
7084
|
+
|
|
7085
|
+
function sessionDisplayName(session) {
|
|
7086
|
+
const raw = session.name || session.id;
|
|
7087
|
+
return SESSION_UUID_RE.test(raw) ? raw.slice(0, 8) : raw;
|
|
7088
|
+
}
|
|
7089
|
+
|
|
6864
7090
|
function formatDate(dateStr) {
|
|
6865
7091
|
const date = new Date(dateStr);
|
|
6866
7092
|
const now = new Date();
|
|
@@ -7077,7 +7303,7 @@ function renderMarkdown(text) {
|
|
|
7077
7303
|
}
|
|
7078
7304
|
|
|
7079
7305
|
function isLightTheme() {
|
|
7080
|
-
const saved =
|
|
7306
|
+
const saved = store.getItem('theme');
|
|
7081
7307
|
return (
|
|
7082
7308
|
document.body.classList.contains('light') || (!saved && window.matchMedia('(prefers-color-scheme: light)').matches)
|
|
7083
7309
|
);
|
|
@@ -7383,13 +7609,13 @@ document.addEventListener('click', (e) => {
|
|
|
7383
7609
|
|
|
7384
7610
|
if (e.target.closest('.pinned-ungroup-btn')) {
|
|
7385
7611
|
e.stopPropagation();
|
|
7386
|
-
|
|
7612
|
+
store.setItem('groupPinnedSessions', 'false');
|
|
7387
7613
|
renderSessions();
|
|
7388
7614
|
return;
|
|
7389
7615
|
}
|
|
7390
7616
|
|
|
7391
7617
|
if (e.target.closest('.pinned-regroup-banner')) {
|
|
7392
|
-
|
|
7618
|
+
store.setItem('groupPinnedSessions', 'true');
|
|
7393
7619
|
renderSessions();
|
|
7394
7620
|
return;
|
|
7395
7621
|
}
|
|
@@ -7414,7 +7640,7 @@ document.addEventListener('click', (e) => {
|
|
|
7414
7640
|
}
|
|
7415
7641
|
}
|
|
7416
7642
|
|
|
7417
|
-
const header = e.target.closest(
|
|
7643
|
+
const header = e.target.closest(COLLAPSIBLE_HEADER_SELECTOR);
|
|
7418
7644
|
if (header) {
|
|
7419
7645
|
setGroupCollapsed(header, !collapsedProjectGroups.has(header.dataset.groupPath));
|
|
7420
7646
|
}
|
|
@@ -7503,11 +7729,11 @@ function toggleTheme() {
|
|
|
7503
7729
|
if (isCurrentlyLight) {
|
|
7504
7730
|
document.body.classList.remove('light');
|
|
7505
7731
|
document.body.classList.add('dark-forced');
|
|
7506
|
-
|
|
7732
|
+
store.setItem('theme', 'dark');
|
|
7507
7733
|
} else {
|
|
7508
7734
|
document.body.classList.add('light');
|
|
7509
7735
|
document.body.classList.remove('dark-forced');
|
|
7510
|
-
|
|
7736
|
+
store.setItem('theme', 'light');
|
|
7511
7737
|
}
|
|
7512
7738
|
updateThemeIcon();
|
|
7513
7739
|
updateThemeColor(!isCurrentlyLight);
|
|
@@ -7530,7 +7756,7 @@ function updateThemeIcon() {
|
|
|
7530
7756
|
}
|
|
7531
7757
|
|
|
7532
7758
|
function loadTheme() {
|
|
7533
|
-
const saved =
|
|
7759
|
+
const saved = store.getItem('theme');
|
|
7534
7760
|
if (saved === 'light') {
|
|
7535
7761
|
document.body.classList.add('light');
|
|
7536
7762
|
document.body.classList.remove('dark-forced');
|
|
@@ -7543,7 +7769,7 @@ function loadTheme() {
|
|
|
7543
7769
|
updateThemeColor(document.body.classList.contains('light'));
|
|
7544
7770
|
syncHljsTheme();
|
|
7545
7771
|
buildThemeMenu();
|
|
7546
|
-
const colorTheme =
|
|
7772
|
+
const colorTheme = store.getItem('color-theme');
|
|
7547
7773
|
if (colorTheme) document.body.dataset.colorTheme = colorTheme;
|
|
7548
7774
|
syncColorThemeSelect(colorTheme || 'ember');
|
|
7549
7775
|
}
|
|
@@ -7599,10 +7825,10 @@ function syncColorThemeSelect(id) {
|
|
|
7599
7825
|
function setColorTheme(id) {
|
|
7600
7826
|
if (!id || id === 'ember') {
|
|
7601
7827
|
delete document.body.dataset.colorTheme;
|
|
7602
|
-
|
|
7828
|
+
store.removeItem('color-theme');
|
|
7603
7829
|
} else {
|
|
7604
7830
|
document.body.dataset.colorTheme = id;
|
|
7605
|
-
|
|
7831
|
+
store.setItem('color-theme', id);
|
|
7606
7832
|
}
|
|
7607
7833
|
syncColorThemeSelect(id);
|
|
7608
7834
|
}
|
|
@@ -7610,10 +7836,17 @@ function setColorTheme(id) {
|
|
|
7610
7836
|
//#endregion
|
|
7611
7837
|
|
|
7612
7838
|
//#region SIDEBAR_LAYOUT
|
|
7839
|
+
function expandSidebar() {
|
|
7840
|
+
const sidebar = document.querySelector('.sidebar');
|
|
7841
|
+
if (!sidebar.classList.contains('collapsed')) return;
|
|
7842
|
+
sidebar.classList.remove('collapsed');
|
|
7843
|
+
store.setItem('sidebar-collapsed', false);
|
|
7844
|
+
}
|
|
7845
|
+
|
|
7613
7846
|
function toggleSidebar() {
|
|
7614
7847
|
const sidebar = document.querySelector('.sidebar');
|
|
7615
7848
|
const collapsed = sidebar.classList.toggle('collapsed');
|
|
7616
|
-
|
|
7849
|
+
store.setItem('sidebar-collapsed', collapsed);
|
|
7617
7850
|
if (collapsed) {
|
|
7618
7851
|
sidebar.style.width = '';
|
|
7619
7852
|
if (focusZone === 'sidebar') setFocusZone('board');
|
|
@@ -7625,10 +7858,10 @@ function toggleSidebar() {
|
|
|
7625
7858
|
|
|
7626
7859
|
function loadSidebarState() {
|
|
7627
7860
|
const sidebar = document.querySelector('.sidebar');
|
|
7628
|
-
if (
|
|
7861
|
+
if (store.getItem('sidebar-collapsed') === 'true') {
|
|
7629
7862
|
sidebar.classList.add('collapsed');
|
|
7630
7863
|
}
|
|
7631
|
-
const w =
|
|
7864
|
+
const w = store.getItem('sidebar-width');
|
|
7632
7865
|
if (w) {
|
|
7633
7866
|
sidebar.style.setProperty('--sidebar-width', w);
|
|
7634
7867
|
}
|
|
@@ -7683,7 +7916,7 @@ function initSidebarResize() {
|
|
|
7683
7916
|
},
|
|
7684
7917
|
onEnd() {
|
|
7685
7918
|
sidebar.classList.remove('resizing');
|
|
7686
|
-
|
|
7919
|
+
store.setItem('sidebar-width', sidebar.style.getPropertyValue('--sidebar-width'));
|
|
7687
7920
|
},
|
|
7688
7921
|
});
|
|
7689
7922
|
}
|
|
@@ -7704,7 +7937,7 @@ function initPanelResize(panelId, handleId, cssVar, storageKey) {
|
|
|
7704
7937
|
},
|
|
7705
7938
|
onEnd() {
|
|
7706
7939
|
panel.classList.remove('resizing');
|
|
7707
|
-
|
|
7940
|
+
store.setItem(storageKey, panel.style.getPropertyValue(cssVar));
|
|
7708
7941
|
},
|
|
7709
7942
|
});
|
|
7710
7943
|
}
|
|
@@ -7714,7 +7947,7 @@ function loadPanelWidths() {
|
|
|
7714
7947
|
['detail-panel', '--detail-panel-width'],
|
|
7715
7948
|
['message-panel', '--message-panel-width'],
|
|
7716
7949
|
].forEach(([id, cssVar]) => {
|
|
7717
|
-
const w =
|
|
7950
|
+
const w = store.getItem(`${id}-width`);
|
|
7718
7951
|
if (w) document.getElementById(id).style.setProperty(cssVar, w);
|
|
7719
7952
|
});
|
|
7720
7953
|
}
|
|
@@ -8027,6 +8260,174 @@ let _planSessionId = null;
|
|
|
8027
8260
|
|
|
8028
8261
|
//#endregion
|
|
8029
8262
|
|
|
8263
|
+
//#region SESSION_PICKER
|
|
8264
|
+
// Snapshot of what the sidebar is showing, taken once per open: the picker only ever offers
|
|
8265
|
+
// sessions already on screen, and the recency order cannot change while it is up.
|
|
8266
|
+
let spSource = [];
|
|
8267
|
+
let spRows = [];
|
|
8268
|
+
let spIdx = 0;
|
|
8269
|
+
let spPeekTimer = null;
|
|
8270
|
+
let spPeekToken = 0;
|
|
8271
|
+
let spPeekedId = null;
|
|
8272
|
+
// Per-open cache: spSource is frozen while the picker is up, so a fetched log stays valid.
|
|
8273
|
+
const spPeekCache = new Map();
|
|
8274
|
+
|
|
8275
|
+
function openSessionPicker() {
|
|
8276
|
+
const input = document.getElementById('session-picker-input');
|
|
8277
|
+
input.value = '';
|
|
8278
|
+
spSource = getFilteredSessions().sort((a, b) => Date.parse(b.modifiedAt) - Date.parse(a.modifiedAt));
|
|
8279
|
+
spPeekCache.clear();
|
|
8280
|
+
document.getElementById('session-picker-modal').classList.add('visible');
|
|
8281
|
+
renderSessionPicker();
|
|
8282
|
+
input.focus();
|
|
8283
|
+
}
|
|
8284
|
+
|
|
8285
|
+
function closeSessionPicker() {
|
|
8286
|
+
spSetPeeking(false);
|
|
8287
|
+
hideModalOverlay('session-picker-modal');
|
|
8288
|
+
}
|
|
8289
|
+
|
|
8290
|
+
function spIsPeeking() {
|
|
8291
|
+
return _modalEl('session-picker-modal').classList.contains('peeking');
|
|
8292
|
+
}
|
|
8293
|
+
|
|
8294
|
+
function spSetPeeking(on) {
|
|
8295
|
+
clearTimeout(spPeekTimer);
|
|
8296
|
+
spPeekToken++;
|
|
8297
|
+
spPeekedId = null;
|
|
8298
|
+
_modalEl('session-picker-modal').classList.toggle('peeking', on);
|
|
8299
|
+
if (on) spSchedulePeek();
|
|
8300
|
+
else document.getElementById('session-picker-peek').innerHTML = '';
|
|
8301
|
+
}
|
|
8302
|
+
|
|
8303
|
+
function spSchedulePeek() {
|
|
8304
|
+
if (!spIsPeeking() || spRows[spIdx]?.id === spPeekedId) return;
|
|
8305
|
+
clearTimeout(spPeekTimer);
|
|
8306
|
+
spPeekTimer = setTimeout(spLoadPeek, 120);
|
|
8307
|
+
}
|
|
8308
|
+
|
|
8309
|
+
function spRenderPeek(session, messages, note) {
|
|
8310
|
+
const pane = document.getElementById('session-picker-peek');
|
|
8311
|
+
const body =
|
|
8312
|
+
messages === null
|
|
8313
|
+
? ''
|
|
8314
|
+
: messages.length
|
|
8315
|
+
? `<div class="sp-peek-items">${renderMessageList(messages, { readOnly: true })}</div>`
|
|
8316
|
+
: '<div class="msg-empty">No messages in this session</div>';
|
|
8317
|
+
pane.innerHTML = `<div class="sp-peek-title"><span>${escapeHtml(sessionDisplayName(session))}</span><span>${note}</span></div>${body}`;
|
|
8318
|
+
pane.scrollTop = pane.scrollHeight;
|
|
8319
|
+
}
|
|
8320
|
+
|
|
8321
|
+
async function spLoadPeek() {
|
|
8322
|
+
const session = spRows[spIdx];
|
|
8323
|
+
if (!session) {
|
|
8324
|
+
document.getElementById('session-picker-peek').innerHTML = '<div class="msg-empty">No session selected</div>';
|
|
8325
|
+
return;
|
|
8326
|
+
}
|
|
8327
|
+
spPeekedId = session.id;
|
|
8328
|
+
let messages = spPeekCache.get(session.id);
|
|
8329
|
+
if (!messages) {
|
|
8330
|
+
const token = ++spPeekToken;
|
|
8331
|
+
spRenderPeek(session, null, 'loading…');
|
|
8332
|
+
try {
|
|
8333
|
+
messages = (await fetchSessionMessagesPage(session.id))?.messages || [];
|
|
8334
|
+
} catch (_) {
|
|
8335
|
+
messages = [];
|
|
8336
|
+
}
|
|
8337
|
+
if (token !== spPeekToken) return;
|
|
8338
|
+
spPeekCache.set(session.id, messages);
|
|
8339
|
+
}
|
|
8340
|
+
spRenderPeek(session, messages, `last ${messages.length} entries`);
|
|
8341
|
+
}
|
|
8342
|
+
|
|
8343
|
+
function spMatches(session, query) {
|
|
8344
|
+
return (
|
|
8345
|
+
fuzzyMatch(session.name, query) ||
|
|
8346
|
+
fuzzyMatch(session.id, query) ||
|
|
8347
|
+
fuzzyMatch(session.project, query) ||
|
|
8348
|
+
fuzzyMatch(session.gitBranch, query)
|
|
8349
|
+
);
|
|
8350
|
+
}
|
|
8351
|
+
|
|
8352
|
+
// Spelled out rather than interpolated so the class never comes from a variable.
|
|
8353
|
+
const SP_DOTS = {
|
|
8354
|
+
waiting: '<span class="activity-dot waiting"></span>',
|
|
8355
|
+
live: '<span class="activity-dot live"></span>',
|
|
8356
|
+
active: '<span class="activity-dot active"></span>',
|
|
8357
|
+
idle: '<span class="activity-dot"></span>',
|
|
8358
|
+
};
|
|
8359
|
+
|
|
8360
|
+
function spDotHtml(session) {
|
|
8361
|
+
if (isWaitingSession(session)) return SP_DOTS.waiting;
|
|
8362
|
+
if (isSessionLive(session)) return SP_DOTS.live;
|
|
8363
|
+
return isActiveSession(session) ? SP_DOTS.active : SP_DOTS.idle;
|
|
8364
|
+
}
|
|
8365
|
+
|
|
8366
|
+
function renderSessionPicker() {
|
|
8367
|
+
const list = document.getElementById('session-picker-list');
|
|
8368
|
+
const query = document.getElementById('session-picker-input').value.trim();
|
|
8369
|
+
spRows = spSource.filter((s) => spMatches(s, query));
|
|
8370
|
+
|
|
8371
|
+
if (!spRows.length) {
|
|
8372
|
+
spIdx = -1;
|
|
8373
|
+
list.innerHTML = `<div class="sp-empty">${spSource.length ? 'No session matches' : 'No sessions in the current sidebar filter'}</div>`;
|
|
8374
|
+
return;
|
|
8375
|
+
}
|
|
8376
|
+
|
|
8377
|
+
list.innerHTML = spRows
|
|
8378
|
+
.map((s, i) => {
|
|
8379
|
+
const project = s.project ? s.project.split(/[/\\]/).pop() : '';
|
|
8380
|
+
return `<button class="sp-row${s.id === currentSessionId ? ' current' : ''}" data-idx="${i}" title="${escapeHtml(s.id)}">
|
|
8381
|
+
${spDotHtml(s)}
|
|
8382
|
+
<span class="sp-name">${escapeHtml(sessionDisplayName(s))}</span>
|
|
8383
|
+
<span class="sp-project">${escapeHtml(project)}</span>
|
|
8384
|
+
${s.gitBranch ? `<span class="sp-branch">${escapeHtml(s.gitBranch)}</span>` : ''}
|
|
8385
|
+
<span class="sp-count">${s.completed}/${s.taskCount}</span>
|
|
8386
|
+
<span class="sp-time">${formatDate(s.modifiedAt)}</span>
|
|
8387
|
+
</button>`;
|
|
8388
|
+
})
|
|
8389
|
+
.join('');
|
|
8390
|
+
spSelect(0);
|
|
8391
|
+
}
|
|
8392
|
+
|
|
8393
|
+
function spSelect(idx) {
|
|
8394
|
+
const list = document.getElementById('session-picker-list');
|
|
8395
|
+
list.children[spIdx]?.classList.remove('selected');
|
|
8396
|
+
spIdx = Math.min(Math.max(idx, 0), spRows.length - 1);
|
|
8397
|
+
const row = list.children[spIdx];
|
|
8398
|
+
row?.classList.add('selected');
|
|
8399
|
+
row?.scrollIntoView({ block: 'nearest' });
|
|
8400
|
+
spSchedulePeek();
|
|
8401
|
+
}
|
|
8402
|
+
|
|
8403
|
+
async function spOpen(idx) {
|
|
8404
|
+
const session = spRows[idx];
|
|
8405
|
+
if (!session) return;
|
|
8406
|
+
closeSessionPicker();
|
|
8407
|
+
await revealSession(session.id);
|
|
8408
|
+
}
|
|
8409
|
+
|
|
8410
|
+
function initSessionPicker() {
|
|
8411
|
+
const input = document.getElementById('session-picker-input');
|
|
8412
|
+
input.addEventListener('input', renderSessionPicker);
|
|
8413
|
+
// Bound on the input: the global handler returns early on INPUT targets.
|
|
8414
|
+
input.addEventListener('keydown', (e) => {
|
|
8415
|
+
if (e.key === 'Escape') closeSessionPicker();
|
|
8416
|
+
else if (matchKey(e, 'ArrowDown') || (e.ctrlKey && e.key === 'n')) spSelect(spIdx + 1);
|
|
8417
|
+
else if (matchKey(e, 'ArrowUp') || (e.ctrlKey && e.key === 'p')) spSelect(spIdx - 1);
|
|
8418
|
+
else if (e.key === 'Enter') spOpen(spIdx);
|
|
8419
|
+
else if (e.ctrlKey && e.code === 'Space') spSetPeeking(!spIsPeeking());
|
|
8420
|
+
else return;
|
|
8421
|
+
e.preventDefault();
|
|
8422
|
+
e.stopPropagation();
|
|
8423
|
+
});
|
|
8424
|
+
document.getElementById('session-picker-list').addEventListener('click', (e) => {
|
|
8425
|
+
const row = e.target.closest('.sp-row');
|
|
8426
|
+
if (row) spOpen(Number(row.dataset.idx));
|
|
8427
|
+
});
|
|
8428
|
+
}
|
|
8429
|
+
//#endregion
|
|
8430
|
+
|
|
8030
8431
|
//#region PLAN
|
|
8031
8432
|
function refreshOpenPlan() {
|
|
8032
8433
|
if (!_planSessionId || !document.getElementById('plan-modal').classList.contains('visible')) return;
|
|
@@ -8617,7 +9018,7 @@ if ('serviceWorker' in navigator) {
|
|
|
8617
9018
|
|
|
8618
9019
|
//#region INIT
|
|
8619
9020
|
loadTheme();
|
|
8620
|
-
if (
|
|
9021
|
+
if (store.getItem('sessions-filtersCollapsed') === 'true') {
|
|
8621
9022
|
document.getElementById('sessions-filters').classList.add('collapsed');
|
|
8622
9023
|
document.getElementById('sessions-chevron').classList.add('rotated');
|
|
8623
9024
|
}
|
|
@@ -8657,14 +9058,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
8657
9058
|
|
|
8658
9059
|
loadSidebarState();
|
|
8659
9060
|
try {
|
|
8660
|
-
const cg = JSON.parse(
|
|
9061
|
+
const cg = JSON.parse(store.getItem('collapsedGroups') || '[]');
|
|
8661
9062
|
// biome-ignore lint/suspicious/useIterableCallbackReturn: forEach side-effect
|
|
8662
9063
|
cg.forEach((p) => collapsedProjectGroups.add(p));
|
|
8663
9064
|
} catch (_) {}
|
|
8664
9065
|
loadSessionGroups();
|
|
8665
9066
|
initSessionGroupsDnd();
|
|
9067
|
+
initSessionPicker();
|
|
8666
9068
|
try {
|
|
8667
|
-
const af = JSON.parse(
|
|
9069
|
+
const af = JSON.parse(store.getItem('activityFilter') || '[]');
|
|
8668
9070
|
// biome-ignore lint/suspicious/useIterableCallbackReturn: forEach side-effect
|
|
8669
9071
|
af.forEach((k) => activityFilter.add(k));
|
|
8670
9072
|
} catch (_) {}
|
|
@@ -8897,8 +9299,9 @@ document.addEventListener('keydown', (e) => {
|
|
|
8897
9299
|
if (e.ctrlKey && e.altKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
|
|
8898
9300
|
fwd(e.key);
|
|
8899
9301
|
}
|
|
8900
|
-
//
|
|
8901
|
-
|
|
9302
|
+
// Own branch: the Alt+digit case below requires !ctrlKey. The hub owns the Ctrl+Alt+letter
|
|
9303
|
+
// keymap and ignores unbound letters.
|
|
9304
|
+
if (e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey && /^[a-z]$/i.test(e.key)) {
|
|
8902
9305
|
fwd(e.key);
|
|
8903
9306
|
}
|
|
8904
9307
|
if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey && /^[1-9]$/.test(e.key)) {
|