claude-code-kanban 4.29.0 → 4.31.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
@@ -4,6 +4,7 @@ const STORAGE_NS = window.__STORAGE_NS__ ? `${window.__STORAGE_NS__}:` : '';
4
4
  // Theme is hub-wide (echoed to every app via hub:theme), so it stays shared across config dirs.
5
5
  const GLOBAL_KEYS = new Set(['theme', 'color-theme']);
6
6
  const nsKey = (k) => (GLOBAL_KEYS.has(k) ? k : STORAGE_NS + k);
7
+ const { normalizeProjectPath, isExactProjectPath, isExactProjectFilter, projectMatcher } = projectMatch;
7
8
  const store = {
8
9
  keys() {
9
10
  const out = [];
@@ -20,18 +21,24 @@ const store = {
20
21
 
21
22
  // The baseline "nothing is filtered" state: what resetState() returns to, what updateUrl() omits
22
23
  // from the query string, and what decides whether a control tints ember in the header summary.
23
- const FILTER_DEFAULTS = { project: '__recent__', session: 'active', limit: '20' };
24
+ const FILTER_DEFAULTS = { project: '__recent__', session: 'active' };
25
+ const RECENT_PROJECT_HOURS = 24;
24
26
 
25
27
  let sessions = [];
26
28
  let currentSessionId = null;
27
- let lastSessionId = null;
28
- let previousSessionId = null;
29
+ // Kept in the store because a config-dir switch in the hub reloads this page.
30
+ const SWAP_KEY = 'swap-sessions';
31
+ let [lastSessionId, previousSessionId] = readSwapPair();
29
32
  let currentTasks = [];
30
33
  let viewMode = 'session';
31
34
  let sessionFilter = FILTER_DEFAULTS.session;
32
35
  // Only meaningful while sessionFilter === 'active' (filterBySessions clears it otherwise)
33
36
  const activityFilter = new Set(); // kinds: 'waiting' | 'active'
34
- let sessionLimit = FILTER_DEFAULTS.limit;
37
+ // The sidebar asks for one page and grows by a page when it is scrolled to the end. A live
38
+ // refresh asks for the same count, so the loaded rows stay loaded.
39
+ const SESSION_PAGE = 20;
40
+ let sessionLimit = SESSION_PAGE;
41
+ let sessionsHasMore = false;
35
42
  // Narrows the sidebar to the open session only. Kept out of FILTER_DEFAULTS/updateUrl because it
36
43
  // is not a filter — it has no select, no summary token, and never reaches the session picker.
37
44
  // Persisted in localStorage rather than the URL: the hub recreates each iframe at the app's base
@@ -58,6 +65,8 @@ let currentWaiting = null;
58
65
  let lastWaitingHash = '';
59
66
  let lastAgentsHash = '';
60
67
  let messagePanelOpen = false;
68
+ let logAutoOpened = false;
69
+ const logDismissed = new Set();
61
70
  let lastMessagesHash = '';
62
71
  let currentMessages = [];
63
72
  let agentDurationInterval = null;
@@ -91,7 +100,50 @@ let msgUserScrolledUp = false;
91
100
  const MSG_MAX_LOADED = 200;
92
101
  let currentProjectPath = null;
93
102
  let currentProjectSessionIds = [];
94
- const dismissedSessionIds = new Set();
103
+ // Session id -> dismissed-at. Kept for a day: past that the session has usually gone stale,
104
+ // and a reload (the hub reloads this page on a config-dir switch) must not bring it back.
105
+ const DISMISSED_KEY = 'dismissed-sessions';
106
+ const DISMISS_TTL_MS = 24 * 60 * 60 * 1000;
107
+ const isDismissFresh = (at) => typeof at === 'number' && at > Date.now() - DISMISS_TTL_MS;
108
+ const dismissedSessionIds = loadDismissedSessions();
109
+ let dismissSaveWarned = false;
110
+
111
+ function loadDismissedSessions() {
112
+ try {
113
+ const saved = Object.entries(JSON.parse(store.getItem(DISMISSED_KEY) || '{}'));
114
+ return new Map(saved.filter(([, at]) => isDismissFresh(at)));
115
+ } catch {
116
+ return new Map();
117
+ }
118
+ }
119
+
120
+ function persistDismissedSessions() {
121
+ for (const [id, at] of dismissedSessionIds) if (!isDismissFresh(at)) dismissedSessionIds.delete(id);
122
+ try {
123
+ store.setItem(DISMISSED_KEY, JSON.stringify(Object.fromEntries(dismissedSessionIds)));
124
+ } catch (e) {
125
+ console.error('[dismissed-sessions]', e);
126
+ // The map still works in memory, so only the next reload is affected. Say so once.
127
+ if (!dismissSaveWarned) {
128
+ dismissSaveWarned = true;
129
+ showToast(
130
+ 'Browser storage is full: dismissed sessions come back on reload. Storage → Clean Orphaned frees space.',
131
+ 'error',
132
+ );
133
+ }
134
+ }
135
+ }
136
+
137
+ function setSessionDismissed(id, dismissed) {
138
+ if (dismissed === dismissedSessionIds.has(id)) return false;
139
+ if (dismissed) dismissedSessionIds.set(id, Date.now());
140
+ else dismissedSessionIds.delete(id);
141
+ persistDismissedSessions();
142
+ updateDismissBtnState();
143
+ renderSessions();
144
+ renderActivityChip();
145
+ return true;
146
+ }
95
147
 
96
148
  function resetMessageScrollState() {
97
149
  msgUserScrolledUp = false;
@@ -107,9 +159,7 @@ function getUrlState() {
107
159
  const params = new URLSearchParams(window.location.search);
108
160
  return {
109
161
  session: params.get('session'),
110
- view: params.get('view'),
111
162
  filter: params.get('filter'),
112
- limit: params.get('limit'),
113
163
  project: params.get('project'),
114
164
  owner: params.get('owner'),
115
165
  search: params.get('search'),
@@ -120,15 +170,13 @@ function getUrlState() {
120
170
 
121
171
  function updateUrl() {
122
172
  const params = new URLSearchParams();
123
- if (viewMode === 'all') params.set('view', 'all');
124
173
  if (viewMode === 'project' && currentProjectPath) params.set('projectView', btoa(currentProjectPath));
125
174
  if (currentSessionId) params.set('session', currentSessionId);
126
175
  if (sessionFilter !== FILTER_DEFAULTS.session) params.set('filter', sessionFilter);
127
- if (sessionLimit !== FILTER_DEFAULTS.limit) params.set('limit', sessionLimit);
128
176
  if (filterProject && filterProject !== FILTER_DEFAULTS.project) params.set('project', filterProject);
129
177
  if (ownerFilter) params.set('owner', ownerFilter);
130
178
  if (searchQuery) params.set('search', searchQuery);
131
- if (messagePanelOpen) params.set('messages', '1');
179
+ if (messagePanelOpen && !logAutoOpened) params.set('messages', '1');
132
180
  const qs = params.toString();
133
181
  const url = qs ? `?${qs}` : window.location.pathname;
134
182
  history.replaceState(null, '', url);
@@ -165,16 +213,12 @@ function resetState() {
165
213
  store.removeItem(LAST_VIEW_KEY);
166
214
  } catch (_) {}
167
215
  sessionFilter = FILTER_DEFAULTS.session;
168
- sessionLimit = FILTER_DEFAULTS.limit;
216
+ sessionLimit = SESSION_PAGE;
169
217
  filterProject = FILTER_DEFAULTS.project;
170
218
  zenMode = false;
171
219
  store.removeItem(ZEN_KEY);
172
220
  renderZenState();
173
- ownerFilter = '';
174
221
  searchQuery = '';
175
- viewMode = 'all';
176
- if (agentLogMode) exitAgentLogMode();
177
- currentSessionId = null;
178
222
  currentProjectPath = null;
179
223
  currentProjectSessionIds = [];
180
224
  resetMessageScrollState();
@@ -182,7 +226,8 @@ function resetState() {
182
226
  if (searchInput) searchInput.value = '';
183
227
  document.getElementById('search-clear-btn')?.classList.remove('visible');
184
228
  renderFilterState();
185
- fetchSessions().then(() => showAllTasks());
229
+ showNoSession();
230
+ fetchSessions();
186
231
  }
187
232
 
188
233
  //#endregion
@@ -223,51 +268,90 @@ async function fetchSessions(includeTasks = true) {
223
268
  // opened from outside the current filter would leave the view on the previous one.
224
269
  const includeParam = currentSessionId ? `&include=${encodeURIComponent(currentSessionId)}` : '';
225
270
  const projectParam =
226
- filterProject && filterProject !== '__recent__' ? `&project=${encodeURIComponent(filterProject)}` : '';
271
+ filterProject === '__recent__'
272
+ ? `&recentHours=${RECENT_PROJECT_HOURS}`
273
+ : filterProject
274
+ ? `&project=${encodeURIComponent(filterProject)}`
275
+ : '';
227
276
  const filterParam = sessionFilter === 'active' ? '&filter=active' : '';
228
277
  const sessionsPromise = fetch(
229
278
  `/api/sessions?limit=${sessionLimit}${pinnedParam}${includeParam}${projectParam}${filterParam}`,
230
- ).then((r) => r.json());
231
-
232
- let newSessions, newTasks;
233
- if (includeTasks) {
234
- [newSessions, newTasks] = await Promise.all([sessionsPromise, fetch('/api/tasks/all').then((r) => r.json())]);
235
- } else {
236
- newSessions = await sessionsPromise;
237
- }
279
+ ).then((r) => {
280
+ sessionsHasMore = r.headers.get('X-Has-More') === 'true';
281
+ return r.json();
282
+ });
238
283
 
284
+ const newSessions = await sessionsPromise;
239
285
  const sessionsHash = JSON.stringify(newSessions);
240
- if (includeTasks) {
241
- const tasksHash = JSON.stringify(newTasks);
242
- if (sessionsHash === lastSessionsHash && tasksHash === lastTasksHash) return;
243
- lastTasksHash = tasksHash;
244
- allTasksCache = newTasks;
245
- } else {
246
- if (sessionsHash === lastSessionsHash) return;
286
+ if (sessionsHash !== lastSessionsHash) {
287
+ lastSessionsHash = sessionsHash;
288
+ sessions = mergePlaceholders(newSessions);
289
+ renderSessions();
290
+ renderActivityChip();
247
291
  }
248
- lastSessionsHash = sessionsHash;
292
+ // A list too short to scroll gives no scroll event, so fill it until it can scroll.
293
+ if (sessionsHasMore) setTimeout(loadMoreIfNearEnd);
249
294
 
250
- sessions = mergePlaceholders(newSessions);
251
- renderSessions();
252
- renderActivityChip();
295
+ if (includeTasks && searchQuery) await loadSearchTasks();
253
296
  } catch (error) {
254
297
  console.error('Failed to fetch sessions:', error);
255
298
  }
256
299
  }
257
300
 
258
- // One session by id, ignoring every sidebar filter — that is what `include` means.
259
- async function fetchSessionById(id) {
301
+ async function loadSearchTasks() {
302
+ const newTasks = await fetch('/api/tasks/all').then((r) => r.json());
303
+ const tasksHash = JSON.stringify(newTasks);
304
+ if (tasksHash === lastTasksHash) return;
305
+ lastTasksHash = tasksHash;
306
+ allTasksCache = newTasks;
307
+ if (searchQuery) renderSessions();
308
+ }
309
+
310
+ let sessionsLoadingMore = false;
311
+ async function loadMoreSessions() {
312
+ if (!sessionsHasMore || sessionsLoadingMore) return;
313
+ sessionsLoadingMore = true;
314
+ sessionLimit += SESSION_PAGE;
260
315
  try {
261
- const r = await fetch(`/api/sessions?limit=1&include=${encodeURIComponent(id)}`);
262
- return r.ok ? (await r.json()).find((s) => s.id === id) || null : null;
316
+ await fetchSessions(false);
317
+ } finally {
318
+ sessionsLoadingMore = false;
319
+ }
320
+ }
321
+
322
+ function resetSessionPage() {
323
+ sessionLimit = SESSION_PAGE;
324
+ sessionsList.scrollTop = 0;
325
+ }
326
+
327
+ const LOAD_MORE_MARGIN_PX = 240;
328
+ function loadMoreIfNearEnd() {
329
+ const { scrollTop, clientHeight, scrollHeight } = sessionsList;
330
+ if (clientHeight > 0 && scrollTop + clientHeight >= scrollHeight - LOAD_MORE_MARGIN_PX) loadMoreSessions();
331
+ }
332
+ sessionsList.addEventListener('scroll', loadMoreIfNearEnd, { passive: true });
333
+
334
+ // Sessions by id, in the order given, ignoring every sidebar filter — that is what `include` means.
335
+ async function fetchSessionsByIds(ids) {
336
+ try {
337
+ const r = await fetch(`/api/sessions?limit=1&include=${ids.map(encodeURIComponent).join(',')}`);
338
+ if (!r.ok) return [];
339
+ const byId = new Map((await r.json()).map((s) => [s.id, s]));
340
+ return ids.map((id) => byId.get(id)).filter(Boolean);
263
341
  } catch (_) {
264
- return null;
342
+ return [];
265
343
  }
266
344
  }
267
345
 
346
+ async function fetchSessionById(id) {
347
+ return (await fetchSessionsByIds([id]))[0] || null;
348
+ }
349
+
268
350
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
269
351
  function handleSearch(query) {
352
+ const wasSearching = !!searchQuery;
270
353
  searchQuery = query.toLowerCase().trim();
354
+ if (searchQuery && !wasSearching) loadSearchTasks().catch(() => {});
271
355
 
272
356
  // Show/hide clear button
273
357
  const clearBtn = document.getElementById('search-clear-btn');
@@ -465,11 +549,10 @@ async function fetchTasks(sessionId) {
465
549
  revealedStorageSessionId = null;
466
550
  }
467
551
  if (currentSessionId && currentSessionId !== sessionId) deferredPinPlacement.delete(currentSessionId);
468
- if (lastSessionId !== sessionId) {
469
- previousSessionId = lastSessionId;
470
- lastSessionId = sessionId;
471
- }
552
+ if (lastSessionId !== sessionId) setSwapPair(sessionId, lastSessionId);
553
+ const switched = sessionId !== currentSessionId;
472
554
  currentSessionId = sessionId;
555
+ if (switched) autoRevealLog(sessionId, newTasks.length === 0);
473
556
  currentPins = loadPins(sessionId);
474
557
  ownerFilter = '';
475
558
  resetMessageScrollState();
@@ -632,7 +715,27 @@ async function refreshProjectAgents() {
632
715
  }
633
716
 
634
717
  //#region MESSAGE_PANEL
718
+ function setMessagePanelVisible(open) {
719
+ messagePanelOpen = open;
720
+ document.getElementById('message-panel').classList.toggle('visible', open);
721
+ document.getElementById('message-toggle')?.classList.toggle('active', open);
722
+ }
723
+
724
+ // An empty board has nothing to show, so the log opens in its place. It is not the user's choice,
725
+ // so it is not stored, it closes again on a session with tasks, and a manual close sticks for that session.
726
+ function autoRevealLog(sessionId, empty) {
727
+ if (empty && !messagePanelOpen && !logDismissed.has(sessionId) && !wantsTerminal()) {
728
+ setMessagePanelVisible(true);
729
+ logAutoOpened = true;
730
+ } else if (!empty && logAutoOpened) {
731
+ setMessagePanelVisible(false);
732
+ logAutoOpened = false;
733
+ }
734
+ }
735
+
635
736
  function toggleMessagePanel() {
737
+ if (logAutoOpened && messagePanelOpen && currentSessionId) logDismissed.add(currentSessionId);
738
+ logAutoOpened = false;
636
739
  const panel = document.getElementById('message-panel');
637
740
  messagePanelOpen = !messagePanelOpen;
638
741
  termState.panelHidden = false;
@@ -646,6 +749,34 @@ function toggleMessagePanel() {
646
749
  updateUrl();
647
750
  }
648
751
 
752
+ function applyMessagePanelFull(on) {
753
+ document.getElementById('message-panel').classList.toggle('full-width', on);
754
+ const btn = document.getElementById('message-panel-full-btn');
755
+ btn.classList.toggle('active', on);
756
+ btn.setAttribute('aria-pressed', String(on));
757
+ const label = on ? 'Restore width' : 'Expand to full width';
758
+ btn.title = label;
759
+ btn.setAttribute('aria-label', label);
760
+ }
761
+
762
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
763
+ function toggleMessagePanelFull() {
764
+ const on = !document.getElementById('message-panel').classList.contains('full-width');
765
+ store.setItem('message-panel-full', on);
766
+ applyMessagePanelFull(on);
767
+ }
768
+
769
+ function initMessagePanelFull() {
770
+ const panel = document.getElementById('message-panel');
771
+ const sidebar = document.querySelector('.sidebar');
772
+ if (sidebar) {
773
+ const sync = () => panel.style.setProperty('--sidebar-edge', `${sidebar.getBoundingClientRect().right}px`);
774
+ new ResizeObserver(sync).observe(sidebar);
775
+ sync();
776
+ }
777
+ applyMessagePanelFull(store.getItem('message-panel-full') === 'true');
778
+ }
779
+
649
780
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
650
781
  async function openSessionWithBookmarks(sessionId) {
651
782
  if (!messagePanelOpen) {
@@ -2243,22 +2374,41 @@ function isAnyModalOpen() {
2243
2374
 
2244
2375
  const ZOOM_KEYS = { '+': 0.1, '=': 0.1, NumpadAdd: 0.1, '-': -0.1, _: -0.1, NumpadSubtract: -0.1, 0: 0, Numpad0: 0 };
2245
2376
 
2377
+ function zoomDelta(e) {
2378
+ if (!(e.ctrlKey || e.metaKey) || e.altKey) return undefined;
2379
+ return ZOOM_KEYS[e.key] ?? ZOOM_KEYS[e.code];
2380
+ }
2381
+
2246
2382
  let _toastTimer = null;
2247
2383
  let _manualRefreshing = false;
2248
2384
  //#endregion
2249
2385
 
2250
2386
  //#region TOAST
2251
- function showToast(msg, type) {
2387
+ // `action` ({label, onClick}) adds a button and keeps the toast up long enough to reach it.
2388
+ function showToast(msg, type, action) {
2252
2389
  const el = document.getElementById('toast');
2253
2390
  clearTimeout(_toastTimer);
2254
2391
  el.style.transition = 'none';
2255
- el.classList.remove('visible', 'toast-success', 'toast-error', 'toast-info');
2392
+ el.classList.remove('visible', 'toast-success', 'toast-error', 'toast-info', 'has-action');
2256
2393
  void el.offsetHeight;
2257
2394
  el.style.transition = '';
2258
2395
  el.textContent = msg;
2396
+ const hide = () => el.classList.remove('visible', 'has-action');
2397
+ if (action) {
2398
+ const btn = document.createElement('button');
2399
+ btn.type = 'button';
2400
+ btn.className = 'toast-action';
2401
+ btn.textContent = action.label;
2402
+ btn.addEventListener('click', () => {
2403
+ hide();
2404
+ action.onClick();
2405
+ });
2406
+ el.append(btn);
2407
+ el.classList.add('has-action');
2408
+ }
2259
2409
  if (type) el.classList.add(`toast-${type}`);
2260
2410
  el.classList.add('visible');
2261
- _toastTimer = setTimeout(() => el.classList.remove('visible'), 2000);
2411
+ _toastTimer = setTimeout(hide, action ? 8000 : 2000);
2262
2412
  }
2263
2413
 
2264
2414
  async function copyWithFeedback(text, btn) {
@@ -3073,6 +3223,8 @@ let revealedStorageSessionId = null;
3073
3223
  // Opens a session and scrolls the sidebar to it, refetching first when the id
3074
3224
  // fell outside the session list the current filters asked for.
3075
3225
  async function revealSession(id) {
3226
+ // Explicit opens restore a dismissed session; activity and auto-follow do not.
3227
+ setSessionDismissed(id, false);
3076
3228
  let session = sessions.find((s) => s.id === id);
3077
3229
  if (!session) {
3078
3230
  lastSessionsHash = '';
@@ -3107,53 +3259,18 @@ async function revealPlanSession(planSessionId) {
3107
3259
  await revealSession(planSessionId);
3108
3260
  }
3109
3261
 
3110
- async function showAllTasks() {
3111
- try {
3112
- viewMode = 'all';
3113
- if (agentLogMode) exitAgentLogMode();
3114
- currentSessionId = null;
3115
- ownerFilter = '';
3116
- resetAgentState();
3117
- const res = await fetch('/api/tasks/all');
3118
- allTasksCache = await res.json();
3119
- let tasks = allTasksCache;
3120
- if (filterProject) {
3121
- tasks = tasks.filter((t) => matchesProjectFilter(t.project));
3122
- }
3123
- currentTasks = tasks;
3124
- updateUrl();
3125
- renderAllTasks();
3126
- renderSessions();
3127
- renderActivityChip();
3128
- } catch (error) {
3129
- console.error('Failed to fetch all tasks:', error);
3130
- }
3131
- }
3132
-
3133
- function renderAllTasks() {
3134
- noSession.style.display = 'none';
3135
- sessionView.classList.add('visible');
3136
- document.getElementById('owner-filter-bar').classList.remove('visible');
3137
-
3138
- const visibleTasks = currentTasks.filter((t) => !isInternalTask(t));
3139
- const totalTasks = visibleTasks.length;
3140
- const completed = visibleTasks.filter((t) => t.status === 'completed').length;
3141
- const percent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0;
3142
-
3143
- const isFiltered = filterProject && filterProject !== '__recent__';
3144
- const projectName = isFiltered ? filterProject.split(/[/\\]/).pop() : null;
3145
- sessionTitle.textContent = isFiltered
3146
- ? `Tasks: ${projectName}`
3147
- : filterProject === '__recent__'
3148
- ? 'Recent Tasks'
3149
- : 'All Tasks';
3150
- sessionMeta.textContent = isFiltered
3151
- ? `${totalTasks} tasks in this project`
3152
- : `${totalTasks} tasks across ${sessions.length} sessions`;
3153
- progressPercent.textContent = `${percent}%`;
3154
- progressBar.style.width = `${percent}%`;
3155
-
3156
- renderKanban();
3262
+ function showNoSession() {
3263
+ viewMode = 'none';
3264
+ if (agentLogMode) exitAgentLogMode();
3265
+ currentSessionId = null;
3266
+ ownerFilter = '';
3267
+ resetAgentState();
3268
+ currentTasks = [];
3269
+ updateUrl();
3270
+ sessionView.classList.remove('visible');
3271
+ noSession.style.display = '';
3272
+ renderSessions();
3273
+ renderActivityChip();
3157
3274
  }
3158
3275
 
3159
3276
  // Filter pipeline: active filter → force-include revealed/current (non-pinned) sessions →
@@ -3260,8 +3377,7 @@ function renderSessions() {
3260
3377
  // Rebuilding the list under the pointer cancels an in-flight drop and would swallow a
3261
3378
  // half-typed group name, and the SSE path can fire at any moment — defer instead.
3262
3379
  if (sgDrag || sgIsEditing() || zenPanelIsEditing()) return;
3263
- // Update project dropdown
3264
- updateProjectDropdown();
3380
+ refreshProjectList();
3265
3381
 
3266
3382
  // Zen narrows the rendered list only — the session picker keeps calling getFilteredSessions()
3267
3383
  // and still offers everything, or there would be no way to switch sessions without leaving zen.
@@ -3274,16 +3390,16 @@ function renderSessions() {
3274
3390
 
3275
3391
  if (zenMode) {
3276
3392
  emptyMsg = 'Zen mode: no session open';
3277
- emptyHint = 'Press Shift+Z to leave zen mode, then open a session';
3393
+ emptyHint = 'Press Ctrl+Shift+Z to leave zen mode, then open a session';
3278
3394
  } else if (searchQuery) {
3279
3395
  emptyMsg = `No results for "${searchQuery}"`;
3280
3396
  emptyHint = 'Try a different search term or clear the search';
3281
3397
  } else if (filterProject && sessionFilter === 'active') {
3282
- emptyMsg = 'No active sessions for this project';
3283
- emptyHint = 'Try "All Sessions" or "All Projects"';
3398
+ emptyMsg = 'No active sessions for this project filter';
3399
+ emptyHint = 'Try "All Sessions" or clear the project filter';
3284
3400
  } else if (filterProject) {
3285
- emptyMsg = 'No sessions for this project';
3286
- emptyHint = 'Select "All Projects" to see all';
3401
+ emptyMsg = 'No sessions for this project filter';
3402
+ emptyHint = 'Clear the project filter to see all';
3287
3403
  } else if (sessionFilter === 'active') {
3288
3404
  emptyMsg = 'No active sessions';
3289
3405
  emptyHint = 'Select "All Sessions" to see all';
@@ -3338,7 +3454,6 @@ function renderSessions() {
3338
3454
  ${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
3339
3455
  ${gitBranch ? `<div class="session-branch">${gitBranch}</div>` : ''}
3340
3456
  ${session.planTitle ? `<div class="session-plan">${escapeHtml(session.planTitle)}</div>` : ''}
3341
- ${renderGoalSubtitle(session)}
3342
3457
  <div class="session-progress">
3343
3458
  <span class="session-indicators">
3344
3459
  ${isTeam ? `<span class="team-badge" title="${memberCount} team members"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>${memberCount}</span>` : ''}
@@ -3729,9 +3844,8 @@ function renderProjectView() {
3729
3844
 
3730
3845
  function renderTaskCard(task) {
3731
3846
  const isBlocked = task.blockedBy && task.blockedBy.length > 0;
3732
- const useSlug = viewMode === 'all' || viewMode === 'project';
3847
+ const useSlug = viewMode === 'project';
3733
3848
  const taskId = useSlug ? `${(task._taskDir || task.sessionId || '')?.slice(0, 4)}-${task.id}` : task.id;
3734
- const sessionLabel = viewMode === 'all' && task.sessionName ? task.sessionName : null;
3735
3849
  const statusClass = task.status.replace('_', '-');
3736
3850
  const actualSessionId = task._taskDir || task.sessionId || currentSessionId || '';
3737
3851
 
@@ -3762,7 +3876,6 @@ function renderTaskCard(task) {
3762
3876
  }
3763
3877
  </div>
3764
3878
  <div class="task-title">${escapeHtml(task.subject)}</div>
3765
- ${sessionLabel ? `<div class="task-session">${escapeHtml(sessionLabel)}</div>` : ''}
3766
3879
  ${task.status === 'in_progress' && task.activeForm ? `<div class="task-active">${escapeHtml(task.activeForm)}</div>` : ''}
3767
3880
  ${isBlocked ? `<div class="task-blocked">Waiting on ${task.blockedBy.map((id) => `#${id}`).join(', ')}</div>` : ''}
3768
3881
  ${task.description ? `<div class="task-desc">${escapeHtml(task.description.split('\n')[0])}</div>` : ''}
@@ -4196,7 +4309,6 @@ function sgAssign(groupId, type, ref, opts = {}) {
4196
4309
  if (loose) member.loose = true;
4197
4310
  group.members.push(member);
4198
4311
  }
4199
- if (collapsedProjectGroups.delete(sgKey(groupId))) persistCollapsedGroups();
4200
4312
  persistSessionGroups();
4201
4313
  }
4202
4314
 
@@ -4468,7 +4580,11 @@ function sgOnDrop(e) {
4468
4580
  if (hit.groupId) {
4469
4581
  if (hit.reorder) sgReorderMember(hit.groupId, drag, hit.reorder);
4470
4582
  else if (drag.kind === 'group') sgReorderGroup(drag.ref, hit.groupId);
4471
- else sgAssign(hit.groupId, drag.kind, drag.ref, { under: hit.under, loose: hit.loose });
4583
+ else {
4584
+ sgAssign(hit.groupId, drag.kind, drag.ref, { under: hit.under, loose: hit.loose });
4585
+ // A drop shows where the card landed; a move from the context menu leaves the group as it was.
4586
+ if (collapsedProjectGroups.delete(sgKey(hit.groupId))) persistCollapsedGroups();
4587
+ }
4472
4588
  } else {
4473
4589
  sgDetach(drag.kind, drag.ref);
4474
4590
  if (drag.kind === 'session') sgReleased.add(drag.ref);
@@ -4505,6 +4621,10 @@ function initSessionGroupsDnd() {
4505
4621
  sessionsList.addEventListener('drop', sgOnDrop);
4506
4622
  sessionsList.addEventListener('dragend', sgOnDragEnd);
4507
4623
  sessionsList.addEventListener('contextmenu', sgOnContextMenu);
4624
+ // The Menu key fires contextmenu at the focused element, which by then is our menu.
4625
+ document.addEventListener('contextmenu', (e) => {
4626
+ if (e.target.closest?.('.sg-menu')) e.preventDefault();
4627
+ });
4508
4628
  sessionsList.addEventListener('keydown', (e) => {
4509
4629
  const input = e.target.closest?.('.sg-name-input');
4510
4630
  if (!input) return;
@@ -4539,32 +4659,68 @@ function initSessionGroupsDnd() {
4539
4659
  let sgLongPressTimer = null;
4540
4660
 
4541
4661
  function sgCloseMenu() {
4662
+ document.removeEventListener('keydown', sgMenuKeydown, true);
4542
4663
  const menu = document.getElementById('sg-menu');
4543
4664
  if (menu) menu.remove();
4544
4665
  }
4545
4666
 
4667
+ // Capture phase: the global handler would otherwise read these keys as sidebar shortcuts.
4668
+ function sgMenuKeydown(e) {
4669
+ const menu = document.getElementById('sg-menu');
4670
+ if (!menu || e.ctrlKey || e.altKey || e.metaKey) return;
4671
+ const items = [...menu.querySelectorAll('.sg-menu-item')];
4672
+ const i = items.indexOf(document.activeElement);
4673
+ e.stopPropagation();
4674
+ if (e.key === 'Enter' || e.key === ' ') return;
4675
+ e.preventDefault();
4676
+ let next = null;
4677
+ if (matchKey(e, 'ArrowDown')) next = (i + 1) % items.length;
4678
+ else if (matchKey(e, 'ArrowUp')) next = i < 0 ? items.length - 1 : (i - 1 + items.length) % items.length;
4679
+ else if (e.key === 'Home') next = 0;
4680
+ else if (e.key === 'End') next = items.length - 1;
4681
+ else if (e.key === 'Escape' || e.key === 'Tab') sgCloseMenu();
4682
+ if (next !== null) items[next]?.focus();
4683
+ }
4684
+
4685
+ function sgOpenMenuForKbSelection() {
4686
+ const el = sessionsList.querySelector('.kb-selected');
4687
+ if (!el?.matches('.session-item, .project-group-header')) return false;
4688
+ const payload = sgDragPayload(el);
4689
+ if (!payload?.ref) return false;
4690
+ const rect = el.getBoundingClientRect();
4691
+ sgOpenMenu(rect.left + 16, rect.bottom, payload.kind, payload.ref);
4692
+ return true;
4693
+ }
4694
+
4546
4695
  function sgOpenMenu(x, y, kind, ref) {
4547
4696
  sgCloseMenu();
4548
4697
  const current = sgGroupOf(kind, ref);
4549
4698
  const label = kind === 'project' ? 'project' : 'session';
4550
4699
  const rows = sessionGroups
4551
4700
  .filter((g) => g.id !== current?.id)
4552
- .map((g) => `<button class="sg-menu-item" data-sg-move="${escapeHtml(g.id)}">${escapeHtml(g.name)}</button>`)
4701
+ .map(
4702
+ (g) =>
4703
+ `<button class="sg-menu-item" role="menuitem" data-sg-move="${escapeHtml(g.id)}">${escapeHtml(g.name)}</button>`,
4704
+ )
4553
4705
  .join('');
4554
4706
  const menu = document.createElement('div');
4555
4707
  menu.id = 'sg-menu';
4556
4708
  menu.className = 'sg-menu';
4709
+ menu.setAttribute('role', 'menu');
4710
+ menu.setAttribute('aria-label', `Move ${label} to group`);
4557
4711
  menu.dataset.sgKind = kind;
4558
4712
  menu.dataset.sgRef = ref;
4559
4713
  menu.innerHTML = `
4560
- <div class="sg-menu-label">Move ${label} to group</div>
4714
+ <div class="sg-menu-label" aria-hidden="true">Move ${label} to group</div>
4561
4715
  ${rows}
4562
- <button class="sg-menu-item" data-sg-move="__new__">+ New group…</button>
4563
- ${current ? `<button class="sg-menu-item sg-menu-remove" data-sg-move="__none__">Remove from “${escapeHtml(current.name)}”</button>` : ''}`;
4716
+ <button class="sg-menu-item" role="menuitem" data-sg-move="__new__">+ New group…</button>
4717
+ ${current ? `<button class="sg-menu-item sg-menu-remove" role="menuitem" data-sg-move="__none__">Remove from “${escapeHtml(current.name)}”</button>` : ''}`;
4564
4718
  document.body.appendChild(menu);
4565
4719
  const rect = menu.getBoundingClientRect();
4566
4720
  menu.style.left = `${Math.min(x, window.innerWidth - rect.width - 8)}px`;
4567
4721
  menu.style.top = `${Math.min(y, window.innerHeight - rect.height - 8)}px`;
4722
+ document.addEventListener('keydown', sgMenuKeydown, true);
4723
+ menu.querySelector('.sg-menu-item')?.focus();
4568
4724
  }
4569
4725
 
4570
4726
  function sgOnContextMenu(e) {
@@ -5198,13 +5354,7 @@ async function saveTaskField(taskId, sessionId, field, value) {
5198
5354
 
5199
5355
  if (res.ok) {
5200
5356
  lastCurrentTasksHash = null;
5201
- if (viewMode === 'all') {
5202
- const tasksRes = await fetch('/api/tasks/all');
5203
- currentTasks = await tasksRes.json();
5204
- renderKanban();
5205
- } else {
5206
- await fetchTasks(sessionId);
5207
- }
5357
+ await fetchTasks(sessionId);
5208
5358
  showTaskDetail(taskId, sessionId);
5209
5359
  }
5210
5360
  } catch (error) {
@@ -5288,11 +5438,11 @@ function deleteTask(taskId, sessionId) {
5288
5438
  if (e.key === 'Escape') {
5289
5439
  e.preventDefault();
5290
5440
  closeDeleteConfirmModal();
5291
- } else if (matchKey(e, 'ArrowLeft', 'KeyH')) {
5441
+ } else if (matchKey(e, 'ArrowLeft')) {
5292
5442
  e.preventDefault();
5293
5443
  focusIdx = 0;
5294
5444
  buttons[focusIdx].focus();
5295
- } else if (matchKey(e, 'ArrowRight', 'KeyL')) {
5445
+ } else if (matchKey(e, 'ArrowRight')) {
5296
5446
  e.preventDefault();
5297
5447
  focusIdx = 1;
5298
5448
  buttons[focusIdx].focus();
@@ -5354,10 +5504,10 @@ const SHORTCUT_PAIRS = [
5354
5504
  {
5355
5505
  title: 'Navigate',
5356
5506
  rows: [
5357
- { keys: ['J', '↓'], label: 'Next item' },
5358
- { keys: ['K', '↑'], label: 'Previous item' },
5359
- { keys: ['H', '←'], label: 'Left column / collapse group' },
5360
- { keys: ['L', '→'], label: 'Right column / expand group' },
5507
+ { keys: ['↓'], label: 'Next item' },
5508
+ { keys: ['↑'], label: 'Previous item' },
5509
+ { keys: ['←'], label: 'Left column / collapse group' },
5510
+ { keys: ['→'], label: 'Right column / expand group' },
5361
5511
  { keys: ['Tab'], label: 'Switch sidebar ↔ board' },
5362
5512
  { keys: ['Enter', 'Space'], label: 'Open selected item' },
5363
5513
  ],
@@ -5366,14 +5516,15 @@ const SHORTCUT_PAIRS = [
5366
5516
  title: 'Session',
5367
5517
  rows: [
5368
5518
  { keys: ['P'], label: 'Open plan' },
5369
- { keys: ['Shift', 'P'], combo: true, label: 'Session picker' },
5519
+ { keys: ['Ctrl', 'Shift', 'P'], combo: true, label: 'Session picker' },
5370
5520
  { keys: ['I'], label: 'Session info' },
5371
5521
  { keys: ['.'], label: 'Pin / unpin' },
5372
5522
  { keys: ['>'], label: 'Toggle sticky' },
5523
+ { keys: ['Shift', 'F10'], combo: true, label: 'Move to group (or Menu key)' },
5373
5524
  { keys: ['Ctrl', 'D'], combo: true, label: 'Dismiss session' },
5374
5525
  { keys: ['Shift', 'L'], combo: true, label: 'Toggle session log' },
5375
5526
  { keys: ['Shift', 'M'], combo: true, label: 'Open last message' },
5376
- { keys: ['J', 'K'], label: 'Previous / next message in detail' },
5527
+ { keys: ['↑', '↓'], label: 'Previous / next message in detail' },
5377
5528
  { keys: ['Ctrl', 'Enter'], combo: true, label: 'Allow / approve the waiting prompt' },
5378
5529
  ],
5379
5530
  },
@@ -5395,7 +5546,7 @@ const SHORTCUT_PAIRS = [
5395
5546
  { keys: ['['], label: 'Toggle sidebar' },
5396
5547
  { keys: ['T'], label: 'Toggle theme' },
5397
5548
  { keys: ['Shift', 'S'], combo: true, label: 'Storage manager' },
5398
- { keys: ['Shift', 'Z'], combo: true, label: 'Zen mode (current session only)' },
5549
+ { keys: ['Ctrl', 'Shift', 'Z'], combo: true, label: 'Zen mode (current session only)' },
5399
5550
  { keys: ['Ctrl', '+'], combo: true, label: 'Larger modal text' },
5400
5551
  { keys: ['Ctrl', '−'], combo: true, label: 'Smaller modal text' },
5401
5552
  { keys: ['Ctrl', '0'], combo: true, label: 'Reset modal text size' },
@@ -5432,6 +5583,7 @@ const SHORTCUT_PAIRS = [
5432
5583
  { keys: ['Alt', '`'], combo: true, label: 'Focus terminal / page' },
5433
5584
  { keys: ['Alt', 'Shift', '`'], combo: true, label: 'End and close terminal' },
5434
5585
  { keys: ['Ctrl', 'Shift', '`'], combo: true, label: 'All terminals' },
5586
+ { keys: ['Ctrl', '+/−/0'], combo: true, label: 'Terminal text size' },
5435
5587
  ],
5436
5588
  },
5437
5589
  {
@@ -5490,9 +5642,7 @@ function closeHelpModal() {
5490
5642
  }
5491
5643
 
5492
5644
  async function refreshCurrentView() {
5493
- if (viewMode === 'all') {
5494
- await showAllTasks();
5495
- } else if (currentSessionId) {
5645
+ if (currentSessionId) {
5496
5646
  await fetchTasks(currentSessionId);
5497
5647
  } else {
5498
5648
  await fetchSessions();
@@ -5979,11 +6129,12 @@ function _renderStorageLinkedDocs() {
5979
6129
  const name = linkedDocLabel(p);
5980
6130
  const sid = escAttrJs(sessionId);
5981
6131
  const jsPath = escAttrJs(p);
6132
+ const what = LINKED_DOC_OPENERS[linkedDocOpener(p)].noun;
5982
6133
  return `<div class="storage-item" style="padding-left:24px;">
5983
6134
  <span class="storage-item-id" title="${escapeHtml(p)}">${escapeHtml(name)}</span>
5984
6135
  <div class="storage-item-actions">
5985
6136
  <button onclick="_storagePreviewLinkedDoc('${jsPath}')">View</button>
5986
- <button onclick="copyWithFeedback('${jsPath}', this)" title="Copy path" aria-label="Copy path">${ICON_COPY}</button>
6137
+ <button onclick="copyWithFeedback('${jsPath}', this)" title="Copy ${what}" aria-label="Copy ${what}">${ICON_COPY}</button>
5987
6138
  <button class="danger" onclick="_storageUnlinkDoc('${sid}','${jsPath}')">Unlink</button>
5988
6139
  </div>
5989
6140
  </div>`;
@@ -6025,10 +6176,12 @@ function _storagePreviewLinkedDoc(path) {
6025
6176
 
6026
6177
  function _storageUnlinkDoc(sessionId, path) {
6027
6178
  setSessionDocLink(sessionId, path, true);
6179
+ forgetServerLinkedDoc(sessionId, path);
6028
6180
  }
6029
6181
 
6030
6182
  function _storageClearLinkedDocs(sessionId) {
6031
6183
  store.removeItem(PREVIEW_STORAGE_PREFIX + sessionId);
6184
+ forgetServerLinkedDoc(sessionId);
6032
6185
  // The auto-link record deliberately survives: clearing is unlinking every row at
6033
6186
  // once, and an unlinked pad must not come back on the next read of the transcript.
6034
6187
  afterLinkedDocsChanged(sessionId);
@@ -6130,13 +6283,11 @@ document.addEventListener('keydown', (e) => {
6130
6283
  // Scale the open modal's reading surface instead of letting the browser zoom
6131
6284
  // the whole page. Sits above the text-field guard so it still works with the
6132
6285
  // caret in a field inside the modal.
6133
- if ((e.ctrlKey || e.metaKey) && !e.altKey) {
6134
- const delta = ZOOM_KEYS[e.key] ?? ZOOM_KEYS[e.code];
6135
- if (delta !== undefined && isZoomableModalOpen()) {
6136
- e.preventDefault();
6137
- adjustModalZoom(delta);
6138
- return;
6139
- }
6286
+ const zoom = zoomDelta(e);
6287
+ if (zoom !== undefined && isZoomableModalOpen()) {
6288
+ e.preventDefault();
6289
+ adjustModalZoom(zoom);
6290
+ return;
6140
6291
  }
6141
6292
 
6142
6293
  // Above the text-field guard: xterm's input is a textarea, and the toggle must work from it.
@@ -6148,11 +6299,7 @@ document.addEventListener('keydown', (e) => {
6148
6299
  return;
6149
6300
  }
6150
6301
  if (e.key === 'Enter' || e.key === ' ') return;
6151
- const step = matchKey(e, 'ArrowLeft', 'ArrowUp', 'KeyH', 'KeyK')
6152
- ? -1
6153
- : matchKey(e, 'ArrowRight', 'ArrowDown', 'KeyL', 'KeyJ')
6154
- ? 1
6155
- : 0;
6302
+ const step = matchKey(e, 'ArrowLeft', 'ArrowUp') ? -1 : matchKey(e, 'ArrowRight', 'ArrowDown') ? 1 : 0;
6156
6303
  if (step) {
6157
6304
  e.preventDefault();
6158
6305
  const buttons = [...terminalPrompt.querySelectorAll('button')];
@@ -6172,7 +6319,7 @@ document.addEventListener('keydown', (e) => {
6172
6319
  return;
6173
6320
  }
6174
6321
 
6175
- // Modal guard — only Escape, Shift+M, and msg-detail J/K navigation pass through
6322
+ // Modal guard — only Escape, Shift+M, and msg-detail arrow navigation pass through
6176
6323
  if (isAnyModalOpen()) {
6177
6324
  if (e.key === 'Escape') {
6178
6325
  if (_scratchpadModal.classList.contains('visible')) {
@@ -6206,7 +6353,7 @@ document.addEventListener('keydown', (e) => {
6206
6353
  ) {
6207
6354
  e.preventDefault();
6208
6355
  respondWaiting({ behavior: 'allow' });
6209
- } else if (matchKey(e, 'ArrowDown', 'KeyJ')) {
6356
+ } else if (matchKey(e, 'ArrowDown')) {
6210
6357
  e.preventDefault();
6211
6358
  if (currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX) {
6212
6359
  msgDetailFollowLatest = true;
@@ -6221,7 +6368,7 @@ document.addEventListener('keydown', (e) => {
6221
6368
  msgDetailFollowLatest = true;
6222
6369
  showMsgDetail(currentMsgDetailIdx);
6223
6370
  }
6224
- } else if (matchKey(e, 'ArrowUp', 'KeyK')) {
6371
+ } else if (matchKey(e, 'ArrowUp')) {
6225
6372
  e.preventDefault();
6226
6373
  if (currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX) {
6227
6374
  if (currentMessages.length) {
@@ -6272,12 +6419,6 @@ document.addEventListener('keydown', (e) => {
6272
6419
  toggleZenMode();
6273
6420
  return;
6274
6421
  }
6275
- // Ctrl+Shift+P is the browser's own; only the bare chord opens the picker.
6276
- if (e.code === 'KeyP' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
6277
- e.preventDefault();
6278
- openSessionPicker();
6279
- return;
6280
- }
6281
6422
  if (e.key === '.' || e.key === '>') {
6282
6423
  const sid = sessionsList.querySelector('.kb-selected')?.dataset.sessionId || currentSessionId;
6283
6424
  if (sid) {
@@ -6306,22 +6447,22 @@ document.addEventListener('keydown', (e) => {
6306
6447
 
6307
6448
  // Sidebar navigation
6308
6449
  if (focusZone === 'sidebar') {
6309
- if (matchKey(e, 'ArrowDown', 'KeyJ')) {
6450
+ if (matchKey(e, 'ArrowDown')) {
6310
6451
  e.preventDefault();
6311
6452
  navigateSession(1);
6312
6453
  return;
6313
6454
  }
6314
- if (matchKey(e, 'ArrowUp', 'KeyK')) {
6455
+ if (matchKey(e, 'ArrowUp')) {
6315
6456
  e.preventDefault();
6316
6457
  navigateSession(-1);
6317
6458
  return;
6318
6459
  }
6319
- if (matchKey(e, 'ArrowLeft', 'KeyH')) {
6460
+ if (matchKey(e, 'ArrowLeft')) {
6320
6461
  e.preventDefault();
6321
6462
  handleSidebarHorizontal(-1);
6322
6463
  return;
6323
6464
  }
6324
- if (matchKey(e, 'ArrowRight', 'KeyL')) {
6465
+ if (matchKey(e, 'ArrowRight')) {
6325
6466
  e.preventDefault();
6326
6467
  handleSidebarHorizontal(1);
6327
6468
  return;
@@ -6331,6 +6472,11 @@ document.addEventListener('keydown', (e) => {
6331
6472
  activateSelectedSession();
6332
6473
  return;
6333
6474
  }
6475
+ const menuKey = e.key === 'ContextMenu' || (e.key === 'F10' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey);
6476
+ if (menuKey && sgOpenMenuForKbSelection()) {
6477
+ e.preventDefault();
6478
+ return;
6479
+ }
6334
6480
  if (e.key === 'Escape') {
6335
6481
  // Plain unfocus — drop the keyboard cursor without jumping into the board
6336
6482
  clearKbSelection();
@@ -6341,16 +6487,16 @@ document.addEventListener('keydown', (e) => {
6341
6487
 
6342
6488
  // Board navigation
6343
6489
  if (focusZone === 'board') {
6344
- if (matchKey(e, 'ArrowDown', 'KeyJ', 'ArrowUp', 'KeyK', 'ArrowLeft', 'KeyH', 'ArrowRight', 'KeyL')) {
6490
+ if (matchKey(e, 'ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight')) {
6345
6491
  e.preventDefault();
6346
6492
  if (!selectedTaskId && !document.querySelector('.task-card.selected')) {
6347
6493
  setFocusZone('sidebar');
6348
6494
  return;
6349
6495
  }
6350
- if (matchKey(e, 'ArrowDown', 'KeyJ')) navigateVertical(1);
6351
- else if (matchKey(e, 'ArrowUp', 'KeyK')) navigateVertical(-1);
6352
- else if (matchKey(e, 'ArrowLeft', 'KeyH')) navigateHorizontal(-1);
6353
- else if (matchKey(e, 'ArrowRight', 'KeyL')) navigateHorizontal(1);
6496
+ if (matchKey(e, 'ArrowDown')) navigateVertical(1);
6497
+ else if (matchKey(e, 'ArrowUp')) navigateVertical(-1);
6498
+ else if (matchKey(e, 'ArrowLeft')) navigateHorizontal(-1);
6499
+ else if (matchKey(e, 'ArrowRight')) navigateHorizontal(1);
6354
6500
 
6355
6501
  if (selectedTaskId && detailPanel.classList.contains('visible')) {
6356
6502
  showTaskDetail(selectedTaskId, selectedSessionId);
@@ -6433,12 +6579,8 @@ document.addEventListener('keydown', (e) => {
6433
6579
  }
6434
6580
  if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && e.key === 'd') {
6435
6581
  e.preventDefault();
6436
- if (!contextSid || dismissedSessionIds.has(contextSid)) return;
6437
6582
  const prevIdx = selectedSessionIdx;
6438
- dismissedSessionIds.add(contextSid);
6439
- updateDismissBtnState();
6440
- renderSessions();
6441
- renderActivityChip();
6583
+ if (!contextSid || !setSessionDismissed(contextSid, true)) return;
6442
6584
  const newItems = getNavigableItems();
6443
6585
  const targetIdx = newItems.length > 0 ? Math.max(0, prevIdx - 1) : -1;
6444
6586
  // If the dismissed session is currently open, navigate to the previous one
@@ -6449,10 +6591,11 @@ document.addEventListener('keydown', (e) => {
6449
6591
  if (targetSid) {
6450
6592
  fetchTasks(targetSid).then(() => selectSessionByIndex(targetIdx, getNavigableItems()));
6451
6593
  } else {
6452
- showAllTasks().then(() => selectSessionByIndex(targetIdx, getNavigableItems()));
6594
+ showNoSession();
6595
+ selectSessionByIndex(targetIdx, getNavigableItems());
6453
6596
  }
6454
6597
  } else {
6455
- showAllTasks();
6598
+ showNoSession();
6456
6599
  }
6457
6600
  } else if (targetIdx >= 0) {
6458
6601
  selectSessionByIndex(targetIdx, newItems);
@@ -6555,6 +6698,31 @@ function setSessionDocLink(sessionId, filePath, unlink) {
6555
6698
  afterLinkedDocsChanged(sessionId);
6556
6699
  }
6557
6700
 
6701
+ // Links sent while no tab was open reach the browser only through the server copy.
6702
+ async function mergeServerLinkedDocs() {
6703
+ let bySession;
6704
+ try {
6705
+ const res = await fetch('/api/document/links');
6706
+ if (!res.ok) return;
6707
+ bySession = await res.json();
6708
+ } catch {
6709
+ return;
6710
+ }
6711
+ for (const [sessionId, paths] of Object.entries(bySession || {})) {
6712
+ const local = getSessionPreviewPaths(sessionId);
6713
+ const known = new Set(local.map(canonicalPath));
6714
+ const missing = (paths || []).filter((p) => !known.has(canonicalPath(p)));
6715
+ if (!missing.length) continue;
6716
+ store.setItem(PREVIEW_STORAGE_PREFIX + sessionId, JSON.stringify([...missing, ...local].slice(0, 20)));
6717
+ afterLinkedDocsChanged(sessionId);
6718
+ }
6719
+ }
6720
+
6721
+ function forgetServerLinkedDoc(sessionId, filePath) {
6722
+ const q = filePath ? `?path=${encodeURIComponent(filePath)}` : '';
6723
+ fetch(`/api/document/links/${encodeURIComponent(sessionId)}${q}`, { method: 'DELETE' }).catch(() => {});
6724
+ }
6725
+
6558
6726
  function afterLinkedDocsChanged(sessionId) {
6559
6727
  if (_infoModalSessionId === sessionId) refreshInfoModalLinkedDocs();
6560
6728
  renderSessions();
@@ -6770,6 +6938,7 @@ function handleSessionOpenEvent(data) {
6770
6938
  if (!isSessionActive(target)) {
6771
6939
  stickySessionIds.add(id);
6772
6940
  }
6941
+ setSessionDismissed(id, false);
6773
6942
  fetchTasks(id);
6774
6943
  }
6775
6944
 
@@ -6789,10 +6958,13 @@ async function handlePreviewOpenEvent(data) {
6789
6958
  // Linked docs live in localStorage, so the CLI can only reach them through a tab:
6790
6959
  // the server broadcasts the resolved path and every tab applies it idempotently.
6791
6960
  function handleDocumentLinkEvent(data) {
6792
- const { path: filePath, sessionId, unlink } = data;
6961
+ const { path: filePath, sessionId, unlink, open } = data;
6793
6962
  if (!filePath || !sessionId) return;
6794
6963
  setSessionDocLink(sessionId, filePath, unlink);
6795
- showToast(`${unlink ? 'Unlinked' : 'Linked'} ${filePath.split(/[\\/]/).pop()}`);
6964
+ const msg = `${unlink ? 'Unlinked' : 'Linked'} ${linkedDocLabel(filePath)}`;
6965
+ // Every tab gets the event; only the one on screen offers to open the URL.
6966
+ if (open && isOnScreen()) showToast(msg, 'info', { label: 'Open', onClick: () => openExternal(filePath) });
6967
+ else showToast(msg);
6796
6968
  }
6797
6969
 
6798
6970
  function getSessionBaseDir(sessionId) {
@@ -6874,10 +7046,13 @@ function isScratchpadPath(p) {
6874
7046
  // `linkedDocOpener` is the only place a linked path is classified; the row's tag,
6875
7047
  // tooltip and action are all read from here, so a new destination is one entry.
6876
7048
  const LINKED_DOC_OPENERS = {
6877
- preview: { tag: '', hint: '' },
7049
+ preview: { tag: '', hint: '', cls: '', noun: 'path' },
7050
+ url: { tag: '', hint: ' — opens in a new tab', cls: ' is-url', noun: 'URL' },
6878
7051
  scratch: {
6879
7052
  tag: '(scratchpad)',
6880
7053
  hint: ' — opens in the scratch viewer',
7054
+ cls: ' is-editor',
7055
+ noun: 'path',
6881
7056
  url: '/api/scratchpad/open',
6882
7057
  body: (p) => ({ path: p }),
6883
7058
  toast: 'in the scratch viewer',
@@ -6885,6 +7060,8 @@ const LINKED_DOC_OPENERS = {
6885
7060
  editor: {
6886
7061
  tag: '(editor)',
6887
7062
  hint: ' — opens in editor',
7063
+ cls: ' is-editor',
7064
+ noun: 'path',
6888
7065
  url: '/api/open-in-editor',
6889
7066
  body: (p) => ({ file: p }),
6890
7067
  toast: 'in editor',
@@ -6895,6 +7072,7 @@ const LINKED_DOC_OPENERS = {
6895
7072
  // text, but the pad is what the user linked, so the viewer wins over the raw file.
6896
7073
  // Without the CLI there is nothing to launch, so it falls back to the editor.
6897
7074
  function linkedDocOpener(p) {
7075
+ if (linkUrl(p)) return 'url';
6898
7076
  if (isScratchpadPath(p)) return appConfig.scratchAvailable ? 'scratch' : 'editor';
6899
7077
  // A path whose kind has not arrived yet renders as previewable, which is what the
6900
7078
  // click does anyway — openPreviewByPath falls back to the editor on a null kind.
@@ -6904,12 +7082,17 @@ function linkedDocOpener(p) {
6904
7082
  // Every manifest is called scratchpad.json, so the pad's folder names it. Kept
6905
7083
  // independent of the opener: the pad is still the pad when the CLI is missing.
6906
7084
  function linkedDocLabel(p) {
7085
+ if (linkUrl(p)) return p.replace(/^https?:\/\//i, '').replace(/\/$/, '');
6907
7086
  const parts = p.split(/[\\/]/).filter(Boolean);
6908
7087
  return isScratchpadPath(p) ? parts[parts.length - 2] || parts[parts.length - 1] : parts[parts.length - 1];
6909
7088
  }
6910
7089
 
6911
7090
  function openLinkedDoc(p, baseDir) {
6912
7091
  const opener = linkedDocOpener(p);
7092
+ if (opener === 'url') {
7093
+ openExternal(p);
7094
+ return;
7095
+ }
6913
7096
  if (opener === 'preview') {
6914
7097
  openPreviewByPath(p, baseDir, openFileInEditor);
6915
7098
  return;
@@ -6922,21 +7105,24 @@ function renderLinkedDocsHtml(sessionId) {
6922
7105
  const paths = getSessionPreviewPaths(sessionId);
6923
7106
  // Kicked off from the render rather than the three call sites, so a new surface that
6924
7107
  // shows linked docs cannot forget it. Returns at once once every path is cached.
6925
- loadPreviewKinds(sessionId, paths);
7108
+ const rows = paths.map((p) => ({ p, opener: linkedDocOpener(p) }));
7109
+ const filePaths = rows.filter((r) => r.opener !== 'url').map((r) => r.p);
7110
+ loadPreviewKinds(sessionId, filePaths);
6926
7111
  const baseDir = getSessionBaseDir(sessionId);
6927
- const items = paths
6928
- .map((p) => {
6929
- const opener = linkedDocOpener(p);
6930
- const { tag, hint } = LINKED_DOC_OPENERS[opener];
7112
+ const items = rows
7113
+ .map(({ p, opener }) => {
7114
+ const { tag, hint, cls, noun } = LINKED_DOC_OPENERS[opener];
6931
7115
  const name = linkedDocLabel(p);
7116
+ const isUrl = opener === 'url';
6932
7117
  const rel = baseDir ? toRelativeIfUnder(p, baseDir) : null;
6933
7118
  const pathSpan = rel ? `<span class="linked-doc-path" title="${escapeHtml(p)}">${escapeHtml(rel)}</span>` : '';
6934
7119
  const attr = escapeHtml(p);
6935
- return `<li class="linked-doc-item${opener === 'preview' ? '' : ' is-editor'}">
6936
- <a href="#" class="linked-doc-link" data-path="${attr}" title="${escapeHtml(p + hint)}">${escapeHtml(name)}</a>
7120
+ const href = isUrl ? `href="${attr}" target="_blank" rel="noopener"` : 'href="#"';
7121
+ return `<li class="linked-doc-item${cls}">
7122
+ <a ${href} class="linked-doc-link" data-path="${attr}" title="${escapeHtml(p + hint)}">${escapeHtml(name)}</a>${isUrl ? '<span class="linked-doc-external" aria-hidden="true">↗</span>' : ''}
6937
7123
  ${pathSpan}${tag ? `<span class="linked-doc-path">${tag}</span>` : ''}
6938
7124
  <span class="row-actions linked-doc-actions">
6939
- <button type="button" class="linked-doc-copy" data-path="${attr}" title="Copy path" aria-label="Copy path of ${escapeHtml(name)}">${ICON_COPY}</button>
7125
+ <button type="button" class="linked-doc-copy" data-path="${attr}" title="Copy ${noun}" aria-label="Copy ${noun} of ${escapeHtml(name)}">${ICON_COPY}</button>
6940
7126
  <button type="button" class="linked-doc-remove" data-path="${attr}" title="Unlink" aria-label="Unlink ${escapeHtml(name)}">&times;</button>
6941
7127
  </span>
6942
7128
  </li>`;
@@ -6945,13 +7131,13 @@ function renderLinkedDocsHtml(sessionId) {
6945
7131
  // Rendered even when empty — the add button has to stay reachable.
6946
7132
  const body = paths.length
6947
7133
  ? `<ul class="linked-doc-list">${items}</ul>`
6948
- : '<div class="linked-docs-empty">No linked files yet</div>';
7134
+ : '<div class="linked-docs-empty">No linked documents yet</div>';
6949
7135
  return `<div class="linked-docs-section panel-section">
6950
7136
  <div class="panel-section-header">
6951
7137
  ${linkSvg(12)}
6952
7138
  <span>Linked documents</span>
6953
7139
  <span class="panel-section-count">${paths.length}</span>
6954
- <button type="button" class="linked-docs-add-btn" title="Link a file" aria-label="Link a file">+</button>
7140
+ <button type="button" class="linked-docs-add-btn" title="Link a file or URL" aria-label="Link a file or URL">+</button>
6955
7141
  </div>
6956
7142
  <div class="linked-doc-editor-slot"></div>
6957
7143
  ${body}
@@ -7220,7 +7406,7 @@ function bindLinkedDocsHandlers(container, sessionId) {
7220
7406
  if (!container) return;
7221
7407
  container.addEventListener('click', (e) => {
7222
7408
  const hit = e.target.closest('.linked-doc-link, .linked-doc-copy, .linked-doc-remove, .linked-docs-add-btn');
7223
- if (!hit) return;
7409
+ if (!hit || hit.target === '_blank') return;
7224
7410
  e.preventDefault();
7225
7411
  if (hit.classList.contains('linked-docs-add-btn')) {
7226
7412
  startLinkedDocInput(container, sessionId);
@@ -7228,6 +7414,7 @@ function bindLinkedDocsHandlers(container, sessionId) {
7228
7414
  copyWithFeedback(hit.dataset.path, hit);
7229
7415
  } else if (hit.classList.contains('linked-doc-remove')) {
7230
7416
  removeSessionPreviewPath(sessionId, hit.dataset.path);
7417
+ forgetServerLinkedDoc(sessionId, hit.dataset.path);
7231
7418
  afterLinkedDocsChanged(sessionId);
7232
7419
  } else {
7233
7420
  openLinkedDoc(hit.dataset.path, getSessionBaseDir(sessionId));
@@ -7238,7 +7425,7 @@ function bindLinkedDocsHandlers(container, sessionId) {
7238
7425
  function startLinkedDocInput(container, sessionId) {
7239
7426
  const slot = container.querySelector('.linked-doc-editor-slot');
7240
7427
  if (!slot) return;
7241
- slot.innerHTML = `<input class="linked-doc-input" type="text" spellcheck="false" placeholder="Absolute path, file:// URL, or relative to the session cwd">
7428
+ slot.innerHTML = `<input class="linked-doc-input" type="text" spellcheck="false" placeholder="Path, file:// or https:// URL">
7242
7429
  <div class="linked-doc-hint">${escapeHtml(getSessionBaseDir(sessionId) || 'no session cwd — absolute paths only')}</div>
7243
7430
  <div class="linked-doc-error"></div>
7244
7431
  <div class="edit-actions">
@@ -7252,6 +7439,9 @@ function startLinkedDocInput(container, sessionId) {
7252
7439
  };
7253
7440
  slot.querySelector('.edit-save').addEventListener('click', save);
7254
7441
  slot.querySelector('.edit-cancel').addEventListener('click', cancel);
7442
+ input.addEventListener('input', () => {
7443
+ slot.querySelector('.linked-doc-error').textContent = '';
7444
+ });
7255
7445
  input.addEventListener('keydown', (e) => {
7256
7446
  if (e.key === 'Enter') {
7257
7447
  e.preventDefault();
@@ -7274,27 +7464,38 @@ async function linkFileByPath(sessionId, raw, slot) {
7274
7464
  else showToast(msg, 'error');
7275
7465
  };
7276
7466
  if (!value) {
7277
- fail('Enter a file path');
7467
+ fail('Enter a file path or URL');
7278
7468
  return;
7279
7469
  }
7280
- try {
7281
- const qs = new URLSearchParams({ path: value });
7282
- const base = getSessionBaseDir(sessionId);
7283
- if (base) qs.set('base', base);
7284
- const r = await fetch(`/api/file/resolve?${qs}`);
7285
- const data = await r.json().catch(() => ({}));
7286
- if (!r.ok || !data.exists) {
7287
- fail(data.error || 'File not found');
7470
+ let target;
7471
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value) && !/^file:/i.test(value)) {
7472
+ target = linkUrl(value);
7473
+ if (!target) {
7474
+ fail('Only http and https URLs can be linked');
7475
+ return;
7476
+ }
7477
+ } else {
7478
+ try {
7479
+ const qs = new URLSearchParams({ path: value });
7480
+ const base = getSessionBaseDir(sessionId);
7481
+ if (base) qs.set('base', base);
7482
+ const r = await fetch(`/api/file/resolve?${qs}`);
7483
+ const data = await r.json().catch(() => ({}));
7484
+ if (!r.ok || !data.exists) {
7485
+ fail(data.error || 'File not found');
7486
+ return;
7487
+ }
7488
+ target = data.path;
7489
+ } catch {
7490
+ fail('Failed to resolve file');
7288
7491
  return;
7289
7492
  }
7290
- // Close the editor before the refresh: a sidebar panel defers its re-render while an
7291
- // input is open, so leaving it up would hide the file that was just linked.
7292
- if (slot) slot.innerHTML = '';
7293
- setSessionDocLink(sessionId, data.path, false);
7294
- showToast('Linked to session', 'success');
7295
- } catch {
7296
- fail('Failed to resolve file');
7297
7493
  }
7494
+ // Close the editor before the refresh: a sidebar panel defers its re-render while an
7495
+ // input is open, so leaving it up would hide the file that was just linked.
7496
+ if (slot) slot.innerHTML = '';
7497
+ setSessionDocLink(sessionId, target, false);
7498
+ showToast('Linked to session', 'success');
7298
7499
  }
7299
7500
  //#endregion
7300
7501
 
@@ -7324,12 +7525,6 @@ function setupEventSource() {
7324
7525
  const pendingTaskSessionIds = new Set();
7325
7526
  const pendingAgentSessionIds = new Set();
7326
7527
 
7327
- function refreshAllView() {
7328
- currentTasks = filterProject ? allTasksCache.filter((t) => matchesProjectFilter(t.project)) : allTasksCache;
7329
- renderAllTasks();
7330
- renderActivityChip();
7331
- }
7332
-
7333
7528
  async function refreshSessionDetail(sessionId) {
7334
7529
  await fetchAgents(sessionId);
7335
7530
  if (!agentLogMode) fetchMessages(sessionId);
@@ -7349,6 +7544,7 @@ function setupEventSource() {
7349
7544
  .catch(() => {});
7350
7545
  }
7351
7546
  wasConnected = true;
7547
+ mergeServerLinkedDocs();
7352
7548
  retryDelay = 1000;
7353
7549
  hideOffline();
7354
7550
  };
@@ -7383,9 +7579,7 @@ function setupEventSource() {
7383
7579
  taskRefreshTimer = setTimeout(async () => {
7384
7580
  if (skipOffScreen()) return;
7385
7581
  await fetchSessions().catch((err) => console.error('[SSE] fetchSessions failed:', err));
7386
- if (viewMode === 'all') {
7387
- refreshAllView();
7388
- } else if (viewMode === 'project' && currentProjectPath) {
7582
+ if (viewMode === 'project' && currentProjectPath) {
7389
7583
  const hasUpdate = currentProjectSessionIds.some((id) => pendingTaskSessionIds.has(id));
7390
7584
  if (hasUpdate) fetchProjectView(currentProjectPath);
7391
7585
  } else if (currentSessionId && pendingTaskSessionIds.has(currentSessionId)) {
@@ -7472,10 +7666,7 @@ function setupEventSource() {
7472
7666
  pendingTaskSessionIds.clear();
7473
7667
  pendingAgentSessionIds.clear();
7474
7668
  const sessionsLoaded = fetchSessions().catch(() => {});
7475
- if (viewMode === 'all') {
7476
- await sessionsLoaded;
7477
- refreshAllView();
7478
- } else if (viewMode === 'project' && currentProjectPath) {
7669
+ if (viewMode === 'project' && currentProjectPath) {
7479
7670
  await sessionsLoaded;
7480
7671
  fetchProjectView(currentProjectPath);
7481
7672
  } else if (currentSessionId) {
@@ -8133,10 +8324,44 @@ function getMermaidTheme() {
8133
8324
  return isLightTheme() ? 'default' : 'dark';
8134
8325
  }
8135
8326
 
8327
+ // Mermaid is about 3 MB, so it loads on the first diagram instead of on the page's critical path.
8328
+ const MERMAID_SCRIPT = {
8329
+ src: 'https://cdn.jsdelivr.net/npm/mermaid@12.0.0/dist/mermaid.min.js',
8330
+ integrity: 'sha384-xzghz1GQ5u9HCpVskeDPqMsdogD1yvuMQbEK53+wi+G70+6J1AG0L2cfi9PHjDWI',
8331
+ };
8332
+ let mermaidLoading = null;
8333
+
8334
+ function loadMermaid() {
8335
+ mermaidLoading ??= new Promise((resolve, reject) => {
8336
+ const script = document.createElement('script');
8337
+ script.src = MERMAID_SCRIPT.src;
8338
+ script.integrity = MERMAID_SCRIPT.integrity;
8339
+ script.crossOrigin = 'anonymous';
8340
+ script.onload = () => {
8341
+ mermaid.initialize({ startOnLoad: false, theme: getMermaidTheme() });
8342
+ resolve();
8343
+ };
8344
+ script.onerror = () => {
8345
+ script.remove();
8346
+ mermaidLoading = null;
8347
+ reject(new Error('mermaid failed to load'));
8348
+ };
8349
+ document.head.appendChild(script);
8350
+ });
8351
+ return mermaidLoading;
8352
+ }
8353
+
8136
8354
  function initMermaidBlocks(container) {
8137
- if (typeof mermaid === 'undefined') return;
8138
8355
  const blocks = (container || document).querySelectorAll('pre.mermaid:not([data-processed])');
8139
- if (blocks.length) mermaid.run({ nodes: [...blocks] });
8356
+ if (!blocks.length) return;
8357
+ if (typeof mermaid === 'undefined') {
8358
+ loadMermaid().then(
8359
+ () => initMermaidBlocks(container),
8360
+ (err) => console.warn(err.message),
8361
+ );
8362
+ return;
8363
+ }
8364
+ mermaid.run({ nodes: [...blocks] });
8140
8365
  }
8141
8366
 
8142
8367
  function reinitMermaidTheme() {
@@ -8270,18 +8495,106 @@ function toggleFilterMenu(e) {
8270
8495
  document.getElementById('filter-menu-btn')?.setAttribute('aria-expanded', 'true');
8271
8496
  renderFilterState();
8272
8497
  document.addEventListener('click', closeFilterMenu, { once: true });
8273
- // Capture phase: the global keydown handler bails out on SELECT targets, and every
8274
- // focusable thing in this popover is a select — Escape would never reach it otherwise.
8498
+ // Capture phase: the global keydown handler bails out on form fields, and the path box
8499
+ // has focus — Escape would never reach it otherwise.
8275
8500
  document.addEventListener('keydown', filterMenuKeydown, true);
8276
- document.getElementById('project-filter')?.focus();
8501
+ pfIdx = -1;
8502
+ renderProjectOptions();
8503
+ const input = document.getElementById('project-filter');
8504
+ input?.focus();
8505
+ input?.select();
8277
8506
  }
8278
8507
 
8279
8508
  function filterMenuKeydown(e) {
8280
- if (e.key !== 'Escape') return;
8281
- e.stopPropagation();
8282
- if (closeFilterMenu()) document.getElementById('filter-menu-btn')?.focus();
8509
+ if (e.key === 'Escape') {
8510
+ e.stopPropagation();
8511
+ if (closeFilterMenu()) document.getElementById('filter-menu-btn')?.focus();
8512
+ return;
8513
+ }
8514
+ if (e.target.id !== 'project-filter') return;
8515
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
8516
+ e.preventDefault();
8517
+ const n = pfOptions.length;
8518
+ if (!n) return;
8519
+ pfIdx = e.key === 'ArrowDown' ? (pfIdx + 1) % n : (pfIdx - 1 + n) % n;
8520
+ renderProjectOptions();
8521
+ } else if (e.key === 'Enter') {
8522
+ e.preventDefault();
8523
+ if (pfIdx >= 0 && pfOptions[pfIdx]) pickProject(pfOptions[pfIdx].path);
8524
+ else closeFilterMenu();
8525
+ }
8526
+ }
8527
+
8528
+ // The menu's project list: projects the path box matches, newest activity first.
8529
+ const PF_MAX = 7;
8530
+ let pfOptions = [];
8531
+ let pfIdx = -1;
8532
+
8533
+ function renderProjectOptions() {
8534
+ const list = document.getElementById('project-filter-options');
8535
+ const input = document.getElementById('project-filter');
8536
+ if (!list || !input) return;
8537
+ const text = projectFilterText();
8538
+ const q = normalizeProjectPath(text.trim());
8539
+ const partial = !!q && !isExactProjectPath(q);
8540
+ const needle = partial ? q : '';
8541
+ const selected = q && !partial ? q : null;
8542
+ const byRecent = (a, b) => (b.modifiedAt || '').localeCompare(a.modifiedAt || '');
8543
+ pfOptions = (projectsCache || [])
8544
+ .map((p) => ({ ...p, norm: normalizeProjectPath(p.path) }))
8545
+ .filter((p) => !needle || p.norm.includes(needle))
8546
+ .sort(byRecent)
8547
+ .slice(0, PF_MAX);
8548
+ if (pfIdx >= pfOptions.length) pfIdx = pfOptions.length - 1;
8549
+ list.innerHTML = pfOptions.length
8550
+ ? pfOptions
8551
+ .map((p, i) => {
8552
+ const cut = Math.max(p.path.lastIndexOf('/'), p.path.lastIndexOf('\\')) + 1;
8553
+ const at = needle ? p.norm.indexOf(needle) : -1;
8554
+ const range = at >= 0 ? [at, at + needle.length] : null;
8555
+ const isSel = p.norm === selected;
8556
+ return `<li id="pf-opt-${i}" role="option" class="pf-opt${i === pfIdx ? ' hl' : ''}" aria-selected="${isSel ? 'true' : 'false'}" data-path="${escapeHtml(p.path)}" title="${escapeHtml(p.path)}">
8557
+ <span class="pf-name">${markRange(p.path, cut, p.path.length, range)}</span>
8558
+ <span class="pf-dir"><bdi>${markRange(p.path, 0, cut, range)}</bdi></span>
8559
+ <span class="pf-age">${p.modifiedAt ? shortAge(p.modifiedAt) : ''}</span>
8560
+ </li>`;
8561
+ })
8562
+ .join('')
8563
+ : `<li class="pf-empty">${
8564
+ partial ? `No project path contains “${escapeHtml(text.trim())}”` : 'No projects yet'
8565
+ }</li>`;
8566
+ if (pfIdx >= 0) input.setAttribute('aria-activedescendant', `pf-opt-${pfIdx}`);
8567
+ else input.removeAttribute('aria-activedescendant');
8568
+ list.querySelector('.pf-opt.hl')?.scrollIntoView({ block: 'nearest' });
8283
8569
  }
8284
8570
 
8571
+ function shortAge(iso) {
8572
+ const min = Math.max(0, (Date.now() - new Date(iso).getTime()) / 60000);
8573
+ if (min < 60) return `${Math.floor(min)}m`;
8574
+ if (min < 1440) return `${Math.floor(min / 60)}h`;
8575
+ if (min < 60 * 24 * 60) return `${Math.floor(min / 1440)}d`;
8576
+ return `${Math.floor(min / (1440 * 30))}mo`;
8577
+ }
8578
+
8579
+ // The slice [from, to) of text, with the part inside range marked.
8580
+ function markRange(text, from, to, range) {
8581
+ if (!range || range[1] <= from || range[0] >= to) return escapeHtml(text.slice(from, to));
8582
+ const a = Math.max(from, range[0]);
8583
+ const b = Math.min(to, range[1]);
8584
+ return `${escapeHtml(text.slice(from, a))}<mark>${escapeHtml(text.slice(a, b))}</mark>${escapeHtml(text.slice(b, to))}`;
8585
+ }
8586
+
8587
+ function pickProject(path) {
8588
+ filterByProject(path);
8589
+ closeFilterMenu();
8590
+ document.getElementById('filter-menu-btn')?.focus();
8591
+ }
8592
+
8593
+ document.getElementById('project-filter-options')?.addEventListener('click', (e) => {
8594
+ const opt = e.target.closest('.pf-opt');
8595
+ if (opt) pickProject(opt.dataset.path);
8596
+ });
8597
+
8285
8598
  function closeFilterMenu() {
8286
8599
  const menu = document.getElementById('filter-menu');
8287
8600
  if (!menu?.classList.contains('open')) return false;
@@ -8291,50 +8604,53 @@ function closeFilterMenu() {
8291
8604
  return true;
8292
8605
  }
8293
8606
 
8294
- // One row per filter control: the select it mirrors, its value, whether it differs from the
8295
- // default, and two label forms — terse tokens for the header strip (which has ~15 characters
8607
+ // One row per filter: whether it differs from the default, and two label forms — terse tokens for the header strip (which has ~15 characters
8296
8608
  // before the sidebar's width ellipsises it) and long form for its tooltip.
8297
8609
  const FILTERS = [
8298
8610
  {
8299
- id: 'project-filter',
8300
- value: () => filterProject ?? '',
8301
8611
  active: () => filterProject !== FILTER_DEFAULTS.project,
8302
- short: () => (filterProject ? filterProject.split(/[/\\]/).pop() : 'all proj'),
8303
- long: () => (filterProject ? `project ${filterProject}` : 'all projects'),
8612
+ short: () => {
8613
+ if (filterProject === '__recent__') return 'recent';
8614
+ if (!filterProject) return 'all proj';
8615
+ const name = pathBasename(filterProject);
8616
+ return isExactProjectFilter(filterProject) ? name : `*${name}*`;
8617
+ },
8618
+ long: () => {
8619
+ if (filterProject === '__recent__') return 'projects active in the last 24h';
8620
+ if (!filterProject) return 'all projects';
8621
+ return isExactProjectFilter(filterProject)
8622
+ ? `project ${filterProject}`
8623
+ : `project path contains "${filterProject}"`;
8624
+ },
8304
8625
  },
8305
8626
  {
8306
- id: 'session-filter',
8307
- value: () => sessionFilter,
8308
8627
  active: () => sessionFilter !== FILTER_DEFAULTS.session,
8309
8628
  short: () => 'inactive',
8310
8629
  long: () => 'including inactive sessions',
8311
8630
  },
8312
- {
8313
- id: 'session-limit',
8314
- value: () => sessionLimit,
8315
- active: () => sessionLimit !== FILTER_DEFAULTS.limit,
8316
- short: () => (sessionLimit === 'all' ? 'n=∞' : `n=${sessionLimit}`),
8317
- long: () => (sessionLimit === 'all' ? 'no session limit' : `showing ${sessionLimit}`),
8318
- },
8319
8631
  ];
8320
8632
 
8321
- // Keeps the header (summary + funnel tint) and the popover's selects in sync with state.
8633
+ // Keeps the header (summary + funnel tint) and the popover's controls in sync with state.
8322
8634
  // Safe to call on every filter change — it only touches classes, values, and text.
8323
8635
  function renderFilterState() {
8324
8636
  const short = [];
8325
8637
  const long = [];
8326
8638
  for (const f of FILTERS) {
8327
- const active = f.active();
8328
- if (active) {
8329
- short.push(f.short());
8330
- long.push(f.long());
8331
- }
8332
- const el = document.getElementById(f.id);
8333
- if (!el) continue;
8334
- const value = String(f.value());
8335
- if (el.value !== value) el.value = value;
8336
- el.classList.toggle('non-default', active);
8639
+ if (!f.active()) continue;
8640
+ short.push(f.short());
8641
+ long.push(f.long());
8642
+ }
8643
+ const input = document.getElementById('project-filter');
8644
+ if (input) {
8645
+ const value = projectFilterText();
8646
+ if (input.value !== value) input.value = value;
8337
8647
  }
8648
+ const recent = document.getElementById('project-recent-btn');
8649
+ recent?.setAttribute('aria-pressed', String(filterProject === '__recent__'));
8650
+ for (const b of document.querySelectorAll('[data-session-filter]')) {
8651
+ b.setAttribute('aria-checked', String(b.dataset.sessionFilter === sessionFilter));
8652
+ }
8653
+ if (document.getElementById('filter-menu')?.classList.contains('open')) renderProjectOptions();
8338
8654
 
8339
8655
  const summary = document.getElementById('filter-summary');
8340
8656
  if (summary) {
@@ -8359,12 +8675,16 @@ function renderZenState() {
8359
8675
  if (!btn) return;
8360
8676
  btn.classList.toggle('active', zenMode);
8361
8677
  btn.setAttribute('aria-pressed', String(zenMode));
8362
- btn.title = zenMode ? 'Zen mode on — show all sessions (Shift+Z)' : 'Zen mode: current session only (Shift+Z)';
8678
+ btn.title = zenMode
8679
+ ? 'Zen mode on — show all sessions (Ctrl+Shift+Z)'
8680
+ : 'Zen mode: current session only (Ctrl+Shift+Z)';
8363
8681
  }
8364
8682
 
8365
8683
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
8366
8684
  function filterBySessions(value) {
8685
+ if (value === sessionFilter) return;
8367
8686
  sessionFilter = value;
8687
+ resetSessionPage();
8368
8688
  if (value !== 'active') activityFilter.clear();
8369
8689
  renderFilterState();
8370
8690
  updateUrl();
@@ -8376,18 +8696,18 @@ function filterBySessions(value) {
8376
8696
  fetchSessions(false);
8377
8697
  }
8378
8698
 
8379
- // biome-ignore lint/correctness/noUnusedVariables: used in HTML
8380
- function changeSessionLimit(value) {
8381
- sessionLimit = value;
8382
- renderFilterState();
8383
- updateUrl();
8384
- fetchSessions();
8699
+ function projectFilterText() {
8700
+ return filterProject && filterProject !== '__recent__' ? filterProject : '';
8385
8701
  }
8386
8702
 
8703
+ let filterMatcher = { query: null, match: null };
8387
8704
  function matchesProjectFilter(project) {
8388
8705
  if (!filterProject) return true;
8389
8706
  if (filterProject === '__recent__') return recentProjects.has(project);
8390
- return project === filterProject;
8707
+ if (filterMatcher.query !== filterProject) {
8708
+ filterMatcher = { query: filterProject, match: projectMatcher(filterProject) };
8709
+ }
8710
+ return filterMatcher.match(project);
8391
8711
  }
8392
8712
 
8393
8713
  //#endregion
@@ -8507,11 +8827,31 @@ document.addEventListener('click', (e) => {
8507
8827
 
8508
8828
  // Used in HTML; also called by the hub project shim, so no biome suppression is needed.
8509
8829
  function filterByProject(project) {
8830
+ clearTimeout(projectFilterTimer);
8510
8831
  filterProject = project || null;
8832
+ resetSessionPage();
8511
8833
  renderFilterState();
8512
- updateUrl();
8513
8834
  fetchSessions(false);
8514
- showAllTasks();
8835
+ showNoSession();
8836
+ }
8837
+
8838
+ // The sidebar narrows on every key from the rows it has; the server round trip for rows past
8839
+ // the limit waits for a pause in typing.
8840
+ const PROJECT_FILTER_DEBOUNCE_MS = 250;
8841
+ let projectFilterTimer = null;
8842
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
8843
+ function typeProjectFilter(value) {
8844
+ filterProject = value.trim() ? value : null;
8845
+ pfIdx = -1;
8846
+ updateUrl();
8847
+ renderSessions();
8848
+ clearTimeout(projectFilterTimer);
8849
+ projectFilterTimer = setTimeout(() => filterByProject(filterProject), PROJECT_FILTER_DEBOUNCE_MS);
8850
+ }
8851
+
8852
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
8853
+ function toggleRecentProjects() {
8854
+ filterByProject(filterProject === '__recent__' ? null : '__recent__');
8515
8855
  }
8516
8856
 
8517
8857
  let projectsCache = null;
@@ -8532,22 +8872,20 @@ async function loadProjects() {
8532
8872
  return projects;
8533
8873
  }
8534
8874
 
8535
- async function updateProjectDropdown() {
8536
- const dropdown = document.getElementById('project-filter');
8537
-
8875
+ async function refreshProjectList() {
8538
8876
  if (!projectsCacheDirty && projectsCache) {
8539
- renderProjectDropdown(dropdown, projectsCache);
8877
+ renderFilterState();
8540
8878
  return;
8541
8879
  }
8542
8880
 
8543
8881
  const projects = await loadProjects();
8544
- const cutoff = Date.now() - 24 * 60 * 60 * 1000;
8882
+ const cutoff = Date.now() - RECENT_PROJECT_HOURS * 60 * 60 * 1000;
8545
8883
  const prevRecent = recentProjects;
8546
8884
  recentProjects = new Set(
8547
8885
  projects.filter((p) => p.modifiedAt && new Date(p.modifiedAt).getTime() > cutoff).map((p) => p.path),
8548
8886
  );
8549
8887
 
8550
- renderProjectDropdown(dropdown, projects);
8888
+ renderFilterState();
8551
8889
 
8552
8890
  // recentProjects was empty before — sidebar rendered with __recent__ filter
8553
8891
  // dropping every session. Re-render now that we know which projects qualify.
@@ -8556,28 +8894,6 @@ async function updateProjectDropdown() {
8556
8894
  }
8557
8895
  }
8558
8896
 
8559
- function renderProjectDropdown(dropdown, projects) {
8560
- const recentSelected = filterProject === '__recent__' ? ' selected' : '';
8561
- // A hub-pushed project with no session history isn't in /api/projects, so synthesize its
8562
- // option here rather than in the hub shim — this also survives the SSE re-render below.
8563
- const list =
8564
- filterProject && filterProject !== '__recent__' && !projects.some((p) => p.path === filterProject)
8565
- ? [...projects, { path: filterProject, modifiedAt: null }]
8566
- : projects;
8567
- dropdown.innerHTML =
8568
- '<option value="">All Projects</option>' +
8569
- `<option value="__recent__"${recentSelected}>Recent (24h)</option>` +
8570
- list
8571
- .map((p) => {
8572
- const name = p.path.split(/[/\\]/).pop();
8573
- const selected = p.path === filterProject ? ' selected' : '';
8574
- return `<option value="${escapeHtml(p.path)}"${selected} title="${escapeHtml(p.path)}">${escapeHtml(name)}</option>`;
8575
- })
8576
- .join('');
8577
- // The option list just changed under the select — re-assert tint and header summary.
8578
- renderFilterState();
8579
- }
8580
-
8581
8897
  function updateThemeColor(isLight) {
8582
8898
  document.querySelectorAll('meta[name="theme-color"]').forEach((m) => {
8583
8899
  m.setAttribute('content', isLight ? '#e8e6e3' : '#101114');
@@ -8993,13 +9309,6 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
8993
9309
  });
8994
9310
  html += `</div>`;
8995
9311
 
8996
- if (session.goal?.condition) {
8997
- html += `<div class="info-goal-card">
8998
- <div class="info-goal-head"><span class="info-goal-icon">◎</span>Goal</div>
8999
- <div class="info-goal-text">${escapeHtml(session.goal.condition)}</div>
9000
- </div>`;
9001
- }
9002
-
9003
9312
  if (session.contextStatus) {
9004
9313
  html += `<hr style="border: none; border-top: 1px solid var(--border); margin: 12px 0;">`;
9005
9314
  html += renderContextDetail(session.contextStatus);
@@ -9121,14 +9430,7 @@ function openSessionFromInfo(sessionId) {
9121
9430
 
9122
9431
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
9123
9432
  function toggleDismissSession(sessionId) {
9124
- if (dismissedSessionIds.has(sessionId)) {
9125
- dismissedSessionIds.delete(sessionId);
9126
- } else {
9127
- dismissedSessionIds.add(sessionId);
9128
- }
9129
- updateDismissBtnState();
9130
- renderSessions();
9131
- renderActivityChip();
9433
+ setSessionDismissed(sessionId, !dismissedSessionIds.has(sessionId));
9132
9434
  }
9133
9435
 
9134
9436
  function updateDismissBtnState() {
@@ -9152,47 +9454,67 @@ let spIdx = 0;
9152
9454
  let spPeekTimer = null;
9153
9455
  let spPeekToken = 0;
9154
9456
  let spPeekedId = null;
9457
+ let spFromTerminal = false;
9458
+ let spRenderedQuery = null;
9155
9459
  // Per-open cache: spSource is frozen while the picker is up, so a fetched log stays valid.
9156
9460
  const spPeekCache = new Map();
9157
9461
 
9158
- // A session outside the sidebar filter has no name to fuzzy-match against, so a full id
9159
- // is read as a request for that exact session and resolved against the server instead.
9160
- // Values: 'pending' while the request is out, then the session or null.
9161
- const spGlobal = new Map();
9462
+ // A query also searches session ids and names in every transcript on the server, so a
9463
+ // session outside the sidebar filter is found too. Values: 'pending', then the rows.
9464
+ const spSearch = new Map();
9465
+ let spSearchTimer = null;
9162
9466
 
9163
- async function spResolveGlobal(id) {
9164
- spGlobal.set(id, 'pending');
9165
- spGlobal.set(id, await fetchSessionById(id));
9166
- if (document.getElementById('session-picker-input').value.trim().toLowerCase() === id) renderSessionPicker();
9467
+ function spSearchKey(query) {
9468
+ return query.length >= 3 ? query.toLowerCase() : null;
9167
9469
  }
9168
9470
 
9169
- // The row to show for a query nothing in the list matched, as `{ hit, empty }` — one of
9170
- // the two is always null. Starts the lookup when the query is an id not asked about yet.
9171
- function spResolveMiss(query) {
9172
- if (!SESSION_UUID_RE.test(query)) {
9173
- return { hit: null, empty: spSource.length ? 'No session matches' : 'No sessions in the current sidebar filter' };
9174
- }
9175
- const id = query.toLowerCase();
9176
- if (!spGlobal.has(id)) spResolveGlobal(id);
9177
- const found = spGlobal.get(id);
9178
- if (found === 'pending') return { hit: null, empty: 'Looking up session…' };
9179
- return found ? { hit: found, empty: null } : { hit: null, empty: 'No session with that id' };
9471
+ function spScheduleSearch(key) {
9472
+ clearTimeout(spSearchTimer);
9473
+ spSearchTimer = setTimeout(async () => {
9474
+ spSearch.set(key, 'pending');
9475
+ let ids = [];
9476
+ try {
9477
+ const r = await fetch(`/api/sessions/search?q=${encodeURIComponent(key)}`);
9478
+ if (r.ok) ids = await r.json();
9479
+ } catch (_) {}
9480
+ spSearch.set(key, ids.length ? await fetchSessionsByIds(ids) : []);
9481
+ renderSessionPicker();
9482
+ }, 100);
9483
+ }
9484
+
9485
+ // The server rows not already matched in the sidebar list, and whether a search is still out.
9486
+ function spSearchRows(key, localRows) {
9487
+ if (!key) return { rows: [], pending: false };
9488
+ const found = spSearch.get(key);
9489
+ if (!found) spScheduleSearch(key);
9490
+ if (!Array.isArray(found)) return { rows: [], pending: true };
9491
+ const seen = new Set(localRows.map((s) => s.id));
9492
+ return { rows: found.filter((s) => !seen.has(s.id)), pending: false };
9180
9493
  }
9181
9494
 
9182
9495
  function openSessionPicker() {
9496
+ spFromTerminal = terminalPaneFocused();
9183
9497
  const input = document.getElementById('session-picker-input');
9184
9498
  input.value = '';
9185
9499
  spSource = getFilteredSessions().sort((a, b) => Date.parse(b.modifiedAt) - Date.parse(a.modifiedAt));
9186
9500
  spPeekCache.clear();
9187
- spGlobal.clear();
9501
+ spSearch.clear();
9502
+ spRenderedQuery = null;
9188
9503
  document.getElementById('session-picker-modal').classList.add('visible');
9189
9504
  renderSessionPicker();
9190
9505
  input.focus();
9191
9506
  }
9192
9507
 
9193
- function closeSessionPicker() {
9508
+ // refocus: false when a session is being opened, which moves the terminal focus itself.
9509
+ function closeSessionPicker({ refocus = true } = {}) {
9194
9510
  spSetPeeking(false);
9195
9511
  hideModalOverlay('session-picker-modal');
9512
+ if (refocus && spFromTerminal && wantsTerminal()) focusTerminalPane();
9513
+ }
9514
+
9515
+ function toggleSessionPicker() {
9516
+ if (document.getElementById('session-picker-modal').classList.contains('visible')) closeSessionPicker();
9517
+ else openSessionPicker();
9196
9518
  }
9197
9519
 
9198
9520
  function spIsPeeking() {
@@ -9274,21 +9596,30 @@ function spDotHtml(session) {
9274
9596
  function renderSessionPicker() {
9275
9597
  const list = document.getElementById('session-picker-list');
9276
9598
  const query = document.getElementById('session-picker-input').value.trim();
9277
- spRows = spSource.filter((s) => spMatches(s, query));
9599
+ // Search rows that arrive for the same query append below, so the cursor stays on its row.
9600
+ const selectedId = query === spRenderedQuery ? spRows[spIdx]?.id : null;
9601
+ spRenderedQuery = query;
9602
+ const key = spSearchKey(query);
9603
+ const local = spSource.filter((s) => spMatches(s, query));
9604
+ const found = spSearchRows(key, local);
9605
+ spRows = [...local, ...found.rows];
9278
9606
  if (!spRows.length) {
9279
- const { hit, empty } = spResolveMiss(query);
9280
- if (hit) spRows = [hit];
9281
- else {
9282
- spIdx = -1;
9283
- list.innerHTML = `<div class="sp-empty">${empty}</div>`;
9284
- return;
9285
- }
9607
+ spIdx = -1;
9608
+ const empty = found.pending
9609
+ ? 'Searching all sessions…'
9610
+ : spSource.length || key
9611
+ ? 'No session matches'
9612
+ : 'No sessions in the current sidebar filter';
9613
+ list.innerHTML = `<div class="sp-empty">${empty}</div>`;
9614
+ return;
9286
9615
  }
9287
9616
 
9617
+ // Rows past the sidebar list come from the server search and show their full project path.
9288
9618
  list.innerHTML = spRows
9289
9619
  .map((s, i) => {
9290
- const project = s.project ? s.project.split(/[/\\]/).pop() : '';
9291
- return `<button class="sp-row${s.id === currentSessionId ? ' current' : ''}" data-idx="${i}" title="${escapeHtml(s.id)}">
9620
+ const outside = i >= local.length;
9621
+ const project = s.project ? (outside ? s.project : pathBasename(s.project)) : '';
9622
+ return `<button class="sp-row${s.id === currentSessionId ? ' current' : ''}${outside ? ' outside' : ''}" data-idx="${i}" title="${escapeHtml(s.project ? `${s.id}\n${s.project}` : s.id)}">
9292
9623
  ${spDotHtml(s)}
9293
9624
  <span class="sp-name">${escapeHtml(sessionDisplayName(s))}</span>
9294
9625
  <span class="sp-project">${escapeHtml(project)}</span>
@@ -9298,7 +9629,8 @@ function renderSessionPicker() {
9298
9629
  </button>`;
9299
9630
  })
9300
9631
  .join('');
9301
- spSelect(0);
9632
+ const keep = spRows.findIndex((s) => s.id === selectedId);
9633
+ spSelect(Math.max(0, keep));
9302
9634
  }
9303
9635
 
9304
9636
  function spSelect(idx) {
@@ -9314,7 +9646,8 @@ function spSelect(idx) {
9314
9646
  async function spOpen(idx) {
9315
9647
  const session = spRows[idx];
9316
9648
  if (!session) return;
9317
- closeSessionPicker();
9649
+ closeSessionPicker({ refocus: false });
9650
+ if (spFromTerminal) termState.focusNext = true;
9318
9651
  await revealSession(session.id);
9319
9652
  }
9320
9653
 
@@ -9325,7 +9658,7 @@ function initSessionPicker() {
9325
9658
  input.addEventListener('keydown', (e) => {
9326
9659
  if (e.key === 'Escape') closeSessionPicker();
9327
9660
  else if (matchKey(e, 'ArrowDown') || (e.ctrlKey && e.key === 'n')) spSelect(spIdx + 1);
9328
- else if (matchKey(e, 'ArrowUp') || (e.ctrlKey && e.key === 'p')) spSelect(spIdx - 1);
9661
+ else if (matchKey(e, 'ArrowUp') || (e.ctrlKey && !e.shiftKey && e.key === 'p')) spSelect(spIdx - 1);
9329
9662
  else if (e.key === 'Enter') spOpen(spIdx);
9330
9663
  else if (e.ctrlKey && e.code === 'Space') spSetPeeking(!spIsPeeking());
9331
9664
  else return;
@@ -9455,16 +9788,6 @@ function renderLoopModalBody(data) {
9455
9788
  body.innerHTML = section('Wakeups', wakeups, 'wakeup') + section('Cron jobs', crons, 'cron');
9456
9789
  }
9457
9790
 
9458
- function renderGoalSubtitle(session) {
9459
- const g = session.goal;
9460
- if (!g?.condition) return '';
9461
- const short = g.condition.length > 70 ? `${g.condition.slice(0, 70)}…` : g.condition;
9462
- // Only active (unmet) goals reach here — a met goal auto-clears. Clicking
9463
- // opens the info modal (full text); stopPropagation so it doesn't also
9464
- // trigger the card's fetchTasks.
9465
- return `<div class="session-goal" onclick="event.stopPropagation(); showSessionInfoModal('${escAttrJs(session.id)}')" title="${escapeHtml(g.condition)}"><span class="session-goal-icon">◎</span><span class="session-goal-text">${escapeHtml(short)}</span></div>`;
9466
- }
9467
-
9468
9791
  function renderLoopBadge(session) {
9469
9792
  const li = session.loopInfo;
9470
9793
  const total = (li?.wakeupCount || 0) + (li?.cronCount || 0);
@@ -9944,6 +10267,9 @@ const TERMINAL_TOKEN_RE = /^[0-9a-f]{64}$/;
9944
10267
  // Swap to the previous session.
9945
10268
  const CCK_CTRL_ALT_KEYS = new Set(['KeyN', 'KeyR', 'KeyS']);
9946
10269
  const TERMINAL_MODES_KEY = 'terminal-sessions';
10270
+ const TERMINAL_FONT_KEY = 'terminal-font-size';
10271
+ const TERMINAL_FONT_MIN = 8;
10272
+ const TERMINAL_FONT_MAX = 32;
9947
10273
  const ACK_BATCH_BYTES = 32 * 1024;
9948
10274
  const TERMINAL_RETRY_MS = [500, 1000, 2000, 4000, 8000];
9949
10275
  const TERMINAL_STABLE_MS = 5000;
@@ -10056,9 +10382,24 @@ function terminalPaneFocused() {
10056
10382
  return document.getElementById('terminal-pane').contains(document.activeElement);
10057
10383
  }
10058
10384
 
10385
+ const TEXT_FIELD_SELECTOR = 'input, textarea, select, [contenteditable]';
10386
+
10059
10387
  function terminalShortcut(e) {
10060
10388
  const ctrlAlt = e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey;
10389
+ const ctrlShift = e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey;
10390
+ // Ctrl+_ is Claude Code's undo, so the zoom keys leave it to the terminal.
10391
+ const zoom = e.key === '_' ? undefined : zoomDelta(e);
10392
+ if (zoom !== undefined && termState.term && terminalPaneFocused()) {
10393
+ return () => adjustTerminalFontSize(Math.sign(zoom));
10394
+ }
10061
10395
  if (ctrlAlt && e.code === 'KeyS') return swapToPreviousSession;
10396
+ // Cancelling the default also stops Chrome's system print dialog, which this page has no use for.
10397
+ if (ctrlShift && e.code === 'KeyP') return toggleSessionPicker;
10398
+ // Ctrl+Shift+Z is redo in a text field, so only the terminal's own textarea gives it up.
10399
+ if (ctrlShift && e.code === 'KeyZ') {
10400
+ const field = e.target?.closest?.(TEXT_FIELD_SELECTOR);
10401
+ return field && !document.getElementById('terminal-pane').contains(field) ? null : toggleZenMode;
10402
+ }
10062
10403
  if (ctrlAlt && (e.code === 'KeyN' || e.code === 'KeyR') && terminalAvailable()) {
10063
10404
  return () => openNewSession(null, e.code === 'KeyR');
10064
10405
  }
@@ -10073,6 +10414,22 @@ function terminalShortcut(e) {
10073
10414
  return null;
10074
10415
  }
10075
10416
 
10417
+ function readSwapPair() {
10418
+ try {
10419
+ const pair = JSON.parse(store.getItem(SWAP_KEY));
10420
+ if (Array.isArray(pair) && pair.length === 2) return pair;
10421
+ } catch {}
10422
+ return [null, null];
10423
+ }
10424
+
10425
+ function setSwapPair(last, previous) {
10426
+ lastSessionId = last;
10427
+ previousSessionId = previous;
10428
+ try {
10429
+ store.setItem(SWAP_KEY, JSON.stringify([last, previous]));
10430
+ } catch {}
10431
+ }
10432
+
10076
10433
  function swapToPreviousSession() {
10077
10434
  const target = currentSessionId === lastSessionId ? previousSessionId : lastSessionId;
10078
10435
  if (!target || target === currentSessionId) return;
@@ -10100,7 +10457,10 @@ function syncTerminal() {
10100
10457
  if (view !== termState.syncedView) {
10101
10458
  termState.syncedView = view;
10102
10459
  // The terminal shows the same conversation, so the log panel only takes width; it comes back when the terminal goes.
10103
- if (on && messagePanelOpen) {
10460
+ if (on && logAutoOpened) {
10461
+ setMessagePanelVisible(false);
10462
+ logAutoOpened = false;
10463
+ } else if (on && messagePanelOpen) {
10104
10464
  toggleMessagePanel();
10105
10465
  termState.panelHidden = true;
10106
10466
  } else if (!on && arrived && termState.panelHidden) toggleMessagePanel();
@@ -10129,6 +10489,7 @@ function syncTerminal() {
10129
10489
 
10130
10490
  // Only paths the user drives call this; restores, deep links and refreshes call fetchTasks, so they never take focus.
10131
10491
  function openSession(sessionId) {
10492
+ setSessionDismissed(sessionId, false);
10132
10493
  const alreadyOpen = sessionId === currentSessionId && viewMode === 'session' && termState.shown;
10133
10494
  if (!alreadyOpen) termState.openedByUser = sessionId;
10134
10495
  else if (termState.attached && promptAwaitsUser(sessionId)) focusTerminalPane();
@@ -10148,7 +10509,7 @@ function takeTerminalFocus(sessionId) {
10148
10509
  // The terminal attaches asynchronously; by then the user may be typing elsewhere or reading a modal.
10149
10510
  function promptAwaitsUser(sessionId) {
10150
10511
  if (!sessions.find((s) => s.id === sessionId)?.hasWaitingForUser || isAnyModalOpen()) return false;
10151
- return !document.activeElement?.matches('input, textarea, select, [contenteditable]');
10512
+ return !document.activeElement?.matches(TEXT_FIELD_SELECTOR);
10152
10513
  }
10153
10514
 
10154
10515
  function onTerminalShown(focus = true) {
@@ -10249,9 +10610,10 @@ function ensureTerm() {
10249
10610
  const host = document.getElementById('terminal-host');
10250
10611
  const fontFamily =
10251
10612
  cfg.fontFamily || getComputedStyle(document.body).getPropertyValue('--font-mono').trim() || 'monospace';
10613
+ const fontSize = terminalFontSize();
10252
10614
  const term = new window.Terminal({
10253
10615
  fontFamily,
10254
- fontSize: cfg.fontSize,
10616
+ fontSize,
10255
10617
  scrollback: cfg.scrollback,
10256
10618
  cursorBlink: true,
10257
10619
  allowProposedApi: true,
@@ -10275,7 +10637,7 @@ function ensureTerm() {
10275
10637
  // No WebGL: xterm falls back to its DOM renderer.
10276
10638
  }
10277
10639
  document.fonts
10278
- ?.load(`${cfg.fontSize}px ${fontFamily}`)
10640
+ ?.load(`${fontSize}px ${fontFamily}`)
10279
10641
  .then(repaintTerminal)
10280
10642
  .catch(() => {});
10281
10643
  term.attachCustomKeyEventHandler(terminalKeyFilter);
@@ -10355,6 +10717,27 @@ function fitTerminal() {
10355
10717
  } catch (_) {}
10356
10718
  }
10357
10719
 
10720
+ function clampTerminalFont(v) {
10721
+ return Math.min(TERMINAL_FONT_MAX, Math.max(TERMINAL_FONT_MIN, v));
10722
+ }
10723
+
10724
+ function terminalFontSize() {
10725
+ const saved = Number.parseInt(store.getItem(TERMINAL_FONT_KEY), 10);
10726
+ return saved ? clampTerminalFont(saved) : appConfig.terminal.fontSize;
10727
+ }
10728
+
10729
+ function adjustTerminalFontSize(step) {
10730
+ const term = termState.term;
10731
+ const size = step === 0 ? appConfig.terminal.fontSize : clampTerminalFont(term.options.fontSize + step);
10732
+ if (size !== term.options.fontSize) {
10733
+ if (step === 0) store.removeItem(TERMINAL_FONT_KEY);
10734
+ else store.setItem(TERMINAL_FONT_KEY, String(size));
10735
+ term.options.fontSize = size;
10736
+ fitTerminal();
10737
+ }
10738
+ showToast(`Terminal ${size}px`);
10739
+ }
10740
+
10358
10741
  function terminalSend(msg) {
10359
10742
  if (termState.ws?.readyState === WebSocket.OPEN) termState.ws.send(JSON.stringify(msg));
10360
10743
  }
@@ -10562,8 +10945,8 @@ function terminalOpenMode(sessionId) {
10562
10945
  // replays the same screen, and the placeholder gives way to the real card.
10563
10946
  function adoptPickedSession(oldId, id) {
10564
10947
  const focused = terminalPaneFocused();
10565
- if (lastSessionId === oldId) lastSessionId = id;
10566
- if (previousSessionId === oldId) previousSessionId = id;
10948
+ const adopt = (x) => (x === oldId ? id : x);
10949
+ setSwapPair(adopt(lastSessionId), adopt(previousSessionId));
10567
10950
  setTerminalMode(id, true);
10568
10951
  forgetPlaceholder(oldId);
10569
10952
  if (currentSessionId !== oldId || viewMode !== 'session') return renderSessions();
@@ -10842,7 +11225,7 @@ async function onDispatchUpdate() {
10842
11225
  // With no first message there is no transcript, so an ended placeholder leaves nothing to come back to.
10843
11226
  function dropPlaceholder(id) {
10844
11227
  if (!forgetPlaceholder(id)) return;
10845
- if (currentSessionId === id && viewMode === 'session') showAllTasks();
11228
+ if (currentSessionId === id && viewMode === 'session') showNoSession();
10846
11229
  else renderSessions();
10847
11230
  }
10848
11231
 
@@ -10852,8 +11235,8 @@ function forgetPlaceholder(id) {
10852
11235
  // Before the socket's close handler runs, or it would reconnect and start the session again.
10853
11236
  if (termState.sessionId === id) detachTerminal();
10854
11237
  sessions = sessions.filter((s) => s.id !== id);
10855
- if (previousSessionId === id) previousSessionId = null;
10856
- if (lastSessionId === id) lastSessionId = null;
11238
+ const forget = (x) => (x === id ? null : x);
11239
+ setSwapPair(forget(lastSessionId), forget(previousSessionId));
10857
11240
  return true;
10858
11241
  }
10859
11242
 
@@ -11233,19 +11616,16 @@ document.addEventListener('DOMContentLoaded', () => {
11233
11616
  marked.use({ renderer });
11234
11617
  }
11235
11618
 
11236
- if (typeof mermaid !== 'undefined') {
11237
- mermaid.initialize({ startOnLoad: false, theme: getMermaidTheme() });
11238
- let mermaidPending = false;
11239
- const mo = new MutationObserver(() => {
11240
- if (mermaidPending) return;
11241
- mermaidPending = true;
11242
- queueMicrotask(() => {
11243
- mermaidPending = false;
11244
- initMermaidBlocks();
11245
- });
11619
+ let mermaidPending = false;
11620
+ const mo = new MutationObserver(() => {
11621
+ if (mermaidPending) return;
11622
+ mermaidPending = true;
11623
+ queueMicrotask(() => {
11624
+ mermaidPending = false;
11625
+ initMermaidBlocks();
11246
11626
  });
11247
- mo.observe(document.body, { childList: true, subtree: true });
11248
- }
11627
+ });
11628
+ mo.observe(document.body, { childList: true, subtree: true });
11249
11629
  });
11250
11630
 
11251
11631
  loadSidebarState();
@@ -11269,6 +11649,7 @@ initModalResize();
11269
11649
  loadPanelWidths();
11270
11650
  initPanelResize('detail-panel', 'detail-panel-resize', '--detail-panel-width', 'detail-panel-width');
11271
11651
  initPanelResize('message-panel', 'message-panel-resize', '--message-panel-width', 'message-panel-width');
11652
+ initMessagePanelFull();
11272
11653
 
11273
11654
  const msgContentEl = document.getElementById('message-panel-content');
11274
11655
  const jumpLatestBtn = document.createElement('button');
@@ -11305,7 +11686,7 @@ msgContentEl.addEventListener('wheel', function (e) {
11305
11686
  }
11306
11687
  });
11307
11688
 
11308
- const footerState = { version: null, limitsKey: null, timer: null };
11689
+ const footerState = { version: null, plugin: null, limitsKey: null, timer: null };
11309
11690
  function formatResetIn(epochSec) {
11310
11691
  if (!epochSec) return null;
11311
11692
  const ms = epochSec * 1000 - Date.now();
@@ -11351,12 +11732,45 @@ function renderSidebarFooter(rateLimits) {
11351
11732
  const children = [];
11352
11733
  if (footerState.version) {
11353
11734
  const v = document.createElement('span');
11735
+ v.className = 'footer-version';
11354
11736
  v.textContent = `v${footerState.version}`;
11737
+ const warn = pluginWarning(footerState.plugin);
11738
+ if (warn) v.append(warn);
11355
11739
  children.push(v);
11356
11740
  }
11357
11741
  if (fh != null || sd != null) children.push(makeLimitSpan(rateLimits));
11358
11742
  el.replaceChildren(...children);
11359
11743
  }
11744
+ function pluginWarning(plugin) {
11745
+ const problem =
11746
+ plugin &&
11747
+ {
11748
+ missing: 'The claude-code-kanban plugin is not installed',
11749
+ disabled: `The claude-code-kanban plugin ${plugin.installed} is disabled`,
11750
+ mismatch: `Plugin ${plugin.installed} is installed, this board ships ${plugin.bundled}`,
11751
+ }[plugin.state];
11752
+ if (!problem) return null;
11753
+ const fix =
11754
+ plugin.state === 'disabled'
11755
+ ? 'Enable it with /plugin in Claude Code.'
11756
+ : `Click to copy: ${plugin.installCommand}\nThen restart open sessions, they keep the old hooks.`;
11757
+ const btn = document.createElement('button');
11758
+ btn.type = 'button';
11759
+ btn.className = 'footer-plugin-warning';
11760
+ btn.textContent = '⚠';
11761
+ btn.title = `${problem} (${plugin.configDir}).\n${fix}`;
11762
+ btn.setAttribute('aria-label', problem);
11763
+ if (plugin.state !== 'disabled') {
11764
+ btn.addEventListener('click', () =>
11765
+ navigator.clipboard
11766
+ .writeText(plugin.installCommand)
11767
+ .then(() => showToast('Install command copied', 'success'))
11768
+ .catch(() => showToast('Could not copy the command', 'error')),
11769
+ );
11770
+ }
11771
+ return btn;
11772
+ }
11773
+
11360
11774
  function refreshRateLimits() {
11361
11775
  if (footerState.timer) return;
11362
11776
  footerState.timer = setTimeout(() => {
@@ -11383,6 +11797,7 @@ fetch('/api/version')
11383
11797
  .then((r) => r.json())
11384
11798
  .then((d) => {
11385
11799
  footerState.version = d.version;
11800
+ footerState.plugin = d.plugin ?? null;
11386
11801
  renderSidebarFooter(null);
11387
11802
  refreshRateLimits();
11388
11803
  })
@@ -11391,7 +11806,6 @@ fetch('/api/version')
11391
11806
  const urlState = getUrlState();
11392
11807
  const lastView = loadLastView();
11393
11808
  sessionFilter = urlState.filter || FILTER_DEFAULTS.session;
11394
- sessionLimit = urlState.limit || FILTER_DEFAULTS.limit;
11395
11809
  // The URL wins; the persisted view only fills in when it carries a project key, so 'all' (null)
11396
11810
  // restores as 'all' and pre-existing blobs without the key still default to '__recent__'.
11397
11811
  filterProject = urlState.project || (lastView && 'project' in lastView ? lastView.project : FILTER_DEFAULTS.project);
@@ -11436,27 +11850,26 @@ Promise.all([
11436
11850
  try {
11437
11851
  await fetchProjectView(atob(urlState.projectView));
11438
11852
  } catch (_) {
11439
- showAllTasks();
11853
+ showNoSession();
11440
11854
  }
11441
11855
  } else if (urlState.session) {
11442
11856
  await fetchTasks(urlState.session);
11443
- } else if (urlState.view === 'all') {
11444
- showAllTasks();
11445
11857
  } else {
11446
11858
  const last = loadLastView();
11447
11859
  if (last?.view === 'project' && last.projectPath && sessions.some((s) => s.project === last.projectPath)) {
11448
11860
  try {
11449
11861
  await fetchProjectView(last.projectPath);
11450
11862
  } catch (_) {
11451
- showAllTasks();
11863
+ showNoSession();
11452
11864
  }
11453
11865
  } else if (last?.view === 'session' && last.session && sessions.some((s) => s.id === last.session)) {
11454
11866
  await fetchTasks(last.session);
11455
11867
  } else {
11456
- showAllTasks();
11868
+ showNoSession();
11457
11869
  }
11458
11870
  }
11459
11871
  if (urlState.messages && currentSessionId) {
11872
+ if (logAutoOpened) setMessagePanelVisible(false);
11460
11873
  toggleMessagePanel();
11461
11874
  // Re-render after panel layout settles so scroll dimensions are correct
11462
11875
  requestAnimationFrame(() => {
@@ -11472,7 +11885,7 @@ Promise.all([
11472
11885
  window.addEventListener('popstate', () => {
11473
11886
  const s = getUrlState();
11474
11887
  sessionFilter = s.filter || FILTER_DEFAULTS.session;
11475
- sessionLimit = s.limit || FILTER_DEFAULTS.limit;
11888
+ resetSessionPage();
11476
11889
  filterProject = s.project || FILTER_DEFAULTS.project;
11477
11890
  ownerFilter = s.owner || '';
11478
11891
  searchQuery = s.search || '';
@@ -11484,10 +11897,10 @@ window.addEventListener('popstate', () => {
11484
11897
  try {
11485
11898
  fetchProjectView(atob(s.projectView));
11486
11899
  } catch (_) {
11487
- showAllTasks();
11900
+ showNoSession();
11488
11901
  }
11489
11902
  } else if (s.session) fetchTasks(s.session);
11490
- else showAllTasks();
11903
+ else showNoSession();
11491
11904
  if (s.messages !== messagePanelOpen) toggleMessagePanel();
11492
11905
  });
11493
11906
  //#endregion
@@ -11498,6 +11911,7 @@ window.addEventListener('popstate', () => {
11498
11911
  // these tests only decide whether a press is the hub's to handle.
11499
11912
  function isHubKey(e) {
11500
11913
  if (!window.__HUB__?.enabled) return false;
11914
+ if (hubKeys) return hubKeys.has(hubCombo(e));
11501
11915
  if (e.ctrlKey && e.altKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) return true;
11502
11916
  // Own branch: the Alt+digit case below requires !ctrlKey. The hub owns the Ctrl+Alt+letter
11503
11917
  // keymap and ignores unbound letters.
@@ -11514,6 +11928,19 @@ function isHubKey(e) {
11514
11928
  return e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey && (/^[1-9]$/.test(e.key) || /^Digit[1-9]$/.test(e.code));
11515
11929
  }
11516
11930
 
11931
+ // The combos the hub binds, from its hub:keys message. Null until one arrives: a hub from before
11932
+ // hub:keys sends none, and the fallback filter above is what such a hub expects.
11933
+ let hubKeys = null;
11934
+
11935
+ // A copy of the hub's comboOf(): its names must match the hub:keys list.
11936
+ function hubCombo(e) {
11937
+ const lower = (e.key || '').toLowerCase();
11938
+ const m = /^(?:Key|Digit)([A-Z1-9])$/.exec(e.code || '');
11939
+ const key = /^[a-z1-9]$/.test(lower) ? lower : m ? m[1].toLowerCase() : e.key;
11940
+ const mods = [e.ctrlKey && 'ctrl', e.altKey && 'alt', e.shiftKey && 'shift', e.metaKey && 'meta'];
11941
+ return [...mods, key].filter(Boolean).join('+');
11942
+ }
11943
+
11517
11944
  document.addEventListener('keydown', (e) => {
11518
11945
  if (!isHubKey(e)) return;
11519
11946
  e.preventDefault();
@@ -11539,6 +11966,13 @@ document.addEventListener('click', (e) => {
11539
11966
  hubPost({ type: 'hub:openExternal', url: url.href });
11540
11967
  });
11541
11968
 
11969
+ // The click handler above only catches anchors. A script open goes the same way, because
11970
+ // in the hub's installed PWA window a framed app's own `_blank` open does nothing.
11971
+ function openExternal(url) {
11972
+ if (window.__HUB__?.enabled) hubPost({ type: 'hub:openExternal', url });
11973
+ else window.open(url, '_blank', 'noopener');
11974
+ }
11975
+
11542
11976
  window.hubNavigate = function hubNavigate(app, url) {
11543
11977
  if (!window.__HUB__?.enabled) return;
11544
11978
  hubPost({ type: 'hub:navigate', app, url });
@@ -11586,6 +12020,14 @@ function hubPost(message) {
11586
12020
  });
11587
12021
  })();
11588
12022
 
12023
+ (function initHubKeys() {
12024
+ window.addEventListener('message', (e) => {
12025
+ if (e.source !== window.parent || e.origin !== hubOrigin()) return;
12026
+ if (e.data?.type !== 'hub:keys' || !Array.isArray(e.data.keys)) return;
12027
+ hubKeys = new Set(e.data.keys.filter((k) => typeof k === 'string'));
12028
+ });
12029
+ })();
12030
+
11589
12031
  (function initHubProject() {
11590
12032
  window.addEventListener('message', (e) => {
11591
12033
  if (e.source !== window.parent || e.origin !== hubOrigin()) return;
@@ -11593,7 +12035,6 @@ function hubPost(message) {
11593
12035
  const dirPath = e.data.project;
11594
12036
  if (typeof dirPath !== 'string' || !dirPath || dirPath === filterProject) return;
11595
12037
  filterByProject(dirPath);
11596
- updateProjectDropdown();
11597
12038
  });
11598
12039
  })();
11599
12040
  // #endregion HUB_INTEGRATION