claude-code-kanban 4.24.0 → 4.26.0

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