claude-code-kanban 4.25.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
@@ -30,6 +30,12 @@ let sessionFilter = FILTER_DEFAULTS.session;
30
30
  // Only meaningful while sessionFilter === 'active' (filterBySessions clears it otherwise)
31
31
  const activityFilter = new Set(); // kinds: 'waiting' | 'active'
32
32
  let sessionLimit = FILTER_DEFAULTS.limit;
33
+ // Narrows the sidebar to the open session only. Kept out of FILTER_DEFAULTS/updateUrl because it
34
+ // is not a filter — it has no select, no summary token, and never reaches the session picker.
35
+ // Persisted in localStorage rather than the URL: the hub recreates each iframe at the app's base
36
+ // URL, so a query string would not survive a hub reload.
37
+ const ZEN_KEY = 'zenMode';
38
+ let zenMode = store.getItem(ZEN_KEY) === 'true';
33
39
  let filterProject = FILTER_DEFAULTS.project; // null = all, '__recent__' = last 24h, or project path
34
40
  let recentProjects = new Set();
35
41
  let projectsCacheDirty = true;
@@ -144,6 +150,9 @@ function resetState() {
144
150
  sessionFilter = FILTER_DEFAULTS.session;
145
151
  sessionLimit = FILTER_DEFAULTS.limit;
146
152
  filterProject = FILTER_DEFAULTS.project;
153
+ zenMode = false;
154
+ store.removeItem(ZEN_KEY);
155
+ renderZenState();
147
156
  ownerFilter = '';
148
157
  searchQuery = '';
149
158
  viewMode = 'all';
@@ -191,15 +200,16 @@ async function fetchSessions(includeTasks = true) {
191
200
  const allPinnedIds = new Set([...pinnedSessionIds, ...stickySessionIds]);
192
201
  if (revealedPlanSessionId) allPinnedIds.add(revealedPlanSessionId);
193
202
  if (revealedStorageSessionId) allPinnedIds.add(revealedStorageSessionId);
194
- // When server filters by activity, the focused session may not be active —
195
- // include it in pinned so the server still returns it.
196
- if (sessionFilter === 'active' && currentSessionId) allPinnedIds.add(currentSessionId);
197
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)}` : '';
198
208
  const projectParam =
199
209
  filterProject && filterProject !== '__recent__' ? `&project=${encodeURIComponent(filterProject)}` : '';
200
210
  const filterParam = sessionFilter === 'active' ? '&filter=active' : '';
201
211
  const sessionsPromise = fetch(
202
- `/api/sessions?limit=${sessionLimit}${pinnedParam}${projectParam}${filterParam}`,
212
+ `/api/sessions?limit=${sessionLimit}${pinnedParam}${includeParam}${projectParam}${filterParam}`,
203
213
  ).then((r) => r.json());
204
214
 
205
215
  let newSessions, newTasks;
@@ -228,6 +238,16 @@ async function fetchSessions(includeTasks = true) {
228
238
  }
229
239
  }
230
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
+
231
251
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
232
252
  function handleSearch(query) {
233
253
  searchQuery = query.toLowerCase().trim();
@@ -407,14 +427,8 @@ async function fetchTasks(sessionId) {
407
427
  document.getElementById('message-toggle')?.style.removeProperty('display');
408
428
  const res = await fetch(`/api/sessions/${sessionId}`);
409
429
 
410
- let newTasks;
411
- if (res.ok) {
412
- newTasks = await res.json();
413
- } else if (res.status === 404) {
414
- newTasks = [];
415
- } else {
416
- throw new Error(`Failed to fetch tasks: ${res.status}`);
417
- }
430
+ if (!res.ok) throw new Error(`Failed to fetch tasks: ${res.status}`);
431
+ const newTasks = await res.json();
418
432
 
419
433
  const hash = JSON.stringify(newTasks);
420
434
  if (sessionId === currentSessionId && hash === lastCurrentTasksHash) {
@@ -426,6 +440,7 @@ async function fetchTasks(sessionId) {
426
440
  if (agentLogMode && sessionId !== currentSessionId) exitAgentLogMode();
427
441
  if (sessionId !== currentSessionId && document.getElementById('scratchpad-modal').classList.contains('visible'))
428
442
  closeScratchpad();
443
+ if (sessionId !== currentSessionId && detailPanel.classList.contains('visible')) closeDetailPanel();
429
444
  if (revealedPlanSessionId && sessionId !== revealedPlanSessionId) {
430
445
  revealedPlanSessionId = null;
431
446
  }
@@ -493,6 +508,7 @@ async function fetchAgents(sessionId) {
493
508
  lastWaitingHash = waitHash;
494
509
  if (messagePanelOpen && currentMessages.length) renderMessages(currentMessages);
495
510
  maybeFollowLatest();
511
+ maybeAutoOpenWaiting();
496
512
  }
497
513
  } catch (e) {
498
514
  console.error('[fetchAgents]', e);
@@ -1144,11 +1160,22 @@ function renderMessageListBody(messages) {
1144
1160
 
1145
1161
  const clickable = `data-msg-idx="${i}" onclick="msgDetailFollowLatest=false;showMsgDetail(${i})" style="cursor:pointer"`;
1146
1162
  const pinBtn = renderMsgPinBtn(m, i);
1163
+
1147
1164
  if (m.type === 'user') {
1148
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>' : '';
1149
1176
  parts.push(`<div class="msg-item msg-system" ${clickable}>
