claude-code-kanban 4.26.0 → 4.27.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
@@ -200,15 +200,16 @@ async function fetchSessions(includeTasks = true) {
200
200
  const allPinnedIds = new Set([...pinnedSessionIds, ...stickySessionIds]);
201
201
  if (revealedPlanSessionId) allPinnedIds.add(revealedPlanSessionId);
202
202
  if (revealedStorageSessionId) allPinnedIds.add(revealedStorageSessionId);
203
- // When server filters by activity, the focused session may not be active —
204
- // include it in pinned so the server still returns it.
205
- if ((sessionFilter === 'active' || zenMode) && currentSessionId) allPinnedIds.add(currentSessionId);
206
203
  const pinnedParam = allPinnedIds.size > 0 ? `&pinned=${[...allPinnedIds].join(',')}` : '';
204
+ // The focused session must come back whatever the filters say: renderSession and the
205
+ // info modal both look it up in `sessions` and bail when it is absent, so a session
206
+ // opened from outside the current filter would leave the view on the previous one.
207
+ const includeParam = currentSessionId ? `&include=${encodeURIComponent(currentSessionId)}` : '';
207
208
  const projectParam =
208
209
  filterProject && filterProject !== '__recent__' ? `&project=${encodeURIComponent(filterProject)}` : '';
209
210
  const filterParam = sessionFilter === 'active' ? '&filter=active' : '';
210
211
  const sessionsPromise = fetch(
211
- `/api/sessions?limit=${sessionLimit}${pinnedParam}${projectParam}${filterParam}`,
212
+ `/api/sessions?limit=${sessionLimit}${pinnedParam}${includeParam}${projectParam}${filterParam}`,
212
213
  ).then((r) => r.json());
213
214
 
214
215
  let newSessions, newTasks;
@@ -237,6 +238,16 @@ async function fetchSessions(includeTasks = true) {
237
238
  }
238
239
  }
239
240
 
