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/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
- localStorage.setItem(LAST_VIEW_KEY, JSON.stringify(data));
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(localStorage.getItem(LAST_VIEW_KEY)) || null;
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
- localStorage.removeItem(LAST_VIEW_KEY);
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
- localStorage.setItem(`${containerId}Collapsed`, collapsed);
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
- localStorage.setItem('activityFilter', JSON.stringify([...activityFilter]));
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
- localStorage.setItem('message-panel-open', messagePanelOpen);
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
- localStorage.setItem('message-panel-open', 'true');
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 res = await fetch(`/api/sessions/${sessionId}/messages?limit=15`);
685
- if (!res.ok) return;
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 before = currentMessages[0].timestamp;
738
- const res = await fetch(`/api/sessions/${currentSessionId}/messages?limit=15&before=${encodeURIComponent(before)}`);
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(localStorage.getItem(`pinned-messages-${sessionId}`)) || [];
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
- localStorage.setItem(`pinned-messages-${sessionId}`, JSON.stringify(pins));
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(localStorage.getItem('pinned-sessions')) || []);
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(localStorage.getItem('sticky-sessions')) || []);
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
- localStorage.setItem('pinned-sessions', JSON.stringify([...pinnedSessionIds]));
1663
- localStorage.setItem('sticky-sessions', JSON.stringify([...stickySessionIds]));
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
- localStorage.setItem(`modal-fullscreen-${modalId}`, String(on));
2063
+ store.setItem(`modal-fullscreen-${modalId}`, String(on));
2018
2064
  }
2019
2065
 