1150
- ${MSG_ICON_SYSTEM}
1151
- <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}
1152
1179
  </div>`);
1153
1180
  } else {
1154
1181
  const cmd = parseCommandMessage(m.text);
@@ -1382,9 +1409,13 @@ function clearWaitingUi() {
1382
1409
  waitingCustomDraft = {};
1383
1410
  if (currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX) {
1384
1411
  // In follow mode stay in the modal and swap to the latest message —
1385
- // closing it would silently drop the user out of following.
1386
- if (msgDetailFollowLatest && currentMessages.length) showMsgDetail(currentMessages.length - 1);
1387
- 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
+ }
1388
1419
  }
1389
1420
  // The saved-plan modal stays open after a decision — only its approval row goes
1390
1421
  document.getElementById('plan-approval-footer')?.remove();
@@ -1975,7 +2006,7 @@ function showMsgDetail(idx) {
1975
2006
  const rawText = stripAnsi(m.fullText || m.text || '');
1976
2007
  const cmd = m.type === 'user' ? parseCommandMessage(rawText) : null;
1977
2008
  document.getElementById('msg-detail-title').textContent =
1978
- m.type === 'assistant' ? 'Claude' : m.systemLabel ? 'System' : 'User';
2009
+ m.type === 'assistant' ? 'Claude' : m.agentMessage ? m.systemLabel : m.systemLabel ? 'System' : 'User';
1979
2010
  document.getElementById('msg-detail-agent-btn').style.display = 'none';
1980
2011
  const userExtras = m.type === 'user' ? renderUserAttachments(m) : '';
1981
2012
  if (m.compactSummary) {
@@ -2013,6 +2044,7 @@ function showMsgDetail(idx) {
2013
2044
  function closeMsgDetailModal() {
2014
2045
  hideModalOverlay('msg-detail-modal');
2015
2046
  msgDetailFollowLatest = false;
2047
+ waitingDetailAutoOpened = false;
2016
2048
  // Drop the message highlight on close, mirroring task-card behavior.
2017
2049
  msgHighlightDimmed = true;
2018
2050
  const container = document.getElementById('message-panel-content');
@@ -2181,6 +2213,10 @@ function isZoomableModalOpen() {
2181
2213
  return document.querySelector('.modal-overlay.visible .modal-zoomable') !== null;
2182
2214
  }
2183
2215
 
2216
+ function isAnyModalOpen() {
2217
+ return document.querySelector('.modal-overlay.visible') !== null;
2218
+ }
2219
+
2184
2220
  const ZOOM_KEYS = { '+': 0.1, '=': 0.1, NumpadAdd: 0.1, '-': -0.1, _: -0.1, NumpadSubtract: -0.1, 0: 0, Numpad0: 0 };
2185
2221
 
2186
2222
  let _toastTimer = null;
@@ -3007,6 +3043,13 @@ async function revealSession(id) {
3007
3043
  await fetchSessions();
3008
3044
  session = sessions.find((s) => s.id === id);
3009
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
+ }
3010
3053
  const uncollapsed = session ? uncollapseFor(session) : false;
3011
3054
  if (uncollapsed) persistCollapsedGroups();
3012
3055
  expandSidebar();
@@ -3110,7 +3153,10 @@ function getFilteredSessions() {
3110
3153
  }
3111
3154
  }
3112
3155
  if (filterProject) {
3113
- 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);
3114
3160
  }
3115
3161
 
3116
3162
  if (activityFilter.size > 0) {
@@ -3167,20 +3213,32 @@ function getFilteredSessions() {
3167
3213
  return filteredSessions;
3168
3214
  }
3169
3215
 
3216
+ // A live refresh would replace the zen panel's link-a-file input mid-type, the same way it
3217
+ // would swallow a half-typed group name. Off zen there is no panel, so skip the DOM probe.
3218
+ function zenPanelIsEditing() {
3219
+ return zenMode && !!sessionsList.querySelector('.linked-doc-input');
3220
+ }
3221
+
3170
3222
  function renderSessions() {
3171
3223
  // Rebuilding the list under the pointer cancels an in-flight drop and would swallow a
3172
3224
  // half-typed group name, and the SSE path can fire at any moment — defer instead.
3173
- if (sgDrag || sgIsEditing()) return;
3225
+ if (sgDrag || sgIsEditing() || zenPanelIsEditing()) return;
3174
3226
  // Update project dropdown
3175
3227
  updateProjectDropdown();
3176
3228
 
3177
- const filteredSessions = getFilteredSessions();
3229
+ // Zen narrows the rendered list only — the session picker keeps calling getFilteredSessions()
3230
+ // and still offers everything, or there would be no way to switch sessions without leaving zen.
3231
+ const zenSession = zenMode && currentSessionId ? sessions.find((s) => s.id === currentSessionId) : null;
3232
+ const filteredSessions = zenMode ? (zenSession ? [zenSession] : []) : getFilteredSessions();
3178
3233
 
3179
3234
  if (filteredSessions.length === 0) {
3180
3235
  let emptyMsg = 'No sessions found';
3181
3236
  let emptyHint = 'Tasks appear when you use Claude Code';
3182
3237
 
3183
- if (searchQuery) {
3238
+ if (zenMode) {
3239
+ emptyMsg = 'Zen mode: no session open';
3240
+ emptyHint = 'Press Shift+Z to leave zen mode, then open a session';
3241
+ } else if (searchQuery) {
3184
3242
  emptyMsg = `No results for "${searchQuery}"`;
3185
3243
  emptyHint = 'Try a different search term or clear the search';
3186
3244
  } else if (filterProject && sessionFilter === 'active') {
@@ -3209,9 +3267,7 @@ function renderSessions() {
3209
3267
  const isActive = session.id === currentSessionId && viewMode === 'session';
3210
3268
  const isLive = isSessionLive(session);
3211
3269
  const sessionName = sessionDisplayName(session);
3212
- const useGrouped = sessionFilter === 'active' && session.project;
3213
- const primaryName = useGrouped ? sessionName : session.project ? session.project.split('/').pop() : sessionName;
3214
- const secondaryName = useGrouped ? null : session.project ? sessionName : null;
3270
+ const projectHtml = renderProjectIdentity(session);
3215
3271
 
3216
3272
  const gitBranch = session.gitBranch ? escapeHtml(session.gitBranch) : null;
3217
3273
  const createdDisplay = session.createdAt ? formatDate(session.createdAt) : '';
@@ -3220,7 +3276,9 @@ function renderSessions() {
3220
3276
  session.createdAt && createdDisplay !== modifiedDisplay
3221
3277
  ? `Created ${createdDisplay} · Modified ${modifiedDisplay}`
3222
3278
  : modifiedDisplay;
3223
- const tooltip = [session.id, timeDisplay, gitBranch ? `Branch: ${gitBranch}` : ''].filter(Boolean).join(' | ');
3279
+ const tooltip = [session.id, session.project, timeDisplay, gitBranch ? `Branch: ${gitBranch}` : '']
3280
+ .filter(Boolean)
3281
+ .join(' | ');
3224
3282
  const isTeam = session.isTeam;
3225
3283
  const memberCount = session.memberCount || 0;
3226
3284
 
@@ -3228,7 +3286,8 @@ function renderSessions() {
3228
3286
  const pinClass = pinState === 'sticky' ? ' sticky' : pinState === 'pinned' ? ' pinned' : '';
3229
3287
  const pinTitle =
3230
3288
  pinState === 'pinned' || pinState === 'sticky' ? 'Unpin session (.)' : 'Pin session (. · > sticky)';
3231
- const showCtx = !!session.contextStatus;
3289
+ // Zen renders the full context detail below the card, so the compact bar would just repeat it.
3290
+ const showCtx = !!session.contextStatus && !zenMode;
3232
3291
  const linkedDocsCount = getSessionPreviewPaths(session.id).length;
3233
3292
  const bookmarksCount = loadPins(session.id).length;
3234
3293
  const hasScratchpad = !!(store.getItem(_sessionScratchpadKey(session.id)) || '').trim();
@@ -3237,8 +3296,8 @@ function renderSessions() {
3237
3296
  return `