241
+ // One session by id, ignoring every sidebar filter — that is what `include` means.
242
+ async function fetchSessionById(id) {
243
+ try {
244
+ const r = await fetch(`/api/sessions?limit=1&include=${encodeURIComponent(id)}`);
245
+ return r.ok ? (await r.json()).find((s) => s.id === id) || null : null;
246
+ } catch (_) {
247
+ return null;
248
+ }
249
+ }
250
+
240
251
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
241
252
  function handleSearch(query) {
242
253
  searchQuery = query.toLowerCase().trim();
@@ -416,14 +427,8 @@ async function fetchTasks(sessionId) {
416
427
  document.getElementById('message-toggle')?.style.removeProperty('display');
417
428
  const res = await fetch(`/api/sessions/${sessionId}`);
418
429
 
419
- let newTasks;
420
- if (res.ok) {
421
- newTasks = await res.json();
422
- } else if (res.status === 404) {
423
- newTasks = [];
424
- } else {
425
- throw new Error(`Failed to fetch tasks: ${res.status}`);
426
- }
430
+ if (!res.ok) throw new Error(`Failed to fetch tasks: ${res.status}`);
431
+ const newTasks = await res.json();
427
432
 
428
433
  const hash = JSON.stringify(newTasks);
429
434
  if (sessionId === currentSessionId && hash === lastCurrentTasksHash) {
@@ -435,6 +440,7 @@ async function fetchTasks(sessionId) {
435
440
  if (agentLogMode && sessionId !== currentSessionId) exitAgentLogMode();
436
441
  if (sessionId !== currentSessionId && document.getElementById('scratchpad-modal').classList.contains('visible'))
437
442
  closeScratchpad();
443
+ if (sessionId !== currentSessionId && detailPanel.classList.contains('visible')) closeDetailPanel();
438
444
  if (revealedPlanSessionId && sessionId !== revealedPlanSessionId) {
439
445
  revealedPlanSessionId = null;
440
446
  }
@@ -1154,11 +1160,22 @@ function renderMessageListBody(messages) {
1154
1160
 
1155
1161
  const clickable = `data-msg-idx="${i}" onclick="msgDetailFollowLatest=false;showMsgDetail(${i})" style="cursor:pointer"`;
1156
1162
  const pinBtn = renderMsgPinBtn(m, i);
1163
+
1157
1164
  if (m.type === 'user') {
1158
1165
  if (m.systemLabel) {
1166
+ // A message another agent sent into this session (subagent hand-back, peer
1167
+ // session) is agent output: the agent icon, a sender label, and a markdown
1168
+ // preview of the report — never the person icon.
1169
+ const preview = m.agentMessage ? buildAssistantPreview(m.fullText || m.text) : null;
1170
+ const bodyHtml = preview?.md
1171
+ ? `<div class="msg-text msg-text-md">
1172
+ <div class="msg-md-content rendered-md${preview.remainder ? ' is-truncated' : ''}">${renderMarkdown(preview.md)}</div>${preview.remainder ? `<div class="msg-md-more">${escapeHtml(preview.remainder)}</div>` : ''}
1173
+ </div>`
1174
+ : '';
1175
+ const queuedTag = m.queued ? '<span class="msg-queued-tag">queued</span>' : '';
1159
1176
  parts.push(`<div class="msg-item msg-system" ${clickable}>
1160
- ${MSG_ICON_SYSTEM}
1161
- <div class="msg-body"><div class="msg-text"><code>${escapeHtml(m.systemLabel)}</code></div><div class="msg-time">${formatDate(m.timestamp)}</div></div>${pinBtn}
1177
+ ${m.agentMessage ? ICON_AGENT : MSG_ICON_SYSTEM}
1178
+ <div class="msg-body"><div class="msg-text"><code>${escapeHtml(m.systemLabel)}</code></div>${bodyHtml}<div class="msg-time">${formatDate(m.timestamp)}${queuedTag}</div></div>${pinBtn}
1162
1179
  </div>`);
1163
1180
  } else {
1164
1181
  const cmd = parseCommandMessage(m.text);
@@ -1392,9 +1409,13 @@ function clearWaitingUi() {
1392
1409
  waitingCustomDraft = {};
1393
1410
  if (currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX) {
1394
1411
  // In follow mode stay in the modal and swap to the latest message —
1395
- // closing it would silently drop the user out of following.
1396
- if (msgDetailFollowLatest && currentMessages.length) showMsgDetail(currentMessages.length - 1);
1397
- else closeMsgDetailModal();
1412
+ // closing it would silently drop the user out of following. An ask that
1413
+ // opened its own window is the exception: there was no following to drop.
1414
+ if (msgDetailFollowLatest && !waitingDetailAutoOpened && currentMessages.length) {
1415
+ showMsgDetail(currentMessages.length - 1);
1416
+ } else {
1417
+ closeMsgDetailModal();
1418
+ }
1398
1419
  }
1399
1420
  // The saved-plan modal stays open after a decision — only its approval row goes
1400
1421
  document.getElementById('plan-approval-footer')?.remove();
@@ -1985,7 +2006,7 @@ function showMsgDetail(idx) {
1985
2006
  const rawText = stripAnsi(m.fullText || m.text || '');
1986
2007
  const cmd = m.type === 'user' ? parseCommandMessage(rawText) : null;
1987
2008
  document.getElementById('msg-detail-title').textContent =
1988
- m.type === 'assistant' ? 'Claude' : m.systemLabel ? 'System' : 'User';
2009
+ m.type === 'assistant' ? 'Claude' : m.agentMessage ? m.systemLabel : m.systemLabel ? 'System' : 'User';
1989
2010
  document.getElementById('msg-detail-agent-btn').style.display = 'none';
1990
2011
  const userExtras = m.type === 'user' ? renderUserAttachments(m) : '';
1991
2012
  if (m.compactSummary) {
@@ -2023,6 +2044,7 @@ function showMsgDetail(idx) {
2023
2044
  function closeMsgDetailModal() {
2024
2045
  hideModalOverlay('msg-detail-modal');
2025
2046
  msgDetailFollowLatest = false;
2047
+ waitingDetailAutoOpened = false;
2026
2048
  // Drop the message highlight on close, mirroring task-card behavior.
2027
2049
  msgHighlightDimmed = true;
2028
2050
  const container = document.getElementById('message-panel-content');
@@ -3021,6 +3043,13 @@ async function revealSession(id) {
3021
3043
  await fetchSessions();
3022
3044
  session = sessions.find((s) => s.id === id);
3023
3045
  }
3046
+ if (!session) {
3047
+ // The id is outside the list the filters asked for. renderSession bails on a session
3048
+ // missing from `sessions`, so pull that one row in before opening it; later polls
3049
+ // keep it, because fetchSessions pins currentSessionId.
3050
+ session = await fetchSessionById(id);
3051
+ if (session) sessions.push(session);
3052
+ }
3024
3053
  const uncollapsed = session ? uncollapseFor(session) : false;
3025
3054
  if (uncollapsed) persistCollapsedGroups();
3026
3055
  expandSidebar();
@@ -3124,7 +3153,10 @@ function getFilteredSessions() {
3124
3153
  }
3125
3154
  }
3126
3155
  if (filterProject) {
3127
- filteredSessions = filteredSessions.filter((s) => matchesProjectFilter(s.project));
3156
+ // The open session keeps its row even when it belongs to another project, so that
3157
+ // revealing one by id has something to scroll to. Same exemption the active branch
3158
+ // above already makes, for the same reason.
3159
+ filteredSessions = filteredSessions.filter((s) => matchesProjectFilter(s.project) || s.id === currentSessionId);
3128
3160
  }
3129
3161
 
3130
3162
  if (activityFilter.size > 0) {
@@ -3303,9 +3335,12 @@ function renderSessions() {
3303
3335
  ${renderScratchpadRow(zenSession)}
3304
3336
  ${renderLinkedDocsHtml(zenSession.id)}
3305
3337
  ${renderArtifactsHtml(zenSession.id)}
3306
- </div>`;
3338
+ </div>
3339
+ ${zenSession.hasWorkflow ? renderWorkflowLiveHtml(zenSession.id) : ''}`;
3307
3340
  bindLinkedDocsHandlers(sessionsList.querySelector('.linked-docs-section'), zenSession.id);
3308
- ensureSessionArtifacts(zenSession.id);
3341
+ artifactsSection.load(zenSession.id);
3342
+ scratchFilesSection.load(zenSession.id);
3343
+ if (zenSession.hasWorkflow) pollWorkflowLive(zenSession.id);
3309
3344
  return;
3310
3345
  }
3311
3346
 
@@ -3581,6 +3616,11 @@ function renderSession() {
3581
3616
  const session = sessions.find((s) => s.id === currentSessionId);
3582
3617
  if (!session) return;
3583
3618
 
3619
+ // Opening the session, not opening a linked-docs surface: the card badge is the
3620
+ // first place a pad shows up, and it would otherwise stay at zero until the user
3621
+ // happened to open the info modal or zen panel.
3622
+ loadSessionPads(currentSessionId);
3623
+
3584
3624
  const displayName =
3585
3625
  session.customTitle || session.name || session.gitBranch || session.description || currentSessionId;
3586
3626
 
@@ -5717,12 +5757,8 @@ function _renderStorageLinkedDocs() {
5717
5757
  const entries = [];
5718
5758
  for (const key of store.keys()) {
5719
5759
  if (!key.startsWith(PREVIEW_STORAGE_PREFIX)) continue;
5720
- try {
5721
- const arr = JSON.parse(store.getItem(key)) || [];
5722
- if (Array.isArray(arr) && arr.length) {
5723
- entries.push({ sessionId: key.slice(PREVIEW_STORAGE_PREFIX.length), paths: arr });
5724
- }
5725
- } catch {}
5760
+ const paths = readStoredList(key);
5761
+ if (paths.length) entries.push({ sessionId: key.slice(PREVIEW_STORAGE_PREFIX.length), paths });
5726
5762
  }
5727
5763
  if (!entries.length) return '<div class="storage-empty">No linked documents</div>';
5728
5764
 
@@ -5783,6 +5819,8 @@ function _storageUnlinkDoc(sessionId, path) {
5783
5819
 
5784
5820
  function _storageClearLinkedDocs(sessionId) {
5785
5821
  store.removeItem(PREVIEW_STORAGE_PREFIX + sessionId);
5822
+ // The auto-link record deliberately survives: clearing is unlinking every row at
5823
+ // once, and an unlinked pad must not come back on the next read of the transcript.
5786
5824
  afterLinkedDocsChanged(sessionId);
5787
5825
  }
5788
5826
 
@@ -5799,6 +5837,8 @@ function _findOrphanedKeys() {
5799
5837
  if (!known.has(key.slice('scratchpad-'.length))) orphaned.push(key);
5800
5838
  } else if (key.startsWith(PREVIEW_STORAGE_PREFIX)) {
5801
5839
  if (!known.has(key.slice(PREVIEW_STORAGE_PREFIX.length))) orphaned.push(key);
5840
+ } else if (key.startsWith(PAD_LINKED_PREFIX)) {
5841
+ if (!known.has(key.slice(PAD_LINKED_PREFIX.length))) orphaned.push(key);
5802
5842
  }
5803
5843
  }
5804
5844
  return orphaned;
@@ -6206,27 +6246,40 @@ document.addEventListener('keydown', (e) => {
6206
6246
  const PREVIEW_STORAGE_PREFIX = 'preview-paths-';
6207
6247
  let currentPreviewPath = null;
6208
6248
 
6209
- function getSessionPreviewPaths(sessionId) {
6210
- if (!sessionId) return [];
6249
+ function readStoredList(key) {
6211
6250
  try {
6212
- const raw = store.getItem(PREVIEW_STORAGE_PREFIX + sessionId);
6213
- const arr = raw ? JSON.parse(raw) : [];
6251
+ const arr = JSON.parse(store.getItem(key) || '[]');
6214
6252
  return Array.isArray(arr) ? arr : [];
6215
6253
  } catch {
6216
6254
  return [];
6217
6255
  }
6218
6256
  }
6219
6257
 
6258
+ function getSessionPreviewPaths(sessionId) {
6259
+ return sessionId ? readStoredList(PREVIEW_STORAGE_PREFIX + sessionId) : [];
6260
+ }
6261
+
6262
+ // Windows reaches one file as both `C:\a\b` and `C:/a/b`, and the two sides of a link
6263
+ // disagree by construction: a pad path is built with `path.join`, a hand-typed one is
6264
+ // stored as it was typed. Rows are compared through here so the same file cannot be
6265
+ // listed twice — and lists written before this existed heal on the next link.
6266
+ function canonicalPath(filePath) {
6267
+ const slashed = String(filePath).replace(/\\/g, '/');
6268
+ return /^[A-Za-z]:/.test(slashed) ? slashed.toLowerCase() : slashed;
6269
+ }
6270
+
6220
6271
  function addSessionPreviewPath(sessionId, filePath) {
6221
6272
  if (!sessionId || !filePath) return;
6222
- const paths = getSessionPreviewPaths(sessionId).filter((p) => p !== filePath);
6273
+ const key = canonicalPath(filePath);
6274
+ const paths = getSessionPreviewPaths(sessionId).filter((p) => canonicalPath(p) !== key);
6223
6275
  paths.unshift(filePath);
6224
6276
  store.setItem(PREVIEW_STORAGE_PREFIX + sessionId, JSON.stringify(paths.slice(0, 20)));
6225
6277
  }
6226
6278
 
6227
6279
  function removeSessionPreviewPath(sessionId, filePath) {
6228
6280
  if (!sessionId) return;
6229
- const paths = getSessionPreviewPaths(sessionId).filter((p) => p !== filePath);
6281
+ const key = canonicalPath(filePath);
6282
+ const paths = getSessionPreviewPaths(sessionId).filter((p) => canonicalPath(p) !== key);
6230
6283
  if (paths.length) store.setItem(PREVIEW_STORAGE_PREFIX + sessionId, JSON.stringify(paths));
6231
6284
  else store.removeItem(PREVIEW_STORAGE_PREFIX + sessionId);
6232
6285
  }
@@ -6296,23 +6349,48 @@ function bindPreviewRelativeLinks(bodyEl) {
6296
6349
  const isAbsolutePath = href.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(href);
6297
6350
  if (isAbsoluteUrl) return;
6298
6351
  const cleanHref = href.replace(/#.*$/, '');
6299
- if (!isPreviewablePath(cleanHref)) return;
6300
6352
  e.preventDefault();
6301
- openPreviewByPath(cleanHref, isAbsolutePath ? undefined : currentPreviewPath);
6353
+ openPreviewByPath(cleanHref, isAbsolutePath ? undefined : currentPreviewPath, openFileInEditor);
6302
6354
  });
6303
6355
  bodyEl.dataset.relLinkBound = '1';
6304
6356
  }
6305
6357
 
6306
6358
  // `kind` comes from the server, which is the only place that decides what is previewable.
6359
+ // hljs gets the extension as the language hint and `highlightAuto` is deliberately not
6360
+ // the fallback: on a config or data file it guesses wildly, and wrong colors read as a
6361
+ // broken file. Above the size cap it is skipped outright — it is O(size) with heavy
6362
+ // backtracking on the main thread, and .jsonl/.log files in a scratchpad reach megabytes.
6363
+ const HLJS_MAX_CHARS = 256 * 1024;
6364
+
6365
+ function renderSourcePreview(filePath, content) {
6366
+ const ext = (filePath.split('.').pop() || '').toLowerCase();
6367
+ if (typeof hljs !== 'undefined' && content.length <= HLJS_MAX_CHARS && hljs.getLanguage(ext)) {
6368
+ try {
6369
+ const value = hljs.highlight(content, { language: ext, ignoreIllegals: true }).value;
6370
+ return `<pre class="preview-source"><code class="hljs language-${escapeHtml(ext)}">${value}</code></pre>`;
6371
+ } catch (_) {}
6372
+ }
6373
+ return `<pre class="preview-source"><code class="hljs">${escapeHtml(content)}</code></pre>`;
6374
+ }
6375
+
6307
6376
  function openPreviewModal(filePath, content, kind) {
6308
6377
  currentPreviewPath = filePath;
6309
- document.getElementById('preview-modal-title').textContent = filePath.split(/[\\/]/).pop();
6378
+ const fileName = filePath.split(/[\\/]/).pop();
6379
+ document.getElementById('preview-modal-title').textContent = fileName;
6310
6380
  const bodyEl = document.getElementById('preview-modal-body');
6311
6381
  const isHtml = kind === 'html';
6312
6382
  document.querySelector('#preview-modal .modal').classList.toggle('preview-html', isHtml);
6313
6383
  bindPreviewRelativeLinks(bodyEl);
6314
6384
  if (isHtml) {
6315
6385
  renderHtmlPreview(bodyEl, content);
6386
+ } else if (kind === 'text') {
6387
+ bodyEl.innerHTML = renderSourcePreview(filePath, content);
6388
+ } else if (kind === 'image') {
6389
+ const img = document.createElement('img');
6390
+ img.className = 'preview-image';
6391
+ img.src = `/api/preview/image?${new URLSearchParams({ path: filePath })}`;
6392
+ img.alt = fileName;
6393
+ bodyEl.replaceChildren(img);
6316
6394
  } else {
6317
6395
  const { fm, body } = splitFrontmatter(content);
6318
6396
  bodyEl.innerHTML = (fm ? renderFrontmatterBlock(fm) : '') + renderMarkdown(body);
@@ -6324,7 +6402,8 @@ function openPreviewModal(filePath, content, kind) {
6324
6402
 
6325
6403
  function isPreviewLinkedToCurrentSession() {
6326
6404
  if (!currentPreviewPath || !currentSessionId) return false;
6327
- return getSessionPreviewPaths(currentSessionId).includes(currentPreviewPath);
6405
+ const key = canonicalPath(currentPreviewPath);
6406
+ return getSessionPreviewPaths(currentSessionId).some((p) => canonicalPath(p) === key);
6328
6407
  }
6329
6408
 
6330
6409
  function updatePreviewLinkBtn() {
@@ -6375,13 +6454,20 @@ function closePreviewModal() {
6375
6454
  currentPreviewPath = null;
6376
6455
  }
6377
6456
 
6457
+ function openFileInEditor(filePath) {
6458
+ postAndToast('/api/open-in-editor', { file: filePath }, 'in editor');
6459
+ }
6460
+
6378
6461
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
6379
6462
  function openPreviewInEditor() {
6380
6463
  if (!currentPreviewPath) return;
6381
- postAndToast('/api/open-in-editor', { file: currentPreviewPath }, 'in editor');
6464
+ openFileInEditor(currentPreviewPath);
6382
6465
  }
6383
6466
 
6384
- async function openPreviewByPath(filePath, base) {
6467
+ // The server is the only place that knows what it can render, so a null `kind` is its
6468
+ // answer to "not previewable" rather than a failure: callers that have a fallback
6469
+ // destination pass `onUnsupported` instead of keeping a copy of the extension list here.
6470
+ async function openPreviewByPath(filePath, base, onUnsupported) {
6385
6471
  if (!filePath) return;
6386
6472
  try {
6387
6473
  const qs = new URLSearchParams({ path: filePath });
@@ -6392,6 +6478,15 @@ async function openPreviewByPath(filePath, base) {
6392
6478
  return;
6393
6479
  }
6394
6480
  const data = await r.json();
6481
+ if (data.exists === false) {
6482
+ showToast('File not found');
6483
+ return;
6484
+ }
6485
+ if (data.kind === null) {
6486
+ if (onUnsupported) onUnsupported(data.path);
6487
+ else showToast('Nothing to preview in this file');
6488
+ return;
6489
+ }
6395
6490
  openPreviewModal(data.path, data.content, data.kind);
6396
6491
  } catch {
6397
6492
  showToast('Failed to load preview');
@@ -6445,10 +6540,69 @@ function getSessionBaseDir(sessionId) {
6445
6540
  return s?.cwd || s?.project || '';
6446
6541
  }
6447
6542
 
6448
- // Linked paths are stored as bare strings, so previewability is re-derived from the
6449
- // extension on every render instead of being remembered alongside the path.
6450
- function isPreviewablePath(p) {
6451
- return /\.(md|markdown|html?)$/i.test(p);
6543
+ // Linked paths are stored as bare strings, so the row has to ask what each one is. The
6544
+ // server owns that answer — /api/file/resolve reports `kind` for any file, previewable
6545
+ // or not — and it is cached for the tab because a path's kind does not change under it.
6546
+ // A resolve that fails is cached as unpreviewable: the editor is the right destination
6547
+ // for a file the server cannot read either, and re-asking on every render of a panel the
6548
+ // SSE tick repaints would be a poll.
6549
+ const previewKindByPath = new Map();
6550
+ const kindResolveInFlight = new Set();
6551
+
6552
+ async function loadPreviewKinds(sessionId, paths) {
6553
+ const pending = paths.filter((p) => !previewKindByPath.has(p) && !kindResolveInFlight.has(p));
6554
+ if (!pending.length) return;
6555
+ for (const p of pending) kindResolveInFlight.add(p);
6556
+ try {
6557
+ await Promise.all(
6558
+ pending.map(async (p) => {
6559
+ let kind = null;
6560
+ try {
6561
+ const r = await fetch(`/api/file/resolve?path=${encodeURIComponent(p)}`);
6562
+ if (r.ok) kind = (await r.json()).kind;
6563
+ } catch (_) {}
6564
+ previewKindByPath.set(p, kind);
6565
+ }),
6566
+ );
6567
+ } finally {
6568
+ for (const p of pending) kindResolveInFlight.delete(p);
6569
+ }
6570
+ afterLinkedDocsChanged(sessionId);
6571
+ }
6572
+
6573
+ // A pad the session made with `scratch new` becomes an ordinary linked document.
6574
+ // The server derives the pads on every read, so this record of the ones already
6575
+ // linked is what stops the next read undoing an unlink.
6576
+ const PAD_LINKED_PREFIX = 'autolinked-pads-';
6577
+ const padsFetched = new Set();
6578
+
6579
+ function getOfferedPadPaths(sessionId) {
6580
+ return readStoredList(PAD_LINKED_PREFIX + sessionId);
6581
+ }
6582
+
6583
+ function setOfferedPadPaths(sessionId, paths) {
6584
+ store.setItem(PAD_LINKED_PREFIX + sessionId, JSON.stringify(paths));
6585
+ }
6586
+
6587
+ async function loadSessionPads(sessionId) {
6588
+ if (!sessionId || padsFetched.has(sessionId)) return;
6589
+ padsFetched.add(sessionId);
6590
+ let pads;
6591
+ try {
6592
+ const r = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/pads`);
6593
+ if (!r.ok) throw new Error(r.status);
6594
+ pads = (await r.json()).pads || [];
6595
+ } catch (_) {
6596
+ padsFetched.delete(sessionId);
6597
+ return;
6598
+ }
6599
+ const offered = new Set(getOfferedPadPaths(sessionId).map(canonicalPath));
6600
+ const fresh = pads.map((p) => p.path).filter((p) => !offered.has(canonicalPath(p)));
6601
+ if (!fresh.length) return;
6602
+ // Recorded before the link, so a render triggered by the link already sees a pad
6603
+ // that will not be offered again.
6604
+ setOfferedPadPaths(sessionId, [...offered, ...fresh]);
6605
+ for (const p of fresh) setSessionDocLink(sessionId, p, false);
6452
6606
  }
6453
6607
 
6454
6608
  // A scratchpad manifest opens in the external `scratch` viewer, not the editor —
@@ -6477,11 +6631,14 @@ const LINKED_DOC_OPENERS = {
6477
6631
  },
6478
6632
  };
6479
6633
 
6634
+ // The manifest is checked before previewability, not after: its `.json` is previewable
6635
+ // text, but the pad is what the user linked, so the viewer wins over the raw file.
6636
+ // Without the CLI there is nothing to launch, so it falls back to the editor.
6480
6637
  function linkedDocOpener(p) {
6481
- if (isPreviewablePath(p)) return 'preview';
6482
- // Without the CLI there is nothing to launch, so the manifest falls back to the editor.
6483
- if (isScratchpadPath(p) && appConfig.scratchAvailable) return 'scratch';
6484
- return 'editor';
6638
+ if (isScratchpadPath(p)) return appConfig.scratchAvailable ? 'scratch' : 'editor';
6639
+ // A path whose kind has not arrived yet renders as previewable, which is what the
6640
+ // click does anyway — openPreviewByPath falls back to the editor on a null kind.
6641
+ return previewKindByPath.get(p) === null ? 'editor' : 'preview';
6485
6642
  }
6486
6643
 
6487
6644
  // Every manifest is called scratchpad.json, so the pad's folder names it. Kept
@@ -6494,7 +6651,7 @@ function linkedDocLabel(p) {
6494
6651
  function openLinkedDoc(p, baseDir) {
6495
6652
  const opener = linkedDocOpener(p);
6496
6653
  if (opener === 'preview') {
6497
- openPreviewByPath(p, baseDir);
6654
+ openPreviewByPath(p, baseDir, openFileInEditor);
6498
6655
  return;
6499
6656
  }
6500
6657
  const { url, body, toast } = LINKED_DOC_OPENERS[opener];
@@ -6503,6 +6660,9 @@ function openLinkedDoc(p, baseDir) {
6503
6660
 
6504
6661
  function renderLinkedDocsHtml(sessionId) {
6505
6662
  const paths = getSessionPreviewPaths(sessionId);
6663
+ // Kicked off from the render rather than the three call sites, so a new surface that
6664
+ // shows linked docs cannot forget it. Returns at once once every path is cached.
6665
+ loadPreviewKinds(sessionId, paths);
6506
6666
  const baseDir = getSessionBaseDir(sessionId);
6507
6667
  const items = paths
6508
6668
  .map((p) => {
@@ -6538,11 +6698,41 @@ function renderLinkedDocsHtml(sessionId) {
6538
6698
  </div>`;
6539
6699
  }
6540
6700
 
6541
- // Artifacts are read out of the transcript rather than stored with the session, so
6542
- // the list arrives after the panel is already on screen and the slot — always
6543
- // rendered, empty or not — is patched in place once it does.
6544
- const artifactsBySession = new Map();
6545
- const artifactsInFlight = new Set();
6701
+ function paintSectionSlots(attr, sessionId, html) {
6702
+ for (const slot of document.querySelectorAll(`[${attr}="${CSS.escape(sessionId)}"]`)) {
6703
+ slot.innerHTML = html;
6704
+ }
6705
+ }
6706
+
6707
+ // A panel section whose data is not part of the session object arrives after the panel
6708
+ // is already on screen, so every one of them needs the same lifecycle: one fetch per
6709
+ // session at a time, and a patch of the slot — always rendered, empty or not — once the
6710
+ // data lands. `ttlMs` is for the sections the server does not cache itself; at 0 the
6711
+ // section re-asks on every render, which is right when the server holds the answer.
6712
+ function makeSectionLoader({ attr, endpoint, pick, innerHtml, ttlMs = 0 }) {
6713
+ const store = new Map();
6714
+ const inFlight = new Set();
6715
+ return {
6716
+ get: (sessionId) => store.get(sessionId)?.data || [],
6717
+ repaint: (sessionId) => paintSectionSlots(attr, sessionId, innerHtml(sessionId)),
6718
+ async load(sessionId) {
6719
+ if (!sessionId || inFlight.has(sessionId)) return;
6720
+ const cached = store.get(sessionId);
6721
+ if (cached && Date.now() - cached.at < ttlMs) return;
6722
+ inFlight.add(sessionId);
6723
+ try {
6724
+ const res = await fetch(`/api/sessions/${sessionId}/${endpoint}`);
6725
+ if (!res.ok) return;
6726
+ store.set(sessionId, { at: Date.now(), data: pick(await res.json()) });
6727
+ } catch (_) {
6728
+ return;
6729
+ } finally {
6730
+ inFlight.delete(sessionId);
6731
+ }
6732
+ paintSectionSlots(attr, sessionId, innerHtml(sessionId));
6733
+ },
6734
+ };
6735
+ }
6546
6736
 
6547
6737
  function artifactLabel(a) {
6548
6738
  if (a.title) return a.title;
@@ -6550,7 +6740,7 @@ function artifactLabel(a) {
6550
6740
  }
6551
6741
 
6552
6742
  function artifactsInnerHtml(sessionId) {
6553
- const list = artifactsBySession.get(sessionId) || [];
6743
+ const list = artifactsSection.get(sessionId);
6554
6744
  if (!list.length) return '';
6555
6745
  const items = list
6556
6746
  .map(
@@ -6574,27 +6764,196 @@ function renderArtifactsHtml(sessionId) {
6574
6764
  return `<div class="artifacts-section panel-section" data-artifacts-for="${escapeHtml(sessionId)}">${artifactsInnerHtml(sessionId)}</div>`;
6575
6765
  }
6576
6766
 
6577
- // Always asks: the server holds the scan until the transcript grows, so a repeat
6578
- // costs one stat, and an artifact published while the panel is open still appears.
6579
- async function ensureSessionArtifacts(sessionId) {
6580
- if (!sessionId || artifactsInFlight.has(sessionId)) return;
6581
- artifactsInFlight.add(sessionId);
6767
+ // No TTL: the server holds the scan until the transcript grows, so a repeat costs one
6768
+ // stat, and an artifact published while the panel is open still appears.
6769
+ const artifactsSection = makeSectionLoader({
6770
+ attr: 'data-artifacts-for',
6771
+ endpoint: 'artifacts',
6772
+ pick: (data) => data.artifacts || [],
6773
+ innerHtml: (sessionId) => artifactsInnerHtml(sessionId),
6774
+ });
6775
+
6776
+ // The server decides liveness (a run has no terminal journal entry) and answers with
6777
+ // nothing once the workflow finishes, so the section empties itself and disappears.
6778
+ const workflowLiveSection = makeSectionLoader({
6779
+ attr: 'data-workflow-live-for',
6780
+ endpoint: 'workflow-live',
6781
+ pick: (data) => (data.workflow ? [data.workflow] : []),
6782
+ innerHtml: (sessionId) => workflowLiveInnerHtml(sessionId),
6783
+ // The agent hooks re-render the sidebar on every subagent start and stop, so the TTL is
6784
+ // what keeps that burst down to one ask per run step.
6785
+ ttlMs: 4000,
6786
+ });
6787
+
6788
+ function workflowLiveInnerHtml(sessionId) {
6789
+ const run = workflowLiveSection.get(sessionId)[0];
6790
+ if (!run) return '';
6791
+ const pct = run.startedCount ? Math.round((run.doneCount / run.startedCount) * 100) : 0;
6792
+ const phases = (run.phases || [])
6793
+ .map((p) => {
6794
+ const state = p.started === 0 ? 'pending' : p.done >= p.started ? 'done' : 'running';
6795
+ const count = p.started ? `${p.done}/${p.started}` : '';
6796
+ return `<span class="wf-live-phase ${escapeHtml(state)}">
6797
+ <span class="wf-live-phase-title">${escapeHtml(p.title)}</span>
6798
+ <span class="wf-live-phase-count">${count}</span>
6799
+ </span>`;
6800
+ })
6801
+ .join('');
6802
+ const running = run.running?.length
6803
+ ? `<div class="wf-live-running" title="${escapeHtml(run.running.join(', '))}">${escapeHtml(run.running.join(' · '))}</div>`
6804
+ : '';
6805
+ return `<div class="panel-section-header">
6806
+ ${WORKFLOW_GEAR_SVG}
6807
+ <span>Workflow</span>
6808
+ <span class="panel-section-count">${run.doneCount}/${run.startedCount} agents</span>
6809
+ </div>
6810
+ <div class="wf-live" onclick="showWorkflowModal('${escAttrJs(sessionId)}')" title="Open workflow run">
6811
+ <div class="wf-live-name"><span class="pulse"></span>${escapeHtml(run.name || run.id)}</div>
6812
+ <div class="progress-bar wf-live-bar"><div class="progress-fill" style="width: ${pct}%"></div></div>
6813
+ ${phases ? `<div class="wf-live-phases">${phases}</div>` : ''}
6814
+ ${running}
6815
+ </div>`;
6816
+ }
6817
+
6818
+ function renderWorkflowLiveHtml(sessionId) {
6819
+ return `<div class="workflow-live-section" data-workflow-live-for="${escapeHtml(sessionId)}">${workflowLiveInnerHtml(sessionId)}</div>`;
6820
+ }
6821
+
6822
+ // Every agent start and stop already re-renders the sidebar through the agent-activity
6823
+ // feed, which is what keeps the card current. The timer is the fallback for a config dir
6824
+ // with no cck hooks installed: there the last agent's result reaches nobody, and the card
6825
+ // would sit on a finished run until the next render. Slow on purpose — the hooked case
6826
+ // never waits for it. The generation guard drops a chain left mid-fetch by an earlier
6827
+ // render, so renders can't stack up chains.
6828
+ const WORKFLOW_LIVE_POLL_MS = 30000;
6829
+ let workflowLivePoll = null;
6830
+ let workflowLivePollFor = null;
6831
+
6832
+ function pollWorkflowLive(sessionId) {
6833
+ workflowLiveSection.load(sessionId);
6834
+ // A render must not restart the timer: renders arrive far faster than the interval, and
6835
+ // each restart would push the fallback tick back out of reach, leaving a finished run on
6836
+ // screen forever in a config dir with no hooks.
6837
+ if (workflowLivePoll && workflowLivePollFor === sessionId) return;
6838
+ clearTimeout(workflowLivePoll);
6839
+ workflowLivePollFor = sessionId;
6840
+ const tick = async () => {
6841
+ workflowLivePoll = null;
6842
+ if (workflowLivePollFor !== sessionId) return;
6843
+ if (!document.querySelector(`[data-workflow-live-for="${CSS.escape(sessionId)}"]`)) return;
6844
+ await workflowLiveSection.load(sessionId);
6845
+ if (workflowLivePollFor !== sessionId || !workflowLiveSection.get(sessionId).length) return;
6846
+ workflowLivePoll = setTimeout(tick, WORKFLOW_LIVE_POLL_MS);
6847
+ };
6848
+ workflowLivePoll = setTimeout(tick, WORKFLOW_LIVE_POLL_MS);
6849
+ }
6850
+
6851
+ const SCRATCH_FILES_COLLAPSED = 3;
6852
+ // Unlike the artifacts scan the server caches nothing here, so a TTL is what stops the
6853
+ // zen panel — re-rendered on every SSE tick — from turning a readdir plus a stat per
6854
+ // file into a 2 s poll.
6855
+ const scratchFilesSection = makeSectionLoader({
6856
+ attr: 'data-scratch-files-for',
6857
+ endpoint: 'scratchpad-files',
6858
+ pick: (data) => data.files || [],
6859
+ innerHtml: (sessionId) => scratchFilesInnerHtml(sessionId),
6860
+ ttlMs: 10000,
6861
+ });
6862
+ // Outlives the cache entries on purpose: a refetch on the TTL must not collapse a list
6863
+ // the user opened.
6864
+ const scratchFilesExpanded = new Set();
6865
+ // Open folders only, keyed by absolute path: path -> { status: 'loading' | 'ready' |
6866
+ // 'error', children }. Closing a folder drops its entry, so the cache is bounded by
6867
+ // what is on screen and a reopen shows fresh contents for one readdir.
6868
+ const scratchFolders = new Map();
6869
+
6870
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
6871
+ function openScratchFile(filePath) {
6872
+ openPreviewByPath(filePath, undefined, openFileInEditor);
6873
+ }
6874
+
6875
+ function scratchRowHtml(f) {
6876
+ const escPath = escapeHtml(f.path);
6877
+ const isDir = f.kind === 'dir';
6878
+ const folder = isDir ? scratchFolders.get(f.path) : null;
6879
+ const name = escapeHtml(f.name);
6880
+ const link = isDir
6881
+ ? `<button type="button" class="scratch-file-link scratch-folder-toggle${folder ? '' : ' collapsed'}" title="${escPath}" ${folder ? 'aria-expanded="true"' : 'aria-expanded="false"'}>${groupChevronSvg(10)}${name}/</button>`
6882
+ : `<button type="button" class="scratch-file-link" onclick="openScratchFile(this.closest('li').dataset.file)" title="${escPath}">${name}</button>`;
6883
+ let body = '';
6884
+ if (folder?.status === 'loading') body = '<div class="scratch-folder-empty">Loading…</div>';
6885
+ else if (folder?.status === 'error') body = '<div class="scratch-folder-empty">Failed to load</div>';
6886
+ else if (folder && !folder.children.length) body = '<div class="scratch-folder-empty">Empty</div>';
6887
+ else if (folder) body = scratchRowsHtml(folder.children);
6888
+ return `<li class="scratch-file-item${isDir ? ' scratch-folder-item' : ''}" data-file="${escPath}">
6889
+ ${link}
6890
+ <span class="scratch-file-time">${formatDate(f.modifiedAt)}</span>
6891
+ <span class="row-actions scratch-file-actions">
6892
+ <button type="button" onclick="copyWithFeedback(this.closest('li').dataset.file, this)" title="Copy path" aria-label="Copy path">${ICON_COPY}</button>
6893
+ <button type="button" onclick="${isDir ? 'openFolderInEditor' : 'openFileInEditor'}(this.closest('li').dataset.file)" title="Open in editor" aria-label="Open in editor">${ICON_OPEN_EXTERNAL}</button>
6894
+ </span>
6895
+ ${body}
6896
+ </li>`;
6897
+ }
6898
+
6899
+ function scratchRowsHtml(rows) {
6900
+ return `<ul class="scratch-file-list">${rows.map(scratchRowHtml).join('')}</ul>`;
6901
+ }
6902
+
6903
+ function scratchFilesInnerHtml(sessionId) {
6904
+ const list = scratchFilesSection.get(sessionId);
6905
+ if (!list.length) return '';
6906
+ const expanded = scratchFilesExpanded.has(sessionId);
6907
+ const shown = expanded ? list : list.slice(0, SCRATCH_FILES_COLLAPSED);
6908
+ const more =
6909
+ list.length > SCRATCH_FILES_COLLAPSED
6910
+ ? `<button type="button" class="expand-toggle-btn scratch-files-more" onclick="toggleScratchFiles('${escAttrJs(sessionId)}')">${expanded ? 'Show less' : `Show all ${list.length}`}</button>`
6911
+ : '';
6912
+ return `${scratchRowsHtml(shown)}${more}`;
6913
+ }
6914
+
6915
+ // Delegated at the document because the whole zen panel is re-rendered on every SSE tick,
6916
+ // which would drop a listener bound on the section.
6917
+ document.addEventListener('click', (e) => {
6918
+ const toggle = e.target.closest('.scratch-folder-toggle');
6919
+ if (!toggle) return;
6920
+ const section = toggle.closest('[data-scratch-files-for]');
6921
+ if (section) toggleScratchFolder(section.dataset.scratchFilesFor, toggle.closest('li').dataset.file);
6922
+ });
6923
+
6924
+ async function toggleScratchFolder(sessionId, dirPath) {
6925
+ if (scratchFolders.has(dirPath)) {
6926
+ scratchFolders.delete(dirPath);
6927
+ scratchFilesSection.repaint(sessionId);
6928
+ return;
6929
+ }
6930
+ scratchFolders.set(dirPath, { status: 'loading', children: [] });
6931
+ scratchFilesSection.repaint(sessionId);
6932
+ let entry = { status: 'error', children: [] };
6582
6933
  try {
6583
- const res = await fetch(`/api/sessions/${sessionId}/artifacts`);
6584
- if (!res.ok) return;
6585
- const data = await res.json();
6586
- artifactsBySession.set(sessionId, data.artifacts || []);
6934
+ const res = await fetch(`/api/sessions/${sessionId}/scratchpad-files?path=${encodeURIComponent(dirPath)}`);
6935
+ if (res.ok) entry = { status: 'ready', children: (await res.json()).files || [] };
6587
6936
  } catch (_) {
6588
- return;
6589
- } finally {
6590
- artifactsInFlight.delete(sessionId);
6937
+ // Reads "Failed to load"; closing and reopening the folder retries.
6591
6938
  }
6592
- const html = artifactsInnerHtml(sessionId);
6593
- for (const slot of document.querySelectorAll(`[data-artifacts-for="${CSS.escape(sessionId)}"]`)) {
6594
- slot.innerHTML = html;
6939
+ // The user may have closed the folder while the request was in flight.
6940
+ if (scratchFolders.has(dirPath)) {
6941
+ scratchFolders.set(dirPath, entry);
6942
+ scratchFilesSection.repaint(sessionId);
6595
6943
  }
6596
6944
  }
6597
6945
 
6946
+ function renderScratchFilesHtml(sessionId) {
6947
+ return `<div class="scratch-files-section" data-scratch-files-for="${escapeHtml(sessionId)}">${scratchFilesInnerHtml(sessionId)}</div>`;
6948
+ }
6949
+
6950
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
6951
+ function toggleScratchFiles(sessionId) {
6952
+ if (scratchFilesExpanded.has(sessionId)) scratchFilesExpanded.delete(sessionId);
6953
+ else scratchFilesExpanded.add(sessionId);
6954
+ scratchFilesSection.repaint(sessionId);
6955
+ }
6956
+
6598
6957
  // One delegated listener per section: the list is re-rendered on every change, so
6599
6958
  // per-row handlers would only be rebound each time anyway.
6600
6959
  function bindLinkedDocsHandlers(container, sessionId) {
@@ -6664,7 +7023,7 @@ async function linkFileByPath(sessionId, raw, slot) {
6664
7023
  if (base) qs.set('base', base);
6665
7024
  const r = await fetch(`/api/file/resolve?${qs}`);
6666
7025
  const data = await r.json().catch(() => ({}));
6667
- if (!r.ok) {
7026
+ if (!r.ok || !data.exists) {
6668
7027
  fail(data.error || 'File not found');
6669
7028
  return;
6670
7029
  }
@@ -6999,11 +7358,16 @@ function maybeFollowLatest() {
6999
7358
  // on the card and the badge until they get to it. Each ask id is offered once,
7000
7359
  // so closing the modal does not bring it back on the next poll.
7001
7360
  let lastAutoOpenedWaitingId = null;
7361
+ // Set only on that self-opening path: answering an ask the user never asked to see
7362
+ // should put the screen back the way it was, where answering one they navigated to
7363
+ // keeps them following. Cleared with the modal, so a manual reopen is not "auto".
7364
+ let waitingDetailAutoOpened = false;
7002
7365
  function maybeAutoOpenWaiting() {
7003
7366
  if (!isWaitingAnswerable() || !isWaitingFresh()) return;
7004
7367
  if (currentWaiting.id === lastAutoOpenedWaitingId || isAnyModalOpen()) return;
7005
7368
  lastAutoOpenedWaitingId = currentWaiting.id;
7006
7369
  msgDetailFollowLatest = true;
7370
+ waitingDetailAutoOpened = true;
7007
7371
  maybeFollowLatest();
7008
7372
  }
7009
7373
 
@@ -8124,7 +8488,11 @@ async function showSessionInfoModal(sessionId) {
8124
8488
  _planSessionId = sessionId;
8125
8489
  const cachedTasks = currentSessionId === sessionId ? currentTasks : [];
8126
8490
  showInfoModal(session, null, cachedTasks, null, null);
8127
- ensureSessionArtifacts(sessionId);
8491
+ artifactsSection.load(sessionId);
8492
+ // The modal reaches any card's session, not just the open one, so this is the second
8493
+ // place a pad can first be seen. Linking belongs on the two open events rather than
8494
+ // in renderLinkedDocsHtml, which only builds a string and runs on every repaint.
8495
+ loadSessionPads(sessionId);
8128
8496
 
8129
8497
  const rerender = (teamConfig, tasks, planContent, parentInfo) => {
8130
8498
  if (_planSessionId !== sessionId) return; // user opened a different modal
@@ -8196,7 +8564,8 @@ function renderScratchpadRow(session) {
8196
8564
  <button onclick="copyWithFeedback('${escAttrJs(dir)}', this)" title="Copy">${ICON_COPY}</button>
8197
8565
  <button data-folder="${escapeHtml(dir)}" onclick="openFolderInEditor(this.dataset.folder)" title="Open in editor">${ICON_OPEN_EXTERNAL}</button>
8198
8566
  </span>
8199
- </div>`;
8567
+ </div>
8568
+ ${renderScratchFilesHtml(session.id)}`;
8200
8569
  }
8201
8570
 
8202
8571
  function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
@@ -8446,11 +8815,36 @@ let spPeekedId = null;
8446
8815
  // Per-open cache: spSource is frozen while the picker is up, so a fetched log stays valid.
8447
8816
  const spPeekCache = new Map();
8448
8817
 
8818
+ // A session outside the sidebar filter has no name to fuzzy-match against, so a full id
8819
+ // is read as a request for that exact session and resolved against the server instead.
8820
+ // Values: 'pending' while the request is out, then the session or null.
8821
+ const spGlobal = new Map();
8822
+
8823
+ async function spResolveGlobal(id) {
8824
+ spGlobal.set(id, 'pending');
8825
+ spGlobal.set(id, await fetchSessionById(id));
8826
+ if (document.getElementById('session-picker-input').value.trim().toLowerCase() === id) renderSessionPicker();
8827
+ }
8828
+
8829
+ // The row to show for a query nothing in the list matched, as `{ hit, empty }` — one of
8830
+ // the two is always null. Starts the lookup when the query is an id not asked about yet.
8831
+ function spResolveMiss(query) {
8832
+ if (!SESSION_UUID_RE.test(query)) {
8833
+ return { hit: null, empty: spSource.length ? 'No session matches' : 'No sessions in the current sidebar filter' };
8834
+ }
8835
+ const id = query.toLowerCase();
8836
+ if (!spGlobal.has(id)) spResolveGlobal(id);
8837
+ const found = spGlobal.get(id);
8838
+ if (found === 'pending') return { hit: null, empty: 'Looking up session…' };
8839
+ return found ? { hit: found, empty: null } : { hit: null, empty: 'No session with that id' };
8840
+ }
8841
+
8449
8842
  function openSessionPicker() {
8450
8843
  const input = document.getElementById('session-picker-input');
8451
8844
  input.value = '';
8452
8845
  spSource = getFilteredSessions().sort((a, b) => Date.parse(b.modifiedAt) - Date.parse(a.modifiedAt));
8453
8846
  spPeekCache.clear();
8847
+ spGlobal.clear();
8454
8848
  document.getElementById('session-picker-modal').classList.add('visible');
8455
8849
  renderSessionPicker();
8456
8850
  input.focus();
@@ -8541,11 +8935,14 @@ function renderSessionPicker() {
8541
8935
  const list = document.getElementById('session-picker-list');
8542
8936
  const query = document.getElementById('session-picker-input').value.trim();
8543
8937
  spRows = spSource.filter((s) => spMatches(s, query));
8544
-
8545
8938
  if (!spRows.length) {
8546
- spIdx = -1;
8547
- list.innerHTML = `<div class="sp-empty">${spSource.length ? 'No session matches' : 'No sessions in the current sidebar filter'}</div>`;
8548
- return;
8939
+ const { hit, empty } = spResolveMiss(query);
8940
+ if (hit) spRows = [hit];
8941
+ else {
8942
+ spIdx = -1;
8943
+ list.innerHTML = `<div class="sp-empty">${empty}</div>`;
8944
+ return;
8945
+ }
8549
8946
  }
8550
8947
 
8551
8948
  list.innerHTML = spRows
@@ -8599,6 +8996,19 @@ function initSessionPicker() {
8599
8996
  const row = e.target.closest('.sp-row');
8600
8997
  if (row) spOpen(Number(row.dataset.idx));
8601
8998
  });
8999
+ // Vimium eats Escape inside a text input and only blurs it, so the page never sees the key.
9000
+ // A blur that no click in the picker caused, while the window keeps focus, is that Escape.
9001
+ const modal = document.getElementById('session-picker-modal');
9002
+ let pointerDown = false;
9003
+ modal.addEventListener('mousedown', () => {
9004
+ pointerDown = true;
9005
+ });
9006
+ document.addEventListener('mouseup', () => {
9007
+ pointerDown = false;
9008
+ });
9009
+ input.addEventListener('blur', () => {
9010
+ if (!pointerDown && modal.classList.contains('visible') && document.hasFocus()) closeSessionPicker();
9011
+ });
8602
9012
  }
8603
9013
  //#endregion
8604
9014
 
@@ -9465,22 +9875,25 @@ window.addEventListener('popstate', () => {
9465
9875
  //#endregion
9466
9876
 
9467
9877
  // #region HUB_INTEGRATION
9878
+ // e.code travels with e.key because macOS composes Option+<key> into a character (Option+P is
9879
+ // 'π'), so the key alone cannot identify the binding. The hub owns the keymap and normalizes;
9880
+ // these tests only decide whether a press is the hub's to handle.
9468
9881
  document.addEventListener('keydown', (e) => {
9469
9882
  if (!window.__HUB__?.enabled) return;
9470
- const fwd = (key) => {
9883
+ const fwd = () => {
9471
9884
  e.preventDefault();
9472
- hubPost({ type: 'hub:keydown', key, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey });
9885
+ hubPost({ type: 'hub:keydown', key: e.key, code: e.code, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey });
9473
9886
  };
9474
9887
  if (e.ctrlKey && e.altKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
9475
- fwd(e.key);
9888
+ fwd();
9476
9889
  }
9477
9890
  // Own branch: the Alt+digit case below requires !ctrlKey. The hub owns the Ctrl+Alt+letter
9478
9891
  // keymap and ignores unbound letters.
9479
- if (e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey && /^[a-z]$/i.test(e.key)) {
9480
- fwd(e.key);
9892
+ if (e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey && (/^[a-z]$/i.test(e.key) || /^Key[A-Z]$/.test(e.code))) {
9893
+ fwd();
9481
9894
  }
9482
- if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey && /^[1-9]$/.test(e.key)) {
9483
- fwd(e.key);
9895
+ if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey && (/^[1-9]$/.test(e.key) || /^Digit[1-9]$/.test(e.code))) {
9896
+ fwd();
9484
9897
  }
9485
9898
  });
9486
9899