claude-code-kanban 4.28.0 → 4.30.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;
315
+ try {
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) {
260
336
  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;
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;
@@ -3073,6 +3176,8 @@ let revealedStorageSessionId = null;
3073
3176
  // Opens a session and scrolls the sidebar to it, refetching first when the id
3074
3177
  // fell outside the session list the current filters asked for.
3075
3178
  async function revealSession(id) {
3179
+ // Explicit opens restore a dismissed session; activity and auto-follow do not.
3180
+ setSessionDismissed(id, false);
3076
3181
  let session = sessions.find((s) => s.id === id);
3077
3182
  if (!session) {
3078
3183
  lastSessionsHash = '';
@@ -3107,53 +3212,18 @@ async function revealPlanSession(planSessionId) {
3107
3212
  await revealSession(planSessionId);
3108
3213
  }
3109
3214
 
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();
3215
+ function showNoSession() {
3216
+ viewMode = 'none';
3217
+ if (agentLogMode) exitAgentLogMode();
3218
+ currentSessionId = null;
3219
+ ownerFilter = '';
3220
+ resetAgentState();
3221
+ currentTasks = [];
3222
+ updateUrl();
3223
+ sessionView.classList.remove('visible');
3224
+ noSession.style.display = '';
3225
+ renderSessions();
3226
+ renderActivityChip();
3157
3227
  }
3158
3228
 
3159
3229
  // Filter pipeline: active filter → force-include revealed/current (non-pinned) sessions →
@@ -3260,8 +3330,7 @@ function renderSessions() {
3260
3330
  // Rebuilding the list under the pointer cancels an in-flight drop and would swallow a
3261
3331
  // half-typed group name, and the SSE path can fire at any moment — defer instead.
3262
3332
  if (sgDrag || sgIsEditing() || zenPanelIsEditing()) return;
3263
- // Update project dropdown
3264
- updateProjectDropdown();
3333
+ refreshProjectList();
3265
3334
 
3266
3335
  // Zen narrows the rendered list only — the session picker keeps calling getFilteredSessions()
3267
3336
  // and still offers everything, or there would be no way to switch sessions without leaving zen.
@@ -3279,11 +3348,11 @@ function renderSessions() {
3279
3348
  emptyMsg = `No results for "${searchQuery}"`;
3280
3349
  emptyHint = 'Try a different search term or clear the search';
3281
3350
  } else if (filterProject && sessionFilter === 'active') {
3282
- emptyMsg = 'No active sessions for this project';
3283
- emptyHint = 'Try "All Sessions" or "All Projects"';
3351
+ emptyMsg = 'No active sessions for this project filter';
3352
+ emptyHint = 'Try "All Sessions" or clear the project filter';
3284
3353
  } else if (filterProject) {
3285
- emptyMsg = 'No sessions for this project';
3286
- emptyHint = 'Select "All Projects" to see all';
3354
+ emptyMsg = 'No sessions for this project filter';
3355
+ emptyHint = 'Clear the project filter to see all';
3287
3356
  } else if (sessionFilter === 'active') {
3288
3357
  emptyMsg = 'No active sessions';
3289
3358
  emptyHint = 'Select "All Sessions" to see all';
@@ -3338,7 +3407,6 @@ function renderSessions() {
3338
3407
  ${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
3339
3408
  ${gitBranch ? `<div class="session-branch">${gitBranch}</div>` : ''}
3340
3409
  ${session.planTitle ? `<div class="session-plan">${escapeHtml(session.planTitle)}</div>` : ''}
3341
- ${renderGoalSubtitle(session)}
3342
3410
  <div class="session-progress">
3343
3411
  <span class="session-indicators">
3344
3412
  ${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 +3797,8 @@ function renderProjectView() {
3729
3797
 
3730
3798
  function renderTaskCard(task) {
3731
3799
  const isBlocked = task.blockedBy && task.blockedBy.length > 0;
3732
- const useSlug = viewMode === 'all' || viewMode === 'project';
3800
+ const useSlug = viewMode === 'project';
3733
3801
  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
3802
  const statusClass = task.status.replace('_', '-');
3736
3803
  const actualSessionId = task._taskDir || task.sessionId || currentSessionId || '';
3737
3804
 
@@ -3762,7 +3829,6 @@ function renderTaskCard(task) {
3762
3829
  }
3763
3830
  </div>
3764
3831
  <div class="task-title">${escapeHtml(task.subject)}</div>
3765
- ${sessionLabel ? `<div class="task-session">${escapeHtml(sessionLabel)}</div>` : ''}
3766
3832
  ${task.status === 'in_progress' && task.activeForm ? `<div class="task-active">${escapeHtml(task.activeForm)}</div>` : ''}
3767
3833
  ${isBlocked ? `<div class="task-blocked">Waiting on ${task.blockedBy.map((id) => `#${id}`).join(', ')}</div>` : ''}
3768
3834
  ${task.description ? `<div class="task-desc">${escapeHtml(task.description.split('\n')[0])}</div>` : ''}
@@ -4196,7 +4262,6 @@ function sgAssign(groupId, type, ref, opts = {}) {
4196
4262
  if (loose) member.loose = true;
4197
4263
  group.members.push(member);
4198
4264
  }
4199
- if (collapsedProjectGroups.delete(sgKey(groupId))) persistCollapsedGroups();
4200
4265
  persistSessionGroups();
4201
4266
  }
4202
4267
 
@@ -4468,7 +4533,11 @@ function sgOnDrop(e) {
4468
4533
  if (hit.groupId) {
4469
4534
  if (hit.reorder) sgReorderMember(hit.groupId, drag, hit.reorder);
4470
4535
  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 });
4536
+ else {
4537
+ sgAssign(hit.groupId, drag.kind, drag.ref, { under: hit.under, loose: hit.loose });
4538
+ // A drop shows where the card landed; a move from the context menu leaves the group as it was.
4539
+ if (collapsedProjectGroups.delete(sgKey(hit.groupId))) persistCollapsedGroups();
4540
+ }
4472
4541
  } else {
4473
4542
  sgDetach(drag.kind, drag.ref);
4474
4543
  if (drag.kind === 'session') sgReleased.add(drag.ref);
@@ -4505,6 +4574,10 @@ function initSessionGroupsDnd() {
4505
4574
  sessionsList.addEventListener('drop', sgOnDrop);
4506
4575
  sessionsList.addEventListener('dragend', sgOnDragEnd);
4507
4576
  sessionsList.addEventListener('contextmenu', sgOnContextMenu);
4577
+ // The Menu key fires contextmenu at the focused element, which by then is our menu.
4578
+ document.addEventListener('contextmenu', (e) => {
4579
+ if (e.target.closest?.('.sg-menu')) e.preventDefault();
4580
+ });
4508
4581
  sessionsList.addEventListener('keydown', (e) => {
4509
4582
  const input = e.target.closest?.('.sg-name-input');
4510
4583
  if (!input) return;
@@ -4539,32 +4612,68 @@ function initSessionGroupsDnd() {
4539
4612
  let sgLongPressTimer = null;
4540
4613
 
4541
4614
  function sgCloseMenu() {
4615
+ document.removeEventListener('keydown', sgMenuKeydown, true);
4542
4616
  const menu = document.getElementById('sg-menu');
4543
4617
  if (menu) menu.remove();
4544
4618
  }
4545
4619
 
4620
+ // Capture phase: the global handler would otherwise read these keys as sidebar shortcuts.
4621
+ function sgMenuKeydown(e) {
4622
+ const menu = document.getElementById('sg-menu');
4623
+ if (!menu || e.ctrlKey || e.altKey || e.metaKey) return;
4624
+ const items = [...menu.querySelectorAll('.sg-menu-item')];
4625
+ const i = items.indexOf(document.activeElement);
4626
+ e.stopPropagation();
4627
+ if (e.key === 'Enter' || e.key === ' ') return;
4628
+ e.preventDefault();
4629
+ let next = null;
4630
+ if (matchKey(e, 'ArrowDown')) next = (i + 1) % items.length;
4631
+ else if (matchKey(e, 'ArrowUp')) next = i < 0 ? items.length - 1 : (i - 1 + items.length) % items.length;
4632
+ else if (e.key === 'Home') next = 0;
4633
+ else if (e.key === 'End') next = items.length - 1;
4634
+ else if (e.key === 'Escape' || e.key === 'Tab') sgCloseMenu();
4635
+ if (next !== null) items[next]?.focus();
4636
+ }
4637
+
4638
+ function sgOpenMenuForKbSelection() {
4639
+ const el = sessionsList.querySelector('.kb-selected');
4640
+ if (!el?.matches('.session-item, .project-group-header')) return false;
4641
+ const payload = sgDragPayload(el);
4642
+ if (!payload?.ref) return false;
4643
+ const rect = el.getBoundingClientRect();
4644
+ sgOpenMenu(rect.left + 16, rect.bottom, payload.kind, payload.ref);
4645
+ return true;
4646
+ }
4647
+
4546
4648
  function sgOpenMenu(x, y, kind, ref) {
4547
4649
  sgCloseMenu();
4548
4650
  const current = sgGroupOf(kind, ref);
4549
4651
  const label = kind === 'project' ? 'project' : 'session';
4550
4652
  const rows = sessionGroups
4551
4653
  .filter((g) => g.id !== current?.id)
4552
- .map((g) => `<button class="sg-menu-item" data-sg-move="${escapeHtml(g.id)}">${escapeHtml(g.name)}</button>`)
4654
+ .map(
4655
+ (g) =>
4656
+ `<button class="sg-menu-item" role="menuitem" data-sg-move="${escapeHtml(g.id)}">${escapeHtml(g.name)}</button>`,
4657
+ )
4553
4658
  .join('');
4554
4659
  const menu = document.createElement('div');
4555
4660
  menu.id = 'sg-menu';
4556
4661
  menu.className = 'sg-menu';
4662
+ menu.setAttribute('role', 'menu');
4663
+ menu.setAttribute('aria-label', `Move ${label} to group`);
4557
4664
  menu.dataset.sgKind = kind;
4558
4665
  menu.dataset.sgRef = ref;
4559
4666
  menu.innerHTML = `
4560
- <div class="sg-menu-label">Move ${label} to group</div>
4667
+ <div class="sg-menu-label" aria-hidden="true">Move ${label} to group</div>
4561
4668
  ${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>` : ''}`;
4669
+ <button class="sg-menu-item" role="menuitem" data-sg-move="__new__">+ New group…</button>
4670
+ ${current ? `<button class="sg-menu-item sg-menu-remove" role="menuitem" data-sg-move="__none__">Remove from “${escapeHtml(current.name)}”</button>` : ''}`;
4564
4671
  document.body.appendChild(menu);
4565
4672
  const rect = menu.getBoundingClientRect();
4566
4673
  menu.style.left = `${Math.min(x, window.innerWidth - rect.width - 8)}px`;
4567
4674
  menu.style.top = `${Math.min(y, window.innerHeight - rect.height - 8)}px`;
4675
+ document.addEventListener('keydown', sgMenuKeydown, true);
4676
+ menu.querySelector('.sg-menu-item')?.focus();
4568
4677
  }
4569
4678
 
4570
4679
  function sgOnContextMenu(e) {
@@ -5198,13 +5307,7 @@ async function saveTaskField(taskId, sessionId, field, value) {
5198
5307
 
5199
5308
  if (res.ok) {
5200
5309
  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
- }
5310
+ await fetchTasks(sessionId);
5208
5311
  showTaskDetail(taskId, sessionId);
5209
5312
  }
5210
5313
  } catch (error) {
@@ -5288,11 +5391,11 @@ function deleteTask(taskId, sessionId) {
5288
5391
  if (e.key === 'Escape') {
5289
5392
  e.preventDefault();
5290
5393
  closeDeleteConfirmModal();
5291
- } else if (matchKey(e, 'ArrowLeft', 'KeyH')) {
5394
+ } else if (matchKey(e, 'ArrowLeft')) {
5292
5395
  e.preventDefault();
5293
5396
  focusIdx = 0;
5294
5397
  buttons[focusIdx].focus();
5295
- } else if (matchKey(e, 'ArrowRight', 'KeyL')) {
5398
+ } else if (matchKey(e, 'ArrowRight')) {
5296
5399
  e.preventDefault();
5297
5400
  focusIdx = 1;
5298
5401
  buttons[focusIdx].focus();
@@ -5354,10 +5457,10 @@ const SHORTCUT_PAIRS = [
5354
5457
  {
5355
5458
  title: 'Navigate',
5356
5459
  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' },
5460
+ { keys: ['↓'], label: 'Next item' },
5461
+ { keys: ['↑'], label: 'Previous item' },
5462
+ { keys: ['←'], label: 'Left column / collapse group' },
5463
+ { keys: ['→'], label: 'Right column / expand group' },
5361
5464
  { keys: ['Tab'], label: 'Switch sidebar ↔ board' },
5362
5465
  { keys: ['Enter', 'Space'], label: 'Open selected item' },
5363
5466
  ],
@@ -5366,14 +5469,15 @@ const SHORTCUT_PAIRS = [
5366
5469
  title: 'Session',
5367
5470
  rows: [
5368
5471
  { keys: ['P'], label: 'Open plan' },
5369
- { keys: ['Shift', 'P'], combo: true, label: 'Session picker' },
5472
+ { keys: ['Ctrl', 'Shift', 'P'], combo: true, label: 'Session picker' },
5370
5473
  { keys: ['I'], label: 'Session info' },
5371
5474
  { keys: ['.'], label: 'Pin / unpin' },
5372
5475
  { keys: ['>'], label: 'Toggle sticky' },
5476
+ { keys: ['Shift', 'F10'], combo: true, label: 'Move to group (or Menu key)' },
5373
5477
  { keys: ['Ctrl', 'D'], combo: true, label: 'Dismiss session' },
5374
5478
  { keys: ['Shift', 'L'], combo: true, label: 'Toggle session log' },
5375
5479
  { keys: ['Shift', 'M'], combo: true, label: 'Open last message' },
5376
- { keys: ['J', 'K'], label: 'Previous / next message in detail' },
5480
+ { keys: ['↑', '↓'], label: 'Previous / next message in detail' },
5377
5481
  { keys: ['Ctrl', 'Enter'], combo: true, label: 'Allow / approve the waiting prompt' },
5378
5482
  ],
5379
5483
  },
@@ -5490,9 +5594,7 @@ function closeHelpModal() {
5490
5594
  }
5491
5595
 
5492
5596
  async function refreshCurrentView() {
5493
- if (viewMode === 'all') {
5494
- await showAllTasks();
5495
- } else if (currentSessionId) {
5597
+ if (currentSessionId) {
5496
5598
  await fetchTasks(currentSessionId);
5497
5599
  } else {
5498
5600
  await fetchSessions();
@@ -6025,10 +6127,12 @@ function _storagePreviewLinkedDoc(path) {
6025
6127
 
6026
6128
  function _storageUnlinkDoc(sessionId, path) {
6027
6129
  setSessionDocLink(sessionId, path, true);
6130
+ forgetServerLinkedDoc(sessionId, path);
6028
6131
  }
6029
6132
 
6030
6133
  function _storageClearLinkedDocs(sessionId) {
6031
6134
  store.removeItem(PREVIEW_STORAGE_PREFIX + sessionId);
6135
+ forgetServerLinkedDoc(sessionId);
6032
6136
  // The auto-link record deliberately survives: clearing is unlinking every row at
6033
6137
  // once, and an unlinked pad must not come back on the next read of the transcript.
6034
6138
  afterLinkedDocsChanged(sessionId);
@@ -6148,11 +6252,7 @@ document.addEventListener('keydown', (e) => {
6148
6252
  return;
6149
6253
  }
6150
6254
  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;
6255
+ const step = matchKey(e, 'ArrowLeft', 'ArrowUp') ? -1 : matchKey(e, 'ArrowRight', 'ArrowDown') ? 1 : 0;
6156
6256
  if (step) {
6157
6257
  e.preventDefault();
6158
6258
  const buttons = [...terminalPrompt.querySelectorAll('button')];
@@ -6172,7 +6272,7 @@ document.addEventListener('keydown', (e) => {
6172
6272
  return;
6173
6273
  }
6174
6274
 
6175
- // Modal guard — only Escape, Shift+M, and msg-detail J/K navigation pass through
6275
+ // Modal guard — only Escape, Shift+M, and msg-detail arrow navigation pass through
6176
6276
  if (isAnyModalOpen()) {
6177
6277
  if (e.key === 'Escape') {
6178
6278
  if (_scratchpadModal.classList.contains('visible')) {
@@ -6206,7 +6306,7 @@ document.addEventListener('keydown', (e) => {
6206
6306
  ) {
6207
6307
  e.preventDefault();
6208
6308
  respondWaiting({ behavior: 'allow' });
6209
- } else if (matchKey(e, 'ArrowDown', 'KeyJ')) {
6309
+ } else if (matchKey(e, 'ArrowDown')) {
6210
6310
  e.preventDefault();
6211
6311
  if (currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX) {
6212
6312
  msgDetailFollowLatest = true;
@@ -6221,7 +6321,7 @@ document.addEventListener('keydown', (e) => {
6221
6321
  msgDetailFollowLatest = true;
6222
6322
  showMsgDetail(currentMsgDetailIdx);
6223
6323
  }
6224
- } else if (matchKey(e, 'ArrowUp', 'KeyK')) {
6324
+ } else if (matchKey(e, 'ArrowUp')) {
6225
6325
  e.preventDefault();
6226
6326
  if (currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX) {
6227
6327
  if (currentMessages.length) {
@@ -6272,12 +6372,6 @@ document.addEventListener('keydown', (e) => {
6272
6372
  toggleZenMode();
6273
6373
  return;
6274
6374
  }
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
6375
  if (e.key === '.' || e.key === '>') {
6282
6376
  const sid = sessionsList.querySelector('.kb-selected')?.dataset.sessionId || currentSessionId;
6283
6377
  if (sid) {
@@ -6306,22 +6400,22 @@ document.addEventListener('keydown', (e) => {
6306
6400
 
6307
6401
  // Sidebar navigation
6308
6402
  if (focusZone === 'sidebar') {
6309
- if (matchKey(e, 'ArrowDown', 'KeyJ')) {
6403
+ if (matchKey(e, 'ArrowDown')) {
6310
6404
  e.preventDefault();
6311
6405
  navigateSession(1);
6312
6406
  return;
6313
6407
  }
6314
- if (matchKey(e, 'ArrowUp', 'KeyK')) {
6408
+ if (matchKey(e, 'ArrowUp')) {
6315
6409
  e.preventDefault();
6316
6410
  navigateSession(-1);
6317
6411
  return;
6318
6412
  }
6319
- if (matchKey(e, 'ArrowLeft', 'KeyH')) {
6413
+ if (matchKey(e, 'ArrowLeft')) {
6320
6414
  e.preventDefault();
6321
6415
  handleSidebarHorizontal(-1);
6322
6416
  return;
6323
6417
  }
6324
- if (matchKey(e, 'ArrowRight', 'KeyL')) {
6418
+ if (matchKey(e, 'ArrowRight')) {
6325
6419
  e.preventDefault();
6326
6420
  handleSidebarHorizontal(1);
6327
6421
  return;
@@ -6331,6 +6425,11 @@ document.addEventListener('keydown', (e) => {
6331
6425
  activateSelectedSession();
6332
6426
  return;
6333
6427
  }
6428
+ const menuKey = e.key === 'ContextMenu' || (e.key === 'F10' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey);
6429
+ if (menuKey && sgOpenMenuForKbSelection()) {
6430
+ e.preventDefault();
6431
+ return;
6432
+ }
6334
6433
  if (e.key === 'Escape') {
6335
6434
  // Plain unfocus — drop the keyboard cursor without jumping into the board
6336
6435
  clearKbSelection();
@@ -6341,16 +6440,16 @@ document.addEventListener('keydown', (e) => {
6341
6440
 
6342
6441
  // Board navigation
6343
6442
  if (focusZone === 'board') {
6344
- if (matchKey(e, 'ArrowDown', 'KeyJ', 'ArrowUp', 'KeyK', 'ArrowLeft', 'KeyH', 'ArrowRight', 'KeyL')) {
6443
+ if (matchKey(e, 'ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight')) {
6345
6444
  e.preventDefault();
6346
6445
  if (!selectedTaskId && !document.querySelector('.task-card.selected')) {
6347
6446
  setFocusZone('sidebar');
6348
6447
  return;
6349
6448
  }
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);
6449
+ if (matchKey(e, 'ArrowDown')) navigateVertical(1);
6450
+ else if (matchKey(e, 'ArrowUp')) navigateVertical(-1);
6451
+ else if (matchKey(e, 'ArrowLeft')) navigateHorizontal(-1);
6452
+ else if (matchKey(e, 'ArrowRight')) navigateHorizontal(1);
6354
6453
 
6355
6454
  if (selectedTaskId && detailPanel.classList.contains('visible')) {
6356
6455
  showTaskDetail(selectedTaskId, selectedSessionId);
@@ -6433,12 +6532,8 @@ document.addEventListener('keydown', (e) => {
6433
6532
  }
6434
6533
  if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && e.key === 'd') {
6435
6534
  e.preventDefault();
6436
- if (!contextSid || dismissedSessionIds.has(contextSid)) return;
6437
6535
  const prevIdx = selectedSessionIdx;
6438
- dismissedSessionIds.add(contextSid);
6439
- updateDismissBtnState();
6440
- renderSessions();
6441
- renderActivityChip();
6536
+ if (!contextSid || !setSessionDismissed(contextSid, true)) return;
6442
6537
  const newItems = getNavigableItems();
6443
6538
  const targetIdx = newItems.length > 0 ? Math.max(0, prevIdx - 1) : -1;
6444
6539
  // If the dismissed session is currently open, navigate to the previous one
@@ -6449,10 +6544,11 @@ document.addEventListener('keydown', (e) => {
6449
6544
  if (targetSid) {
6450
6545
  fetchTasks(targetSid).then(() => selectSessionByIndex(targetIdx, getNavigableItems()));
6451
6546
  } else {
6452
- showAllTasks().then(() => selectSessionByIndex(targetIdx, getNavigableItems()));
6547
+ showNoSession();
6548
+ selectSessionByIndex(targetIdx, getNavigableItems());
6453
6549
  }
6454
6550
  } else {
6455
- showAllTasks();
6551
+ showNoSession();
6456
6552
  }
6457
6553
  } else if (targetIdx >= 0) {
6458
6554
  selectSessionByIndex(targetIdx, newItems);
@@ -6555,6 +6651,31 @@ function setSessionDocLink(sessionId, filePath, unlink) {
6555
6651
  afterLinkedDocsChanged(sessionId);
6556
6652
  }
6557
6653
 
6654
+ // Links sent while no tab was open reach the browser only through the server copy.
6655
+ async function mergeServerLinkedDocs() {
6656
+ let bySession;
6657
+ try {
6658
+ const res = await fetch('/api/document/links');
6659
+ if (!res.ok) return;
6660
+ bySession = await res.json();
6661
+ } catch {
6662
+ return;
6663
+ }
6664
+ for (const [sessionId, paths] of Object.entries(bySession || {})) {
6665
+ const local = getSessionPreviewPaths(sessionId);
6666
+ const known = new Set(local.map(canonicalPath));
6667
+ const missing = (paths || []).filter((p) => !known.has(canonicalPath(p)));
6668
+ if (!missing.length) continue;
6669
+ store.setItem(PREVIEW_STORAGE_PREFIX + sessionId, JSON.stringify([...missing, ...local].slice(0, 20)));
6670
+ afterLinkedDocsChanged(sessionId);
6671
+ }
6672
+ }
6673
+
6674
+ function forgetServerLinkedDoc(sessionId, filePath) {
6675
+ const q = filePath ? `?path=${encodeURIComponent(filePath)}` : '';
6676
+ fetch(`/api/document/links/${encodeURIComponent(sessionId)}${q}`, { method: 'DELETE' }).catch(() => {});
6677
+ }
6678
+
6558
6679
  function afterLinkedDocsChanged(sessionId) {
6559
6680
  if (_infoModalSessionId === sessionId) refreshInfoModalLinkedDocs();
6560
6681
  renderSessions();
@@ -6770,6 +6891,7 @@ function handleSessionOpenEvent(data) {
6770
6891
  if (!isSessionActive(target)) {
6771
6892
  stickySessionIds.add(id);
6772
6893
  }
6894
+ setSessionDismissed(id, false);
6773
6895
  fetchTasks(id);
6774
6896
  }
6775
6897
 
@@ -7228,6 +7350,7 @@ function bindLinkedDocsHandlers(container, sessionId) {
7228
7350
  copyWithFeedback(hit.dataset.path, hit);
7229
7351
  } else if (hit.classList.contains('linked-doc-remove')) {
7230
7352
  removeSessionPreviewPath(sessionId, hit.dataset.path);
7353
+ forgetServerLinkedDoc(sessionId, hit.dataset.path);
7231
7354
  afterLinkedDocsChanged(sessionId);
7232
7355
  } else {
7233
7356
  openLinkedDoc(hit.dataset.path, getSessionBaseDir(sessionId));
@@ -7324,12 +7447,6 @@ function setupEventSource() {
7324
7447
  const pendingTaskSessionIds = new Set();
7325
7448
  const pendingAgentSessionIds = new Set();
7326
7449
 
7327
- function refreshAllView() {
7328
- currentTasks = filterProject ? allTasksCache.filter((t) => matchesProjectFilter(t.project)) : allTasksCache;
7329
- renderAllTasks();
7330
- renderActivityChip();
7331
- }
7332
-
7333
7450
  async function refreshSessionDetail(sessionId) {
7334
7451
  await fetchAgents(sessionId);
7335
7452
  if (!agentLogMode) fetchMessages(sessionId);
@@ -7349,6 +7466,7 @@ function setupEventSource() {
7349
7466
  .catch(() => {});
7350
7467
  }
7351
7468
  wasConnected = true;
7469
+ mergeServerLinkedDocs();
7352
7470
  retryDelay = 1000;
7353
7471
  hideOffline();
7354
7472
  };
@@ -7383,9 +7501,7 @@ function setupEventSource() {
7383
7501
  taskRefreshTimer = setTimeout(async () => {
7384
7502
  if (skipOffScreen()) return;
7385
7503
  await fetchSessions().catch((err) => console.error('[SSE] fetchSessions failed:', err));
7386
- if (viewMode === 'all') {
7387
- refreshAllView();
7388
- } else if (viewMode === 'project' && currentProjectPath) {
7504
+ if (viewMode === 'project' && currentProjectPath) {
7389
7505
  const hasUpdate = currentProjectSessionIds.some((id) => pendingTaskSessionIds.has(id));
7390
7506
  if (hasUpdate) fetchProjectView(currentProjectPath);
7391
7507
  } else if (currentSessionId && pendingTaskSessionIds.has(currentSessionId)) {
@@ -7472,10 +7588,7 @@ function setupEventSource() {
7472
7588
  pendingTaskSessionIds.clear();
7473
7589
  pendingAgentSessionIds.clear();
7474
7590
  const sessionsLoaded = fetchSessions().catch(() => {});
7475
- if (viewMode === 'all') {
7476
- await sessionsLoaded;
7477
- refreshAllView();
7478
- } else if (viewMode === 'project' && currentProjectPath) {
7591
+ if (viewMode === 'project' && currentProjectPath) {
7479
7592
  await sessionsLoaded;
7480
7593
  fetchProjectView(currentProjectPath);
7481
7594
  } else if (currentSessionId) {
@@ -8270,18 +8383,106 @@ function toggleFilterMenu(e) {
8270
8383
  document.getElementById('filter-menu-btn')?.setAttribute('aria-expanded', 'true');
8271
8384
  renderFilterState();
8272
8385
  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.
8386
+ // Capture phase: the global keydown handler bails out on form fields, and the path box
8387
+ // has focus — Escape would never reach it otherwise.
8275
8388
  document.addEventListener('keydown', filterMenuKeydown, true);
8276
- document.getElementById('project-filter')?.focus();
8389
+ pfIdx = -1;
8390
+ renderProjectOptions();
8391
+ const input = document.getElementById('project-filter');
8392
+ input?.focus();
8393
+ input?.select();
8277
8394
  }
8278
8395
 
8279
8396
  function filterMenuKeydown(e) {
8280
- if (e.key !== 'Escape') return;
8281
- e.stopPropagation();
8282
- if (closeFilterMenu()) document.getElementById('filter-menu-btn')?.focus();
8397
+ if (e.key === 'Escape') {
8398
+ e.stopPropagation();
8399
+ if (closeFilterMenu()) document.getElementById('filter-menu-btn')?.focus();
8400
+ return;
8401
+ }
8402
+ if (e.target.id !== 'project-filter') return;
8403
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
8404
+ e.preventDefault();
8405
+ const n = pfOptions.length;
8406
+ if (!n) return;
8407
+ pfIdx = e.key === 'ArrowDown' ? (pfIdx + 1) % n : (pfIdx - 1 + n) % n;
8408
+ renderProjectOptions();
8409
+ } else if (e.key === 'Enter') {
8410
+ e.preventDefault();
8411
+ if (pfIdx >= 0 && pfOptions[pfIdx]) pickProject(pfOptions[pfIdx].path);
8412
+ else closeFilterMenu();
8413
+ }
8414
+ }
8415
+
8416
+ // The menu's project list: projects the path box matches, newest activity first.
8417
+ const PF_MAX = 7;
8418
+ let pfOptions = [];
8419
+ let pfIdx = -1;
8420
+
8421
+ function renderProjectOptions() {
8422
+ const list = document.getElementById('project-filter-options');
8423
+ const input = document.getElementById('project-filter');
8424
+ if (!list || !input) return;
8425
+ const text = projectFilterText();
8426
+ const q = normalizeProjectPath(text.trim());
8427
+ const partial = !!q && !isExactProjectPath(q);
8428
+ const needle = partial ? q : '';
8429
+ const selected = q && !partial ? q : null;
8430
+ const byRecent = (a, b) => (b.modifiedAt || '').localeCompare(a.modifiedAt || '');
8431
+ pfOptions = (projectsCache || [])
8432
+ .map((p) => ({ ...p, norm: normalizeProjectPath(p.path) }))
8433
+ .filter((p) => !needle || p.norm.includes(needle))
8434
+ .sort(byRecent)
8435
+ .slice(0, PF_MAX);
8436
+ if (pfIdx >= pfOptions.length) pfIdx = pfOptions.length - 1;
8437
+ list.innerHTML = pfOptions.length
8438
+ ? pfOptions
8439
+ .map((p, i) => {
8440
+ const cut = Math.max(p.path.lastIndexOf('/'), p.path.lastIndexOf('\\')) + 1;
8441
+ const at = needle ? p.norm.indexOf(needle) : -1;
8442
+ const range = at >= 0 ? [at, at + needle.length] : null;
8443
+ const isSel = p.norm === selected;
8444
+ 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)}">
8445
+ <span class="pf-name">${markRange(p.path, cut, p.path.length, range)}</span>
8446
+ <span class="pf-dir"><bdi>${markRange(p.path, 0, cut, range)}</bdi></span>
8447
+ <span class="pf-age">${p.modifiedAt ? shortAge(p.modifiedAt) : ''}</span>
8448
+ </li>`;
8449
+ })
8450
+ .join('')
8451
+ : `<li class="pf-empty">${
8452
+ partial ? `No project path contains “${escapeHtml(text.trim())}”` : 'No projects yet'
8453
+ }</li>`;
8454
+ if (pfIdx >= 0) input.setAttribute('aria-activedescendant', `pf-opt-${pfIdx}`);
8455
+ else input.removeAttribute('aria-activedescendant');
8456
+ list.querySelector('.pf-opt.hl')?.scrollIntoView({ block: 'nearest' });
8457
+ }
8458
+
8459
+ function shortAge(iso) {
8460
+ const min = Math.max(0, (Date.now() - new Date(iso).getTime()) / 60000);
8461
+ if (min < 60) return `${Math.floor(min)}m`;
8462
+ if (min < 1440) return `${Math.floor(min / 60)}h`;
8463
+ if (min < 60 * 24 * 60) return `${Math.floor(min / 1440)}d`;
8464
+ return `${Math.floor(min / (1440 * 30))}mo`;
8465
+ }
8466
+
8467
+ // The slice [from, to) of text, with the part inside range marked.
8468
+ function markRange(text, from, to, range) {
8469
+ if (!range || range[1] <= from || range[0] >= to) return escapeHtml(text.slice(from, to));
8470
+ const a = Math.max(from, range[0]);
8471
+ const b = Math.min(to, range[1]);
8472
+ return `${escapeHtml(text.slice(from, a))}<mark>${escapeHtml(text.slice(a, b))}</mark>${escapeHtml(text.slice(b, to))}`;
8473
+ }
8474
+
8475
+ function pickProject(path) {
8476
+ filterByProject(path);
8477
+ closeFilterMenu();
8478
+ document.getElementById('filter-menu-btn')?.focus();
8283
8479
  }
8284
8480
 
8481
+ document.getElementById('project-filter-options')?.addEventListener('click', (e) => {
8482
+ const opt = e.target.closest('.pf-opt');
8483
+ if (opt) pickProject(opt.dataset.path);
8484
+ });
8485
+
8285
8486
  function closeFilterMenu() {
8286
8487
  const menu = document.getElementById('filter-menu');
8287
8488
  if (!menu?.classList.contains('open')) return false;
@@ -8291,50 +8492,53 @@ function closeFilterMenu() {
8291
8492
  return true;
8292
8493
  }
8293
8494
 
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
8495
+ // 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
8496
  // before the sidebar's width ellipsises it) and long form for its tooltip.
8297
8497
  const FILTERS = [
8298
8498
  {
8299
- id: 'project-filter',
8300
- value: () => filterProject ?? '',
8301
8499
  active: () => filterProject !== FILTER_DEFAULTS.project,
8302
- short: () => (filterProject ? filterProject.split(/[/\\]/).pop() : 'all proj'),
8303
- long: () => (filterProject ? `project ${filterProject}` : 'all projects'),
8500
+ short: () => {
8501
+ if (filterProject === '__recent__') return 'recent';
8502
+ if (!filterProject) return 'all proj';
8503
+ const name = pathBasename(filterProject);
8504
+ return isExactProjectFilter(filterProject) ? name : `*${name}*`;
8505
+ },
8506
+ long: () => {
8507
+ if (filterProject === '__recent__') return 'projects active in the last 24h';
8508
+ if (!filterProject) return 'all projects';
8509
+ return isExactProjectFilter(filterProject)
8510
+ ? `project ${filterProject}`
8511
+ : `project path contains "${filterProject}"`;
8512
+ },
8304
8513
  },
8305
8514
  {
8306
- id: 'session-filter',
8307
- value: () => sessionFilter,
8308
8515
  active: () => sessionFilter !== FILTER_DEFAULTS.session,
8309
8516
  short: () => 'inactive',
8310
8517
  long: () => 'including inactive sessions',
8311
8518
  },
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
8519
  ];
8320
8520
 
8321
- // Keeps the header (summary + funnel tint) and the popover's selects in sync with state.
8521
+ // Keeps the header (summary + funnel tint) and the popover's controls in sync with state.
8322
8522
  // Safe to call on every filter change — it only touches classes, values, and text.
8323
8523
  function renderFilterState() {
8324
8524
  const short = [];
8325
8525
  const long = [];
8326
8526
  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);
8527
+ if (!f.active()) continue;
8528
+ short.push(f.short());
8529
+ long.push(f.long());
8530
+ }
8531
+ const input = document.getElementById('project-filter');
8532
+ if (input) {
8533
+ const value = projectFilterText();
8534
+ if (input.value !== value) input.value = value;
8535
+ }
8536
+ const recent = document.getElementById('project-recent-btn');
8537
+ recent?.setAttribute('aria-pressed', String(filterProject === '__recent__'));
8538
+ for (const b of document.querySelectorAll('[data-session-filter]')) {
8539
+ b.setAttribute('aria-checked', String(b.dataset.sessionFilter === sessionFilter));
8337
8540
  }
8541
+ if (document.getElementById('filter-menu')?.classList.contains('open')) renderProjectOptions();
8338
8542
 
8339
8543
  const summary = document.getElementById('filter-summary');
8340
8544
  if (summary) {
@@ -8364,7 +8568,9 @@ function renderZenState() {
8364
8568
 
8365
8569
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
8366
8570
  function filterBySessions(value) {
8571
+ if (value === sessionFilter) return;
8367
8572
  sessionFilter = value;
8573
+ resetSessionPage();
8368
8574
  if (value !== 'active') activityFilter.clear();
8369
8575
  renderFilterState();
8370
8576
  updateUrl();
@@ -8376,18 +8582,18 @@ function filterBySessions(value) {
8376
8582
  fetchSessions(false);
8377
8583
  }
8378
8584
 
8379
- // biome-ignore lint/correctness/noUnusedVariables: used in HTML
8380
- function changeSessionLimit(value) {
8381
- sessionLimit = value;
8382
- renderFilterState();
8383
- updateUrl();
8384
- fetchSessions();
8585
+ function projectFilterText() {
8586
+ return filterProject && filterProject !== '__recent__' ? filterProject : '';
8385
8587
  }
8386
8588
 
8589
+ let filterMatcher = { query: null, match: null };
8387
8590
  function matchesProjectFilter(project) {
8388
8591
  if (!filterProject) return true;
8389
8592
  if (filterProject === '__recent__') return recentProjects.has(project);
8390
- return project === filterProject;
8593
+ if (filterMatcher.query !== filterProject) {
8594
+ filterMatcher = { query: filterProject, match: projectMatcher(filterProject) };
8595
+ }
8596
+ return filterMatcher.match(project);
8391
8597
  }
8392
8598
 
8393
8599
  //#endregion
@@ -8507,11 +8713,31 @@ document.addEventListener('click', (e) => {
8507
8713
 
8508
8714
  // Used in HTML; also called by the hub project shim, so no biome suppression is needed.
8509
8715
  function filterByProject(project) {
8716
+ clearTimeout(projectFilterTimer);
8510
8717
  filterProject = project || null;
8718
+ resetSessionPage();
8511
8719
  renderFilterState();
8512
- updateUrl();
8513
8720
  fetchSessions(false);
8514
- showAllTasks();
8721
+ showNoSession();
8722
+ }
8723
+
8724
+ // The sidebar narrows on every key from the rows it has; the server round trip for rows past
8725
+ // the limit waits for a pause in typing.
8726
+ const PROJECT_FILTER_DEBOUNCE_MS = 250;
8727
+ let projectFilterTimer = null;
8728
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
8729
+ function typeProjectFilter(value) {
8730
+ filterProject = value.trim() ? value : null;
8731
+ pfIdx = -1;
8732
+ updateUrl();
8733
+ renderSessions();
8734
+ clearTimeout(projectFilterTimer);
8735
+ projectFilterTimer = setTimeout(() => filterByProject(filterProject), PROJECT_FILTER_DEBOUNCE_MS);
8736
+ }
8737
+
8738
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
8739
+ function toggleRecentProjects() {
8740
+ filterByProject(filterProject === '__recent__' ? null : '__recent__');
8515
8741
  }
8516
8742
 
8517
8743
  let projectsCache = null;
@@ -8532,22 +8758,20 @@ async function loadProjects() {
8532
8758
  return projects;
8533
8759
  }
8534
8760
 
8535
- async function updateProjectDropdown() {
8536
- const dropdown = document.getElementById('project-filter');
8537
-
8761
+ async function refreshProjectList() {
8538
8762
  if (!projectsCacheDirty && projectsCache) {
8539
- renderProjectDropdown(dropdown, projectsCache);
8763
+ renderFilterState();
8540
8764
  return;
8541
8765
  }
8542
8766
 
8543
8767
  const projects = await loadProjects();
8544
- const cutoff = Date.now() - 24 * 60 * 60 * 1000;
8768
+ const cutoff = Date.now() - RECENT_PROJECT_HOURS * 60 * 60 * 1000;
8545
8769
  const prevRecent = recentProjects;
8546
8770
  recentProjects = new Set(
8547
8771
  projects.filter((p) => p.modifiedAt && new Date(p.modifiedAt).getTime() > cutoff).map((p) => p.path),
8548
8772
  );
8549
8773
 
8550
- renderProjectDropdown(dropdown, projects);
8774
+ renderFilterState();
8551
8775
 
8552
8776
  // recentProjects was empty before — sidebar rendered with __recent__ filter
8553
8777
  // dropping every session. Re-render now that we know which projects qualify.
@@ -8556,28 +8780,6 @@ async function updateProjectDropdown() {
8556
8780
  }
8557
8781
  }
8558
8782
 
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
8783
  function updateThemeColor(isLight) {
8582
8784
  document.querySelectorAll('meta[name="theme-color"]').forEach((m) => {
8583
8785
  m.setAttribute('content', isLight ? '#e8e6e3' : '#101114');
@@ -8993,13 +9195,6 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
8993
9195
  });
8994
9196
  html += `</div>`;
8995
9197
 
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
9198
  if (session.contextStatus) {
9004
9199
  html += `<hr style="border: none; border-top: 1px solid var(--border); margin: 12px 0;">`;
9005
9200
  html += renderContextDetail(session.contextStatus);
@@ -9121,14 +9316,7 @@ function openSessionFromInfo(sessionId) {
9121
9316
 
9122
9317
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
9123
9318
  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();
9319
+ setSessionDismissed(sessionId, !dismissedSessionIds.has(sessionId));
9132
9320
  }
9133
9321
 
9134
9322
  function updateDismissBtnState() {
@@ -9152,47 +9340,67 @@ let spIdx = 0;
9152
9340
  let spPeekTimer = null;
9153
9341
  let spPeekToken = 0;
9154
9342
  let spPeekedId = null;
9343
+ let spFromTerminal = false;
9344
+ let spRenderedQuery = null;
9155
9345
  // Per-open cache: spSource is frozen while the picker is up, so a fetched log stays valid.
9156
9346
  const spPeekCache = new Map();
9157
9347
 
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();
9348
+ // A query also searches session ids and names in every transcript on the server, so a
9349
+ // session outside the sidebar filter is found too. Values: 'pending', then the rows.
9350
+ const spSearch = new Map();
9351
+ let spSearchTimer = null;
9162
9352
 
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();
9353
+ function spSearchKey(query) {
9354
+ return query.length >= 3 ? query.toLowerCase() : null;
9167
9355
  }
9168
9356
 
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' };
9357
+ function spScheduleSearch(key) {
9358
+ clearTimeout(spSearchTimer);
9359
+ spSearchTimer = setTimeout(async () => {
9360
+ spSearch.set(key, 'pending');
9361
+ let ids = [];
9362
+ try {
9363
+ const r = await fetch(`/api/sessions/search?q=${encodeURIComponent(key)}`);
9364
+ if (r.ok) ids = await r.json();
9365
+ } catch (_) {}
9366
+ spSearch.set(key, ids.length ? await fetchSessionsByIds(ids) : []);
9367
+ renderSessionPicker();
9368
+ }, 100);
9369
+ }
9370
+
9371
+ // The server rows not already matched in the sidebar list, and whether a search is still out.
9372
+ function spSearchRows(key, localRows) {
9373
+ if (!key) return { rows: [], pending: false };
9374
+ const found = spSearch.get(key);
9375
+ if (!found) spScheduleSearch(key);
9376
+ if (!Array.isArray(found)) return { rows: [], pending: true };
9377
+ const seen = new Set(localRows.map((s) => s.id));
9378
+ return { rows: found.filter((s) => !seen.has(s.id)), pending: false };
9180
9379
  }
9181
9380
 
9182
9381
  function openSessionPicker() {
9382
+ spFromTerminal = terminalPaneFocused();
9183
9383
  const input = document.getElementById('session-picker-input');
9184
9384
  input.value = '';
9185
9385
  spSource = getFilteredSessions().sort((a, b) => Date.parse(b.modifiedAt) - Date.parse(a.modifiedAt));
9186
9386
  spPeekCache.clear();
9187
- spGlobal.clear();
9387
+ spSearch.clear();
9388
+ spRenderedQuery = null;
9188
9389
  document.getElementById('session-picker-modal').classList.add('visible');
9189
9390
  renderSessionPicker();
9190
9391
  input.focus();
9191
9392
  }
9192
9393
 
9193
- function closeSessionPicker() {
9394
+ // refocus: false when a session is being opened, which moves the terminal focus itself.
9395
+ function closeSessionPicker({ refocus = true } = {}) {
9194
9396
  spSetPeeking(false);
9195
9397
  hideModalOverlay('session-picker-modal');
9398
+ if (refocus && spFromTerminal && wantsTerminal()) focusTerminalPane();
9399
+ }
9400
+
9401
+ function toggleSessionPicker() {
9402
+ if (document.getElementById('session-picker-modal').classList.contains('visible')) closeSessionPicker();
9403
+ else openSessionPicker();
9196
9404
  }
9197
9405
 
9198
9406
  function spIsPeeking() {
@@ -9274,21 +9482,30 @@ function spDotHtml(session) {
9274
9482
  function renderSessionPicker() {
9275
9483
  const list = document.getElementById('session-picker-list');
9276
9484
  const query = document.getElementById('session-picker-input').value.trim();
9277
- spRows = spSource.filter((s) => spMatches(s, query));
9485
+ // Search rows that arrive for the same query append below, so the cursor stays on its row.
9486
+ const selectedId = query === spRenderedQuery ? spRows[spIdx]?.id : null;
9487
+ spRenderedQuery = query;
9488
+ const key = spSearchKey(query);
9489
+ const local = spSource.filter((s) => spMatches(s, query));
9490
+ const found = spSearchRows(key, local);
9491
+ spRows = [...local, ...found.rows];
9278
9492
  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
- }
9493
+ spIdx = -1;
9494
+ const empty = found.pending
9495
+ ? 'Searching all sessions…'
9496
+ : spSource.length || key
9497
+ ? 'No session matches'
9498
+ : 'No sessions in the current sidebar filter';
9499
+ list.innerHTML = `<div class="sp-empty">${empty}</div>`;
9500
+ return;
9286
9501
  }
9287
9502
 
9503
+ // Rows past the sidebar list come from the server search and show their full project path.
9288
9504
  list.innerHTML = spRows
9289
9505
  .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)}">
9506
+ const outside = i >= local.length;
9507
+ const project = s.project ? (outside ? s.project : pathBasename(s.project)) : '';
9508
+ 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
9509
  ${spDotHtml(s)}
9293
9510
  <span class="sp-name">${escapeHtml(sessionDisplayName(s))}</span>
9294
9511
  <span class="sp-project">${escapeHtml(project)}</span>
@@ -9298,7 +9515,8 @@ function renderSessionPicker() {
9298
9515
  </button>`;
9299
9516
  })
9300
9517
  .join('');
9301
- spSelect(0);
9518
+ const keep = spRows.findIndex((s) => s.id === selectedId);
9519
+ spSelect(Math.max(0, keep));
9302
9520
  }
9303
9521
 
9304
9522
  function spSelect(idx) {
@@ -9314,7 +9532,8 @@ function spSelect(idx) {
9314
9532
  async function spOpen(idx) {
9315
9533
  const session = spRows[idx];
9316
9534
  if (!session) return;
9317
- closeSessionPicker();
9535
+ closeSessionPicker({ refocus: false });
9536
+ if (spFromTerminal) termState.focusNext = true;
9318
9537
  await revealSession(session.id);
9319
9538
  }
9320
9539
 
@@ -9325,7 +9544,7 @@ function initSessionPicker() {
9325
9544
  input.addEventListener('keydown', (e) => {
9326
9545
  if (e.key === 'Escape') closeSessionPicker();
9327
9546
  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);
9547
+ else if (matchKey(e, 'ArrowUp') || (e.ctrlKey && !e.shiftKey && e.key === 'p')) spSelect(spIdx - 1);
9329
9548
  else if (e.key === 'Enter') spOpen(spIdx);
9330
9549
  else if (e.ctrlKey && e.code === 'Space') spSetPeeking(!spIsPeeking());
9331
9550
  else return;
@@ -9455,16 +9674,6 @@ function renderLoopModalBody(data) {
9455
9674
  body.innerHTML = section('Wakeups', wakeups, 'wakeup') + section('Cron jobs', crons, 'cron');
9456
9675
  }
9457
9676
 
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
9677
  function renderLoopBadge(session) {
9469
9678
  const li = session.loopInfo;
9470
9679
  const total = (li?.wakeupCount || 0) + (li?.cronCount || 0);
@@ -10059,6 +10268,8 @@ function terminalPaneFocused() {
10059
10268
  function terminalShortcut(e) {
10060
10269
  const ctrlAlt = e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey;
10061
10270
  if (ctrlAlt && e.code === 'KeyS') return swapToPreviousSession;
10271
+ // Cancelling the default also stops Chrome's system print dialog, which this page has no use for.
10272
+ if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey && e.code === 'KeyP') return toggleSessionPicker;
10062
10273
  if (ctrlAlt && (e.code === 'KeyN' || e.code === 'KeyR') && terminalAvailable()) {
10063
10274
  return () => openNewSession(null, e.code === 'KeyR');
10064
10275
  }
@@ -10073,6 +10284,22 @@ function terminalShortcut(e) {
10073
10284
  return null;
10074
10285
  }
10075
10286
 
10287
+ function readSwapPair() {
10288
+ try {
10289
+ const pair = JSON.parse(store.getItem(SWAP_KEY));
10290
+ if (Array.isArray(pair) && pair.length === 2) return pair;
10291
+ } catch {}
10292
+ return [null, null];
10293
+ }
10294
+
10295
+ function setSwapPair(last, previous) {
10296
+ lastSessionId = last;
10297
+ previousSessionId = previous;
10298
+ try {
10299
+ store.setItem(SWAP_KEY, JSON.stringify([last, previous]));
10300
+ } catch {}
10301
+ }
10302
+
10076
10303
  function swapToPreviousSession() {
10077
10304
  const target = currentSessionId === lastSessionId ? previousSessionId : lastSessionId;
10078
10305
  if (!target || target === currentSessionId) return;
@@ -10100,7 +10327,10 @@ function syncTerminal() {
10100
10327
  if (view !== termState.syncedView) {
10101
10328
  termState.syncedView = view;
10102
10329
  // The terminal shows the same conversation, so the log panel only takes width; it comes back when the terminal goes.
10103
- if (on && messagePanelOpen) {
10330
+ if (on && logAutoOpened) {
10331
+ setMessagePanelVisible(false);
10332
+ logAutoOpened = false;
10333
+ } else if (on && messagePanelOpen) {
10104
10334
  toggleMessagePanel();
10105
10335
  termState.panelHidden = true;
10106
10336
  } else if (!on && arrived && termState.panelHidden) toggleMessagePanel();
@@ -10129,6 +10359,7 @@ function syncTerminal() {
10129
10359
 
10130
10360
  // Only paths the user drives call this; restores, deep links and refreshes call fetchTasks, so they never take focus.
10131
10361
  function openSession(sessionId) {
10362
+ setSessionDismissed(sessionId, false);
10132
10363
  const alreadyOpen = sessionId === currentSessionId && viewMode === 'session' && termState.shown;
10133
10364
  if (!alreadyOpen) termState.openedByUser = sessionId;
10134
10365
  else if (termState.attached && promptAwaitsUser(sessionId)) focusTerminalPane();
@@ -10562,8 +10793,8 @@ function terminalOpenMode(sessionId) {
10562
10793
  // replays the same screen, and the placeholder gives way to the real card.
10563
10794
  function adoptPickedSession(oldId, id) {
10564
10795
  const focused = terminalPaneFocused();
10565
- if (lastSessionId === oldId) lastSessionId = id;
10566
- if (previousSessionId === oldId) previousSessionId = id;
10796
+ const adopt = (x) => (x === oldId ? id : x);
10797
+ setSwapPair(adopt(lastSessionId), adopt(previousSessionId));
10567
10798
  setTerminalMode(id, true);
10568
10799
  forgetPlaceholder(oldId);
10569
10800
  if (currentSessionId !== oldId || viewMode !== 'session') return renderSessions();
@@ -10842,7 +11073,7 @@ async function onDispatchUpdate() {
10842
11073
  // With no first message there is no transcript, so an ended placeholder leaves nothing to come back to.
10843
11074
  function dropPlaceholder(id) {
10844
11075
  if (!forgetPlaceholder(id)) return;
10845
- if (currentSessionId === id && viewMode === 'session') showAllTasks();
11076
+ if (currentSessionId === id && viewMode === 'session') showNoSession();
10846
11077
  else renderSessions();
10847
11078
  }
10848
11079
 
@@ -10852,8 +11083,8 @@ function forgetPlaceholder(id) {
10852
11083
  // Before the socket's close handler runs, or it would reconnect and start the session again.
10853
11084
  if (termState.sessionId === id) detachTerminal();
10854
11085
  sessions = sessions.filter((s) => s.id !== id);
10855
- if (previousSessionId === id) previousSessionId = null;
10856
- if (lastSessionId === id) lastSessionId = null;
11086
+ const forget = (x) => (x === id ? null : x);
11087
+ setSwapPair(forget(lastSessionId), forget(previousSessionId));
10857
11088
  return true;
10858
11089
  }
10859
11090
 
@@ -11391,7 +11622,6 @@ fetch('/api/version')
11391
11622
  const urlState = getUrlState();
11392
11623
  const lastView = loadLastView();
11393
11624
  sessionFilter = urlState.filter || FILTER_DEFAULTS.session;
11394
- sessionLimit = urlState.limit || FILTER_DEFAULTS.limit;
11395
11625
  // The URL wins; the persisted view only fills in when it carries a project key, so 'all' (null)
11396
11626
  // restores as 'all' and pre-existing blobs without the key still default to '__recent__'.
11397
11627
  filterProject = urlState.project || (lastView && 'project' in lastView ? lastView.project : FILTER_DEFAULTS.project);
@@ -11436,27 +11666,26 @@ Promise.all([
11436
11666
  try {
11437
11667
  await fetchProjectView(atob(urlState.projectView));
11438
11668
  } catch (_) {
11439
- showAllTasks();
11669
+ showNoSession();
11440
11670
  }
11441
11671
  } else if (urlState.session) {
11442
11672
  await fetchTasks(urlState.session);
11443
- } else if (urlState.view === 'all') {
11444
- showAllTasks();
11445
11673
  } else {
11446
11674
  const last = loadLastView();
11447
11675
  if (last?.view === 'project' && last.projectPath && sessions.some((s) => s.project === last.projectPath)) {
11448
11676
  try {
11449
11677
  await fetchProjectView(last.projectPath);
11450
11678
  } catch (_) {
11451
- showAllTasks();
11679
+ showNoSession();
11452
11680
  }
11453
11681
  } else if (last?.view === 'session' && last.session && sessions.some((s) => s.id === last.session)) {
11454
11682
  await fetchTasks(last.session);
11455
11683
  } else {
11456
- showAllTasks();
11684
+ showNoSession();
11457
11685
  }
11458
11686
  }
11459
11687
  if (urlState.messages && currentSessionId) {
11688
+ if (logAutoOpened) setMessagePanelVisible(false);
11460
11689
  toggleMessagePanel();
11461
11690
  // Re-render after panel layout settles so scroll dimensions are correct
11462
11691
  requestAnimationFrame(() => {
@@ -11472,7 +11701,7 @@ Promise.all([
11472
11701
  window.addEventListener('popstate', () => {
11473
11702
  const s = getUrlState();
11474
11703
  sessionFilter = s.filter || FILTER_DEFAULTS.session;
11475
- sessionLimit = s.limit || FILTER_DEFAULTS.limit;
11704
+ resetSessionPage();
11476
11705
  filterProject = s.project || FILTER_DEFAULTS.project;
11477
11706
  ownerFilter = s.owner || '';
11478
11707
  searchQuery = s.search || '';
@@ -11484,10 +11713,10 @@ window.addEventListener('popstate', () => {
11484
11713
  try {
11485
11714
  fetchProjectView(atob(s.projectView));
11486
11715
  } catch (_) {
11487
- showAllTasks();
11716
+ showNoSession();
11488
11717
  }
11489
11718
  } else if (s.session) fetchTasks(s.session);
11490
- else showAllTasks();
11719
+ else showNoSession();
11491
11720
  if (s.messages !== messagePanelOpen) toggleMessagePanel();
11492
11721
  });
11493
11722
  //#endregion
@@ -11498,6 +11727,7 @@ window.addEventListener('popstate', () => {
11498
11727
  // these tests only decide whether a press is the hub's to handle.
11499
11728
  function isHubKey(e) {
11500
11729
  if (!window.__HUB__?.enabled) return false;
11730
+ if (hubKeys) return hubKeys.has(hubCombo(e));
11501
11731
  if (e.ctrlKey && e.altKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) return true;
11502
11732
  // Own branch: the Alt+digit case below requires !ctrlKey. The hub owns the Ctrl+Alt+letter
11503
11733
  // keymap and ignores unbound letters.
@@ -11514,6 +11744,19 @@ function isHubKey(e) {
11514
11744
  return e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey && (/^[1-9]$/.test(e.key) || /^Digit[1-9]$/.test(e.code));
11515
11745
  }
11516
11746
 
11747
+ // The combos the hub binds, from its hub:keys message. Null until one arrives: a hub from before
11748
+ // hub:keys sends none, and the fallback filter above is what such a hub expects.
11749
+ let hubKeys = null;
11750
+
11751
+ // A copy of the hub's comboOf(): its names must match the hub:keys list.
11752
+ function hubCombo(e) {
11753
+ const lower = (e.key || '').toLowerCase();
11754
+ const m = /^(?:Key|Digit)([A-Z1-9])$/.exec(e.code || '');
11755
+ const key = /^[a-z1-9]$/.test(lower) ? lower : m ? m[1].toLowerCase() : e.key;
11756
+ const mods = [e.ctrlKey && 'ctrl', e.altKey && 'alt', e.shiftKey && 'shift', e.metaKey && 'meta'];
11757
+ return [...mods, key].filter(Boolean).join('+');
11758
+ }
11759
+
11517
11760
  document.addEventListener('keydown', (e) => {
11518
11761
  if (!isHubKey(e)) return;
11519
11762
  e.preventDefault();
@@ -11586,6 +11829,14 @@ function hubPost(message) {
11586
11829
  });
11587
11830
  })();
11588
11831
 
11832
+ (function initHubKeys() {
11833
+ window.addEventListener('message', (e) => {
11834
+ if (e.source !== window.parent || e.origin !== hubOrigin()) return;
11835
+ if (e.data?.type !== 'hub:keys' || !Array.isArray(e.data.keys)) return;
11836
+ hubKeys = new Set(e.data.keys.filter((k) => typeof k === 'string'));
11837
+ });
11838
+ })();
11839
+
11589
11840
  (function initHubProject() {
11590
11841
  window.addEventListener('message', (e) => {
11591
11842
  if (e.source !== window.parent || e.origin !== hubOrigin()) return;
@@ -11593,7 +11844,6 @@ function hubPost(message) {
11593
11844
  const dirPath = e.data.project;
11594
11845
  if (typeof dirPath !== 'string' || !dirPath || dirPath === filterProject) return;
11595
11846
  filterByProject(dirPath);
11596
- updateProjectDropdown();
11597
11847
  });
11598
11848
  })();
11599
11849
  // #endregion HUB_INTEGRATION