3238
3297
  <button onclick="fetchTasks('${sid}')" draggable="true" data-session-id="${escapeHtml(session.id)}" class="session-item ${isActive ? 'active' : ''} ${session.hasWaitingForUser ? 'permission-pending' : ''} ${tempClass} ${showCtx ? 'has-context' : ''}" title="${escapeHtml(tooltip)}">
3239
3298
  <span class="session-pin-btn${pinClass}" onclick="event.stopPropagation();toggleSessionPin('${sid}')" title="${pinTitle} session">${pinState === 'sticky' ? SESSION_STAR_SVG : SESSION_PIN_SVG}</span>
3240
- <div class="session-name">${escapeHtml(primaryName)}</div>
3241
- ${secondaryName ? `<div class="session-secondary">${escapeHtml(secondaryName)}</div>` : ''}
3299
+ <div class="session-name">${escapeHtml(sessionName)}</div>
3300
+ ${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
3242
3301
  ${gitBranch ? `<div class="session-branch">${gitBranch}</div>` : ''}
3243
3302
  ${session.planTitle ? `<div class="session-plan">${escapeHtml(session.planTitle)}</div>` : ''}
3244
3303
  ${renderGoalSubtitle(session)}
@@ -3267,6 +3326,24 @@ function renderSessions() {
3267
3326
  `;
3268
3327
  };
3269
3328
 
3329
+ // Zen draws one card and its panel, so it returns before the grouping below — leaving it in
3330
+ // that chain would build the whole list only to discard it.
3331
+ if (zenMode) {
3332
+ sessionsList.innerHTML = `${renderSessionCard(zenSession)}
3333
+ <div class="zen-panel">
3334
+ ${renderContextDetail(zenSession.contextStatus) || '<div class="zen-panel-empty">No context data for this session</div>'}
3335
+ ${renderScratchpadRow(zenSession)}
3336
+ ${renderLinkedDocsHtml(zenSession.id)}
3337
+ ${renderArtifactsHtml(zenSession.id)}
3338
+ </div>
3339
+ ${zenSession.hasWorkflow ? renderWorkflowLiveHtml(zenSession.id) : ''}`;
3340
+ bindLinkedDocsHandlers(sessionsList.querySelector('.linked-docs-section'), zenSession.id);
3341
+ artifactsSection.load(zenSession.id);
3342
+ scratchFilesSection.load(zenSession.id);
3343
+ if (zenSession.hasWorkflow) pollWorkflowLive(zenSession.id);
3344
+ return;
3345
+ }
3346
+
3270
3347
  const groupPinned = store.getItem('groupPinnedSessions') !== 'false';
3271
3348
  const pinWeight = (s) => (isPlacedSticky(s.id) ? 2 : isPlacedPinned(s.id) && !isSessionActive(s) ? 1 : 0);
3272
3349
  const pinSort = (a, b) => pinWeight(b) - pinWeight(a);
@@ -3539,13 +3616,18 @@ function renderSession() {
3539
3616
  const session = sessions.find((s) => s.id === currentSessionId);
3540
3617
  if (!session) return;
3541
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
+
3542
3624
  const displayName =
3543
3625
  session.customTitle || session.name || session.gitBranch || session.description || currentSessionId;
3544
3626
 
3545
3627
  sessionTitle.textContent = displayName;
3546
3628
 
3547
3629
  // Build meta text with project path and description
3548
- const projectName = session.project ? session.project.split('/').pop() : null;
3630
+ const projectName = session.project ? pathBasename(session.project) : null;
3549
3631
  const metaParts = [`${currentTasks.length} tasks`];
3550
3632
  if (projectName) {
3551
3633
  metaParts.push(projectName);
@@ -4649,16 +4731,18 @@ function expandActiveGroups({ onlyNew = false } = {}) {
4649
4731
  }
4650
4732
 
4651
4733
  // Drops every collapse key that could hide this session: its project, a named group wrapping
4652
- // the project or the session itself, the pinned sub-sections of both, and — since which section
4653
- // holds it depends on the view — every section header. Returns whether anything changed.
4734
+ // the project or the session itself, the pinned sub-section only when the card renders inside
4735
+ // it (pinned, not sticky, idle — see renderGroupSessions), and — since which section holds it
4736
+ // depends on the view — every section header. Returns whether anything changed.
4654
4737
  function uncollapseFor(session) {
4655
4738
  const project = session.project || '__ungrouped__';
4656
4739
  const group = sgGroupForSession(session);
4740
+ const inPinned = isPlacedPinned(session.id) && !isPlacedSticky(session.id) && !isSessionActive(session);
4657
4741
  const keys = [
4658
4742
  project,
4659
- pinKey(project),
4743
+ inPinned && pinKey(project),
4660
4744
  group && sgKey(group.id),
4661
- group && `__pinned_group_${group.id}__`,
4745
+ group && inPinned && `__pinned_group_${group.id}__`,
4662
4746
  SECTION_GROUPS,
4663
4747
  SECTION_PROJECTS,
4664
4748
  SECTION_SESSIONS,
@@ -5194,6 +5278,7 @@ const SHORTCUT_PAIRS = [
5194
5278
  { keys: ['['], label: 'Toggle sidebar' },
5195
5279
  { keys: ['T'], label: 'Toggle theme' },
5196
5280
  { keys: ['Shift', 'S'], combo: true, label: 'Storage manager' },
5281
+ { keys: ['Shift', 'Z'], combo: true, label: 'Zen mode (current session only)' },
5197
5282
  { keys: ['Ctrl', '+'], combo: true, label: 'Larger modal text' },
5198
5283
  { keys: ['Ctrl', '−'], combo: true, label: 'Smaller modal text' },
5199
5284
  { keys: ['Ctrl', '0'], combo: true, label: 'Reset modal text size' },
@@ -5407,7 +5492,7 @@ function _groupByProject(sessionIds) {
5407
5492
 
5408
5493
  function _projectLabel(project) {
5409
5494
  if (project === '(no project)') return '(no project)';
5410
- return project.split(/[/\\]/).pop() || project;
5495
+ return pathBasename(project);
5411
5496
  }
5412
5497
 
5413
5498
  function _renderProjectGroup(label, meta, innerHtml) {
@@ -5672,12 +5757,8 @@ function _renderStorageLinkedDocs() {
5672
5757
  const entries = [];
5673
5758
  for (const key of store.keys()) {
5674
5759
  if (!key.startsWith(PREVIEW_STORAGE_PREFIX)) continue;
5675
- try {
5676
- const arr = JSON.parse(store.getItem(key)) || [];
5677
- if (Array.isArray(arr) && arr.length) {
5678
- entries.push({ sessionId: key.slice(PREVIEW_STORAGE_PREFIX.length), paths: arr });
5679
- }
5680
- } catch {}
5760
+ const paths = readStoredList(key);
5761
+ if (paths.length) entries.push({ sessionId: key.slice(PREVIEW_STORAGE_PREFIX.length), paths });
5681
5762
  }
5682
5763
  if (!entries.length) return '<div class="storage-empty">No linked documents</div>';
5683
5764
 
@@ -5738,6 +5819,8 @@ function _storageUnlinkDoc(sessionId, path) {
5738
5819
 
5739
5820
  function _storageClearLinkedDocs(sessionId) {
5740
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.
5741
5824
  afterLinkedDocsChanged(sessionId);
5742
5825
  }
5743
5826
 
@@ -5754,6 +5837,8 @@ function _findOrphanedKeys() {
5754
5837
  if (!known.has(key.slice('scratchpad-'.length))) orphaned.push(key);
5755
5838
  } else if (key.startsWith(PREVIEW_STORAGE_PREFIX)) {
5756
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);
5757
5842
  }
5758
5843
  }
5759
5844
  return orphaned;
@@ -5844,7 +5929,7 @@ document.addEventListener('keydown', (e) => {
5844
5929
  }
5845
5930
 
5846
5931
  // Modal guard — only Escape, Shift+M, and msg-detail J/K navigation pass through
5847
- if (document.querySelector('.modal-overlay.visible')) {
5932
+ if (isAnyModalOpen()) {
5848
5933
  if (e.key === 'Escape') {
5849
5934
  if (_scratchpadModal.classList.contains('visible')) {
5850
5935
  closeScratchpad();
@@ -5929,6 +6014,11 @@ document.addEventListener('keydown', (e) => {
5929
6014
  showStorageManager();
5930
6015
  return;
5931
6016
  }
6017
+ if (e.code === 'KeyZ' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
6018
+ e.preventDefault();
6019
+ toggleZenMode();
6020
+ return;
6021
+ }
5932
6022
  // Ctrl+Shift+P is the browser's own; only the bare chord opens the picker.
5933
6023
  if (e.code === 'KeyP' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
5934
6024
  e.preventDefault();
@@ -6156,27 +6246,40 @@ document.addEventListener('keydown', (e) => {
6156
6246
  const PREVIEW_STORAGE_PREFIX = 'preview-paths-';
6157
6247
  let currentPreviewPath = null;
6158
6248
 
6159
- function getSessionPreviewPaths(sessionId) {
6160
- if (!sessionId) return [];
6249
+ function readStoredList(key) {
6161
6250
  try {
6162
- const raw = store.getItem(PREVIEW_STORAGE_PREFIX + sessionId);
6163
- const arr = raw ? JSON.parse(raw) : [];
6251
+ const arr = JSON.parse(store.getItem(key) || '[]');
6164
6252
  return Array.isArray(arr) ? arr : [];
6165
6253
  } catch {
6166
6254
  return [];
6167
6255
  }
6168
6256
  }
6169
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
+
6170
6271
  function addSessionPreviewPath(sessionId, filePath) {
6171
6272
  if (!sessionId || !filePath) return;
6172
- const paths = getSessionPreviewPaths(sessionId).filter((p) => p !== filePath);
6273
+ const key = canonicalPath(filePath);
6274
+ const paths = getSessionPreviewPaths(sessionId).filter((p) => canonicalPath(p) !== key);
6173
6275
  paths.unshift(filePath);
6174
6276
  store.setItem(PREVIEW_STORAGE_PREFIX + sessionId, JSON.stringify(paths.slice(0, 20)));
6175
6277
  }
6176
6278
 
6177
6279
  function removeSessionPreviewPath(sessionId, filePath) {
6178
6280
  if (!sessionId) return;
6179
- const paths = getSessionPreviewPaths(sessionId).filter((p) => p !== filePath);
6281
+ const key = canonicalPath(filePath);
6282
+ const paths = getSessionPreviewPaths(sessionId).filter((p) => canonicalPath(p) !== key);
6180
6283
  if (paths.length) store.setItem(PREVIEW_STORAGE_PREFIX + sessionId, JSON.stringify(paths));
6181
6284
  else store.removeItem(PREVIEW_STORAGE_PREFIX + sessionId);
6182
6285
  }
@@ -6246,23 +6349,48 @@ function bindPreviewRelativeLinks(bodyEl) {
6246
6349
  const isAbsolutePath = href.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(href);
6247
6350
  if (isAbsoluteUrl) return;
6248
6351
  const cleanHref = href.replace(/#.*$/, '');
6249
- if (!isPreviewablePath(cleanHref)) return;
6250
6352
  e.preventDefault();
6251
- openPreviewByPath(cleanHref, isAbsolutePath ? undefined : currentPreviewPath);
6353
+ openPreviewByPath(cleanHref, isAbsolutePath ? undefined : currentPreviewPath, openFileInEditor);
6252
6354
  });
6253
6355
  bodyEl.dataset.relLinkBound = '1';
6254
6356
  }
6255
6357
 
6256
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
+
6257
6376
  function openPreviewModal(filePath, content, kind) {
6258
6377
  currentPreviewPath = filePath;
6259
- document.getElementById('preview-modal-title').textContent = filePath.split(/[\\/]/).pop();
6378
+ const fileName = filePath.split(/[\\/]/).pop();
6379
+ document.getElementById('preview-modal-title').textContent = fileName;
6260
6380
  const bodyEl = document.getElementById('preview-modal-body');
6261
6381
  const isHtml = kind === 'html';
6262
6382
  document.querySelector('#preview-modal .modal').classList.toggle('preview-html', isHtml);
6263
6383
  bindPreviewRelativeLinks(bodyEl);
6264
6384
  if (isHtml) {
6265
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);
6266
6394
  } else {
6267
6395
  const { fm, body } = splitFrontmatter(content);
6268
6396
  bodyEl.innerHTML = (fm ? renderFrontmatterBlock(fm) : '') + renderMarkdown(body);
@@ -6274,7 +6402,8 @@ function openPreviewModal(filePath, content, kind) {
6274
6402
 
6275
6403
  function isPreviewLinkedToCurrentSession() {
6276
6404
  if (!currentPreviewPath || !currentSessionId) return false;
6277
- return getSessionPreviewPaths(currentSessionId).includes(currentPreviewPath);
6405
+ const key = canonicalPath(currentPreviewPath);
6406
+ return getSessionPreviewPaths(currentSessionId).some((p) => canonicalPath(p) === key);
6278
6407
  }
6279
6408
 
6280
6409
  function updatePreviewLinkBtn() {
@@ -6325,13 +6454,20 @@ function closePreviewModal() {
6325
6454
  currentPreviewPath = null;
6326
6455
  }
6327
6456
 
6457
+ function openFileInEditor(filePath) {
6458
+ postAndToast('/api/open-in-editor', { file: filePath }, 'in editor');
6459
+ }
6460
+
6328
6461
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
6329
6462
  function openPreviewInEditor() {
6330
6463
  if (!currentPreviewPath) return;
6331
- postAndToast('/api/open-in-editor', { file: currentPreviewPath }, 'in editor');
6464
+ openFileInEditor(currentPreviewPath);
6332
6465
  }
6333
6466
 
6334
- 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) {
6335
6471
  if (!filePath) return;
6336
6472
  try {
6337
6473
  const qs = new URLSearchParams({ path: filePath });
@@ -6342,6 +6478,15 @@ async function openPreviewByPath(filePath, base) {
6342
6478
  return;
6343
6479
  }
6344
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
+ }
6345
6490
  openPreviewModal(data.path, data.content, data.kind);
6346
6491
  } catch {
6347
6492
  showToast('Failed to load preview');
@@ -6395,10 +6540,69 @@ function getSessionBaseDir(sessionId) {
6395
6540
  return s?.cwd || s?.project || '';
6396
6541
  }
6397
6542
 
6398
- // Linked paths are stored as bare strings, so previewability is re-derived from the
6399
- // extension on every render instead of being remembered alongside the path.
6400
- function isPreviewablePath(p) {
6401
- 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);
6402
6606
  }
6403
6607
 
6404
6608
  // A scratchpad manifest opens in the external `scratch` viewer, not the editor —
@@ -6427,11 +6631,14 @@ const LINKED_DOC_OPENERS = {
6427
6631
  },
6428
6632
  };
6429
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.
6430
6637
  function linkedDocOpener(p) {
6431
- if (isPreviewablePath(p)) return 'preview';
6432
- // Without the CLI there is nothing to launch, so the manifest falls back to the editor.
6433
- if (isScratchpadPath(p) && appConfig.scratchAvailable) return 'scratch';
6434
- 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';
6435
6642
  }
6436
6643
 
6437
6644
  // Every manifest is called scratchpad.json, so the pad's folder names it. Kept
@@ -6444,7 +6651,7 @@ function linkedDocLabel(p) {
6444
6651
  function openLinkedDoc(p, baseDir) {
6445
6652
  const opener = linkedDocOpener(p);
6446
6653
  if (opener === 'preview') {
6447
- openPreviewByPath(p, baseDir);
6654
+ openPreviewByPath(p, baseDir, openFileInEditor);
6448
6655
  return;
6449
6656
  }
6450
6657
  const { url, body, toast } = LINKED_DOC_OPENERS[opener];
@@ -6453,6 +6660,9 @@ function openLinkedDoc(p, baseDir) {
6453
6660
 
6454
6661
  function renderLinkedDocsHtml(sessionId) {
6455
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);
6456
6666
  const baseDir = getSessionBaseDir(sessionId);
6457
6667
  const items = paths
6458
6668
  .map((p) => {
@@ -6476,11 +6686,11 @@ function renderLinkedDocsHtml(sessionId) {
6476
6686
  const body = paths.length
6477
6687
  ? `<ul class="linked-doc-list">${items}</ul>`
6478
6688
  : '<div class="linked-docs-empty">No linked files yet</div>';
6479
- return `<div class="linked-docs-section" style="margin-bottom:16px;font-size:12px;">
6480
- <div style="font-size:11px;font-weight:500;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px;display:flex;align-items:center;gap:6px;">
6689
+ return `<div class="linked-docs-section panel-section">
6690
+ <div class="panel-section-header">
6481
6691
  ${linkSvg(12)}
6482
6692
  <span>Linked documents</span>
6483
- <span style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:10px;padding:0 6px;font-size:10px;color:var(--text-secondary);">${paths.length}</span>
6693
+ <span class="panel-section-count">${paths.length}</span>
6484
6694
  <button type="button" class="linked-docs-add-btn" title="Link a file" aria-label="Link a file">+</button>
6485
6695
  </div>
6486
6696
  <div class="linked-doc-editor-slot"></div>
@@ -6488,6 +6698,262 @@ function renderLinkedDocsHtml(sessionId) {
6488
6698
  </div>`;
6489
6699
  }
6490
6700
 
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
+ }
6736
+
6737
+ function artifactLabel(a) {
6738
+ if (a.title) return a.title;
6739
+ return a.path ? pathBasename(a.path).replace(/\.html?$/i, '') : 'Artifact';
6740
+ }
6741
+
6742
+ function artifactsInnerHtml(sessionId) {
6743
+ const list = artifactsSection.get(sessionId);
6744
+ if (!list.length) return '';
6745
+ const items = list
6746
+ .map(
6747
+ (a) => `<li class="artifact-item">
6748
+ <a class="artifact-link" href="${escapeHtml(a.url)}" target="_blank" rel="noopener" title="${escapeHtml(a.url)}">${escapeHtml(artifactLabel(a))}</a>
6749
+ <span class="row-actions artifact-actions">
6750
+ <button type="button" onclick="copyWithFeedback('${escAttrJs(a.url)}', this)" title="Copy link" aria-label="Copy artifact link">${ICON_COPY}</button>
6751
+ </span>
6752
+ </li>`,
6753
+ )
6754
+ .join('');
6755
+ return `<div class="panel-section-header">
6756
+ ${ICON_OPEN_EXTERNAL}
6757
+ <span>Artifacts</span>
6758
+ <span class="panel-section-count">${list.length}</span>
6759
+ </div>
6760
+ <ul class="artifact-list">${items}</ul>`;
6761
+ }
6762
+
6763
+ function renderArtifactsHtml(sessionId) {
6764
+ return `<div class="artifacts-section panel-section" data-artifacts-for="${escapeHtml(sessionId)}">${artifactsInnerHtml(sessionId)}</div>`;
6765
+ }
6766
+
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: [] };
6933
+ try {
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 || [] };
6936
+ } catch (_) {
6937
+ // Reads "Failed to load"; closing and reopening the folder retries.
6938
+ }
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);
6943
+ }
6944
+ }
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
+
6491
6957
  // One delegated listener per section: the list is re-rendered on every change, so