2020
2066
  function loadModalFullscreen() {
2021
2067
  _forEachFullscreenModal((modalId) => {
2022
- if (localStorage.getItem(`modal-fullscreen-${modalId}`) === 'true') _applyModalFullscreen(modalId, true);
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 = localStorage.getItem(wKey);
2060
- const savedH = localStorage.getItem(hKey);
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
- localStorage.setItem(wKey, `${w}px`);
2095
- localStorage.setItem(hKey, `${h}px`);
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
- localStorage.removeItem(wKey);
2103
- localStorage.removeItem(hKey);
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(localStorage.getItem(MODAL_ZOOM_KEY)) || 1);
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
- localStorage.setItem(MODAL_ZOOM_KEY, String(modalZoom));
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 = localStorage.getItem('agentFooterCollapsed') === 'true';
2771
+ const collapsed = store.getItem('agentFooterCollapsed') === 'true';
2726
2772
  footer.classList.toggle('collapsed', collapsed);
2727
2773
  document.getElementById('agent-footer-toggle').innerHTML = collapsed ? '&#x25B4;' : '&#x25BE;';
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
- localStorage.setItem('agentFooterCollapsed', collapsed);
2849
+ store.setItem('agentFooterCollapsed', collapsed);
2804
2850
  document.getElementById('agent-footer-toggle').innerHTML = collapsed ? '&#x25B4;' : '&#x25BE;';
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
- if (!sessions.some((s) => s.id === planSessionId)) {
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
- function renderSessions() {
3021
- // Rebuilding the list under the pointer cancels an in-flight drop and would swallow a
3022
- // half-typed group name, and the SSE path can fire at any moment — defer instead.
3023
- if (sgDrag || sgIsEditing()) return;
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
- // hasRecentLog carries the server's registry-idle correction: an open-but-idle
3144
- // terminal keeps touching its JSONL, so mtime alone is not proof of activity.
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 = !!(localStorage.getItem(_sessionScratchpadKey(session.id)) || '').trim();
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 = localStorage.getItem('groupPinnedSessions') !== 'false';
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, `__pinned_${projectPath}__`)}
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 = '<div class="sg-section-label">Groups</div>';
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 out;
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
- // Rendered even when nothing is left under it: it is the drop target for ungrouping.
3393
- if (sessionGroups.length > 0) {
3394
- html += '<div class="sg-section-label sg-ungroup-zone" title="Drop here to remove from a group">Projects</div>';
3395
- }
3396
- for (const [projectPath, projectSessions] of sortedGroups) {
3397
- html += projectBlock(projectPath, projectSessions, false);
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
- if (ungrouped.length > 0 && sortedGroups.length > 0) {
3401
- const isCollapsed = collapsedProjectGroups.has('__ungrouped__');
3402
- html += `
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, '__pinned___ungrouped__')}
3483
+ ${renderGroupSessions(ungrouped, pinKey('__ungrouped__'))}
3410
3484
  </div>
3411
3485
  `;
3412
- } else {
3413
- html += ungrouped.map(renderSessionCard).join('');
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 ungroupZone =
3419
- sessionGroups.length > 0
3420
- ? '<div class="sg-section-label sg-ungroup-zone" title="Drop here to remove from a group">Sessions</div>'
3421
- : '';
3422
- sessionsList.innerHTML = html + sgSectionHtml(true) + ungroupZone + sgRest.map(renderSessionCard).join('');
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(localStorage.getItem(SESSION_GROUPS_KEY) || 'null')?.groups;
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
- localStorage.setItem(SESSION_GROUPS_KEY, JSON.stringify({ version: 1, groups: sessionGroups }));
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('pinned-sub-header')
4419
- ? 'pinned-sub-items'
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
- while (el && !el.classList.contains(cls)) el = el.nextElementSibling;
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
- for (const el of sessionsList.children) {
4452
- if (el.classList.contains('project-group-header') || el.classList.contains('session-group-header')) {
4453
- items.push(el);
4454
- if (!collapsedProjectGroups.has(el.dataset.groupPath)) {
4455
- walkGroupContainer(getGroupSessionsContainer(el));
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
- localStorage.setItem('collapsedGroups', JSON.stringify([...collapsedProjectGroups]));
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 (collapsedProjectGroups.delete(s.project || '__ungrouped__')) changed = true;
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
- if (sidebar.classList.contains('collapsed')) {
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 = localStorage.getItem(key) || '';
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 = !!(localStorage.getItem(key) || '').trim();
5345
+ const had = !!(store.getItem(key) || '').trim();
5128
5346
  const has = !!val.trim();
5129
5347
  if (has) {
5130
- localStorage.setItem(key, val);
5348
+ store.setItem(key, val);
5131
5349
  } else {
5132
- localStorage.removeItem(key);
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 (let i = 0; i < localStorage.length; i++) {
5155
- const k = localStorage.key(i);
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 (let i = 0; i < localStorage.length; i++) {
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(localStorage.getItem(key)) || [];
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
- if (!sessions.some((s) => s.id === id)) {
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
- localStorage.removeItem(`pinned-messages-${sessionId}`);
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(localStorage.getItem(key)) || [];
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) localStorage.setItem(key, JSON.stringify(pins));
5358
- else localStorage.removeItem(key);
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 (let i = 0; i < localStorage.length; i++) {
5372
- const key = localStorage.key(i);
5581
+ for (const key of store.keys()) {
5373
5582
  if (!key.startsWith('scratchpad-')) continue;
5374
- const val = localStorage.getItem(key) || '';
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(localStorage.getItem(key)) || [];
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
- localStorage.removeItem(key);
5666
+ store.removeItem(key);
5458
5667
  _renderStorageTab();
5459
5668
  _updateStorageTotal();
5460
5669
  }
5461
5670
 
5462
5671
  function _renderStorageLinkedDocs() {
5463
5672
  const entries = [];
5464
- for (let i = 0; i < localStorage.length; i++) {
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(localStorage.getItem(key)) || [];
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
- localStorage.removeItem(PREVIEW_STORAGE_PREFIX + sessionId);
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 (let i = 0; i < localStorage.length; i++) {
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
- localStorage.removeItem(key);
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(contextSid)
5903
- .then(() => showToast(`Copied session id: ${contextSid.slice(0, 8)}`, 'success'))
5904
- .catch(() => showToast('Failed to copy session id'));
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 = localStorage.getItem(PREVIEW_STORAGE_PREFIX + sessionId);
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
- localStorage.setItem(PREVIEW_STORAGE_PREFIX + sessionId, JSON.stringify(paths.slice(0, 20)));
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) localStorage.setItem(PREVIEW_STORAGE_PREFIX + sessionId, JSON.stringify(paths));
5962
- else localStorage.removeItem(PREVIEW_STORAGE_PREFIX + sessionId);
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 = localStorage.getItem('theme');
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
- localStorage.setItem('groupPinnedSessions', 'false');
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
- localStorage.setItem('groupPinnedSessions', 'true');
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('.session-group-header, .pinned-sub-header, .project-group-header');
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
- localStorage.setItem('theme', 'dark');
7732
+ store.setItem('theme', 'dark');
7507
7733
  } else {
7508
7734
  document.body.classList.add('light');
7509
7735
  document.body.classList.remove('dark-forced');
7510
- localStorage.setItem('theme', 'light');
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 = localStorage.getItem('theme');
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 = localStorage.getItem('color-theme');
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
- localStorage.removeItem('color-theme');
7828
+ store.removeItem('color-theme');
7603
7829
  } else {
7604
7830
  document.body.dataset.colorTheme = id;
7605
- localStorage.setItem('color-theme', id);
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
- localStorage.setItem('sidebar-collapsed', collapsed);
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 (localStorage.getItem('sidebar-collapsed') === 'true') {
7861
+ if (store.getItem('sidebar-collapsed') === 'true') {
7629
7862
  sidebar.classList.add('collapsed');
7630
7863
  }
7631
- const w = localStorage.getItem('sidebar-width');
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
- localStorage.setItem('sidebar-width', sidebar.style.getPropertyValue('--sidebar-width'));
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
- localStorage.setItem(storageKey, panel.style.getPropertyValue(cssVar));
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 = localStorage.getItem(`${id}-width`);
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 (localStorage.getItem('sessions-filtersCollapsed') === 'true') {
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(localStorage.getItem('collapsedGroups') || '[]');
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(localStorage.getItem('activityFilter') || '[]');
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
- // Its own branch: the Alt+digit case below requires !ctrlKey.
8901
- if (e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey && e.key.toLowerCase() === 'p') {
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)) {