6492
6958
  // per-row handlers would only be rebound each time anyway.
6493
6959
  function bindLinkedDocsHandlers(container, sessionId) {
@@ -6557,10 +7023,13 @@ async function linkFileByPath(sessionId, raw, slot) {
6557
7023
  if (base) qs.set('base', base);
6558
7024
  const r = await fetch(`/api/file/resolve?${qs}`);
6559
7025
  const data = await r.json().catch(() => ({}));
6560
- if (!r.ok) {
7026
+ if (!r.ok || !data.exists) {
6561
7027
  fail(data.error || 'File not found');
6562
7028
  return;
6563
7029
  }
7030
+ // Close the editor before the refresh: a sidebar panel defers its re-render while an
7031
+ // input is open, so leaving it up would hide the file that was just linked.
7032
+ if (slot) slot.innerHTML = '';
6564
7033
  setSessionDocLink(sessionId, data.path, false);
6565
7034
  showToast('Linked to session', 'success');
6566
7035
  } catch {
@@ -6884,6 +7353,24 @@ function maybeFollowLatest() {
6884
7353
  }
6885
7354
  }
6886
7355
 
7356
+ // A new answerable ask opens its modal on its own, but only onto an empty
7357
+ // screen: an open modal means the user is reading something, so the ask stays
7358
+ // on the card and the badge until they get to it. Each ask id is offered once,
7359
+ // so closing the modal does not bring it back on the next poll.
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;
7365
+ function maybeAutoOpenWaiting() {
7366
+ if (!isWaitingAnswerable() || !isWaitingFresh()) return;
7367
+ if (currentWaiting.id === lastAutoOpenedWaitingId || isAnyModalOpen()) return;
7368
+ lastAutoOpenedWaitingId = currentWaiting.id;
7369
+ msgDetailFollowLatest = true;
7370
+ waitingDetailAutoOpened = true;
7371
+ maybeFollowLatest();
7372
+ }
7373
+
6887
7374
  function isWaitingFresh() {
6888
7375
  if (!currentWaiting?.timestamp) return false;
6889
7376
  return Date.now() - new Date(currentWaiting.timestamp).getTime() < WAITING_TTL_MS;
@@ -7087,6 +7574,24 @@ function sessionDisplayName(session) {
7087
7574
  return SESSION_UUID_RE.test(raw) ? raw.slice(0, 8) : raw;
7088
7575
  }
7089
7576
 
7577
+ // Project paths arrive in the OS spelling, so a Windows path is backslash-separated.
7578
+ function pathBasename(p) {
7579
+ return p.split(/[/\\]/).filter(Boolean).pop() || p;
7580
+ }
7581
+
7582
+ // `session.worktree` is resolved server-side from the `.git` pointer file, so it holds for a
7583
+ // worktree parked beside its repo as well as one under <repo>/.claude/worktrees/.
7584
+ function renderProjectIdentity(session) {
7585
+ if (!session.project) return null;
7586
+ const wt = session.worktree;
7587
+ if (!wt) return escapeHtml(pathBasename(session.project));
7588
+ const repo = pathBasename(wt.repo);
7589
+ // Worktrees beside the repo are conventionally named `<repo>-<branch>`, and repeating the
7590
+ // repo inside a chip that already sits next to it wastes the whole width of the card.
7591
+ const name = wt.name.startsWith(repo) ? wt.name.slice(repo.length).replace(/^[-._]/, '') || wt.name : wt.name;
7592
+ return `${escapeHtml(repo)}<span class="wt-chip" title="Worktree of ${escapeHtml(wt.repo)}">⑂ ${escapeHtml(name)}</span>`;
7593
+ }
7594
+
7090
7595
  function formatDate(dateStr) {
7091
7596
  const date = new Date(dateStr);
7092
7597
  const now = new Date();
@@ -7524,6 +8029,24 @@ function renderFilterState() {
7524
8029
  document.getElementById('filter-menu-btn')?.classList.toggle('has-filters', short.length > 0);
7525
8030
  }
7526
8031
 
8032
+ function toggleZenMode() {
8033
+ zenMode = !zenMode;
8034
+ store.setItem(ZEN_KEY, String(zenMode));
8035
+ renderZenState();
8036
+ if (zenMode) closeFilterMenu();
8037
+ renderSessions();
8038
+ // The open session may be outside the current server page/filter; refetch pins it in.
8039
+ if (zenMode) fetchSessions(false);
8040
+ }
8041
+
8042
+ function renderZenState() {
8043
+ const btn = document.getElementById('zen-mode-btn');
8044
+ if (!btn) return;
8045
+ btn.classList.toggle('active', zenMode);
8046
+ btn.setAttribute('aria-pressed', String(zenMode));
8047
+ btn.title = zenMode ? 'Zen mode on — show all sessions (Shift+Z)' : 'Zen mode: current session only (Shift+Z)';
8048
+ }
8049
+
7527
8050
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
7528
8051
  function filterBySessions(value) {
7529
8052
  sessionFilter = value;
@@ -7965,6 +8488,11 @@ async function showSessionInfoModal(sessionId) {
7965
8488
  _planSessionId = sessionId;
7966
8489
  const cachedTasks = currentSessionId === sessionId ? currentTasks : [];
7967
8490
  showInfoModal(session, null, cachedTasks, null, null);
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);
7968
8496
 
7969
8497
  const rerender = (teamConfig, tasks, planContent, parentInfo) => {
7970
8498
  if (_planSessionId !== sessionId) return; // user opened a different modal
@@ -8023,7 +8551,21 @@ function updateStickyBtnState() {
8023
8551
  // carry information. The full path stays in the tooltip and the copy button.
8024
8552
  function abbreviateScratchpadDir(dir) {
8025
8553
  const sep = dir.includes('\\') ? '\\' : '/';
8026
- return ['$tmp', '…', ...dir.split(/[/\\]/).slice(-2)].join(sep);
8554
+ return dir.split(/[/\\]/).slice(-2).join(sep);
8555
+ }
8556
+
8557
+ function renderScratchpadRow(session) {
8558
+ const dir = session?.scratchpadDir;
8559
+ if (!dir) return '';
8560
+ return `<div class="zen-scratchpad">
8561
+ <span class="zen-scratchpad-label">Scratchpad</span>
8562
+ <span class="zen-scratchpad-path" title="${escapeHtml(dir)}">${escapeHtml(abbreviateScratchpadDir(dir))}</span>
8563
+ <span class="row-actions">
8564
+ <button onclick="copyWithFeedback('${escAttrJs(dir)}', this)" title="Copy">${ICON_COPY}</button>
8565
+ <button data-folder="${escapeHtml(dir)}" onclick="openFolderInEditor(this.dataset.folder)" title="Open in editor">${ICON_OPEN_EXTERNAL}</button>
8566
+ </span>
8567
+ </div>
8568
+ ${renderScratchFilesHtml(session.id)}`;
8027
8569
  }
8028
8570
 
8029
8571
  function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
@@ -8138,6 +8680,7 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
8138
8680
  }
8139
8681
 
8140
8682
  html += renderLinkedDocsHtml(session.id);
8683
+ html += renderArtifactsHtml(session.id);
8141
8684
 
8142
8685
  // Team info section
8143
8686
  if (teamConfig) {
@@ -8272,11 +8815,36 @@ let spPeekedId = null;
8272
8815
  // Per-open cache: spSource is frozen while the picker is up, so a fetched log stays valid.
8273
8816
  const spPeekCache = new Map();
8274
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
+
8275
8842
  function openSessionPicker() {
8276
8843
  const input = document.getElementById('session-picker-input');
8277
8844
  input.value = '';
8278
8845
  spSource = getFilteredSessions().sort((a, b) => Date.parse(b.modifiedAt) - Date.parse(a.modifiedAt));
8279
8846
  spPeekCache.clear();
8847
+ spGlobal.clear();
8280
8848
  document.getElementById('session-picker-modal').classList.add('visible');
8281
8849
  renderSessionPicker();
8282
8850
  input.focus();
@@ -8367,11 +8935,14 @@ function renderSessionPicker() {
8367
8935
  const list = document.getElementById('session-picker-list');
8368
8936
  const query = document.getElementById('session-picker-input').value.trim();
8369
8937
  spRows = spSource.filter((s) => spMatches(s, query));
8370
-
8371
8938
  if (!spRows.length) {
8372
- spIdx = -1;
8373
- list.innerHTML = `<div class="sp-empty">${spSource.length ? 'No session matches' : 'No sessions in the current sidebar filter'}</div>`;
8374
- return;
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
+ }
8375
8946
  }
8376
8947
 
8377
8948
  list.innerHTML = spRows
@@ -8425,6 +8996,19 @@ function initSessionPicker() {
8425
8996
  const row = e.target.closest('.sp-row');
8426
8997
  if (row) spOpen(Number(row.dataset.idx));
8427
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
+ });
8428
9012
  }
8429
9013
  //#endregion
8430
9014
 
@@ -9207,6 +9791,7 @@ ownerFilter = urlState.owner || '';
9207
9791
  searchQuery = urlState.search || '';
9208
9792
 
9209
9793
  renderFilterState();
9794
+ renderZenState();
9210
9795
  pinnedSessionIds = loadPinnedSessions();
9211
9796
  stickySessionIds = loadStickySessions();
9212
9797
  setupEventSource();
@@ -9290,22 +9875,25 @@ window.addEventListener('popstate', () => {
9290
9875
  //#endregion
9291
9876
 
9292
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.
9293
9881
  document.addEventListener('keydown', (e) => {
9294
9882
  if (!window.__HUB__?.enabled) return;
9295
- const fwd = (key) => {
9883
+ const fwd = () => {
9296
9884
  e.preventDefault();
9297
- 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 });
9298
9886
  };
9299
9887
  if (e.ctrlKey && e.altKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
9300
- fwd(e.key);
9888
+ fwd();
9301
9889
  }
9302
9890
  // Own branch: the Alt+digit case below requires !ctrlKey. The hub owns the Ctrl+Alt+letter
9303
9891
  // keymap and ignores unbound letters.
9304
- if (e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey && /^[a-z]$/i.test(e.key)) {
9305
- 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();
9306
9894
  }
9307
- if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey && /^[1-9]$/.test(e.key)) {
9308
- 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();
9309
9897
  }
9310
9898
  });
9311
9899