claude-code-kanban 4.26.0 → 4.28.0-rc.1

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
@@ -24,6 +24,8 @@ const FILTER_DEFAULTS = { project: '__recent__', session: 'active', limit: '20'
24
24
 
25
25
  let sessions = [];
26
26
  let currentSessionId = null;
27
+ let lastSessionId = null;
28
+ let previousSessionId = null;
27
29
  let currentTasks = [];
28
30
  let viewMode = 'session';
29
31
  let sessionFilter = FILTER_DEFAULTS.session;
@@ -45,6 +47,8 @@ const SECTION_PROJECTS = '__section_projects__';
45
47
  const SECTION_SESSIONS = '__section_sessions__';
46
48
  let stableGroupOrder = []; // cached project path order to prevent jumping
47
49
  let sessionGroups = []; // user-named groups: [{id, name, color, members:[{type,ref}]}]
50
+ // Sessions the user pulled out of their dispatch placement; the server's group map is not theirs to edit.
51
+ let sgReleased = new Set();
48
52
  let sgDrag = null; // in-flight sidebar drag: {kind:'session'|'project'|'group', ref}
49
53
  let searchQuery = ''; // Search query for fuzzy search
50
54
  let allTasksCache = []; // Cache all tasks for search
@@ -58,12 +62,24 @@ let lastMessagesHash = '';
58
62
  let currentMessages = [];
59
63
  let agentDurationInterval = null;
60
64
  let agentPollInterval = null;
65
+ // Inside the hub an inactive app is a display:none iframe whose document.hidden stays false,
66
+ // so the hub tells us via hub:active. Standalone, or if that message is lost, cck stays active.
67
+ let hubActive = true;
68
+ let missedWhileHidden = false;
69
+ const isOnScreen = () => hubActive && !document.hidden;
70
+ function skipOffScreen() {
71
+ if (isOnScreen()) return false;
72
+ missedWhileHidden = true;
73
+ return true;
74
+ }
75
+ const METADATA_MAX_WAIT_MS = 5000;
61
76
  let selectedTaskId = null;
62
77
  let selectedSessionId = null;
63
78
  // Task stays selected (keyboard nav) but its white highlight is dimmed once the detail panel closes.
64
79
  let taskHighlightDimmed = false;
65
80
  let focusZone = 'board'; // 'board' | 'sidebar'
66
81
  let appConfig = { marketplaceUrl: null, costUrl: null, memoryUrl: null, scratchAvailable: false };
82
+ let runningTerminals = new Set();
67
83
  let selectedSessionIdx = -1;
68
84
  let selectedSessionKbId = null;
69
85
  let sessionJustSelected = false;
@@ -117,6 +133,7 @@ function updateUrl() {
117
133
  const url = qs ? `?${qs}` : window.location.pathname;
118
134
  history.replaceState(null, '', url);
119
135
  persistLastView();
136
+ syncTerminal();
120
137
  }
121
138
 
122
139
  const LAST_VIEW_KEY = 'lastView';
@@ -200,15 +217,16 @@ async function fetchSessions(includeTasks = true) {
200
217
  const allPinnedIds = new Set([...pinnedSessionIds, ...stickySessionIds]);
201
218
  if (revealedPlanSessionId) allPinnedIds.add(revealedPlanSessionId);
202
219
  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
220
  const pinnedParam = allPinnedIds.size > 0 ? `&pinned=${[...allPinnedIds].join(',')}` : '';
221
+ // The focused session must come back whatever the filters say: renderSession and the
222
+ // info modal both look it up in `sessions` and bail when it is absent, so a session
223
+ // opened from outside the current filter would leave the view on the previous one.
224
+ const includeParam = currentSessionId ? `&include=${encodeURIComponent(currentSessionId)}` : '';
207
225
  const projectParam =
208
226
  filterProject && filterProject !== '__recent__' ? `&project=${encodeURIComponent(filterProject)}` : '';
209
227
  const filterParam = sessionFilter === 'active' ? '&filter=active' : '';
210
228
  const sessionsPromise = fetch(
211
- `/api/sessions?limit=${sessionLimit}${pinnedParam}${projectParam}${filterParam}`,
229
+ `/api/sessions?limit=${sessionLimit}${pinnedParam}${includeParam}${projectParam}${filterParam}`,
212
230
  ).then((r) => r.json());
213
231
 
214
232
  let newSessions, newTasks;
@@ -229,7 +247,7 @@ async function fetchSessions(includeTasks = true) {
229
247
  }
230
248
  lastSessionsHash = sessionsHash;
231
249
 
232
- sessions = newSessions;
250
+ sessions = mergePlaceholders(newSessions);
233
251
  renderSessions();
234
252
  renderActivityChip();
235
253
  } catch (error) {
@@ -237,6 +255,16 @@ async function fetchSessions(includeTasks = true) {
237
255
  }
238
256
  }
239
257
 
258
+ // One session by id, ignoring every sidebar filter — that is what `include` means.
259
+ async function fetchSessionById(id) {
260
+ try {
261
+ const r = await fetch(`/api/sessions?limit=1&include=${encodeURIComponent(id)}`);
262
+ return r.ok ? (await r.json()).find((s) => s.id === id) || null : null;
263
+ } catch (_) {
264
+ return null;
265
+ }
266
+ }
267
+
240
268
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
241
269
  function handleSearch(query) {
242
270
  searchQuery = query.toLowerCase().trim();
@@ -416,14 +444,8 @@ async function fetchTasks(sessionId) {
416
444
  document.getElementById('message-toggle')?.style.removeProperty('display');
417
445
  const res = await fetch(`/api/sessions/${sessionId}`);
418
446
 
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
- }
447
+ if (!res.ok) throw new Error(`Failed to fetch tasks: ${res.status}`);
448
+ const newTasks = await res.json();
427
449
 
428
450
  const hash = JSON.stringify(newTasks);
429
451
  if (sessionId === currentSessionId && hash === lastCurrentTasksHash) {
@@ -435,6 +457,7 @@ async function fetchTasks(sessionId) {
435
457
  if (agentLogMode && sessionId !== currentSessionId) exitAgentLogMode();
436
458
  if (sessionId !== currentSessionId && document.getElementById('scratchpad-modal').classList.contains('visible'))
437
459
  closeScratchpad();
460
+ if (sessionId !== currentSessionId && detailPanel.classList.contains('visible')) closeDetailPanel();
438
461
  if (revealedPlanSessionId && sessionId !== revealedPlanSessionId) {
439
462
  revealedPlanSessionId = null;
440
463
  }
@@ -442,6 +465,10 @@ async function fetchTasks(sessionId) {
442
465
  revealedStorageSessionId = null;
443
466
  }
444
467
  if (currentSessionId && currentSessionId !== sessionId) deferredPinPlacement.delete(currentSessionId);
468
+ if (lastSessionId !== sessionId) {
469
+ previousSessionId = lastSessionId;
470
+ lastSessionId = sessionId;
471
+ }
445
472
  currentSessionId = sessionId;
446
473
  currentPins = loadPins(sessionId);
447
474
  ownerFilter = '';
@@ -608,6 +635,7 @@ async function refreshProjectAgents() {
608
635
  function toggleMessagePanel() {
609
636
  const panel = document.getElementById('message-panel');
610
637
  messagePanelOpen = !messagePanelOpen;
638
+ termState.panelHidden = false;
611
639
  store.setItem('message-panel-open', messagePanelOpen);
612
640
  panel.classList.toggle('visible', messagePanelOpen);
613
641
  document.getElementById('message-toggle')?.classList.toggle('active', messagePanelOpen);
@@ -1154,11 +1182,22 @@ function renderMessageListBody(messages) {
1154
1182
 
1155
1183
  const clickable = `data-msg-idx="${i}" onclick="msgDetailFollowLatest=false;showMsgDetail(${i})" style="cursor:pointer"`;
1156
1184
  const pinBtn = renderMsgPinBtn(m, i);
1185
+
1157
1186
  if (m.type === 'user') {
1158
1187
  if (m.systemLabel) {
1188
+ // A message another agent sent into this session (subagent hand-back, peer
1189
+ // session) is agent output: the agent icon, a sender label, and a markdown
1190
+ // preview of the report — never the person icon.
1191
+ const preview = m.agentMessage ? buildAssistantPreview(m.fullText || m.text) : null;
1192
+ const bodyHtml = preview?.md
1193
+ ? `<div class="msg-text msg-text-md">
1194
+ <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>` : ''}
1195
+ </div>`
1196
+ : '';
1197
+ const queuedTag = m.queued ? '<span class="msg-queued-tag">queued</span>' : '';
1159
1198
  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}
1199
+ ${m.agentMessage ? ICON_AGENT : MSG_ICON_SYSTEM}
1200
+ <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
1201
  </div>`);
1163
1202
  } else {
1164
1203
  const cmd = parseCommandMessage(m.text);
@@ -1392,9 +1431,13 @@ function clearWaitingUi() {
1392
1431
  waitingCustomDraft = {};
1393
1432
  if (currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX) {
1394
1433
  // 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();
1434
+ // closing it would silently drop the user out of following. An ask that
1435
+ // opened its own window is the exception: there was no following to drop.
1436
+ if (msgDetailFollowLatest && !waitingDetailAutoOpened && currentMessages.length) {
1437
+ showMsgDetail(currentMessages.length - 1);
1438
+ } else {
1439
+ closeMsgDetailModal();
1440
+ }
1398
1441
  }
1399
1442
  // The saved-plan modal stays open after a decision — only its approval row goes
1400
1443
  document.getElementById('plan-approval-footer')?.remove();
@@ -1482,6 +1525,8 @@ const ICON_AGENT_WAITING =
1482
1525
  '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>';
1483
1526
  const ICON_AGENT_ACTIVE =
1484
1527
  '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><line x1="8" y1="16" x2="8" y2="16"/><line x1="16" y1="16" x2="16" y2="16"/></svg>';
1528
+ const ICON_TERMINAL =
1529
+ '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>';
1485
1530
  const ICON_CHAT =
1486
1531
  '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
1487
1532
  const TOOL_ICONS = {
@@ -1985,7 +2030,7 @@ function showMsgDetail(idx) {
1985
2030
  const rawText = stripAnsi(m.fullText || m.text || '');
1986
2031
  const cmd = m.type === 'user' ? parseCommandMessage(rawText) : null;
1987
2032
  document.getElementById('msg-detail-title').textContent =
1988
- m.type === 'assistant' ? 'Claude' : m.systemLabel ? 'System' : 'User';
2033
+ m.type === 'assistant' ? 'Claude' : m.agentMessage ? m.systemLabel : m.systemLabel ? 'System' : 'User';
1989
2034
  document.getElementById('msg-detail-agent-btn').style.display = 'none';
1990
2035
  const userExtras = m.type === 'user' ? renderUserAttachments(m) : '';
1991
2036
  if (m.compactSummary) {
@@ -2023,6 +2068,7 @@ function showMsgDetail(idx) {
2023
2068
  function closeMsgDetailModal() {
2024
2069
  hideModalOverlay('msg-detail-modal');
2025
2070
  msgDetailFollowLatest = false;
2071
+ waitingDetailAutoOpened = false;
2026
2072
  // Drop the message highlight on close, mirroring task-card behavior.
2027
2073
  msgHighlightDimmed = true;
2028
2074
  const container = document.getElementById('message-panel-content');
@@ -2536,6 +2582,17 @@ function sanitizeOutputHtml(text) {
2536
2582
  return typeof text === 'string' ? ansiToHtml(stripLineNumbers(text)) : '';
2537
2583
  }
2538
2584
 
2585
+ function toolOutputHtml(text) {
2586
+ if (typeof text === 'string' && text.length <= HLJS_MAX_CHARS && /^\s*[[{]/.test(text)) {
2587
+ try {
2588
+ const pretty = JSON.stringify(JSON.parse(text), null, 2);
2589
+ if (typeof hljs === 'undefined' || !hljs.getLanguage('json')) return escapeHtml(pretty);
2590
+ return `<code class="hljs language-json">${hljs.highlight(pretty, { language: 'json' }).value}</code>`;
2591
+ } catch (_) {}
2592
+ }
2593
+ return sanitizeOutputHtml(text);
2594
+ }
2595
+
2539
2596
  function highlightBash(escaped) {
2540
2597
  return escaped
2541
2598
  .replace(/^(\s*)(#.*)$/gm, '$1<span style="color:#6a9955">$2</span>')
@@ -2601,11 +2658,11 @@ function autoSizeModal(modal, body) {
2601
2658
 
2602
2659
  function renderToolResultHtml(toolResult, isTruncated, fullResult, toolUseId) {
2603
2660
  if (!toolResult) return '';
2604
- const escaped = sanitizeOutputHtml(toolResult);
2661
+ const escaped = toolOutputHtml(toolResult);
2605
2662
  let truncLabel = '',
2606
2663
  fullBlock = '';
2607
2664
  if (isTruncated && fullResult) {
2608
- const toggle = makeExpandToggle(escaped, sanitizeOutputHtml(fullResult));
2665
+ const toggle = makeExpandToggle(escaped, toolOutputHtml(fullResult));
2609
2666
  truncLabel = toggle.btn;
2610
2667
  fullBlock = toggle.full;
2611
2668
  } else if (isTruncated && toolUseId) {
@@ -2647,7 +2704,7 @@ async function _toggleToolResultExpand(btn) {
2647
2704
  );
2648
2705
  if (!r.ok) throw new Error(`HTTP ${r.status}`);
2649
2706
  const { content } = await r.json();
2650
- f.innerHTML = sanitizeOutputHtml(content);
2707
+ f.innerHTML = toolOutputHtml(content);
2651
2708
  btn.dataset.loaded = '1';
2652
2709
  } catch (_e) {
2653
2710
  btn.textContent = 'Show more';
@@ -2838,9 +2895,10 @@ function renderAgentFooter() {
2838
2895
 
2839
2896
  clearInterval(agentDurationInterval);
2840
2897
  if (visible.some((a) => a.status === 'active' || a.status === 'idle')) {
2841
- agentDurationInterval = setInterval(() => renderAgentFooter(), 1000);
2898
+ agentDurationInterval = setInterval(() => isOnScreen() && renderAgentFooter(), 1000);
2842
2899
  if (!agentPollInterval) {
2843
2900
  agentPollInterval = setInterval(() => {
2901
+ if (skipOffScreen()) return;
2844
2902
  if (viewMode === 'project' && currentProjectPath) {
2845
2903
  refreshProjectAgents();
2846
2904
  } else if (currentSessionId) {
@@ -2849,7 +2907,7 @@ function renderAgentFooter() {
2849
2907
  }, 3000);
2850
2908
  }
2851
2909
  } else {
2852
- agentDurationInterval = setInterval(() => renderAgentFooter(), 10000);
2910
+ agentDurationInterval = setInterval(() => isOnScreen() && renderAgentFooter(), 10000);
2853
2911
  clearInterval(agentPollInterval);
2854
2912
  agentPollInterval = null;
2855
2913
  }
@@ -3021,6 +3079,13 @@ async function revealSession(id) {
3021
3079
  await fetchSessions();
3022
3080
  session = sessions.find((s) => s.id === id);
3023
3081
  }
3082
+ if (!session) {
3083
+ // The id is outside the list the filters asked for. renderSession bails on a session
3084
+ // missing from `sessions`, so pull that one row in before opening it; later polls
3085
+ // keep it, because fetchSessions pins currentSessionId.
3086
+ session = await fetchSessionById(id);
3087
+ if (session) sessions.push(session);
3088
+ }
3024
3089
  const uncollapsed = session ? uncollapseFor(session) : false;
3025
3090
  if (uncollapsed) persistCollapsedGroups();
3026
3091
  expandSidebar();
@@ -3101,11 +3166,12 @@ function getFilteredSessions() {
3101
3166
  filteredSessions = filteredSessions.filter((s) => {
3102
3167
  if (dismissedSessionIds.has(s.id)) return false;
3103
3168
  const isActive =
3104
- s.hasMessages &&
3105
- ((!s.sharedTaskList && (s.pending > 0 || s.inProgress > 0)) ||
3106
- s.hasActiveAgents ||
3107
- s.hasWaitingForUser ||
3108
- s.hasRecentActivity);
3169
+ runningTerminals.has(s.id) ||
3170
+ (s.hasMessages &&
3171
+ ((!s.sharedTaskList && (s.pending > 0 || s.inProgress > 0)) ||
3172
+ s.hasActiveAgents ||
3173
+ s.hasWaitingForUser ||
3174
+ s.hasRecentActivity));
3109
3175
  if (isActive) activeSessionIds.add(s.id);
3110
3176
  return isActive;
3111
3177
  });
@@ -3124,7 +3190,10 @@ function getFilteredSessions() {
3124
3190
  }
3125
3191
  }
3126
3192
  if (filterProject) {
3127
- filteredSessions = filteredSessions.filter((s) => matchesProjectFilter(s.project));
3193
+ // The open session keeps its row even when it belongs to another project, so that
3194
+ // revealing one by id has something to scroll to. Same exemption the active branch
3195
+ // above already makes, for the same reason.
3196
+ filteredSessions = filteredSessions.filter((s) => matchesProjectFilter(s.project) || s.id === currentSessionId);
3128
3197
  }
3129
3198
 
3130
3199
  if (activityFilter.size > 0) {
@@ -3230,6 +3299,7 @@ function renderSessions() {
3230
3299
 
3231
3300
  // Helper to render a single session card
3232
3301
  const renderSessionCard = (session) => {
3302
+ if (session.placeholder) return renderPlaceholderCard(session);
3233
3303
  const total = session.taskCount;
3234
3304
  const percent = total > 0 ? Math.round((session.completed / total) * 100) : 0;
3235
3305
  const isActive = session.id === currentSessionId && viewMode === 'session';
@@ -3258,11 +3328,11 @@ function renderSessions() {
3258
3328
  const showCtx = !!session.contextStatus && !zenMode;
3259
3329
  const linkedDocsCount = getSessionPreviewPaths(session.id).length;
3260
3330
  const bookmarksCount = loadPins(session.id).length;
3261
- const hasScratchpad = !!(store.getItem(_sessionScratchpadKey(session.id)) || '').trim();
3331
+ const hasScratchpad = _hasScratchpad(_sessionScratchpadKey(session.id));
3262
3332
  const tempClass = session.hasRecentLog || session.inProgress || session.hasWaitingForUser ? 'warm' : 'stale';
3263
3333
  const sid = escAttrJs(session.id);
3264
3334
  return `
3265
- <button onclick="fetchTasks('${sid}')" draggable="true" data-session-id="${escapeHtml(session.id)}" class="session-item ${isActive ? 'active' : ''} ${session.hasWaitingForUser ? 'permission-pending' : ''} ${tempClass} ${showCtx ? 'has-context' : ''}" title="${escapeHtml(tooltip)}">
3335
+ <button onclick="openSession('${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)}">
3266
3336
  <span class="session-pin-btn${pinClass}" onclick="event.stopPropagation();toggleSessionPin('${sid}')" title="${pinTitle} session">${pinState === 'sticky' ? SESSION_STAR_SVG : SESSION_PIN_SVG}</span>
3267
3337
  <div class="session-name">${escapeHtml(sessionName)}</div>
3268
3338
  ${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
@@ -3281,6 +3351,7 @@ function renderSessions() {
3281
3351
  ${session.hasPlan && !session.planSourceSessionId ? `<span class="plan-indicator" onclick="event.stopPropagation(); openPlanForSession('${sid}')" title="View plan">${ICON_PLAN}</span>` : ''}
3282
3352
  ${session.planSourceSessionId ? `<span class="plan-indicator" title="Implements plan — click to reveal plan session" onclick="event.stopPropagation(); revealPlanSession('${escAttrJs(session.planSourceSessionId)}')">${ICON_PLAN}</span>` : ''}
3283
3353
  ${session.sharedTaskList ? `<span class="shared-tasklist-badge" title="Shared task list: ${escapeHtml(session.sharedTaskList)}">${linkSvg(12)}</span>` : ''}
3354
+ ${runningTerminals.has(session.id) ? `<span class="terminal-badge" onclick="event.stopPropagation(); showSessionTerminal('${escAttrJs(session.id)}')" title="Running in a terminal here">${ICON_TERMINAL}</span>` : ''}
3284
3355
  ${session.hasWaitingForUser ? `<span class="agent-badge agent-badge-waiting" title="Waiting for user">${ICON_AGENT_WAITING}</span>` : ''}
3285
3356
  ${session.hasRunningAgents && !session.hasWaitingForUser ? `<span class="agent-badge agent-badge-active" title="Agents running">${ICON_AGENT_ACTIVE}</span>` : ''}
3286
3357
  ${isLive || session.hasRunningAgents ? `<span class="pulse" title="${isLive ? 'Live' : 'Active agents'}"></span>` : ''}
@@ -3299,13 +3370,16 @@ function renderSessions() {
3299
3370
  if (zenMode) {
3300
3371
  sessionsList.innerHTML = `${renderSessionCard(zenSession)}
3301
3372
  <div class="zen-panel">
3302
- ${renderContextDetail(zenSession.contextStatus) || '<div class="zen-panel-empty">No context data for this session</div>'}
3373
+ ${renderContextDetail(zenSession.contextStatus, { tokens: false }) || '<div class="zen-panel-empty">No context data for this session</div>'}
3303
3374
  ${renderScratchpadRow(zenSession)}
3304
3375
  ${renderLinkedDocsHtml(zenSession.id)}
3305
3376
  ${renderArtifactsHtml(zenSession.id)}
3306
- </div>`;
3377
+ </div>
3378
+ ${zenSession.hasWorkflow ? renderWorkflowLiveHtml(zenSession.id) : ''}`;
3307
3379
  bindLinkedDocsHandlers(sessionsList.querySelector('.linked-docs-section'), zenSession.id);
3308
- ensureSessionArtifacts(zenSession.id);
3380
+ artifactsSection.load(zenSession.id);
3381
+ scratchFilesSection.load(zenSession.id);
3382
+ if (zenSession.hasWorkflow) pollWorkflowLive(zenSession.id);
3309
3383
  return;
3310
3384
  }
3311
3385
 
@@ -3376,10 +3450,12 @@ function renderSessions() {
3376
3450
  <div class="project-group-header${isCollapsed ? ' collapsed' : ''}${nestedCls}" draggable="true" data-group-path="${escapedPath}" data-project-path="${escapedPath}">
3377
3451
  ${groupChevronSvg()}
3378
3452
  <span class="group-name">${escapeHtml(folderName)}</span>
3379
- ${countHtml(projectSessions)}
3453
+ ${terminalAvailable() ? `<span class="project-new-btn" data-project-path="${escapedPath}" title="New session in ${escapeHtml(folderName)}">${PLUS_SVG}</span>` : ''}
3454
+ ${headerPadBtnHtml(_projectScratchpadKey(projectPath), folderName)}
3380
3455
  <span class="project-view-btn" data-project-path="${escapedPath}" title="Open project view — combined tasks from all sessions">
3381
3456
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
3382
3457
  </span>
3458
+ ${countHtml(projectSessions)}
3383
3459
  </div>
3384
3460
  <div class="project-group-breadcrumb${nestedCls}" data-full-path="${escapedPath}" title="Click to copy path">${breadcrumbHtml}</div>
3385
3461
  <div class="project-group-sessions${isCollapsed ? ' collapsed' : ''}${nestedCls}" data-project-path="${escapedPath}">
@@ -3391,28 +3467,27 @@ function renderSessions() {
3391
3467
  // Named groups are a pure partition of the already-filtered list: whatever they don't claim
3392
3468
  // falls through to the project blocks below, which look exactly as they did before groups.
3393
3469
  const sgBuckets = new Map();
3394
- let sgRest = filteredSessions;
3395
- if (sessionGroups.length > 0) {
3396
- sgRest = [];
3397
- for (const session of filteredSessions) {
3398
- const group = sgGroupForSession(session);
3399
- if (group) {
3400
- if (!sgBuckets.has(group.id)) sgBuckets.set(group.id, []);
3401
- sgBuckets.get(group.id).push(session);
3402
- } else {
3403
- sgRest.push(session);
3404
- }
3470
+ const sgRest = [];
3471
+ for (const session of filteredSessions) {
3472
+ const group = sgGroupForSession(session);
3473
+ if (group) {
3474
+ if (!sgBuckets.has(group.id)) sgBuckets.set(group.id, []);
3475
+ sgBuckets.get(group.id).push(session);
3476
+ } else {
3477
+ sgRest.push(session);
3405
3478
  }
3406
3479
  }
3480
+ for (const [name, g] of transientGroups) if (!sgBuckets.has(g.id)) transientGroups.delete(name);
3407
3481
  // A group emptied by a filter hides its header; a group the user just created (no filters)
3408
3482
  // shows its drop-here state instead of vanishing.
3409
3483
  const sgFiltering = !!searchQuery || activityFilter.size > 0 || (!!filterProject && filterProject !== '__recent__');
3410
3484
 
3411
3485
  // flat = the "All sessions" view, which has no project sub-blocks.
3412
3486
  const sgSectionHtml = (flat) => {
3413
- if (sessionGroups.length === 0) return '';
3487
+ const transient = [...transientGroups.values()];
3488
+ if (sessionGroups.length === 0 && transient.length === 0) return '';
3414
3489
  let out = '';
3415
- for (const group of sessionGroups) {
3490
+ for (const group of [...sessionGroups, ...transient]) {
3416
3491
  const bucket = sgBuckets.get(group.id) || [];
3417
3492
  if (bucket.length === 0 && sgFiltering) continue;
3418
3493
  if (!groupPinned) bucket.sort(pinSort);
@@ -3420,6 +3495,17 @@ function renderSessions() {
3420
3495
  let body = '';
3421
3496
  if (flat) {
3422
3497
  body = bucket.map(renderSessionCard).join('');
3498
+ } else if (group.transient) {
3499
+ // Each session under its own project block, so a worktree session reads as its repo's.
3500
+ const byProject = new Map();
3501
+ for (const s of bucket) {
3502
+ const key = s.project || '';
3503
+ if (!byProject.has(key)) byProject.set(key, []);
3504
+ byProject.get(key).push(s);
3505
+ }
3506
+ for (const [p, arr] of byProject) {
3507
+ body += p ? projectBlock(p, arr, true) : renderGroupSessions(arr, `__pinned_group_${group.id}__`);
3508
+ }
3423
3509
  } else {
3424
3510
  const loose = [];
3425
3511
  const byProject = new Map();
@@ -3451,8 +3537,9 @@ function renderSessions() {
3451
3537
  }
3452
3538
  }
3453
3539
  if (!body) body = '<div class="sg-empty">Drop a session or a project here</div>';
3540
+ const idAttr = group.transient ? '' : ` data-group-id="${escapeHtml(group.id)}"`;
3454
3541
  out += sgHeaderHtml(group, countHtml(bucket));
3455
- out += `<div class="session-group-sessions${collapsed ? ' collapsed' : ''}" data-group-id="${escapeHtml(group.id)}">${body}</div>`;
3542
+ out += `<div class="session-group-sessions${collapsed ? ' collapsed' : ''}"${idAttr}>${body}</div>`;
3456
3543
  }
3457
3544
  if (!out) return '';
3458
3545
  return sectionHtml(SECTION_GROUPS, 'Groups', false, out);
@@ -3506,7 +3593,7 @@ function renderSessions() {
3506
3593
  // The section header is rendered even when nothing is left under it: it is the drop target
3507
3594
  // for ungrouping. Its body is skipped while collapsed — nothing would be visible, and this is
3508
3595
  // the whole project list, re-parsed on every live refresh.
3509
- const sectioned = sessionGroups.length > 0;
3596
+ const sectioned = sessionGroups.length > 0 || transientGroups.size > 0;
3510
3597
  let projectsHtml = '';
3511
3598
  if (!sectioned || !collapsedProjectGroups.has(SECTION_PROJECTS)) {
3512
3599
  for (const [projectPath, projectSessions] of sortedGroups) {
@@ -3534,7 +3621,7 @@ function renderSessions() {
3534
3621
 
3535
3622
  sessionsList.innerHTML = html;
3536
3623
  } else {
3537
- const sectioned = sessionGroups.length > 0;
3624
+ const sectioned = sessionGroups.length > 0 || transientGroups.size > 0;
3538
3625
  const restHtml =
3539
3626
  sectioned && collapsedProjectGroups.has(SECTION_SESSIONS) ? '' : sgRest.map(renderSessionCard).join('');
3540
3627
  const tail = sectioned ? sectionHtml(SECTION_SESSIONS, 'Sessions', true, restHtml) : restHtml;
@@ -3581,6 +3668,11 @@ function renderSession() {
3581
3668
  const session = sessions.find((s) => s.id === currentSessionId);
3582
3669
  if (!session) return;
3583
3670
 
3671
+ // Opening the session, not opening a linked-docs surface: the card badge is the
3672
+ // first place a pad shows up, and it would otherwise stay at zero until the user
3673
+ // happened to open the info modal or zen panel.
3674
+ loadSessionPads(currentSessionId);
3675
+
3584
3676
  const displayName =
3585
3677
  session.customTitle || session.name || session.gitBranch || session.description || currentSessionId;
3586
3678
 
@@ -3960,7 +4052,7 @@ async function onColumnDrop(e) {
3960
4052
  // windows the list), so membership is never garbage-collected on load.
3961
4053
  const SESSION_GROUPS_KEY = 'sessionGroups';
3962
4054
  const SG_ACTION_SELECTOR =
3963
- '.session-pin-btn, .team-info-btn, .plan-indicator, .scratchpad-badge, .bookmarks-badge, .linked-docs-badge, .project-view-btn, .group-path-toggle, .pinned-ungroup-btn, .sg-action';
4055
+ '.session-pin-btn, .team-info-btn, .plan-indicator, .scratchpad-badge, .bookmarks-badge, .linked-docs-badge, .project-view-btn, .project-new-btn, .header-pad-btn, .group-path-toggle, .pinned-ungroup-btn, .sg-action';
3964
4056
 
3965
4057
  // Collapse state shares the existing `collapsedGroups` key; this namespace keeps it from
3966
4058
  // colliding with a project path or the __ungrouped__ / __pinned_* sentinels.
@@ -3995,14 +4087,17 @@ function loadSessionGroups() {
3995
4087
  return out;
3996
4088
  }),
3997
4089
  }));
4090
+ const released = JSON.parse(store.getItem(SESSION_GROUPS_KEY) || 'null')?.released;
4091
+ sgReleased = new Set(Array.isArray(released) ? released.filter((id) => typeof id === 'string') : []);
3998
4092
  } catch (_) {
3999
4093
  sessionGroups = [];
4094
+ sgReleased = new Set();
4000
4095
  }
4001
4096
  }
4002
4097
 
4003
4098
  function persistSessionGroups() {
4004
4099
  try {
4005
- store.setItem(SESSION_GROUPS_KEY, JSON.stringify({ version: 1, groups: sessionGroups }));
4100
+ store.setItem(SESSION_GROUPS_KEY, JSON.stringify({ version: 1, groups: sessionGroups, released: [...sgReleased] }));
4006
4101
  } catch (_) {}
4007
4102
  }
4008
4103
 
@@ -4016,9 +4111,51 @@ function sgGroupOf(type, ref) {
4016
4111
 
4017
4112
  // An individually placed session wins over the placement of its project.
4018
4113
  function sgGroupForSession(session) {
4114
+ return sgUserGroupFor(session) || sgDispatchGroupFor(session);
4115
+ }
4116
+
4117
+ function sgUserGroupFor(session) {
4019
4118
  return sgGroupOf('session', session.id) || (session.project ? sgGroupOf('project', session.project) : null);
4020
4119
  }
4021
4120
 
4121
+ // Transient groups come from the server (`dispatch start --group`) and go when their sessions
4122
+ // end. They are never written to localStorage; the objects are cached only so collapse state,
4123
+ // keyed by id, survives a re-render.
4124
+ const transientGroups = new Map();
4125
+
4126
+ function sgTransientGroup(name) {
4127
+ let group = transientGroups.get(name);
4128
+ if (!group) {
4129
+ group = { id: `t_${name}`, name, transient: true };
4130
+ transientGroups.set(name, group);
4131
+ }
4132
+ return group;
4133
+ }
4134
+
4135
+ // A user group with the same name takes the session in, so Keep does not split a group in two.
4136
+ // With no group of its own, a started session follows its starter into the starter's named group.
4137
+ function sgDispatchGroupFor(session) {
4138
+ if (sgReleased.has(session.id)) return null;
4139
+ const name = session.dispatchGroup;
4140
+ if (name) return sessionGroups.find((g) => g.name.toLowerCase() === name) || sgTransientGroup(name);
4141
+ const starter = session.startedBy && sessions.find((s) => s.id === session.startedBy);
4142
+ return starter ? sgUserGroupFor(starter) : null;
4143
+ }
4144
+
4145
+ function sgKeepTransient(name) {
4146
+ const group = sgTransientGroup(name);
4147
+ const members = sessions.filter((s) => sgGroupForSession(s) === group);
4148
+ const kept = sgCreateGroup(name);
4149
+ for (const s of members) kept.members.push({ type: 'session', ref: s.id });
4150
+ persistSessionGroups();
4151
+ }
4152
+
4153
+ function sgDeleteTransient(name) {
4154
+ const group = sgTransientGroup(name);
4155
+ for (const s of sessions) if (sgGroupForSession(s) === group) sgReleased.add(s.id);
4156
+ persistSessionGroups();
4157
+ }
4158
+
4022
4159
  function sgDetach(type, ref) {
4023
4160
  for (const g of sessionGroups) {
4024
4161
  g.members = g.members.filter((m) => !(m.type === type && m.ref === ref));
@@ -4115,19 +4252,35 @@ function sgSearchMatchIds(query) {
4115
4252
  return ids;
4116
4253
  }
4117
4254
 
4255
+ const PAD_SVG =
4256
+ '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="13" y2="17"/></svg>';
4257
+
4258
+ function headerPadBtnHtml(key, label) {
4259
+ const has = _hasScratchpad(key);
4260
+ return `<span class="header-pad-btn${has ? ' has-pad' : ''}" onclick="event.stopPropagation(); showScratchpad('${escAttrJs(key)}')" title="${has ? 'Open' : 'New'} scratchpad for ${escapeHtml(label)} (N)">${PAD_SVG}</span>`;
4261
+ }
4262
+
4118
4263
  function sgHeaderHtml(group, countHtml) {
4119
4264
  const collapsed = collapsedProjectGroups.has(sgKey(group.id));
4120
4265
  const editing = sgEditingId === group.id;
4121
4266
  const nameHtml = editing
4122
4267
  ? `<input class="sg-name-input" type="text" value="${escapeHtml(group.name)}" aria-label="Group name" spellcheck="false">`
4123
4268
  : `<span class="group-name">${escapeHtml(group.name)}</span>`;
4269
+ const attrs = group.transient
4270
+ ? `class="session-group-header sg-transient${collapsed ? ' collapsed' : ''}" data-transient-group="${escapeHtml(group.name)}" title="${escapeHtml(group.name)} — goes when its sessions end"`
4271
+ : `class="session-group-header${collapsed ? ' collapsed' : ''}" draggable="${editing ? 'false' : 'true'}" data-group-id="${escapeHtml(group.id)}" title="${escapeHtml(group.name)} — drop sessions or projects here"`;
4272
+ const actions = group.transient
4273
+ ? `<span class="sg-action sg-keep" title="Keep this group after its sessions end">Keep</span>
4274
+ <span class="sg-action sg-delete" title="Delete group (sessions return to Projects)">&times;</span>`
4275
+ : `${headerPadBtnHtml(_groupScratchpadKey(group.id), group.name)}
4276
+ <span class="sg-action sg-rename" title="Rename group"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></span>
4277
+ <span class="sg-action sg-delete" title="Delete group (members return to Projects)">&times;</span>`;
4124
4278
  return `
4125
- <div class="session-group-header${collapsed ? ' collapsed' : ''}" draggable="${editing ? 'false' : 'true'}" data-group-path="${escapeHtml(sgKey(group.id))}" data-group-id="${escapeHtml(group.id)}" title="${escapeHtml(group.name)} — drop sessions or projects here">
4279
+ <div ${attrs} data-group-path="${escapeHtml(sgKey(group.id))}">
4126
4280
  ${groupChevronSvg()}
4127
4281
  ${nameHtml}
4282
+ ${actions}
4128
4283
  ${countHtml}
4129
- <span class="sg-action sg-rename" title="Rename group"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></span>
4130
- <span class="sg-action sg-delete" title="Delete group (members return to Projects)">&times;</span>
4131
4284
  </div>`;
4132
4285
  }
4133
4286
 
@@ -4204,8 +4357,9 @@ function sgDropZone(target) {
4204
4357
  ? { zone, groupId: zone.dataset.groupId }
4205
4358
  : null;
4206
4359
  }
4207
- if (zone.classList.contains('sg-ungroup-zone')) return dragGroup ? { zone } : null;
4208
4360
  const dragSession = sgDrag.kind === 'session' ? sessions.find((x) => x.id === sgDrag.ref) : null;
4361
+ const releasable = !!dragSession && !sgUserGroupFor(dragSession) && !!sgDispatchGroupFor(dragSession);
4362
+ if (zone.classList.contains('sg-ungroup-zone')) return dragGroup || releasable ? { zone } : null;
4209
4363
  const fromGroup = dragSession ? sgGroupForSession(dragSession) : dragGroup;
4210
4364
  const fromGroupId = fromGroup?.id || null;
4211
4365
  // A drop that lands on the host the session already has changes nothing, and highlighting it
@@ -4224,7 +4378,8 @@ function sgDropZone(target) {
4224
4378
  const targetSession = sessions.find((s) => s.id === targetSessionId);
4225
4379
  const targetGroup = targetSession ? sgGroupForSession(targetSession) : null;
4226
4380
  const targetGroupId = targetGroup?.id || null;
4227
- if (targetGroup) {
4381
+ // A transient group holds no members to reorder or place under; a drop there pairs instead.
4382
+ if (targetGroup && !targetGroup.transient) {
4228
4383
  // Stacking onto a card adopts that card's placement, project block included.
4229
4384
  const targetHost = sgHostOf(targetGroup, targetSession);
4230
4385
  if (targetHost) {
@@ -4255,7 +4410,7 @@ function sgDropZone(target) {
4255
4410
  return fromHost === path && fromGroupId === pathGroup.id ? null : { zone, groupId: pathGroup.id, under: path };
4256
4411
  }
4257
4412
  // Stacking a session onto its own project block would only fold it back where it sits today.
4258
- if (dragSession?.project === path) return null;
4413
+ if (dragSession?.project === path) return releasable ? { zone } : null;
4259
4414
  return { zone, pairWith: path };
4260
4415
  }
4261
4416
 
@@ -4316,6 +4471,7 @@ function sgOnDrop(e) {
4316
4471
  else sgAssign(hit.groupId, drag.kind, drag.ref, { under: hit.under, loose: hit.loose });
4317
4472
  } else {
4318
4473
  sgDetach(drag.kind, drag.ref);
4474
+ if (drag.kind === 'session') sgReleased.add(drag.ref);
4319
4475
  persistSessionGroups();
4320
4476
  }
4321
4477
  sgFinishDrag();
@@ -5218,6 +5374,7 @@ const SHORTCUT_PAIRS = [
5218
5374
  { keys: ['Shift', 'L'], combo: true, label: 'Toggle session log' },
5219
5375
  { keys: ['Shift', 'M'], combo: true, label: 'Open last message' },
5220
5376
  { keys: ['J', 'K'], label: 'Previous / next message in detail' },
5377
+ { keys: ['Ctrl', 'Enter'], combo: true, label: 'Allow / approve the waiting prompt' },
5221
5378
  ],
5222
5379
  },
5223
5380
  ],
@@ -5227,7 +5384,7 @@ const SHORTCUT_PAIRS = [
5227
5384
  rows: [
5228
5385
  { keys: ['Enter'], label: 'Toggle task detail panel' },
5229
5386
  { keys: ['D'], label: 'Delete selected task' },
5230
- { keys: ['N'], label: 'Toggle scratchpad' },
5387
+ { keys: ['N'], label: 'Toggle scratchpad (in the sidebar: the item under the cursor)' },
5231
5388
  { keys: ['R'], label: 'Refresh data' },
5232
5389
  { keys: ['Esc'], label: 'Close panel / clear selection' },
5233
5390
  ],
@@ -5267,6 +5424,25 @@ const SHORTCUT_PAIRS = [
5267
5424
  ],
5268
5425
  },
5269
5426
  ],
5427
+ [
5428
+ {
5429
+ title: 'Terminal',
5430
+ rows: [
5431
+ { keys: ['Ctrl', '`'], combo: true, label: 'Show / hide terminal' },
5432
+ { keys: ['Alt', '`'], combo: true, label: 'Focus terminal / page' },
5433
+ { keys: ['Alt', 'Shift', '`'], combo: true, label: 'End and close terminal' },
5434
+ { keys: ['Ctrl', 'Shift', '`'], combo: true, label: 'All terminals' },
5435
+ ],
5436
+ },
5437
+ {
5438
+ title: 'Sessions',
5439
+ rows: [
5440
+ { keys: ['Ctrl', 'Alt', 'N'], combo: true, label: 'New session' },
5441
+ { keys: ['Ctrl', 'Alt', 'R'], combo: true, label: 'Resume session (claude -r)' },
5442
+ { keys: ['Ctrl', 'Alt', 'S'], combo: true, label: 'Swap to previous session' },
5443
+ ],
5444
+ },
5445
+ ],
5270
5446
  ];
5271
5447
 
5272
5448
  const EMPTY_GROUP = { title: '', rows: [] };
@@ -5335,18 +5511,52 @@ const _scratchpadCharcount = document.getElementById('scratchpad-charcount');
5335
5511
 
5336
5512
  let _scratchpadKeyOverride = null;
5337
5513
 
5514
+ const SESSION_PAD_PREFIX = 'scratchpad-';
5515
+ const PROJECT_PAD_PREFIX = `${SESSION_PAD_PREFIX}project:`;
5516
+ const GROUP_PAD_PREFIX = `${SESSION_PAD_PREFIX}group:`;
5517
+
5338
5518
  function _sessionScratchpadKey(sessionId) {
5339
- return `scratchpad-${sessionId}`;
5519
+ return SESSION_PAD_PREFIX + sessionId;
5520
+ }
5521
+
5522
+ function _projectScratchpadKey(projectPath) {
5523
+ return PROJECT_PAD_PREFIX + projectPath;
5524
+ }
5525
+
5526
+ function _groupScratchpadKey(groupId) {
5527
+ return GROUP_PAD_PREFIX + groupId;
5528
+ }
5529
+
5530
+ // Every kind shares the session prefix, so the longer prefixes are tested first.
5531
+ function _parsePadKey(key) {
5532
+ for (const [kind, prefix] of [
5533
+ ['group', GROUP_PAD_PREFIX],
5534
+ ['project', PROJECT_PAD_PREFIX],
5535
+ ['session', SESSION_PAD_PREFIX],
5536
+ ]) {
5537
+ if (key.startsWith(prefix)) return { kind, id: key.slice(prefix.length) };
5538
+ }
5539
+ return null;
5340
5540
  }
5341
5541
 
5342
- function _isSessionScratchpadKey(key) {
5343
- return key.startsWith('scratchpad-') && !key.startsWith('scratchpad-project:');
5542
+ function _hasScratchpad(key) {
5543
+ return !!(store.getItem(key) || '').trim();
5344
5544
  }
5345
5545
 
5346
5546
  function _scratchpadKey() {
5347
5547
  if (_scratchpadKeyOverride) return _scratchpadKeyOverride;
5348
5548
  if (currentSessionId) return _sessionScratchpadKey(currentSessionId);
5349
- if (currentProjectPath) return `scratchpad-project:${currentProjectPath}`;
5549
+ if (currentProjectPath) return _projectScratchpadKey(currentProjectPath);
5550
+ return null;
5551
+ }
5552
+
5553
+ function _scratchpadKeyOf(el) {
5554
+ if (!el) return null;
5555
+ if (el.dataset.sessionId) return _sessionScratchpadKey(el.dataset.sessionId);
5556
+ if (el.classList.contains('session-group-header')) return _groupScratchpadKey(el.dataset.groupId);
5557
+ if (el.classList.contains('project-group-header') && el.dataset.projectPath) {
5558
+ return _projectScratchpadKey(el.dataset.projectPath);
5559
+ }
5350
5560
  return null;
5351
5561
  }
5352
5562
 
@@ -5387,14 +5597,14 @@ function saveScratchpad() {
5387
5597
  const key = _scratchpadKey();
5388
5598
  if (!key) return;
5389
5599
  const val = _scratchpadTextarea.value;
5390
- const had = !!(store.getItem(key) || '').trim();
5600
+ const had = _hasScratchpad(key);
5391
5601
  const has = !!val.trim();
5392
5602
  if (has) {
5393
5603
  store.setItem(key, val);
5394
5604
  } else {
5395
5605
  store.removeItem(key);
5396
5606
  }
5397
- if (had !== has && _isSessionScratchpadKey(key)) {
5607
+ if (had !== has) {
5398
5608
  renderSessions();
5399
5609
  }
5400
5610
  }
@@ -5408,6 +5618,30 @@ _scratchpadTextarea.addEventListener('input', () => {
5408
5618
  }, 500);
5409
5619
  });
5410
5620
 
5621
+ // Bound on the textarea: the global handler returns early on TEXTAREA targets.
5622
+ _scratchpadTextarea.addEventListener('keydown', (e) => {
5623
+ if (e.key === 'Escape') closeScratchpad();
5624
+ });
5625
+
5626
+ // Vimium eats Escape inside a text field and only blurs it, so the page never sees the key.
5627
+ // A blur that no click in the modal caused, while the window keeps focus, is that Escape.
5628
+ let _scratchpadPointerDown = false;
5629
+ _scratchpadModal.addEventListener('mousedown', () => {
5630
+ _scratchpadPointerDown = true;
5631
+ });
5632
+ document.addEventListener('mouseup', () => {
5633
+ _scratchpadPointerDown = false;
5634
+ });
5635
+ _scratchpadTextarea.addEventListener('blur', (e) => {
5636
+ if (
5637
+ !e.relatedTarget &&
5638
+ !_scratchpadPointerDown &&
5639
+ _scratchpadModal.classList.contains('visible') &&
5640
+ document.hasFocus()
5641
+ )
5642
+ closeScratchpad();
5643
+ });
5644
+
5411
5645
  //#endregion
5412
5646
 
5413
5647
  //#region STORAGE_MANAGER
@@ -5624,30 +5858,38 @@ function _storageUnpinMessage(sessionId, pinId) {
5624
5858
  function _renderStorageScratchpads() {
5625
5859
  const allItems = [];
5626
5860
  for (const key of store.keys()) {
5627
- if (!key.startsWith('scratchpad-')) continue;
5628
- const val = store.getItem(key) || '';
5629
- const isProject = key.startsWith('scratchpad-project:');
5630
- const id = isProject ? key.slice('scratchpad-project:'.length) : key.slice('scratchpad-'.length);
5631
- allItems.push({ key, id, isProject, chars: val.length });
5861
+ const parsed = _parsePadKey(key);
5862
+ if (!parsed) continue;
5863
+ allItems.push({ key, ...parsed, chars: (store.getItem(key) || '').length });
5632
5864
  }
5633
5865
  if (!allItems.length) return '<div class="storage-empty">No scratchpads</div>';
5634
5866
 
5635
- const projectItems = allItems.filter((i) => i.isProject);
5636
- const sessionItems = allItems.filter((i) => !i.isProject);
5867
+ const groupItems = allItems.filter((i) => i.kind === 'group');
5868
+ const projectItems = allItems.filter((i) => i.kind === 'project');
5869
+ const sessionItems = allItems.filter((i) => i.kind === 'session');
5637
5870
  const sessionIds = sessionItems.map((i) => i.id);
5638
5871
  const { groups: projectGroups, orphans } = _groupByProject(sessionIds);
5639
5872
  const scratchBySession = new Map(sessionItems.map((i) => [i.id, i]));
5640
5873
 
5641
5874
  function renderScratchItem(item) {
5642
- const session = !item.isProject ? sessions.find((s) => s.id === item.id) : null;
5643
- const typeBadge = item.isProject
5644
- ? '<span class="storage-item-badge">project</span>'
5645
- : '<span class="storage-item-badge">session</span>';
5646
5875
  const jsKey = escAttrJs(item.key);
5647
- const label = item.isProject ? escapeHtml(_projectLabel(item.id)) : _sessionLabel(session, item.id);
5876
+ let label;
5877
+ switch (item.kind) {
5878
+ case 'group':
5879
+ label = escapeHtml(sgGroupById(item.id)?.name ?? `${item.id} (deleted)`);
5880
+ break;
5881
+ case 'project':
5882
+ label = escapeHtml(_projectLabel(item.id));
5883
+ break;
5884
+ default:
5885
+ label = _sessionLabel(
5886
+ sessions.find((s) => s.id === item.id),
5887
+ item.id,
5888
+ );
5889
+ }
5648
5890
  return `<div class="storage-item">
5649
5891
  <span class="storage-item-id" title="${escapeHtml(item.id)}">${label}</span>
5650
- ${typeBadge}
5892
+ <span class="storage-item-badge">${item.kind}</span>
5651
5893
  <span class="storage-item-meta">${item.chars} chars</span>
5652
5894
  <div class="storage-item-actions">
5653
5895
  <button onclick="_storagePreviewScratchpad('${jsKey}')">View</button>
@@ -5658,6 +5900,14 @@ function _renderStorageScratchpads() {
5658
5900
 
5659
5901
  let html = '';
5660
5902
 
5903
+ if (groupItems.length) {
5904
+ html += _renderProjectGroup(
5905
+ 'Group Scratchpads',
5906
+ `${groupItems.length}`,
5907
+ groupItems.map(renderScratchItem).join(''),
5908
+ );
5909
+ }
5910
+
5661
5911
  if (projectItems.length) {
5662
5912
  html += _renderProjectGroup(
5663
5913
  'Project Scratchpads',
@@ -5717,12 +5967,8 @@ function _renderStorageLinkedDocs() {
5717
5967
  const entries = [];
5718
5968
  for (const key of store.keys()) {
5719
5969
  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 {}
5970
+ const paths = readStoredList(key);
5971
+ if (paths.length) entries.push({ sessionId: key.slice(PREVIEW_STORAGE_PREFIX.length), paths });
5726
5972
  }
5727
5973
  if (!entries.length) return '<div class="storage-empty">No linked documents</div>';
5728
5974
 
@@ -5783,6 +6029,8 @@ function _storageUnlinkDoc(sessionId, path) {
5783
6029
 
5784
6030
  function _storageClearLinkedDocs(sessionId) {
5785
6031
  store.removeItem(PREVIEW_STORAGE_PREFIX + sessionId);
6032
+ // The auto-link record deliberately survives: clearing is unlinking every row at
6033
+ // once, and an unlinked pad must not come back on the next read of the transcript.
5786
6034
  afterLinkedDocsChanged(sessionId);
5787
6035
  }
5788
6036
 
@@ -5793,12 +6041,17 @@ function _findOrphanedKeys() {
5793
6041
  for (const id of pinnedSessionIds) if (!known.has(id)) orphaned.push(`__pinned__${id}`);
5794
6042
  for (const id of stickySessionIds) if (!known.has(id)) orphaned.push(`__sticky__${id}`);
5795
6043
  for (const key of store.keys()) {
5796
- if (key.startsWith('pinned-messages-')) {
6044
+ const pad = _parsePadKey(key);
6045
+ if (pad?.kind === 'group') {
6046
+ if (!sgGroupById(pad.id)) orphaned.push(key);
6047
+ } else if (key.startsWith('pinned-messages-')) {
5797
6048
  if (!known.has(key.slice('pinned-messages-'.length))) orphaned.push(key);
5798
- } else if (_isSessionScratchpadKey(key)) {
5799
- if (!known.has(key.slice('scratchpad-'.length))) orphaned.push(key);
6049
+ } else if (pad?.kind === 'session') {
6050
+ if (!known.has(pad.id)) orphaned.push(key);
5800
6051
  } else if (key.startsWith(PREVIEW_STORAGE_PREFIX)) {
5801
6052
  if (!known.has(key.slice(PREVIEW_STORAGE_PREFIX.length))) orphaned.push(key);
6053
+ } else if (key.startsWith(PAD_LINKED_PREFIX)) {
6054
+ if (!known.has(key.slice(PAD_LINKED_PREFIX.length))) orphaned.push(key);
5802
6055
  }
5803
6056
  }
5804
6057
  return orphaned;
@@ -5869,6 +6122,8 @@ const MODAL_CLOSERS = {
5869
6122
  'agent-modal': () => closeAgentModal(),
5870
6123
  'help-modal': () => closeHelpModal(),
5871
6124
  'session-picker-modal': () => closeSessionPicker(),
6125
+ 'terminal-manager-modal': () => closeTerminalManager(),
6126
+ 'new-session-modal': () => closeNewSession(),
5872
6127
  };
5873
6128
 
5874
6129
  document.addEventListener('keydown', (e) => {
@@ -5884,6 +6139,35 @@ document.addEventListener('keydown', (e) => {
5884
6139
  }
5885
6140
  }
5886
6141
 
6142
+ // Above the text-field guard: xterm's input is a textarea, and the toggle must work from it.
6143
+ const terminalPrompt = document.getElementById('terminal-prompt');
6144
+ if (terminalPrompt.contains(e.target)) {
6145
+ if (e.key === 'Escape') {
6146
+ e.preventDefault();
6147
+ toggleTerminal();
6148
+ return;
6149
+ }
6150
+ if (e.key === 'Enter' || e.key === ' ') return;
6151
+ const step = matchKey(e, 'ArrowLeft', 'ArrowUp', 'KeyH', 'KeyK')
6152
+ ? -1
6153
+ : matchKey(e, 'ArrowRight', 'ArrowDown', 'KeyL', 'KeyJ')
6154
+ ? 1
6155
+ : 0;
6156
+ if (step) {
6157
+ e.preventDefault();
6158
+ const buttons = [...terminalPrompt.querySelectorAll('button')];
6159
+ const i = buttons.indexOf(e.target);
6160
+ buttons[(i + step + buttons.length) % buttons.length]?.focus();
6161
+ return;
6162
+ }
6163
+ }
6164
+ const terminalAction = terminalShortcut(e);
6165
+ if (terminalAction) {
6166
+ e.preventDefault();
6167
+ terminalAction();
6168
+ return;
6169
+ }
6170
+
5887
6171
  if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') {
5888
6172
  return;
5889
6173
  }
@@ -5913,7 +6197,16 @@ document.addEventListener('keydown', (e) => {
5913
6197
  e.preventDefault();
5914
6198
  closeMsgDetailModal();
5915
6199
  } else if (document.getElementById('msg-detail-modal').classList.contains('visible')) {
5916
- if (matchKey(e, 'ArrowDown', 'KeyJ')) {
6200
+ if (
6201
+ e.key === 'Enter' &&
6202
+ (e.ctrlKey || e.metaKey) &&
6203
+ currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX &&
6204
+ isWaitingAnswerable() &&
6205
+ currentWaiting.kind !== 'question'
6206
+ ) {
6207
+ e.preventDefault();
6208
+ respondWaiting({ behavior: 'allow' });
6209
+ } else if (matchKey(e, 'ArrowDown', 'KeyJ')) {
5917
6210
  e.preventDefault();
5918
6211
  if (currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX) {
5919
6212
  msgDetailFollowLatest = true;
@@ -5997,6 +6290,12 @@ document.addEventListener('keydown', (e) => {
5997
6290
  // Tab toggles focus zone
5998
6291
  if (e.key === 'Tab') {
5999
6292
  e.preventDefault();
6293
+ // The terminal replaces the board, so it takes the board's place in the Tab cycle.
6294
+ if (wantsTerminal()) {
6295
+ if (focusZone === 'sidebar') focusTerminalPane();
6296
+ else leaveTerminalPane();
6297
+ return;
6298
+ }
6000
6299
  if (focusZone === 'sidebar') {
6001
6300
  const hasCards = document.querySelector('.task-card');
6002
6301
  if (!hasCards) return;
@@ -6095,10 +6394,9 @@ document.addEventListener('keydown', (e) => {
6095
6394
  }
6096
6395
 
6097
6396
  // Shared actions — work in both sidebar and board
6397
+ const cursorEl = focusZone === 'sidebar' ? sessionsList.querySelector('.kb-selected') : null;
6098
6398
  const contextSid =
6099
- focusZone === 'sidebar'
6100
- ? sessionsList.querySelector('.kb-selected')?.dataset.sessionId || currentSessionId
6101
- : selectedSessionId || currentSessionId;
6399
+ focusZone === 'sidebar' ? cursorEl?.dataset.sessionId || currentSessionId : selectedSessionId || currentSessionId;
6102
6400
  if (matchKey(e, 'KeyP') && !e.shiftKey) {
6103
6401
  e.preventDefault();
6104
6402
  if (contextSid) openPlanForSession(contextSid);
@@ -6111,7 +6409,9 @@ document.addEventListener('keydown', (e) => {
6111
6409
  }
6112
6410
  if (matchKey(e, 'KeyN') && !e.shiftKey) {
6113
6411
  e.preventDefault();
6114
- toggleScratchpad();
6412
+ const cursorKey = _scratchpadKeyOf(cursorEl);
6413
+ if (cursorKey) showScratchpad(cursorKey);
6414
+ else toggleScratchpad();
6115
6415
  return;
6116
6416
  }
6117
6417
  if (e.key === '$' && !e.ctrlKey && !e.altKey && !e.metaKey) {
@@ -6206,27 +6506,40 @@ document.addEventListener('keydown', (e) => {
6206
6506
  const PREVIEW_STORAGE_PREFIX = 'preview-paths-';
6207
6507
  let currentPreviewPath = null;
6208
6508
 
6209
- function getSessionPreviewPaths(sessionId) {
6210
- if (!sessionId) return [];
6509
+ function readStoredList(key) {
6211
6510
  try {
6212
- const raw = store.getItem(PREVIEW_STORAGE_PREFIX + sessionId);
6213
- const arr = raw ? JSON.parse(raw) : [];
6511
+ const arr = JSON.parse(store.getItem(key) || '[]');
6214
6512
  return Array.isArray(arr) ? arr : [];
6215
6513
  } catch {
6216
6514
  return [];
6217
6515
  }
6218
6516
  }
6219
6517
 
6518
+ function getSessionPreviewPaths(sessionId) {
6519
+ return sessionId ? readStoredList(PREVIEW_STORAGE_PREFIX + sessionId) : [];
6520
+ }
6521
+
6522
+ // Windows reaches one file as both `C:\a\b` and `C:/a/b`, and the two sides of a link
6523
+ // disagree by construction: a pad path is built with `path.join`, a hand-typed one is
6524
+ // stored as it was typed. Rows are compared through here so the same file cannot be
6525
+ // listed twice — and lists written before this existed heal on the next link.
6526
+ function canonicalPath(filePath) {
6527
+ const slashed = String(filePath).replace(/\\/g, '/');
6528
+ return /^[A-Za-z]:/.test(slashed) ? slashed.toLowerCase() : slashed;
6529
+ }
6530
+
6220
6531
  function addSessionPreviewPath(sessionId, filePath) {
6221
6532
  if (!sessionId || !filePath) return;
6222
- const paths = getSessionPreviewPaths(sessionId).filter((p) => p !== filePath);
6533
+ const key = canonicalPath(filePath);
6534
+ const paths = getSessionPreviewPaths(sessionId).filter((p) => canonicalPath(p) !== key);
6223
6535
  paths.unshift(filePath);
6224
6536
  store.setItem(PREVIEW_STORAGE_PREFIX + sessionId, JSON.stringify(paths.slice(0, 20)));
6225
6537
  }
6226
6538
 
6227
6539
  function removeSessionPreviewPath(sessionId, filePath) {
6228
6540
  if (!sessionId) return;
6229
- const paths = getSessionPreviewPaths(sessionId).filter((p) => p !== filePath);
6541
+ const key = canonicalPath(filePath);
6542
+ const paths = getSessionPreviewPaths(sessionId).filter((p) => canonicalPath(p) !== key);
6230
6543
  if (paths.length) store.setItem(PREVIEW_STORAGE_PREFIX + sessionId, JSON.stringify(paths));
6231
6544
  else store.removeItem(PREVIEW_STORAGE_PREFIX + sessionId);
6232
6545
  }
@@ -6296,23 +6609,48 @@ function bindPreviewRelativeLinks(bodyEl) {
6296
6609
  const isAbsolutePath = href.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(href);
6297
6610
  if (isAbsoluteUrl) return;
6298
6611
  const cleanHref = href.replace(/#.*$/, '');
6299
- if (!isPreviewablePath(cleanHref)) return;
6300
6612
  e.preventDefault();
6301
- openPreviewByPath(cleanHref, isAbsolutePath ? undefined : currentPreviewPath);
6613
+ openPreviewByPath(cleanHref, isAbsolutePath ? undefined : currentPreviewPath, openFileInEditor);
6302
6614
  });
6303
6615
  bodyEl.dataset.relLinkBound = '1';
6304
6616
  }
6305
6617
 
6306
6618
  // `kind` comes from the server, which is the only place that decides what is previewable.
6619
+ // hljs gets the extension as the language hint and `highlightAuto` is deliberately not
6620
+ // the fallback: on a config or data file it guesses wildly, and wrong colors read as a
6621
+ // broken file. Above the size cap it is skipped outright — it is O(size) with heavy
6622
+ // backtracking on the main thread, and .jsonl/.log files in a scratchpad reach megabytes.
6623
+ const HLJS_MAX_CHARS = 256 * 1024;
6624
+
6625
+ function renderSourcePreview(filePath, content) {
6626
+ const ext = (filePath.split('.').pop() || '').toLowerCase();
6627
+ if (typeof hljs !== 'undefined' && content.length <= HLJS_MAX_CHARS && hljs.getLanguage(ext)) {
6628
+ try {
6629
+ const value = hljs.highlight(content, { language: ext, ignoreIllegals: true }).value;
6630
+ return `<pre class="preview-source"><code class="hljs language-${escapeHtml(ext)}">${value}</code></pre>`;
6631
+ } catch (_) {}
6632
+ }
6633
+ return `<pre class="preview-source"><code class="hljs">${escapeHtml(content)}</code></pre>`;
6634
+ }
6635
+
6307
6636
  function openPreviewModal(filePath, content, kind) {
6308
6637
  currentPreviewPath = filePath;
6309
- document.getElementById('preview-modal-title').textContent = filePath.split(/[\\/]/).pop();
6638
+ const fileName = filePath.split(/[\\/]/).pop();
6639
+ document.getElementById('preview-modal-title').textContent = fileName;
6310
6640
  const bodyEl = document.getElementById('preview-modal-body');
6311
6641
  const isHtml = kind === 'html';
6312
6642
  document.querySelector('#preview-modal .modal').classList.toggle('preview-html', isHtml);
6313
6643
  bindPreviewRelativeLinks(bodyEl);
6314
6644
  if (isHtml) {
6315
6645
  renderHtmlPreview(bodyEl, content);
6646
+ } else if (kind === 'text') {
6647
+ bodyEl.innerHTML = renderSourcePreview(filePath, content);
6648
+ } else if (kind === 'image') {
6649
+ const img = document.createElement('img');
6650
+ img.className = 'preview-image';
6651
+ img.src = `/api/preview/image?${new URLSearchParams({ path: filePath })}`;
6652
+ img.alt = fileName;
6653
+ bodyEl.replaceChildren(img);
6316
6654
  } else {
6317
6655
  const { fm, body } = splitFrontmatter(content);
6318
6656
  bodyEl.innerHTML = (fm ? renderFrontmatterBlock(fm) : '') + renderMarkdown(body);
@@ -6324,7 +6662,8 @@ function openPreviewModal(filePath, content, kind) {
6324
6662
 
6325
6663
  function isPreviewLinkedToCurrentSession() {
6326
6664
  if (!currentPreviewPath || !currentSessionId) return false;
6327
- return getSessionPreviewPaths(currentSessionId).includes(currentPreviewPath);
6665
+ const key = canonicalPath(currentPreviewPath);
6666
+ return getSessionPreviewPaths(currentSessionId).some((p) => canonicalPath(p) === key);
6328
6667
  }
6329
6668
 
6330
6669
  function updatePreviewLinkBtn() {
@@ -6375,13 +6714,20 @@ function closePreviewModal() {
6375
6714
  currentPreviewPath = null;
6376
6715
  }
6377
6716
 
6717
+ function openFileInEditor(filePath) {
6718
+ postAndToast('/api/open-in-editor', { file: filePath }, 'in editor');
6719
+ }
6720
+
6378
6721
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
6379
6722
  function openPreviewInEditor() {
6380
6723
  if (!currentPreviewPath) return;
6381
- postAndToast('/api/open-in-editor', { file: currentPreviewPath }, 'in editor');
6724
+ openFileInEditor(currentPreviewPath);
6382
6725
  }
6383
6726
 
6384
- async function openPreviewByPath(filePath, base) {
6727
+ // The server is the only place that knows what it can render, so a null `kind` is its
6728
+ // answer to "not previewable" rather than a failure: callers that have a fallback
6729
+ // destination pass `onUnsupported` instead of keeping a copy of the extension list here.
6730
+ async function openPreviewByPath(filePath, base, onUnsupported) {
6385
6731
  if (!filePath) return;
6386
6732
  try {
6387
6733
  const qs = new URLSearchParams({ path: filePath });
@@ -6392,6 +6738,15 @@ async function openPreviewByPath(filePath, base) {
6392
6738
  return;
6393
6739
  }
6394
6740
  const data = await r.json();
6741
+ if (data.exists === false) {
6742
+ showToast('File not found');
6743
+ return;
6744
+ }
6745
+ if (data.kind === null) {
6746
+ if (onUnsupported) onUnsupported(data.path);
6747
+ else showToast('Nothing to preview in this file');
6748
+ return;
6749
+ }
6395
6750
  openPreviewModal(data.path, data.content, data.kind);
6396
6751
  } catch {
6397
6752
  showToast('Failed to load preview');
@@ -6445,10 +6800,69 @@ function getSessionBaseDir(sessionId) {
6445
6800
  return s?.cwd || s?.project || '';
6446
6801
  }
6447
6802
 
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);
6803
+ // Linked paths are stored as bare strings, so the row has to ask what each one is. The
6804
+ // server owns that answer — /api/file/resolve reports `kind` for any file, previewable
6805
+ // or not — and it is cached for the tab because a path's kind does not change under it.
6806
+ // A resolve that fails is cached as unpreviewable: the editor is the right destination
6807
+ // for a file the server cannot read either, and re-asking on every render of a panel the
6808
+ // SSE tick repaints would be a poll.
6809
+ const previewKindByPath = new Map();
6810
+ const kindResolveInFlight = new Set();
6811
+
6812
+ async function loadPreviewKinds(sessionId, paths) {
6813
+ const pending = paths.filter((p) => !previewKindByPath.has(p) && !kindResolveInFlight.has(p));
6814
+ if (!pending.length) return;
6815
+ for (const p of pending) kindResolveInFlight.add(p);
6816
+ try {
6817
+ await Promise.all(
6818
+ pending.map(async (p) => {
6819
+ let kind = null;
6820
+ try {
6821
+ const r = await fetch(`/api/file/resolve?path=${encodeURIComponent(p)}`);
6822
+ if (r.ok) kind = (await r.json()).kind;
6823
+ } catch (_) {}
6824
+ previewKindByPath.set(p, kind);
6825
+ }),
6826
+ );
6827
+ } finally {
6828
+ for (const p of pending) kindResolveInFlight.delete(p);
6829
+ }
6830
+ afterLinkedDocsChanged(sessionId);
6831
+ }
6832
+
6833
+ // A pad the session made with `scratch new` becomes an ordinary linked document.
6834
+ // The server derives the pads on every read, so this record of the ones already
6835
+ // linked is what stops the next read undoing an unlink.
6836
+ const PAD_LINKED_PREFIX = 'autolinked-pads-';
6837
+ const padsFetched = new Set();
6838
+
6839
+ function getOfferedPadPaths(sessionId) {
6840
+ return readStoredList(PAD_LINKED_PREFIX + sessionId);
6841
+ }
6842
+
6843
+ function setOfferedPadPaths(sessionId, paths) {
6844
+ store.setItem(PAD_LINKED_PREFIX + sessionId, JSON.stringify(paths));
6845
+ }
6846
+
6847
+ async function loadSessionPads(sessionId) {
6848
+ if (!sessionId || padsFetched.has(sessionId)) return;
6849
+ padsFetched.add(sessionId);
6850
+ let pads;
6851
+ try {
6852
+ const r = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/pads`);
6853
+ if (!r.ok) throw new Error(r.status);
6854
+ pads = (await r.json()).pads || [];
6855
+ } catch (_) {
6856
+ padsFetched.delete(sessionId);
6857
+ return;
6858
+ }
6859
+ const offered = new Set(getOfferedPadPaths(sessionId).map(canonicalPath));
6860
+ const fresh = pads.map((p) => p.path).filter((p) => !offered.has(canonicalPath(p)));
6861
+ if (!fresh.length) return;
6862
+ // Recorded before the link, so a render triggered by the link already sees a pad
6863
+ // that will not be offered again.
6864
+ setOfferedPadPaths(sessionId, [...offered, ...fresh]);
6865
+ for (const p of fresh) setSessionDocLink(sessionId, p, false);
6452
6866
  }
6453
6867
 
6454
6868
  // A scratchpad manifest opens in the external `scratch` viewer, not the editor —
@@ -6477,11 +6891,14 @@ const LINKED_DOC_OPENERS = {
6477
6891
  },
6478
6892
  };
6479
6893
 
6894
+ // The manifest is checked before previewability, not after: its `.json` is previewable
6895
+ // text, but the pad is what the user linked, so the viewer wins over the raw file.
6896
+ // Without the CLI there is nothing to launch, so it falls back to the editor.
6480
6897
  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';
6898
+ if (isScratchpadPath(p)) return appConfig.scratchAvailable ? 'scratch' : 'editor';
6899
+ // A path whose kind has not arrived yet renders as previewable, which is what the
6900
+ // click does anyway — openPreviewByPath falls back to the editor on a null kind.
6901
+ return previewKindByPath.get(p) === null ? 'editor' : 'preview';
6485
6902
  }
6486
6903
 
6487
6904
  // Every manifest is called scratchpad.json, so the pad's folder names it. Kept
@@ -6494,7 +6911,7 @@ function linkedDocLabel(p) {
6494
6911
  function openLinkedDoc(p, baseDir) {
6495
6912
  const opener = linkedDocOpener(p);
6496
6913
  if (opener === 'preview') {
6497
- openPreviewByPath(p, baseDir);
6914
+ openPreviewByPath(p, baseDir, openFileInEditor);
6498
6915
  return;
6499
6916
  }
6500
6917
  const { url, body, toast } = LINKED_DOC_OPENERS[opener];
@@ -6503,6 +6920,9 @@ function openLinkedDoc(p, baseDir) {
6503
6920
 
6504
6921
  function renderLinkedDocsHtml(sessionId) {
6505
6922
  const paths = getSessionPreviewPaths(sessionId);
6923
+ // Kicked off from the render rather than the three call sites, so a new surface that
6924
+ // shows linked docs cannot forget it. Returns at once once every path is cached.
6925
+ loadPreviewKinds(sessionId, paths);
6506
6926
  const baseDir = getSessionBaseDir(sessionId);
6507
6927
  const items = paths
6508
6928
  .map((p) => {
@@ -6538,11 +6958,41 @@ function renderLinkedDocsHtml(sessionId) {
6538
6958
  </div>`;
6539
6959
  }
6540
6960
 
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();
6961
+ function paintSectionSlots(attr, sessionId, html) {
6962
+ for (const slot of document.querySelectorAll(`[${attr}="${CSS.escape(sessionId)}"]`)) {
6963
+ slot.innerHTML = html;
6964
+ }
6965
+ }
6966
+
6967
+ // A panel section whose data is not part of the session object arrives after the panel
6968
+ // is already on screen, so every one of them needs the same lifecycle: one fetch per
6969
+ // session at a time, and a patch of the slot — always rendered, empty or not — once the
6970
+ // data lands. `ttlMs` is for the sections the server does not cache itself; at 0 the
6971
+ // section re-asks on every render, which is right when the server holds the answer.
6972
+ function makeSectionLoader({ attr, endpoint, pick, innerHtml, ttlMs = 0 }) {
6973
+ const store = new Map();
6974
+ const inFlight = new Set();
6975
+ return {
6976
+ get: (sessionId) => store.get(sessionId)?.data || [],
6977
+ repaint: (sessionId) => paintSectionSlots(attr, sessionId, innerHtml(sessionId)),
6978
+ async load(sessionId) {
6979
+ if (!sessionId || inFlight.has(sessionId)) return;
6980
+ const cached = store.get(sessionId);
6981
+ if (cached && Date.now() - cached.at < ttlMs) return;
6982
+ inFlight.add(sessionId);
6983
+ try {
6984
+ const res = await fetch(`/api/sessions/${sessionId}/${endpoint}`);
6985
+ if (!res.ok) return;
6986
+ store.set(sessionId, { at: Date.now(), data: pick(await res.json()) });
6987
+ } catch (_) {
6988
+ return;
6989
+ } finally {
6990
+ inFlight.delete(sessionId);
6991
+ }
6992
+ paintSectionSlots(attr, sessionId, innerHtml(sessionId));
6993
+ },
6994
+ };
6995
+ }
6546
6996
 
6547
6997
  function artifactLabel(a) {
6548
6998
  if (a.title) return a.title;
@@ -6550,7 +7000,7 @@ function artifactLabel(a) {
6550
7000
  }
6551
7001
 
6552
7002
  function artifactsInnerHtml(sessionId) {
6553
- const list = artifactsBySession.get(sessionId) || [];
7003
+ const list = artifactsSection.get(sessionId);
6554
7004
  if (!list.length) return '';
6555
7005
  const items = list
6556
7006
  .map(
@@ -6574,27 +7024,196 @@ function renderArtifactsHtml(sessionId) {
6574
7024
  return `<div class="artifacts-section panel-section" data-artifacts-for="${escapeHtml(sessionId)}">${artifactsInnerHtml(sessionId)}</div>`;
6575
7025
  }
6576
7026
 
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);
7027
+ // No TTL: the server holds the scan until the transcript grows, so a repeat costs one
7028
+ // stat, and an artifact published while the panel is open still appears.
7029
+ const artifactsSection = makeSectionLoader({
7030
+ attr: 'data-artifacts-for',
7031
+ endpoint: 'artifacts',
7032
+ pick: (data) => data.artifacts || [],
7033
+ innerHtml: (sessionId) => artifactsInnerHtml(sessionId),
7034
+ });
7035
+
7036
+ // The server decides liveness (a run has no terminal journal entry) and answers with
7037
+ // nothing once the workflow finishes, so the section empties itself and disappears.
7038
+ const workflowLiveSection = makeSectionLoader({
7039
+ attr: 'data-workflow-live-for',
7040
+ endpoint: 'workflow-live',
7041
+ pick: (data) => (data.workflow ? [data.workflow] : []),
7042
+ innerHtml: (sessionId) => workflowLiveInnerHtml(sessionId),
7043
+ // The agent hooks re-render the sidebar on every subagent start and stop, so the TTL is
7044
+ // what keeps that burst down to one ask per run step.
7045
+ ttlMs: 4000,
7046
+ });
7047
+
7048
+ function workflowLiveInnerHtml(sessionId) {
7049
+ const run = workflowLiveSection.get(sessionId)[0];
7050
+ if (!run) return '';
7051
+ const pct = run.startedCount ? Math.round((run.doneCount / run.startedCount) * 100) : 0;
7052
+ const phases = (run.phases || [])
7053
+ .map((p) => {
7054
+ const state = p.started === 0 ? 'pending' : p.done >= p.started ? 'done' : 'running';
7055
+ const count = p.started ? `${p.done}/${p.started}` : '';
7056
+ return `<span class="wf-live-phase ${escapeHtml(state)}">
7057
+ <span class="wf-live-phase-title">${escapeHtml(p.title)}</span>
7058
+ <span class="wf-live-phase-count">${count}</span>
7059
+ </span>`;
7060
+ })
7061
+ .join('');
7062
+ const running = run.running?.length
7063
+ ? `<div class="wf-live-running" title="${escapeHtml(run.running.join(', '))}">${escapeHtml(run.running.join(' · '))}</div>`
7064
+ : '';
7065
+ return `<div class="panel-section-header">
7066
+ ${WORKFLOW_GEAR_SVG}
7067
+ <span>Workflow</span>
7068
+ <span class="panel-section-count">${run.doneCount}/${run.startedCount} agents</span>
7069
+ </div>
7070
+ <div class="wf-live" onclick="showWorkflowModal('${escAttrJs(sessionId)}')" title="Open workflow run">
7071
+ <div class="wf-live-name"><span class="pulse"></span>${escapeHtml(run.name || run.id)}</div>
7072
+ <div class="progress-bar wf-live-bar"><div class="progress-fill" style="width: ${pct}%"></div></div>
7073
+ ${phases ? `<div class="wf-live-phases">${phases}</div>` : ''}
7074
+ ${running}
7075
+ </div>`;
7076
+ }
7077
+
7078
+ function renderWorkflowLiveHtml(sessionId) {
7079
+ return `<div class="workflow-live-section" data-workflow-live-for="${escapeHtml(sessionId)}">${workflowLiveInnerHtml(sessionId)}</div>`;
7080
+ }
7081
+
7082
+ // Every agent start and stop already re-renders the sidebar through the agent-activity
7083
+ // feed, which is what keeps the card current. The timer is the fallback for a config dir
7084
+ // with no cck hooks installed: there the last agent's result reaches nobody, and the card
7085
+ // would sit on a finished run until the next render. Slow on purpose — the hooked case
7086
+ // never waits for it. The generation guard drops a chain left mid-fetch by an earlier
7087
+ // render, so renders can't stack up chains.
7088
+ const WORKFLOW_LIVE_POLL_MS = 30000;
7089
+ let workflowLivePoll = null;
7090
+ let workflowLivePollFor = null;
7091
+
7092
+ function pollWorkflowLive(sessionId) {
7093
+ workflowLiveSection.load(sessionId);
7094
+ // A render must not restart the timer: renders arrive far faster than the interval, and
7095
+ // each restart would push the fallback tick back out of reach, leaving a finished run on
7096
+ // screen forever in a config dir with no hooks.
7097
+ if (workflowLivePoll && workflowLivePollFor === sessionId) return;
7098
+ clearTimeout(workflowLivePoll);
7099
+ workflowLivePollFor = sessionId;
7100
+ const tick = async () => {
7101
+ workflowLivePoll = null;
7102
+ if (workflowLivePollFor !== sessionId) return;
7103
+ if (!document.querySelector(`[data-workflow-live-for="${CSS.escape(sessionId)}"]`)) return;
7104
+ await workflowLiveSection.load(sessionId);
7105
+ if (workflowLivePollFor !== sessionId || !workflowLiveSection.get(sessionId).length) return;
7106
+ workflowLivePoll = setTimeout(tick, WORKFLOW_LIVE_POLL_MS);
7107
+ };
7108
+ workflowLivePoll = setTimeout(tick, WORKFLOW_LIVE_POLL_MS);
7109
+ }
7110
+
7111
+ const SCRATCH_FILES_COLLAPSED = 3;
7112
+ // Unlike the artifacts scan the server caches nothing here, so a TTL is what stops the
7113
+ // zen panel — re-rendered on every SSE tick — from turning a readdir plus a stat per
7114
+ // file into a 2 s poll.
7115
+ const scratchFilesSection = makeSectionLoader({
7116
+ attr: 'data-scratch-files-for',
7117
+ endpoint: 'scratchpad-files',
7118
+ pick: (data) => data.files || [],
7119
+ innerHtml: (sessionId) => scratchFilesInnerHtml(sessionId),
7120
+ ttlMs: 10000,
7121
+ });
7122
+ // Outlives the cache entries on purpose: a refetch on the TTL must not collapse a list
7123
+ // the user opened.
7124
+ const scratchFilesExpanded = new Set();
7125
+ // Open folders only, keyed by absolute path: path -> { status: 'loading' | 'ready' |
7126
+ // 'error', children }. Closing a folder drops its entry, so the cache is bounded by
7127
+ // what is on screen and a reopen shows fresh contents for one readdir.
7128
+ const scratchFolders = new Map();
7129
+
7130
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
7131
+ function openScratchFile(filePath) {
7132
+ openPreviewByPath(filePath, undefined, openFileInEditor);
7133
+ }
7134
+
7135
+ function scratchRowHtml(f) {
7136
+ const escPath = escapeHtml(f.path);
7137
+ const isDir = f.kind === 'dir';
7138
+ const folder = isDir ? scratchFolders.get(f.path) : null;
7139
+ const name = escapeHtml(f.name);
7140
+ const link = isDir
7141
+ ? `<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>`
7142
+ : `<button type="button" class="scratch-file-link" onclick="openScratchFile(this.closest('li').dataset.file)" title="${escPath}">${name}</button>`;
7143
+ let body = '';
7144
+ if (folder?.status === 'loading') body = '<div class="scratch-folder-empty">Loading…</div>';
7145
+ else if (folder?.status === 'error') body = '<div class="scratch-folder-empty">Failed to load</div>';
7146
+ else if (folder && !folder.children.length) body = '<div class="scratch-folder-empty">Empty</div>';
7147
+ else if (folder) body = scratchRowsHtml(folder.children);
7148
+ return `<li class="scratch-file-item${isDir ? ' scratch-folder-item' : ''}" data-file="${escPath}">
7149
+ ${link}
7150
+ <span class="scratch-file-time">${formatDate(f.modifiedAt)}</span>
7151
+ <span class="row-actions scratch-file-actions">
7152
+ <button type="button" onclick="copyWithFeedback(this.closest('li').dataset.file, this)" title="Copy path" aria-label="Copy path">${ICON_COPY}</button>
7153
+ <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>
7154
+ </span>
7155
+ ${body}
7156
+ </li>`;
7157
+ }
7158
+
7159
+ function scratchRowsHtml(rows) {
7160
+ return `<ul class="scratch-file-list">${rows.map(scratchRowHtml).join('')}</ul>`;
7161
+ }
7162
+
7163
+ function scratchFilesInnerHtml(sessionId) {
7164
+ const list = scratchFilesSection.get(sessionId);
7165
+ if (!list.length) return '';
7166
+ const expanded = scratchFilesExpanded.has(sessionId);
7167
+ const shown = expanded ? list : list.slice(0, SCRATCH_FILES_COLLAPSED);
7168
+ const more =
7169
+ list.length > SCRATCH_FILES_COLLAPSED
7170
+ ? `<button type="button" class="expand-toggle-btn scratch-files-more" onclick="toggleScratchFiles('${escAttrJs(sessionId)}')">${expanded ? 'Show less' : `Show all ${list.length}`}</button>`
7171
+ : '';
7172
+ return `${scratchRowsHtml(shown)}${more}`;
7173
+ }
7174
+
7175
+ // Delegated at the document because the whole zen panel is re-rendered on every SSE tick,
7176
+ // which would drop a listener bound on the section.
7177
+ document.addEventListener('click', (e) => {
7178
+ const toggle = e.target.closest('.scratch-folder-toggle');
7179
+ if (!toggle) return;
7180
+ const section = toggle.closest('[data-scratch-files-for]');
7181
+ if (section) toggleScratchFolder(section.dataset.scratchFilesFor, toggle.closest('li').dataset.file);
7182
+ });
7183
+
7184
+ async function toggleScratchFolder(sessionId, dirPath) {
7185
+ if (scratchFolders.has(dirPath)) {
7186
+ scratchFolders.delete(dirPath);
7187
+ scratchFilesSection.repaint(sessionId);
7188
+ return;
7189
+ }
7190
+ scratchFolders.set(dirPath, { status: 'loading', children: [] });
7191
+ scratchFilesSection.repaint(sessionId);
7192
+ let entry = { status: 'error', children: [] };
6582
7193
  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 || []);
7194
+ const res = await fetch(`/api/sessions/${sessionId}/scratchpad-files?path=${encodeURIComponent(dirPath)}`);
7195
+ if (res.ok) entry = { status: 'ready', children: (await res.json()).files || [] };
6587
7196
  } catch (_) {
6588
- return;
6589
- } finally {
6590
- artifactsInFlight.delete(sessionId);
7197
+ // Reads "Failed to load"; closing and reopening the folder retries.
6591
7198
  }
6592
- const html = artifactsInnerHtml(sessionId);
6593
- for (const slot of document.querySelectorAll(`[data-artifacts-for="${CSS.escape(sessionId)}"]`)) {
6594
- slot.innerHTML = html;
7199
+ // The user may have closed the folder while the request was in flight.
7200
+ if (scratchFolders.has(dirPath)) {
7201
+ scratchFolders.set(dirPath, entry);
7202
+ scratchFilesSection.repaint(sessionId);
6595
7203
  }
6596
7204
  }
6597
7205
 
7206
+ function renderScratchFilesHtml(sessionId) {
7207
+ return `<div class="scratch-files-section" data-scratch-files-for="${escapeHtml(sessionId)}">${scratchFilesInnerHtml(sessionId)}</div>`;
7208
+ }
7209
+
7210
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
7211
+ function toggleScratchFiles(sessionId) {
7212
+ if (scratchFilesExpanded.has(sessionId)) scratchFilesExpanded.delete(sessionId);
7213
+ else scratchFilesExpanded.add(sessionId);
7214
+ scratchFilesSection.repaint(sessionId);
7215
+ }
7216
+
6598
7217
  // One delegated listener per section: the list is re-rendered on every change, so
6599
7218
  // per-row handlers would only be rebound each time anyway.
6600
7219
  function bindLinkedDocsHandlers(container, sessionId) {
@@ -6664,7 +7283,7 @@ async function linkFileByPath(sessionId, raw, slot) {
6664
7283
  if (base) qs.set('base', base);
6665
7284
  const r = await fetch(`/api/file/resolve?${qs}`);
6666
7285
  const data = await r.json().catch(() => ({}));
6667
- if (!r.ok) {
7286
+ if (!r.ok || !data.exists) {
6668
7287
  fail(data.error || 'File not found');
6669
7288
  return;
6670
7289
  }
@@ -6698,6 +7317,24 @@ function setupEventSource() {
6698
7317
  failCount = 0;
6699
7318
  }
6700
7319
 
7320
+ let taskRefreshTimer = null;
7321
+ let metadataRefreshTimer = null;
7322
+ let metadataDeadline = 0;
7323
+ let agentRefreshTimer = null;
7324
+ const pendingTaskSessionIds = new Set();
7325
+ const pendingAgentSessionIds = new Set();
7326
+
7327
+ function refreshAllView() {
7328
+ currentTasks = filterProject ? allTasksCache.filter((t) => matchesProjectFilter(t.project)) : allTasksCache;
7329
+ renderAllTasks();
7330
+ renderActivityChip();
7331
+ }
7332
+
7333
+ async function refreshSessionDetail(sessionId) {
7334
+ await fetchAgents(sessionId);
7335
+ if (!agentLogMode) fetchMessages(sessionId);
7336
+ }
7337
+
6701
7338
  function connect() {
6702
7339
  eventSource = new EventSource('/api/events');
6703
7340
 
@@ -6706,6 +7343,10 @@ function setupEventSource() {
6706
7343
  console.warn('[SSE] Reconnected after drop — forcing full refresh');
6707
7344
  fetchSessions().catch(() => {});
6708
7345
  if (currentSessionId) fetchTasks(currentSessionId);
7346
+ if (terminalAvailable())
7347
+ loadTerminals()
7348
+ .then(renderSessions)
7349
+ .catch(() => {});
6709
7350
  }
6710
7351
  wasConnected = true;
6711
7352
  retryDelay = 1000;
@@ -6721,31 +7362,29 @@ function setupEventSource() {
6721
7362
  retryDelay = Math.min(retryDelay * 2, 30000);
6722
7363
  };
6723
7364
 
6724
- let taskRefreshTimer = null;
6725
- let metadataRefreshTimer = null;
6726
- let agentRefreshTimer = null;
6727
- const pendingTaskSessionIds = new Set();
6728
- const pendingAgentSessionIds = new Set();
6729
-
6730
7365
  function debouncedRefresh(sessionId, isMetadata) {
6731
7366
  if (isMetadata) {
7367
+ // Busy sessions emit events faster than the 2 s quiet period, so a pure debounce would
7368
+ // never fire; the deadline caps how long a burst can hold the refresh back.
7369
+ metadataDeadline ||= Date.now() + METADATA_MAX_WAIT_MS;
6732
7370
  clearTimeout(metadataRefreshTimer);
6733
- metadataRefreshTimer = setTimeout(async () => {
6734
- fetchSessions(false).catch((err) => console.error('[SSE] fetchSessions failed:', err));
6735
- if (currentSessionId) {
6736
- await fetchAgents(currentSessionId);
6737
- if (!agentLogMode) fetchMessages(currentSessionId);
6738
- }
6739
- }, 2000);
7371
+ metadataRefreshTimer = setTimeout(
7372
+ () => {
7373
+ metadataDeadline = 0;
7374
+ if (skipOffScreen()) return;
7375
+ fetchSessions(false).catch((err) => console.error('[SSE] fetchSessions failed:', err));
7376
+ if (currentSessionId) refreshSessionDetail(currentSessionId);
7377
+ },
7378
+ Math.min(2000, metadataDeadline - Date.now()),
7379
+ );
6740
7380
  } else {
6741
7381
  pendingTaskSessionIds.add(sessionId);
6742
7382
  clearTimeout(taskRefreshTimer);
6743
7383
  taskRefreshTimer = setTimeout(async () => {
7384
+ if (skipOffScreen()) return;
6744
7385
  await fetchSessions().catch((err) => console.error('[SSE] fetchSessions failed:', err));
6745
7386
  if (viewMode === 'all') {
6746
- currentTasks = filterProject ? allTasksCache.filter((t) => matchesProjectFilter(t.project)) : allTasksCache;
6747
- renderAllTasks();
6748
- renderActivityChip();
7387
+ refreshAllView();
6749
7388
  } else if (viewMode === 'project' && currentProjectPath) {
6750
7389
  const hasUpdate = currentProjectSessionIds.some((id) => pendingTaskSessionIds.has(id));
6751
7390
  if (hasUpdate) fetchProjectView(currentProjectPath);
@@ -6765,23 +7404,27 @@ function setupEventSource() {
6765
7404
  }
6766
7405
 
6767
7406
  if (data.type === 'plan-update') {
6768
- refreshOpenPlan();
7407
+ if (!skipOffScreen()) refreshOpenPlan();
6769
7408
  }
6770
7409
 
7410
+ if (data.type === 'terminals-update' && Array.isArray(data.ids)) setRunningTerminals(data.ids);
7411
+
7412
+ if (data.type === 'dispatch-update') onDispatchUpdate();
7413
+
6771
7414
  if (data.type === 'agent-update') {
6772
7415
  pendingAgentSessionIds.add(data.sessionId);
6773
7416
  clearTimeout(agentRefreshTimer);
6774
7417
  agentRefreshTimer = setTimeout(() => {
7418
+ if (skipOffScreen()) return;
6775
7419
  fetchSessions(false).catch((err) => console.error('[SSE] fetchSessions failed:', err));
6776
7420
  if (viewMode === 'project' && currentProjectSessionIds.some((id) => pendingAgentSessionIds.has(id))) {
6777
7421
  refreshProjectAgents();
6778
7422
  } else if (currentSessionId && pendingAgentSessionIds.has(currentSessionId)) {
6779
- fetchAgents(currentSessionId);
6780
7423
  // An agent starting/stopping adds its Agent tool_use + completion rows to
6781
7424
  // the transcript. Refresh the message log on the same signal so they appear
6782
7425
  // at start, not only when an unrelated metadata/context refresh coincides
6783
7426
  // with completion.
6784
- if (!agentLogMode) fetchMessages(currentSessionId);
7427
+ refreshSessionDetail(currentSessionId);
6785
7428
  }
6786
7429
  pendingAgentSessionIds.clear();
6787
7430
  }, 500);
@@ -6789,7 +7432,7 @@ function setupEventSource() {
6789
7432
 
6790
7433
  if (data.type === 'context-update') {
6791
7434
  debouncedRefresh(data.sessionId, true);
6792
- refreshRateLimits();
7435
+ if (!skipOffScreen()) refreshRateLimits();
6793
7436
  }
6794
7437
 
6795
7438
  if (data.type === 'preview:open') {
@@ -6819,20 +7462,42 @@ function setupEventSource() {
6819
7462
  };
6820
7463
  }
6821
7464
 
6822
- // When the tab becomes visible after being hidden, catch up immediately
6823
- let _pollMissed = false;
6824
- document.addEventListener('visibilitychange', () => {
6825
- if (!document.hidden && _pollMissed) {
6826
- _pollMissed = false;
6827
- fetchSessions().catch(() => {});
6828
- if (currentSessionId) fetchTasks(currentSessionId).catch(() => {});
6829
- }
7465
+ // When cck comes back on screen (tab visible or hub:active), catch up immediately
7466
+ async function catchUp() {
7467
+ if (!isOnScreen() || !missedWhileHidden) return;
7468
+ missedWhileHidden = false;
7469
+ // The full refresh below covers whatever the pending debounces would have fetched.
7470
+ for (const t of [taskRefreshTimer, metadataRefreshTimer, agentRefreshTimer]) clearTimeout(t);
7471
+ metadataDeadline = 0;
7472
+ pendingTaskSessionIds.clear();
7473
+ pendingAgentSessionIds.clear();
7474
+ const sessionsLoaded = fetchSessions().catch(() => {});
7475
+ if (viewMode === 'all') {
7476
+ await sessionsLoaded;
7477
+ refreshAllView();
7478
+ } else if (viewMode === 'project' && currentProjectPath) {
7479
+ await sessionsLoaded;
7480
+ fetchProjectView(currentProjectPath);
7481
+ } else if (currentSessionId) {
7482
+ fetchTasks(currentSessionId).catch(() => {});
7483
+ await refreshSessionDetail(currentSessionId);
7484
+ }
7485
+ refreshOpenPlan();
7486
+ refreshRateLimits();
7487
+ renderAgentFooter();
7488
+ }
7489
+ document.addEventListener('visibilitychange', catchUp);
7490
+ window.addEventListener('message', (e) => {
7491
+ if (e.source !== window.parent || e.origin !== hubOrigin() || e.data?.type !== 'hub:active') return;
7492
+ hubActive = !!e.data.active;
7493
+ catchUp();
7494
+ if (hubActive && wantsTerminal()) onTerminalShown();
6830
7495
  });
6831
7496
 
6832
- // Fallback poll every 30s in case SSE silently drops; skip when tab is hidden
7497
+ // Fallback poll every 30s in case SSE silently drops; skip when not on screen
6833
7498
  setInterval(() => {
6834
- if (document.hidden) {
6835
- _pollMissed = true;
7499
+ if (!isOnScreen()) {
7500
+ if (eventSource?.readyState !== EventSource.OPEN) missedWhileHidden = true;
6836
7501
  return;
6837
7502
  }
6838
7503
  fetchSessions().catch(() => {});
@@ -6942,7 +7607,7 @@ function formatCost(usd) {
6942
7607
  return `$${usd.toFixed(2)}`;
6943
7608
  }
6944
7609
 
6945
- function renderContextDetail(raw) {
7610
+ function renderContextDetail(raw, { tokens = true } = {}) {
6946
7611
  const ctx = getCtx(raw);
6947
7612
  if (!ctx) return '';
6948
7613
  const totalK = ctx.size / 1000;
@@ -6951,6 +7616,16 @@ function renderContextDetail(raw) {
6951
7616
  const cw = raw.context_window || {};
6952
7617
  const usage = cw.current_usage || {};
6953
7618
  const cost = raw.cost || {};
7619
+ const tokenRows = tokens
7620
+ ? `<div class="stat-item"><span class="stat-label">Cache read</span><span class="stat-value">${formatTokens((usage.cache_read_input_tokens || 0) / 1000)}</span></div>
7621
+ <div class="stat-item"><span class="stat-label">Cache write</span><span class="stat-value">${formatTokens((usage.cache_creation_input_tokens || 0) / 1000)}</span></div>
7622
+ <div class="stat-item"><span class="stat-label">Current input</span><span class="stat-value">${formatTokens((usage.input_tokens || 0) / 1000)}</span></div>
7623
+ <div class="stat-item"><span class="stat-label">Current output</span><span class="stat-value">${formatTokens((usage.output_tokens || 0) / 1000)}</span></div>
7624
+ <div class="stat-divider"></div>
7625
+ <div class="stat-item"><span class="stat-label">Total input</span><span class="stat-value">${formatTokens(ctx.inputTokens / 1000)}</span></div>
7626
+ <div class="stat-item"><span class="stat-label">Total output</span><span class="stat-value">${formatTokens(ctx.outputTokens / 1000)}</span></div>
7627
+ <div class="stat-divider"></div>`
7628
+ : '';
6954
7629
 
6955
7630
  return `
6956
7631
  <div class="detail-context">
@@ -6966,14 +7641,7 @@ function renderContextDetail(raw) {
6966
7641
  <span>${formatTokens((ctx.pct / 100) * totalK)} / ${formatTokens(totalK)}</span>
6967
7642
  </div>
6968
7643
  <div class="detail-context-stats">
6969
- <div class="stat-item"><span class="stat-label">Cache read</span><span class="stat-value">${formatTokens((usage.cache_read_input_tokens || 0) / 1000)}</span></div>
6970
- <div class="stat-item"><span class="stat-label">Cache write</span><span class="stat-value">${formatTokens((usage.cache_creation_input_tokens || 0) / 1000)}</span></div>
6971
- <div class="stat-item"><span class="stat-label">Current input</span><span class="stat-value">${formatTokens((usage.input_tokens || 0) / 1000)}</span></div>
6972
- <div class="stat-item"><span class="stat-label">Current output</span><span class="stat-value">${formatTokens((usage.output_tokens || 0) / 1000)}</span></div>
6973
- <div class="stat-divider"></div>
6974
- <div class="stat-item"><span class="stat-label">Total input</span><span class="stat-value">${formatTokens(ctx.inputTokens / 1000)}</span></div>
6975
- <div class="stat-item"><span class="stat-label">Total output</span><span class="stat-value">${formatTokens(ctx.outputTokens / 1000)}</span></div>
6976
- <div class="stat-divider"></div>
7644
+ ${tokenRows}
6977
7645
  <div class="stat-item"><span class="stat-label">Cost</span><span class="stat-value" style="color:${getCostColor(cost.total_cost_usd)}">${formatCost(cost.total_cost_usd)}</span></div>
6978
7646
  <div class="stat-item"><span class="stat-label">Duration</span><span class="stat-value">${formatDuration(cost.total_duration_ms)}</span></div>
6979
7647
  <div class="stat-item"><span class="stat-label">API time</span><span class="stat-value">${formatDuration(cost.total_api_duration_ms)}</span></div>
@@ -6999,11 +7667,18 @@ function maybeFollowLatest() {
6999
7667
  // on the card and the badge until they get to it. Each ask id is offered once,
7000
7668
  // so closing the modal does not bring it back on the next poll.
7001
7669
  let lastAutoOpenedWaitingId = null;
7670
+ // Set only on that self-opening path: answering an ask the user never asked to see
7671
+ // should put the screen back the way it was, where answering one they navigated to
7672
+ // keeps them following. Cleared with the modal, so a manual reopen is not "auto".
7673
+ let waitingDetailAutoOpened = false;
7002
7674
  function maybeAutoOpenWaiting() {
7003
7675
  if (!isWaitingAnswerable() || !isWaitingFresh()) return;
7004
7676
  if (currentWaiting.id === lastAutoOpenedWaitingId || isAnyModalOpen()) return;
7677
+ // The terminal on screen shows the same ask; hiding it lets the next poll open the modal.
7678
+ if (termState.shown) return;
7005
7679
  lastAutoOpenedWaitingId = currentWaiting.id;
7006
7680
  msgDetailFollowLatest = true;
7681
+ waitingDetailAutoOpened = true;
7007
7682
  maybeFollowLatest();
7008
7683
  }
7009
7684
 
@@ -7398,6 +8073,10 @@ function stripTeammateWrapper(text) {
7398
8073
  // escaping is explicit here.
7399
8074
  const HTML_ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;', '`': '&#96;' };
7400
8075
 
8076
+ function pickRandom(items) {
8077
+ return items[Math.floor(Math.random() * items.length)];
8078
+ }
8079
+
7401
8080
  function escapeHtml(text) {
7402
8081
  if (text == null) return '';
7403
8082
  return String(text).replace(/[&<>"'`]/g, (c) => HTML_ESCAPES[c]);
@@ -7758,6 +8437,13 @@ document.addEventListener('click', (e) => {
7758
8437
  return;
7759
8438
  }
7760
8439
 
8440
+ const newBtn = e.target.closest('.project-new-btn');
8441
+ if (newBtn) {
8442
+ e.stopPropagation();
8443
+ openNewSession(newBtn.dataset.projectPath);
8444
+ return;
8445
+ }
8446
+
7761
8447
  const projectBtn = e.target.closest('.project-view-btn');
7762
8448
  if (projectBtn) {
7763
8449
  e.stopPropagation();
@@ -7783,20 +8469,34 @@ document.addEventListener('click', (e) => {
7783
8469
  if (sgHeader) {
7784
8470
  if (e.target.closest('.sg-name-input')) return;
7785
8471
  e.stopPropagation();
7786
- const groupId = sgHeader.dataset.groupId;
7787
- const group = sgGroupById(groupId);
7788
- if (!group) return;
7789
- if (e.target.closest('.sg-rename')) {
7790
- sgBeginRename(groupId);
8472
+ if (e.target.closest('.sg-keep')) {
8473
+ sgKeepTransient(sgHeader.dataset.transientGroup);
8474
+ renderSessions();
7791
8475
  return;
7792
8476
  }
7793
- if (e.target.closest('.sg-delete')) {
7794
- if (confirm(`Delete group “${group.name}”? Its sessions and projects go back to Projects.`)) {
7795
- sgDeleteGroup(groupId);
8477
+ if (sgHeader.dataset.transientGroup && e.target.closest('.sg-delete')) {
8478
+ const name = sgHeader.dataset.transientGroup;
8479
+ if (confirm(`Delete group “${name}”? Its sessions go back to Projects.`)) {
8480
+ sgDeleteTransient(name);
7796
8481
  renderSessions();
7797
8482
  }
7798
8483
  return;
7799
8484
  }
8485
+ const groupId = sgHeader.dataset.groupId;
8486
+ const group = sgGroupById(groupId);
8487
+ if (group) {
8488
+ if (e.target.closest('.sg-rename')) {
8489
+ sgBeginRename(groupId);
8490
+ return;
8491
+ }
8492
+ if (e.target.closest('.sg-delete')) {
8493
+ if (confirm(`Delete group “${group.name}”? Its sessions and projects go back to Projects.`)) {
8494
+ sgDeleteGroup(groupId);
8495
+ renderSessions();
8496
+ }
8497
+ return;
8498
+ }
8499
+ } else if (!sgHeader.dataset.transientGroup) return;
7800
8500
  }
7801
8501
 
7802
8502
  const header = e.target.closest(COLLAPSIBLE_HEADER_SELECTOR);
@@ -7816,14 +8516,8 @@ function filterByProject(project) {
7816
8516
 
7817
8517
  let projectsCache = null;
7818
8518
 
7819
- async function updateProjectDropdown() {
7820
- const dropdown = document.getElementById('project-filter');
7821
-
7822
- if (!projectsCacheDirty && projectsCache) {
7823
- renderProjectDropdown(dropdown, projectsCache);
7824
- return;
7825
- }
7826
-
8519
+ async function loadProjects() {
8520
+ if (!projectsCacheDirty && projectsCache) return projectsCache;
7827
8521
  let projects;
7828
8522
  try {
7829
8523
  const res = await fetch('/api/projects');
@@ -7833,10 +8527,20 @@ async function updateProjectDropdown() {
7833
8527
  .sort()
7834
8528
  .map((p) => ({ path: p, modifiedAt: null }));
7835
8529
  }
7836
-
7837
8530
  projectsCache = projects;
7838
8531
  projectsCacheDirty = false;
8532
+ return projects;
8533
+ }
8534
+
8535
+ async function updateProjectDropdown() {
8536
+ const dropdown = document.getElementById('project-filter');
7839
8537
 
8538
+ if (!projectsCacheDirty && projectsCache) {
8539
+ renderProjectDropdown(dropdown, projectsCache);
8540
+ return;
8541
+ }
8542
+
8543
+ const projects = await loadProjects();
7840
8544
  const cutoff = Date.now() - 24 * 60 * 60 * 1000;
7841
8545
  const prevRecent = recentProjects;
7842
8546
  recentProjects = new Set(
@@ -8124,7 +8828,11 @@ async function showSessionInfoModal(sessionId) {
8124
8828
  _planSessionId = sessionId;
8125
8829
  const cachedTasks = currentSessionId === sessionId ? currentTasks : [];
8126
8830
  showInfoModal(session, null, cachedTasks, null, null);
8127
- ensureSessionArtifacts(sessionId);
8831
+ artifactsSection.load(sessionId);
8832
+ // The modal reaches any card's session, not just the open one, so this is the second
8833
+ // place a pad can first be seen. Linking belongs on the two open events rather than
8834
+ // in renderLinkedDocsHtml, which only builds a string and runs on every repaint.
8835
+ loadSessionPads(sessionId);
8128
8836
 
8129
8837
  const rerender = (teamConfig, tasks, planContent, parentInfo) => {
8130
8838
  if (_planSessionId !== sessionId) return; // user opened a different modal
@@ -8196,7 +8904,8 @@ function renderScratchpadRow(session) {
8196
8904
  <button onclick="copyWithFeedback('${escAttrJs(dir)}', this)" title="Copy">${ICON_COPY}</button>
8197
8905
  <button data-folder="${escapeHtml(dir)}" onclick="openFolderInEditor(this.dataset.folder)" title="Open in editor">${ICON_OPEN_EXTERNAL}</button>
8198
8906
  </span>
8199
- </div>`;
8907
+ </div>
8908
+ ${renderScratchFilesHtml(session.id)}`;
8200
8909
  }
8201
8910
 
8202
8911
  function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
@@ -8446,11 +9155,36 @@ let spPeekedId = null;
8446
9155
  // Per-open cache: spSource is frozen while the picker is up, so a fetched log stays valid.
8447
9156
  const spPeekCache = new Map();
8448
9157
 
9158
+ // A session outside the sidebar filter has no name to fuzzy-match against, so a full id
9159
+ // is read as a request for that exact session and resolved against the server instead.
9160
+ // Values: 'pending' while the request is out, then the session or null.
9161
+ const spGlobal = new Map();
9162
+
9163
+ async function spResolveGlobal(id) {
9164
+ spGlobal.set(id, 'pending');
9165
+ spGlobal.set(id, await fetchSessionById(id));
9166
+ if (document.getElementById('session-picker-input').value.trim().toLowerCase() === id) renderSessionPicker();
9167
+ }
9168
+
9169
+ // The row to show for a query nothing in the list matched, as `{ hit, empty }` — one of
9170
+ // the two is always null. Starts the lookup when the query is an id not asked about yet.
9171
+ function spResolveMiss(query) {
9172
+ if (!SESSION_UUID_RE.test(query)) {
9173
+ return { hit: null, empty: spSource.length ? 'No session matches' : 'No sessions in the current sidebar filter' };
9174
+ }
9175
+ const id = query.toLowerCase();
9176
+ if (!spGlobal.has(id)) spResolveGlobal(id);
9177
+ const found = spGlobal.get(id);
9178
+ if (found === 'pending') return { hit: null, empty: 'Looking up session…' };
9179
+ return found ? { hit: found, empty: null } : { hit: null, empty: 'No session with that id' };
9180
+ }
9181
+
8449
9182
  function openSessionPicker() {
8450
9183
  const input = document.getElementById('session-picker-input');
8451
9184
  input.value = '';
8452
9185
  spSource = getFilteredSessions().sort((a, b) => Date.parse(b.modifiedAt) - Date.parse(a.modifiedAt));
8453
9186
  spPeekCache.clear();
9187
+ spGlobal.clear();
8454
9188
  document.getElementById('session-picker-modal').classList.add('visible');
8455
9189
  renderSessionPicker();
8456
9190
  input.focus();
@@ -8541,11 +9275,14 @@ function renderSessionPicker() {
8541
9275
  const list = document.getElementById('session-picker-list');
8542
9276
  const query = document.getElementById('session-picker-input').value.trim();
8543
9277
  spRows = spSource.filter((s) => spMatches(s, query));
8544
-
8545
9278
  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;
9279
+ const { hit, empty } = spResolveMiss(query);
9280
+ if (hit) spRows = [hit];
9281
+ else {
9282
+ spIdx = -1;
9283
+ list.innerHTML = `<div class="sp-empty">${empty}</div>`;
9284
+ return;
9285
+ }
8549
9286
  }
8550
9287
 
8551
9288
  list.innerHTML = spRows
@@ -8599,6 +9336,19 @@ function initSessionPicker() {
8599
9336
  const row = e.target.closest('.sp-row');
8600
9337
  if (row) spOpen(Number(row.dataset.idx));
8601
9338
  });
9339
+ // Vimium eats Escape inside a text input and only blurs it, so the page never sees the key.
9340
+ // A blur that no click in the picker caused, while the window keeps focus, is that Escape.
9341
+ const modal = document.getElementById('session-picker-modal');
9342
+ let pointerDown = false;
9343
+ modal.addEventListener('mousedown', () => {
9344
+ pointerDown = true;
9345
+ });
9346
+ document.addEventListener('mouseup', () => {
9347
+ pointerDown = false;
9348
+ });
9349
+ input.addEventListener('blur', () => {
9350
+ if (!pointerDown && modal.classList.contains('visible') && document.hasFocus()) closeSessionPicker();
9351
+ });
8602
9352
  }
8603
9353
  //#endregion
8604
9354
 
@@ -9183,6 +9933,1274 @@ function filterByOwner(value) {
9183
9933
 
9184
9934
  //#endregion
9185
9935
 
9936
+ //#region TERMINAL
9937
+ // The kanban and the terminal share one slot: terminal mode hides #main-content and shows
9938
+ // the pane. The PTY lives on the server (lib/terminal.js) and outlives the pane, so
9939
+ // leaving the session view or switching sessions drops only the socket; coming back
9940
+ // reattaches and the server replays the screen.
9941
+ const TERMINAL_TOKEN_KEY = 'terminal-token';
9942
+ const TERMINAL_TOKEN_RE = /^[0-9a-f]{64}$/;
9943
+ // Ctrl+Alt letters cck keeps instead of forwarding to the hub: New session, Resume session, and
9944
+ // Swap to the previous session.
9945
+ const CCK_CTRL_ALT_KEYS = new Set(['KeyN', 'KeyR', 'KeyS']);
9946
+ const TERMINAL_MODES_KEY = 'terminal-sessions';
9947
+ const ACK_BATCH_BYTES = 32 * 1024;
9948
+ const TERMINAL_RETRY_MS = [500, 1000, 2000, 4000, 8000];
9949
+ const TERMINAL_STABLE_MS = 5000;
9950
+ const STALE_TOKEN_MSG = 'The terminal token is out of date. Reload the hub window.';
9951
+ // Read at load, before the first updateUrl() rewrites the URL without the fragment.
9952
+ let terminalToken = readTerminalToken();
9953
+ const termState = {
9954
+ loaded: null,
9955
+ term: null,
9956
+ fit: null,
9957
+ webgl: null,
9958
+ ws: null,
9959
+ sessionId: null,
9960
+ syncedView: null,
9961
+ panelHidden: false,
9962
+ shown: false,
9963
+ attached: false,
9964
+ closeGuard: false,
9965
+ leaving: false,
9966
+ focusNext: false,
9967
+ openedByUser: null,
9968
+ ackPending: 0,
9969
+ ackTimer: null,
9970
+ retryTimer: null,
9971
+ };
9972
+
9973
+ // The hub passes the token in the fragment, which is never sent to a server or logged.
9974
+ function readTerminalToken() {
9975
+ const m = /[#&]t=([^&]*)/.exec(location.hash);
9976
+ if (m && TERMINAL_TOKEN_RE.test(m[1])) {
9977
+ storeTerminalToken(m[1]);
9978
+ history.replaceState(null, '', location.pathname + location.search);
9979
+ return m[1];
9980
+ }
9981
+ try {
9982
+ return sessionStorage.getItem(TERMINAL_TOKEN_KEY);
9983
+ } catch (_) {
9984
+ return null;
9985
+ }
9986
+ }
9987
+
9988
+ function storeTerminalToken(token) {
9989
+ try {
9990
+ sessionStorage.setItem(TERMINAL_TOKEN_KEY, token);
9991
+ } catch (_) {}
9992
+ }
9993
+
9994
+ let tokenRefresh = null;
9995
+
9996
+ // The hub mints a new token each run, and this page can outlive a hub restart. Resolves true only
9997
+ // for a token that differs from the one that just failed, so a caller retries at most once.
9998
+ function refreshTerminalToken() {
9999
+ if (!window.__HUB__?.enabled) return Promise.resolve(false);
10000
+ tokenRefresh ??= requestTerminalToken().finally(() => {
10001
+ tokenRefresh = null;
10002
+ });
10003
+ return tokenRefresh;
10004
+ }
10005
+
10006
+ function requestTerminalToken() {
10007
+ return new Promise((resolve) => {
10008
+ const done = (ok) => {
10009
+ window.removeEventListener('message', onMessage);
10010
+ clearTimeout(timer);
10011
+ resolve(ok);
10012
+ };
10013
+ const onMessage = (e) => {
10014
+ if (e.source !== window.parent || e.origin !== hubOrigin() || e.data?.type !== 'hub:terminalToken') return;
10015
+ const token = e.data.token;
10016
+ if (typeof token !== 'string' || !TERMINAL_TOKEN_RE.test(token) || token === terminalToken) return done(false);
10017
+ terminalToken = token;
10018
+ storeTerminalToken(token);
10019
+ done(true);
10020
+ };
10021
+ const timer = setTimeout(() => done(false), 3000);
10022
+ window.addEventListener('message', onMessage);
10023
+ hubPost({ type: 'hub:terminalToken' });
10024
+ });
10025
+ }
10026
+
10027
+ function terminalAvailable() {
10028
+ return !!appConfig.terminal?.available && !!terminalToken;
10029
+ }
10030
+
10031
+ function terminalModes() {
10032
+ return new Set(readStoredList(TERMINAL_MODES_KEY));
10033
+ }
10034
+
10035
+ function setTerminalMode(sessionId, on) {
10036
+ const modes = terminalModes();
10037
+ if (on) modes.add(sessionId);
10038
+ else modes.delete(sessionId);
10039
+ try {
10040
+ store.setItem(TERMINAL_MODES_KEY, JSON.stringify([...modes].slice(-50)));
10041
+ } catch (_) {}
10042
+ }
10043
+
10044
+ function wantsTerminal() {
10045
+ return terminalAvailable() && viewMode === 'session' && !!currentSessionId && terminalModes().has(currentSessionId);
10046
+ }
10047
+
10048
+ function toggleTerminal() {
10049
+ if (!terminalAvailable() || viewMode !== 'session' || !currentSessionId) return;
10050
+ setTerminalMode(currentSessionId, !terminalModes().has(currentSessionId));
10051
+ termState.focusNext = true;
10052
+ syncTerminal();
10053
+ }
10054
+
10055
+ function terminalPaneFocused() {
10056
+ return document.getElementById('terminal-pane').contains(document.activeElement);
10057
+ }
10058
+
10059
+ function terminalShortcut(e) {
10060
+ const ctrlAlt = e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey;
10061
+ if (ctrlAlt && e.code === 'KeyS') return swapToPreviousSession;
10062
+ if (ctrlAlt && (e.code === 'KeyN' || e.code === 'KeyR') && terminalAvailable()) {
10063
+ return () => openNewSession(null, e.code === 'KeyR');
10064
+ }
10065
+ if (e.code !== 'Backquote' || e.metaKey) return null;
10066
+ if (e.ctrlKey && !e.altKey) return e.shiftKey ? openTerminalManager : toggleTerminal;
10067
+ if (e.altKey && !e.ctrlKey && !e.shiftKey) return toggleTerminalFocus;
10068
+ if (!e.altKey || !e.shiftKey || e.ctrlKey) return null;
10069
+ if (termState.attached && wantsTerminal()) return () => closeTerminalSession();
10070
+ if (viewMode === 'session' && runningTerminals.has(currentSessionId)) {
10071
+ return () => closeTerminalSession(currentSessionId);
10072
+ }
10073
+ return null;
10074
+ }
10075
+
10076
+ function swapToPreviousSession() {
10077
+ const target = currentSessionId === lastSessionId ? previousSessionId : lastSessionId;
10078
+ if (!target || target === currentSessionId) return;
10079
+ if (terminalPaneFocused()) termState.focusNext = true;
10080
+ openSession(target);
10081
+ }
10082
+
10083
+ // Esc belongs to Claude, so leaving the terminal without hiding it needs its own key.
10084
+ function toggleTerminalFocus() {
10085
+ if (!wantsTerminal()) {
10086
+ toggleTerminal();
10087
+ return;
10088
+ }
10089
+ if (terminalPaneFocused()) leaveTerminalPane();
10090
+ else focusTerminalPane();
10091
+ }
10092
+
10093
+ // Idempotent: runs on every view change, so whichever path changed the view lands here.
10094
+ function syncTerminal() {
10095
+ const btn = document.getElementById('terminal-toggle');
10096
+ const on = wantsTerminal();
10097
+ const arrived = viewMode === 'session' ? currentSessionId : null;
10098
+ const view = `${arrived}:${on}`;
10099
+ // Only on a change of session or terminal state, so a panel opened by hand over the terminal stays.
10100
+ if (view !== termState.syncedView) {
10101
+ termState.syncedView = view;
10102
+ // The terminal shows the same conversation, so the log panel only takes width; it comes back when the terminal goes.
10103
+ if (on && messagePanelOpen) {
10104
+ toggleMessagePanel();
10105
+ termState.panelHidden = true;
10106
+ } else if (!on && arrived && termState.panelHidden) toggleMessagePanel();
10107
+ }
10108
+ if (btn) {
10109
+ btn.style.display = terminalAvailable() && viewMode === 'session' && currentSessionId ? '' : 'none';
10110
+ btn.classList.toggle('active', on);
10111
+ }
10112
+ sessionView.classList.toggle('terminal-mode', on);
10113
+ if (!on) {
10114
+ termState.shown = false;
10115
+ termState.focusNext = false;
10116
+ termState.openedByUser = null;
10117
+ syncCloseGuard();
10118
+ // Hiding keeps the socket so Ctrl+` back is instant.
10119
+ const keepSocket = termState.sessionId === currentSessionId && viewMode === 'session';
10120
+ if (termState.sessionId && !keepSocket) detachTerminal();
10121
+ return;
10122
+ }
10123
+ const wasShown = termState.shown;
10124
+ termState.shown = true;
10125
+ syncCloseGuard();
10126
+ if (termState.sessionId !== currentSessionId) openTerminal(currentSessionId, terminalOpenMode(currentSessionId));
10127
+ else if (!wasShown) onTerminalShown(takeTerminalFocus(currentSessionId));
10128
+ }
10129
+
10130
+ // Only paths the user drives call this; restores, deep links and refreshes call fetchTasks, so they never take focus.
10131
+ function openSession(sessionId) {
10132
+ const alreadyOpen = sessionId === currentSessionId && viewMode === 'session' && termState.shown;
10133
+ if (!alreadyOpen) termState.openedByUser = sessionId;
10134
+ else if (termState.attached && promptAwaitsUser(sessionId)) focusTerminalPane();
10135
+ return fetchTasks(sessionId);
10136
+ }
10137
+
10138
+ // Selecting a session only shows its terminal; focus follows an explicit toggle, a new session, or
10139
+ // the user opening a session that waits on a prompt, since answering it is why they opened it.
10140
+ function takeTerminalFocus(sessionId) {
10141
+ const opened = termState.openedByUser === sessionId;
10142
+ termState.openedByUser = null;
10143
+ const focus = termState.focusNext || newSpecs.has(sessionId) || (opened && promptAwaitsUser(sessionId));
10144
+ termState.focusNext = false;
10145
+ return focus;
10146
+ }
10147
+
10148
+ // The terminal attaches asynchronously; by then the user may be typing elsewhere or reading a modal.
10149
+ function promptAwaitsUser(sessionId) {
10150
+ if (!sessions.find((s) => s.id === sessionId)?.hasWaitingForUser || isAnyModalOpen()) return false;
10151
+ return !document.activeElement?.matches('input, textarea, select, [contenteditable]');
10152
+ }
10153
+
10154
+ function onTerminalShown(focus = true) {
10155
+ requestAnimationFrame(() => {
10156
+ fitTerminal();
10157
+ repaintTerminal();
10158
+ if (focus) focusTerminalPane();
10159
+ });
10160
+ }
10161
+
10162
+ function focusTerminalPane() {
10163
+ const prompt = document.getElementById('terminal-prompt');
10164
+ if (prompt.classList.contains('visible')) prompt.querySelector('button')?.focus();
10165
+ else termState.term?.focus();
10166
+ }
10167
+
10168
+ function leaveTerminalPane() {
10169
+ termState.leaving = true;
10170
+ document.activeElement.blur();
10171
+ termState.leaving = false;
10172
+ setFocusZone('sidebar');
10173
+ }
10174
+
10175
+ function loadXterm() {
10176
+ if (termState.loaded) return termState.loaded;
10177
+ const css = document.createElement('link');
10178
+ css.rel = 'stylesheet';
10179
+ css.href = '/vendor/xterm/xterm.css';
10180
+ document.head.appendChild(css);
10181
+ const script = (src) =>
10182
+ new Promise((resolve, reject) => {
10183
+ const s = document.createElement('script');
10184
+ s.src = src;
10185
+ s.onload = resolve;
10186
+ s.onerror = () => reject(new Error(`failed to load ${src}`));
10187
+ document.head.appendChild(s);
10188
+ });
10189
+ termState.loaded = script('/vendor/xterm/xterm.js').then(() =>
10190
+ Promise.all(['addon-fit', 'addon-webgl', 'addon-unicode11'].map((n) => script(`/vendor/xterm/${n}.js`))),
10191
+ );
10192
+ termState.loaded.catch(() => {
10193
+ termState.loaded = null;
10194
+ });
10195
+ return termState.loaded;
10196
+ }
10197
+
10198
+ function terminalTheme() {
10199
+ const css = getComputedStyle(document.body);
10200
+ const v = (name) => css.getPropertyValue(name).trim();
10201
+ const theme = {
10202
+ background: v('--bg-deep'),
10203
+ foreground: v('--text-primary'),
10204
+ cursor: v('--accent'),
10205
+ cursorAccent: v('--bg-deep'),
10206
+ selectionBackground: v('--accent-dim'),
10207
+ };
10208
+ for (const name of ANSI_COLOR_NAMES) {
10209
+ theme[name] = v(`--ansi-${name}`);
10210
+ theme[`bright${name[0].toUpperCase()}${name.slice(1)}`] = v(`--ansi-bright-${name}`);
10211
+ }
10212
+ return theme;
10213
+ }
10214
+
10215
+ function terminalThemeOptions() {
10216
+ return {
10217
+ theme: terminalTheme(),
10218
+ // Claude Code on a dark theme (pinned, or output from before it saw the OSC 11 reply) draws
10219
+ // fixed 256/truecolor values no palette reaches; 3 keeps them readable and still blue.
10220
+ minimumContrastRatio: isLightTheme() ? 3 : 1,
10221
+ };
10222
+ }
10223
+
10224
+ // Returning false hands the key back to the page: xterm skips it and the document
10225
+ // listeners (the toggle, hub forwarding, native paste) see it instead.
10226
+ function terminalKeyFilter(e) {
10227
+ if (e.type !== 'keydown') return true;
10228
+ const ctrlOnly = e.ctrlKey && !e.altKey && !e.metaKey;
10229
+ if (terminalShortcut(e)) return false;
10230
+ if (ctrlOnly && e.code === 'KeyC' && (e.shiftKey || termState.term.hasSelection())) {
10231
+ e.preventDefault();
10232
+ navigator.clipboard?.writeText(termState.term.getSelection()).catch(() => {});
10233
+ termState.term.clearSelection();
10234
+ return false;
10235
+ }
10236
+ if (ctrlOnly && e.code === 'KeyV') return false;
10237
+ return !isHubKey(e);
10238
+ }
10239
+
10240
+ function oscColor(hex) {
10241
+ const m = /^#([0-9a-f]{6})$/i.exec(hex || '');
10242
+ if (!m) return null;
10243
+ return `rgb:${[0, 2, 4].map((i) => m[1].slice(i, i + 2).repeat(2)).join('/')}`;
10244
+ }
10245
+
10246
+ function ensureTerm() {
10247
+ if (termState.term) return termState.term;
10248
+ const cfg = appConfig.terminal;
10249
+ const host = document.getElementById('terminal-host');
10250
+ const fontFamily =
10251
+ cfg.fontFamily || getComputedStyle(document.body).getPropertyValue('--font-mono').trim() || 'monospace';
10252
+ const term = new window.Terminal({
10253
+ fontFamily,
10254
+ fontSize: cfg.fontSize,
10255
+ scrollback: cfg.scrollback,
10256
+ cursorBlink: true,
10257
+ allowProposedApi: true,
10258
+ ...terminalThemeOptions(),
10259
+ });
10260
+ termState.fit = new window.FitAddon.FitAddon();
10261
+ term.loadAddon(termState.fit);
10262
+ term.loadAddon(new window.Unicode11Addon.Unicode11Addon());
10263
+ term.unicode.activeVersion = '11';
10264
+ term.open(host);
10265
+ try {
10266
+ const gl = new window.WebglAddon.WebglAddon();
10267
+ gl.onContextLoss(() => {
10268
+ gl.dispose();
10269
+ termState.webgl = null;
10270
+ repaintTerminal();
10271
+ });
10272
+ term.loadAddon(gl);
10273
+ termState.webgl = gl;
10274
+ } catch (_) {
10275
+ // No WebGL: xterm falls back to its DOM renderer.
10276
+ }
10277
+ document.fonts
10278
+ ?.load(`${cfg.fontSize}px ${fontFamily}`)
10279
+ .then(repaintTerminal)
10280
+ .catch(() => {});
10281
+ term.attachCustomKeyEventHandler(terminalKeyFilter);
10282
+ // xterm leaves OSC 10/11 color queries unanswered, so apps that pick a palette from the
10283
+ // background (Claude Code's Auto theme) assume dark even on a light theme.
10284
+ for (const [code, key] of [
10285
+ [10, 'foreground'],
10286
+ [11, 'background'],
10287
+ ]) {
10288
+ term.parser.registerOscHandler(code, (data) => {
10289
+ if (data !== '?') return false;
10290
+ const rgb = oscColor(term.options.theme[key]);
10291
+ if (rgb) terminalSend({ t: 'in', d: `\x1b]${code};${rgb}\x1b\\` });
10292
+ return true;
10293
+ });
10294
+ }
10295
+ term.onData((d) => terminalSend({ t: 'in', d }));
10296
+ term.onResize(({ cols, rows }) => terminalSend({ t: 'resize', cols, rows }));
10297
+ // One fit per frame while a drag resizes the host. The atlas is rebuilt only when the host comes
10298
+ // back from 0×0, which is also how a hidden hub iframe or pane shows again.
10299
+ let frame = 0;
10300
+ let visible = false;
10301
+ new ResizeObserver(() => {
10302
+ if (frame) return;
10303
+ frame = requestAnimationFrame(() => {
10304
+ frame = 0;
10305
+ const nowVisible = host.offsetWidth > 0;
10306
+ fitTerminal();
10307
+ if (nowVisible && !visible) repaintTerminal();
10308
+ visible = nowVisible;
10309
+ });
10310
+ }).observe(host);
10311
+ const themeKey = () => `${isLightTheme()}|${document.body.dataset.colorTheme || ''}`;
10312
+ let appliedTheme = themeKey();
10313
+ new MutationObserver(() => {
10314
+ if (themeKey() === appliedTheme) return;
10315
+ appliedTheme = themeKey();
10316
+ Object.assign(term.options, terminalThemeOptions());
10317
+ repaintTerminal();
10318
+ }).observe(document.body, { attributes: true, attributeFilter: ['class', 'data-color-theme'] });
10319
+ // Vimium eats Escape inside a text field and only blurs it, so the key never reaches Claude.
10320
+ // A blur no click caused, while the pane stays shown and the window keeps focus, is that Escape.
10321
+ let pointerDown = false;
10322
+ document.addEventListener('mousedown', () => (pointerDown = true), true);
10323
+ document.addEventListener('mouseup', () => (pointerDown = false), true);
10324
+ term.textarea.addEventListener('blur', () => {
10325
+ if (pointerDown || termState.leaving) return;
10326
+ requestAnimationFrame(() => {
10327
+ if (!termState.attached || !host.offsetWidth) return;
10328
+ if (!document.hasFocus() || document.activeElement !== document.body) return;
10329
+ terminalSend({ t: 'in', d: '\x1b' });
10330
+ term.focus();
10331
+ });
10332
+ });
10333
+ termState.term = term;
10334
+ return term;
10335
+ }
10336
+
10337
+ // The WebGL renderer keeps its last frame and glyph atlas while the pane (or the hub's iframe) is
10338
+ // display:none, and comes back showing stale cells or backgrounds with no text. xterm does not
10339
+ // repaint on its own when the canvas is shown again.
10340
+ function repaintTerminal() {
10341
+ const term = termState.term;
10342
+ if (!term) return;
10343
+ try {
10344
+ termState.webgl?.clearTextureAtlas();
10345
+ } catch (_) {}
10346
+ term.refresh(0, term.rows - 1);
10347
+ }
10348
+
10349
+ // The fit addon measures 0×0 while the pane (or the hub's iframe) is display:none.
10350
+ function fitTerminal() {
10351
+ const host = document.getElementById('terminal-host');
10352
+ if (!termState.fit || !host.offsetWidth || !host.offsetHeight) return;
10353
+ try {
10354
+ termState.fit.fit();
10355
+ } catch (_) {}
10356
+ }
10357
+
10358
+ function terminalSend(msg) {
10359
+ if (termState.ws?.readyState === WebSocket.OPEN) termState.ws.send(JSON.stringify(msg));
10360
+ }
10361
+
10362
+ function ackTerminal(ws, n) {
10363
+ if (termState.ws !== ws) return;
10364
+ termState.ackPending += n;
10365
+ const flush = () => {
10366
+ clearTimeout(termState.ackTimer);
10367
+ termState.ackTimer = null;
10368
+ if (termState.ws === ws && termState.ackPending) terminalSend({ t: 'ack', n: termState.ackPending });
10369
+ termState.ackPending = 0;
10370
+ };
10371
+ if (termState.ackPending >= ACK_BATCH_BYTES) flush();
10372
+ else if (!termState.ackTimer) termState.ackTimer = setTimeout(flush, 50);
10373
+ }
10374
+
10375
+ function setTerminalStatus(text) {
10376
+ document.getElementById('terminal-status').textContent = text;
10377
+ }
10378
+
10379
+ function hideTerminalPrompt() {
10380
+ document.getElementById('terminal-prompt').classList.remove('visible');
10381
+ }
10382
+
10383
+ const TERMINAL_BOOT_VERBS = ['Waking Claude…', 'Clawding…', 'Warming up the terminal…', 'Summoning the session…'];
10384
+ const TERMINAL_BOOT_MAX_MS = 15000;
10385
+ let terminalBootTimer = null;
10386
+
10387
+ // A new PTY shows only a blinking cursor until Claude draws its first frame.
10388
+ function showTerminalBoot() {
10389
+ document.getElementById('terminal-boot-verb').textContent = pickRandom(TERMINAL_BOOT_VERBS);
10390
+ document.getElementById('terminal-boot').classList.add('visible');
10391
+ clearTimeout(terminalBootTimer);
10392
+ terminalBootTimer = setTimeout(hideTerminalBoot, TERMINAL_BOOT_MAX_MS);
10393
+ }
10394
+
10395
+ function hideTerminalBoot() {
10396
+ clearTimeout(terminalBootTimer);
10397
+ terminalBootTimer = null;
10398
+ document.getElementById('terminal-boot').classList.remove('visible');
10399
+ }
10400
+
10401
+ function showTerminalPrompt(sessionId, title, detail, choices, output = '') {
10402
+ const el = document.getElementById('terminal-prompt');
10403
+ const tail = output ? `<pre class="${TINTED_PRE_CLASS} terminal-prompt-output">${escapeHtml(output)}</pre>` : '';
10404
+ el.innerHTML = `<div class="terminal-prompt-text"><div class="terminal-prompt-title">${escapeHtml(title)}</div><div>${escapeHtml(detail)}</div></div>${tail}<div class="terminal-prompt-actions">${choices
10405
+ .map(
10406
+ ([mode, label]) =>
10407
+ `<button type="button" class="btn btn-secondary" data-mode="${escapeHtml(mode)}">${escapeHtml(label)}</button>`,
10408
+ )
10409
+ .join('')}</div><div class="sp-hint"><kbd>Esc</kbd> hides the terminal</div>`;
10410
+ hideTerminalBoot();
10411
+ el.querySelectorAll('button[data-mode]').forEach((b) => {
10412
+ b.onclick = () => openTerminal(sessionId, b.dataset.mode);
10413
+ });
10414
+ el.classList.add('visible');
10415
+ el.querySelector('button')?.focus();
10416
+ }
10417
+
10418
+ function terminalTail(count) {
10419
+ const buf = termState.term?.buffer.active;
10420
+ if (!buf) return '';
10421
+ const lines = [];
10422
+ for (let i = buf.length - 1; i >= 0 && lines.length < count; i--) {
10423
+ const text = buf.getLine(i)?.translateToString(true) ?? '';
10424
+ if (text.trim() || lines.length) lines.unshift(text);
10425
+ }
10426
+ return lines.join('\n');
10427
+ }
10428
+
10429
+ // Closing the page leaves the PTY running, but Ctrl+W meant for the prompt closes the tab, so ask first.
10430
+ // Under the hub the top frame asks: browsers do not reliably show the dialog for a frame.
10431
+ function setTerminalAttached(on) {
10432
+ termState.attached = on;
10433
+ syncCloseGuard();
10434
+ }
10435
+
10436
+ // A hidden pane stays attached, but Ctrl+W can only be meant for a terminal on screen.
10437
+ function syncCloseGuard() {
10438
+ const on = termState.attached && termState.shown;
10439
+ if (termState.closeGuard === on) return;
10440
+ termState.closeGuard = on;
10441
+ hubPost({ type: 'hub:closeGuard', on });
10442
+ }
10443
+
10444
+ window.addEventListener('beforeunload', (e) => {
10445
+ if (termState.closeGuard && !window.__HUB__?.enabled) e.preventDefault();
10446
+ });
10447
+
10448
+ // The terminal stands in for the board, so it takes the board zone however focus arrives
10449
+ // (Tab, Alt+`, a click); leaveTerminalPane hands it back to the sidebar.
10450
+ document.getElementById('terminal-pane').addEventListener('focusin', () => {
10451
+ if (focusZone !== 'sidebar') return;
10452
+ clearKbSelection();
10453
+ focusZone = 'board';
10454
+ });
10455
+
10456
+ function detachTerminal() {
10457
+ const ws = termState.ws;
10458
+ termState.ws = null;
10459
+ termState.sessionId = null;
10460
+ clearTimeout(termState.ackTimer);
10461
+ termState.ackTimer = null;
10462
+ termState.ackPending = 0;
10463
+ clearTimeout(termState.retryTimer);
10464
+ termState.retryTimer = null;
10465
+ setTerminalAttached(false);
10466
+ if (ws) {
10467
+ ws.onclose = null;
10468
+ ws.close();
10469
+ }
10470
+ hideTerminalPrompt();
10471
+ hideTerminalBoot();
10472
+ }
10473
+
10474
+ async function openTerminal(sessionId, mode, attempt = 0) {
10475
+ detachTerminal();
10476
+ termState.sessionId = sessionId;
10477
+ if (!attempt) setTerminalStatus('');
10478
+ try {
10479
+ await loadXterm();
10480
+ } catch (e) {
10481
+ setTerminalStatus(e.message);
10482
+ return;
10483
+ }
10484
+ if (termState.sessionId !== sessionId) return;
10485
+ const term = ensureTerm();
10486
+ if (!attempt) showTerminalBoot();
10487
+ // RIS through the write queue, not term.reset(): reset() runs at once, and output the previous
10488
+ // session had already queued would still be drawn after it. A retry keeps the last screen
10489
+ // until the replay arrives.
10490
+ const reset = () => term.write('\x1bc', repaintTerminal);
10491
+ if (!attempt) reset();
10492
+ fitTerminal();
10493
+ const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/api/terminal/ws`);
10494
+ ws.binaryType = 'arraybuffer';
10495
+ termState.ws = ws;
10496
+ const spec = newSpecs.get(sessionId);
10497
+ let readyAt = 0;
10498
+ let refused = false;
10499
+ ws.onopen = () =>
10500
+ ws.send(
10501
+ JSON.stringify({
10502
+ t: 'hello',
10503
+ token: terminalToken,
10504
+ id: sessionId,
10505
+ mode,
10506
+ cols: term.cols,
10507
+ rows: term.rows,
10508
+ ...(spec?.mode === mode && {
10509
+ cwd: spec.cwd,
10510
+ name: spec.name,
10511
+ worktree: spec.worktree,
10512
+ model: spec.model,
10513
+ prompt: spec.prompt,
10514
+ }),
10515
+ }),
10516
+ );
10517
+ ws.onmessage = (ev) => {
10518
+ if (termState.ws !== ws) return;
10519
+ if (typeof ev.data !== 'string') {
10520
+ const bytes = new Uint8Array(ev.data);
10521
+ term.write(bytes, () => {
10522
+ ackTerminal(ws, bytes.length);
10523
+ if (terminalBootTimer && termState.ws === ws && terminalTail(1)) hideTerminalBoot();
10524
+ });
10525
+ return;
10526
+ }
10527
+ let msg;
10528
+ try {
10529
+ msg = JSON.parse(ev.data);
10530
+ } catch (_) {
10531
+ return;
10532
+ }
10533
+ if (msg.t === 'ready') {
10534
+ readyAt = Date.now();
10535
+ if (attempt) reset();
10536
+ } else if (msg.t === 'error') refused = true;
10537
+ onTerminalMessage(sessionId, msg);
10538
+ };
10539
+ ws.onclose = (ev) => {
10540
+ if (termState.ws !== ws) return;
10541
+ termState.ws = null;
10542
+ setTerminalAttached(false);
10543
+ // A refusal already shows the server's reason, and retrying would only repeat it, unless the
10544
+ // reason was a token that the hub has since replaced (4001).
10545
+ if (refused && ev.code === 4001) {
10546
+ refreshTerminalToken().then((ok) => {
10547
+ if (ok && termState.sessionId === sessionId) openTerminal(sessionId, mode, attempt + 1);
10548
+ });
10549
+ return;
10550
+ }
10551
+ if (refused || document.getElementById('terminal-prompt').classList.contains('visible')) return;
10552
+ const stable = readyAt && Date.now() - readyAt > TERMINAL_STABLE_MS;
10553
+ retryTerminal(sessionId, stable ? 0 : attempt);
10554
+ };
10555
+ }
10556
+
10557
+ function terminalOpenMode(sessionId) {
10558
+ return newSpecs.get(sessionId)?.mode ?? 'auto';
10559
+ }
10560
+
10561
+ // The server moved the PTY to the session picked in `claude --resume`; reattaching under that id
10562
+ // replays the same screen, and the placeholder gives way to the real card.
10563
+ function adoptPickedSession(oldId, id) {
10564
+ const focused = terminalPaneFocused();
10565
+ if (lastSessionId === oldId) lastSessionId = id;
10566
+ if (previousSessionId === oldId) previousSessionId = id;
10567
+ setTerminalMode(id, true);
10568
+ forgetPlaceholder(oldId);
10569
+ if (currentSessionId !== oldId || viewMode !== 'session') return renderSessions();
10570
+ termState.focusNext = focused;
10571
+ fetchTasks(id);
10572
+ }
10573
+
10574
+ // The PTY outlives the socket, so a dropped connection (cck restart, sleep, proxy hiccup) reattaches
10575
+ // to the same screen. Only a socket that stayed up resets the backoff, so a server that accepts and
10576
+ // then drops every socket still runs out of attempts.
10577
+ function retryTerminal(sessionId, attempt) {
10578
+ const delay = TERMINAL_RETRY_MS[attempt];
10579
+ if (delay === undefined) {
10580
+ setTerminalStatus('');
10581
+ showTerminalPrompt(sessionId, 'Disconnected', 'The connection to the terminal was lost.', [
10582
+ [terminalOpenMode(sessionId), 'Reconnect'],
10583
+ ]);
10584
+ return;
10585
+ }
10586
+ setTerminalStatus('Reconnecting…');
10587
+ termState.retryTimer = setTimeout(() => {
10588
+ termState.retryTimer = null;
10589
+ if (termState.sessionId === sessionId) openTerminal(sessionId, terminalOpenMode(sessionId), attempt + 1);
10590
+ }, delay);
10591
+ }
10592
+
10593
+ function onTerminalMessage(sessionId, msg) {
10594
+ if (msg.t === 'ready') {
10595
+ // The server has the prompt now; a later start of the same placeholder must not send it again.
10596
+ const spec = newSpecs.get(sessionId);
10597
+ if (spec) spec.prompt = null;
10598
+ hideTerminalPrompt();
10599
+ setTerminalStatus('');
10600
+ setTerminalAttached(true);
10601
+ terminalSend({ t: 'resize', cols: termState.term.cols, rows: termState.term.rows });
10602
+ if (takeTerminalFocus(sessionId)) termState.term.focus();
10603
+ } else if (msg.t === 'rekey' && typeof msg.id === 'string') {
10604
+ adoptPickedSession(sessionId, msg.id);
10605
+ if (msg.duplicate) showToast('That session is already open here, so this terminal switched to it', 'info');
10606
+ else if (msg.elsewhere)
10607
+ showToast('That session also runs in another terminal; both write the same transcript', 'error');
10608
+ } else if (msg.t === 'live') {
10609
+ showTerminalPrompt(
10610
+ sessionId,
10611
+ 'Running in another terminal',
10612
+ 'Resuming it here too makes both processes write to the same transcript.',
10613
+ [
10614
+ ['resume', 'Resume anyway'],
10615
+ ['fork', 'Fork'],
10616
+ ['shell', 'Shell only'],
10617
+ ],
10618
+ );
10619
+ } else if (msg.t === 'exit') {
10620
+ setTerminalAttached(false);
10621
+ // A failed exit shows its last lines in the prompt, so the cause stays readable.
10622
+ const clean = msg.code === 0 || msg.ended;
10623
+ // With no first message there is no transcript to resume, only the same new session to start again.
10624
+ const unsent = newSpecs.has(sessionId);
10625
+ if (unsent && clean) {
10626
+ dropPlaceholder(sessionId);
10627
+ return;
10628
+ }
10629
+ showTerminalPrompt(
10630
+ sessionId,
10631
+ msg.ended ? 'The terminal was ended' : 'The shell exited',
10632
+ clean ? 'Choose how to start again.' : `Exit code ${msg.code}.`,
10633
+ unsent
10634
+ ? [
10635
+ [terminalOpenMode(sessionId), 'Start again'],
10636
+ ['shell', 'Shell'],
10637
+ ]
10638
+ : [
10639
+ ['resume', 'Resume'],
10640
+ ['fork', 'Fork'],
10641
+ ['shell', 'Shell'],
10642
+ ],
10643
+ clean ? '' : terminalTail(6),
10644
+ );
10645
+ } else if (msg.t === 'error') {
10646
+ hideTerminalBoot();
10647
+ setTerminalStatus(msg.msg || 'Terminal error');
10648
+ }
10649
+ }
10650
+
10651
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
10652
+ function endTerminalSession() {
10653
+ terminalSend({ t: 'kill' });
10654
+ }
10655
+
10656
+ // Detaching first means no exit message reaches the pane, so no Resume prompt shows before the board.
10657
+ function closeTerminalSession(id = termState.sessionId) {
10658
+ if (id === termState.sessionId) {
10659
+ if (terminalPaneFocused()) leaveTerminalPane();
10660
+ detachTerminal();
10661
+ }
10662
+ setTerminalMode(id, false);
10663
+ syncTerminal();
10664
+ return endTerminal(id);
10665
+ }
10666
+
10667
+ function openTerminalManager() {
10668
+ if (!terminalAvailable()) return;
10669
+ // Esc typed in xterm goes to Claude, so the modal would not close on it.
10670
+ if (terminalPaneFocused()) leaveTerminalPane();
10671
+ document.getElementById('terminal-manager-modal').classList.add('visible');
10672
+ renderTerminalManager();
10673
+ }
10674
+
10675
+ function closeTerminalManager() {
10676
+ hideModalOverlay('terminal-manager-modal');
10677
+ }
10678
+
10679
+ async function renderTerminalManager() {
10680
+ const body = document.getElementById('terminal-manager-body');
10681
+ let list = [];
10682
+ try {
10683
+ list = await loadTerminals();
10684
+ } catch (e) {
10685
+ body.innerHTML = `<div class="terminal-manager-empty">${escapeHtml(e.message)}</div>`;
10686
+ return;
10687
+ }
10688
+ const max = appConfig.terminal?.maxSessions;
10689
+ document.getElementById('terminal-manager-count').textContent = max
10690
+ ? `${list.length} of ${max} running`
10691
+ : `${list.length} running`;
10692
+ document.getElementById('terminal-manager-end-all').hidden = list.length < 2;
10693
+ if (!list.length) {
10694
+ body.innerHTML = '<div class="terminal-manager-empty">No terminals running</div>';
10695
+ return;
10696
+ }
10697
+ list.sort((a, b) => b.startedAt - a.startedAt);
10698
+ body.innerHTML = list
10699
+ .map((t) => {
10700
+ const session = sessions.find((s) => s.id === t.id);
10701
+ const name = session ? sessionDisplayName(session) : t.id.slice(0, 8);
10702
+ const here = t.id === termState.sessionId ? '<span class="terminal-manager-here">this tab</span>' : '';
10703
+ const attached = t.clients ? `${t.clients} attached` : 'detached';
10704
+ return `<div class="terminal-manager-row${t.clients ? ' attached' : ''}">
10705
+ <span class="terminal-manager-dot"></span>
10706
+ <div class="terminal-manager-info">
10707
+ <div class="terminal-manager-name"><span class="terminal-manager-title">${escapeHtml(name)}</span>${here}</div>
10708
+ <div class="terminal-manager-meta">
10709
+ <span class="terminal-manager-mode">${escapeHtml(t.mode)}</span>
10710
+ <span title="${escapeHtml(`pid ${t.pid}`)}">up ${formatDuration(Date.now() - t.startedAt)}</span>
10711
+ <span>${attached}</span>
10712
+ <span class="terminal-manager-cwd" title="${escapeHtml(t.cwd)}">${escapeHtml(pathBasename(t.cwd))}</span>
10713
+ </div>
10714
+ </div>
10715
+ <div class="terminal-manager-actions">
10716
+ <button type="button" class="btn btn-secondary" data-open="${escapeHtml(t.id)}">Open</button>
10717
+ <button type="button" class="btn btn-secondary terminal-manager-end" data-end="${escapeHtml(t.id)}">End</button>
10718
+ </div>
10719
+ </div>`;
10720
+ })
10721
+ .join('');
10722
+ body.querySelectorAll('[data-open]').forEach((b) => {
10723
+ b.onclick = () => {
10724
+ closeTerminalManager();
10725
+ showSessionTerminal(b.dataset.open);
10726
+ };
10727
+ });
10728
+ body.querySelectorAll('[data-end]').forEach((b) => {
10729
+ b.onclick = async () => {
10730
+ b.disabled = true;
10731
+ await endTerminal(b.dataset.end);
10732
+ renderTerminalManager();
10733
+ };
10734
+ });
10735
+ }
10736
+
10737
+ async function terminalFetch(url, method, body) {
10738
+ const send = () =>
10739
+ fetch(url, {
10740
+ method,
10741
+ headers: {
10742
+ 'X-Terminal-Token': terminalToken || '',
10743
+ ...(body && { 'Content-Type': 'application/json' }),
10744
+ },
10745
+ ...(body && { body: JSON.stringify(body) }),
10746
+ });
10747
+ const res = await send();
10748
+ return res.status === 401 && (await refreshTerminalToken()) ? send() : res;
10749
+ }
10750
+
10751
+ async function endTerminal(id) {
10752
+ await terminalFetch(`/api/terminals/${encodeURIComponent(id)}`, 'DELETE').catch(() => {});
10753
+ dropPlaceholder(id);
10754
+ }
10755
+
10756
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
10757
+ async function endAllTerminals() {
10758
+ const buttons = document.querySelectorAll('#terminal-manager-body [data-end]');
10759
+ await Promise.all([...buttons].map((b) => endTerminal(b.dataset.end)));
10760
+ renderTerminalManager();
10761
+ }
10762
+
10763
+ //#endregion
10764
+
10765
+ //#region NEW_SESSION
10766
+ // claude writes no transcript until the first message, so until then the sidebar shows a
10767
+ // placeholder built from the form. newSpecs holds one entry per such session; the entry
10768
+ // goes when the real session shows up in /api/sessions.
10769
+ const NEW_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9 ._-]{0,79}$/;
10770
+ const NEW_WORKTREE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
10771
+ const NS_MAX_MATCHES = 8;
10772
+ const PLUS_SVG =
10773
+ '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>';
10774
+ const newSpecs = new Map();
10775
+ // Sessions another session started (`dispatch start`), shown where they will stay until
10776
+ // their transcript appears. Kept apart from newSpecs: their prompt is already sent.
10777
+ const dispatchSpecs = new Map();
10778
+ const ns = { projects: [], picked: [], matches: [], idx: -1, folder: '', browsing: false, resume: false };
10779
+
10780
+ const PLACEHOLDER_NAMES = { pick: 'Resume session', dispatch: 'Started session', new: 'New session' };
10781
+ const PLACEHOLDER_HINTS = {
10782
+ pick: 'pick a session to resume',
10783
+ dispatch: 'starting',
10784
+ new: 'waiting for your first message',
10785
+ };
10786
+
10787
+ function placeholderSession(id, spec) {
10788
+ return {
10789
+ id,
10790
+ placeholder: true,
10791
+ mode: spec.mode,
10792
+ name: spec.name || PLACEHOLDER_NAMES[spec.mode] || PLACEHOLDER_NAMES.new,
10793
+ dispatchGroup: spec.group || undefined,
10794
+ startedBy: spec.startedBy || undefined,
10795
+ project: spec.cwd,
10796
+ worktree: spec.worktree ? { repo: spec.cwd, name: spec.worktree === true ? 'new' : spec.worktree } : null,
10797
+ modifiedAt: new Date(spec.startedAt).toISOString(),
10798
+ hasMessages: true,
10799
+ hasRecentActivity: true,
10800
+ taskCount: 0,
10801
+ completed: 0,
10802
+ };
10803
+ }
10804
+
10805
+ function mergePlaceholders(list) {
10806
+ if (!newSpecs.size && !dispatchSpecs.size) return list;
10807
+ const ids = new Set(list.map((s) => s.id));
10808
+ for (const specs of [newSpecs, dispatchSpecs]) {
10809
+ for (const id of [...specs.keys()]) if (ids.has(id)) specs.delete(id);
10810
+ }
10811
+ const pending = [...newSpecs, ...dispatchSpecs].sort((a, b) => b[1].startedAt - a[1].startedAt);
10812
+ return [...pending.map(([id, spec]) => placeholderSession(id, spec)), ...list];
10813
+ }
10814
+
10815
+ async function loadDispatches() {
10816
+ try {
10817
+ const res = await fetch('/api/dispatch', { cache: 'no-store' });
10818
+ const { running = [] } = await res.json();
10819
+ dispatchSpecs.clear();
10820
+ for (const r of running) {
10821
+ if (!r.session || sessions.some((s) => s.id === r.session && !s.placeholder)) continue;
10822
+ dispatchSpecs.set(r.session, {
10823
+ mode: 'dispatch',
10824
+ cwd: r.cwd,
10825
+ name: r.name,
10826
+ worktree: r.worktree,
10827
+ group: r.group,
10828
+ startedBy: r.parent,
10829
+ startedAt: r.startedAt,
10830
+ });
10831
+ }
10832
+ } catch (_) {}
10833
+ }
10834
+
10835
+ async function onDispatchUpdate() {
10836
+ await loadDispatches();
10837
+ sessions = mergePlaceholders(sessions.filter((s) => !s.placeholder));
10838
+ renderSessions();
10839
+ fetchSessions(false).catch(() => {});
10840
+ }
10841
+
10842
+ // With no first message there is no transcript, so an ended placeholder leaves nothing to come back to.
10843
+ function dropPlaceholder(id) {
10844
+ if (!forgetPlaceholder(id)) return;
10845
+ if (currentSessionId === id && viewMode === 'session') showAllTasks();
10846
+ else renderSessions();
10847
+ }
10848
+
10849
+ function forgetPlaceholder(id) {
10850
+ if (!newSpecs.delete(id)) return false;
10851
+ setTerminalMode(id, false);
10852
+ // Before the socket's close handler runs, or it would reconnect and start the session again.
10853
+ if (termState.sessionId === id) detachTerminal();
10854
+ sessions = sessions.filter((s) => s.id !== id);
10855
+ if (previousSessionId === id) previousSessionId = null;
10856
+ if (lastSessionId === id) lastSessionId = null;
10857
+ return true;
10858
+ }
10859
+
10860
+ function renderPlaceholderCard(session) {
10861
+ const isActive = session.id === currentSessionId && viewMode === 'session';
10862
+ const projectHtml = renderProjectIdentity(session);
10863
+ return `
10864
+ <button onclick="fetchTasks('${escAttrJs(session.id)}')" data-session-id="${escapeHtml(session.id)}" class="session-item session-placeholder ${isActive ? 'active' : ''}" title="${escapeHtml(`${session.id} | ${session.project}`)}">
10865
+ <div class="session-name">${escapeHtml(session.name)}</div>
10866
+ ${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
10867
+ <div class="session-waiting"><span class="pulse"></span>${PLACEHOLDER_HINTS[session.mode] || PLACEHOLDER_HINTS.new}</div>
10868
+ </button>
10869
+ `;
10870
+ }
10871
+
10872
+ // After a reload the form is gone, but the PTY still runs; its hello options come back from the server.
10873
+ async function restorePendingSessions() {
10874
+ document.getElementById('new-session-btn').hidden = !terminalAvailable();
10875
+ const terminals = terminalAvailable() ? loadTerminals() : null;
10876
+ await loadDispatches();
10877
+ if (!terminals) return;
10878
+ try {
10879
+ for (const t of await terminals) {
10880
+ if ((t.mode !== 'new' && t.mode !== 'pick') || newSpecs.has(t.id) || dispatchSpecs.has(t.id)) continue;
10881
+ newSpecs.set(t.id, { cwd: t.cwd, name: t.name, worktree: t.worktree, mode: t.mode, startedAt: t.startedAt });
10882
+ }
10883
+ } catch (_) {}
10884
+ }
10885
+
10886
+ async function loadTerminals() {
10887
+ const res = await fetch('/api/terminals', { cache: 'no-store' });
10888
+ const list = (await res.json()).sessions || [];
10889
+ runningTerminals = new Set(list.map((t) => t.id));
10890
+ return list;
10891
+ }
10892
+
10893
+ function setRunningTerminals(ids) {
10894
+ runningTerminals = new Set(ids);
10895
+ if (ids.some((id) => !sessions.some((s) => s.id === id))) fetchSessions(false).catch(() => {});
10896
+ else renderSessions();
10897
+ }
10898
+
10899
+ function showSessionTerminal(sessionId) {
10900
+ setTerminalMode(sessionId, true);
10901
+ if (currentSessionId === sessionId && viewMode === 'session') syncTerminal();
10902
+ else fetchTasks(sessionId);
10903
+ }
10904
+
10905
+ function openNewSession(folder, resume = false) {
10906
+ if (!terminalAvailable() || document.getElementById('new-session-modal').classList.contains('visible')) return;
10907
+ if (terminalPaneFocused()) leaveTerminalPane();
10908
+ ns.resume = resume;
10909
+ document.querySelector('.new-session-modal').classList.toggle('ns-resume', resume);
10910
+ document.getElementById('ns-title').textContent = resume ? 'Resume session' : 'New session';
10911
+ for (const id of ['ns-name', 'ns-wt-name', 'ns-prompt']) document.getElementById(id).value = '';
10912
+ document.getElementById('ns-wt').checked = false;
10913
+ document.getElementById('ns-model').value = '';
10914
+ setNewSessionError('');
10915
+ const current = viewMode === 'session' ? sessions.find((s) => s.id === currentSessionId)?.project : null;
10916
+ setNewSessionFolder(folder || current || '');
10917
+ document.getElementById('new-session-modal').classList.add('visible');
10918
+ const input = document.getElementById('ns-folder');
10919
+ input.focus();
10920
+ input.select();
10921
+ loadProjects().then((list) => {
10922
+ ns.projects = list
10923
+ .filter((p) => !p.temp)
10924
+ .sort((a, b) => (b.modifiedAt || '').localeCompare(a.modifiedAt || ''))
10925
+ .map((p) => p.path);
10926
+ if (!ns.folder) setNewSessionFolder(input.value, true);
10927
+ if (folderListOpen()) renderFolderList();
10928
+ });
10929
+ }
10930
+
10931
+ function closeNewSession() {
10932
+ hideModalOverlay('new-session-modal');
10933
+ closeFolderList();
10934
+ promptVoice.rec?.abort();
10935
+ }
10936
+
10937
+ function knownFolders() {
10938
+ return [...new Set([...ns.picked, ...ns.projects])];
10939
+ }
10940
+
10941
+ // The field holds free text; a folder counts as chosen only when the text is a known project
10942
+ // or a folder the dialog returned, because those are the only ones the server accepts.
10943
+ // The sidebar's projects count too, so a prefilled folder is usable before /api/projects answers.
10944
+ function setNewSessionFolder(value, keepList) {
10945
+ const input = document.getElementById('ns-folder');
10946
+ input.value = value;
10947
+ const known = knownFolders().includes(value) || sessions.some((s) => !s.placeholder && s.project === value);
10948
+ ns.folder = value && known ? value : '';
10949
+ if (!keepList) closeFolderList();
10950
+ renderNewSessionForm();
10951
+ }
10952
+
10953
+ function folderRank(path, q) {
10954
+ const name = pathBasename(path).toLowerCase();
10955
+ if (name === q) return 0;
10956
+ if (name.startsWith(q)) return 1;
10957
+ if (name.includes(q)) return 2;
10958
+ return path.toLowerCase().includes(q) ? 3 : 4;
10959
+ }
10960
+
10961
+ function rankFolders(paths, query) {
10962
+ const q = query.toLowerCase();
10963
+ return paths
10964
+ .filter((p) => fuzzyMatch(p, query))
10965
+ .map((p) => ({ p, r: folderRank(p, q) }))
10966
+ .sort((a, b) => a.r - b.r)
10967
+ .map((x) => x.p);
10968
+ }
10969
+
10970
+ function renderFolderList() {
10971
+ const input = document.getElementById('ns-folder');
10972
+ const list = document.getElementById('ns-folder-list');
10973
+ const query = input.value.trim();
10974
+ const all = knownFolders();
10975
+ ns.matches = (query && query !== ns.folder ? rankFolders(all, query) : all).slice(0, NS_MAX_MATCHES);
10976
+ if (ns.idx >= ns.matches.length) ns.idx = ns.matches.length - 1;
10977
+ list.innerHTML = ns.matches.length
10978
+ ? ns.matches
10979
+ .map(
10980
+ (p, i) =>
10981
+ `<div class="ns-option${i === ns.idx ? ' active' : ''}" role="option" data-idx="${i}" aria-selected="${i === ns.idx}"><span class="ns-option-name">${escapeHtml(pathBasename(p))}</span><span class="ns-option-path">${escapeHtml(p)}</span></div>`,
10982
+ )
10983
+ .join('')
10984
+ : '<div class="ns-empty"><span>No project matches</span><button type="button" class="btn btn-secondary ns-browse" data-browse>Browse…</button></div>';
10985
+ list.hidden = false;
10986
+ input.setAttribute('aria-expanded', 'true');
10987
+ }
10988
+
10989
+ function moveFolderHighlight(step) {
10990
+ const n = ns.matches.length;
10991
+ if (!n) return;
10992
+ ns.idx = (ns.idx + step + n) % n;
10993
+ for (const row of document.querySelectorAll('#ns-folder-list .ns-option')) {
10994
+ const on = Number(row.dataset.idx) === ns.idx;
10995
+ row.classList.toggle('active', on);
10996
+ row.setAttribute('aria-selected', on);
10997
+ }
10998
+ }
10999
+
11000
+ function closeFolderList() {
11001
+ document.getElementById('ns-folder-list').hidden = true;
11002
+ document.getElementById('ns-folder').setAttribute('aria-expanded', 'false');
11003
+ ns.idx = -1;
11004
+ }
11005
+
11006
+ function folderListOpen() {
11007
+ return !document.getElementById('ns-folder-list').hidden;
11008
+ }
11009
+
11010
+ function newSessionValues() {
11011
+ const wtOn = document.getElementById('ns-wt').checked;
11012
+ const wtName = document.getElementById('ns-wt-name').value.trim();
11013
+ return {
11014
+ cwd: ns.folder,
11015
+ name: document.getElementById('ns-name').value.trim() || null,
11016
+ worktree: wtOn ? wtName || true : false,
11017
+ model: document.getElementById('ns-model').value || null,
11018
+ prompt: document.getElementById('ns-prompt').value.trim() || null,
11019
+ };
11020
+ }
11021
+
11022
+ function newSessionProblem(v) {
11023
+ if (v.name && !NEW_NAME_RE.test(v.name))
11024
+ return 'Name: use letters, digits, spaces and . _ -, starting with a letter or digit.';
11025
+ if (typeof v.worktree === 'string' && !NEW_WORKTREE_RE.test(v.worktree))
11026
+ return 'Worktree name: use letters, digits and . _ -, starting with a letter or digit.';
11027
+ return '';
11028
+ }
11029
+
11030
+ function setNewSessionError(text) {
11031
+ const el = document.getElementById('ns-error');
11032
+ el.textContent = text;
11033
+ el.hidden = !text;
11034
+ }
11035
+
11036
+ function renderNewSessionForm() {
11037
+ const v = newSessionValues();
11038
+ const wtOn = v.worktree !== false;
11039
+ const wtInput = document.getElementById('ns-wt-name');
11040
+ wtInput.disabled = !wtOn;
11041
+ const hint = document.getElementById('ns-wt-hint');
11042
+ hint.hidden = !wtOn;
11043
+ hint.textContent = `.claude/worktrees/${typeof v.worktree === 'string' ? v.worktree : '<name chosen by Claude>'}`;
11044
+ const problem = newSessionProblem(v);
11045
+ setNewSessionError(problem);
11046
+ const start = document.getElementById('ns-start');
11047
+ start.disabled = !v.cwd || !!problem || ns.browsing;
11048
+ const verb = ns.resume ? 'Resume' : 'Start';
11049
+ start.textContent = v.cwd ? `${verb} in ${pathBasename(v.cwd)}` : verb;
11050
+ }
11051
+
11052
+ async function browseNewSessionFolder() {
11053
+ if (ns.browsing) return;
11054
+ ns.browsing = true;
11055
+ closeFolderList();
11056
+ const btn = document.getElementById('ns-browse');
11057
+ btn.textContent = 'Opening…';
11058
+ renderNewSessionForm();
11059
+ try {
11060
+ const start = ns.folder || ns.picked[0] || ns.projects[0];
11061
+ const res = await terminalFetch('/api/terminal/pick-folder', 'POST', start ? { start } : null);
11062
+ const body = await res.json().catch(() => ({}));
11063
+ if (res.status === 401) setNewSessionError(STALE_TOKEN_MSG);
11064
+ else if (!res.ok) setNewSessionError(body.error || `Folder dialog failed (${res.status})`);
11065
+ else if (body.path) {
11066
+ ns.picked = [body.path, ...ns.picked.filter((p) => p !== body.path)];
11067
+ setNewSessionFolder(body.path);
11068
+ }
11069
+ } catch (e) {
11070
+ setNewSessionError(e.message);
11071
+ } finally {
11072
+ ns.browsing = false;
11073
+ btn.textContent = 'Browse…';
11074
+ renderNewSessionForm();
11075
+ document.getElementById(ns.folder ? fieldAfterFolder() : 'ns-folder').focus();
11076
+ }
11077
+ }
11078
+
11079
+ function fieldAfterFolder() {
11080
+ return ns.resume ? 'ns-start' : 'ns-name';
11081
+ }
11082
+
11083
+ function startNewSession() {
11084
+ const v = newSessionValues();
11085
+ if (!v.cwd || newSessionProblem(v) || ns.browsing) return;
11086
+ const id = crypto.randomUUID();
11087
+ newSpecs.set(id, { ...(ns.resume ? { cwd: v.cwd } : v), mode: ns.resume ? 'pick' : 'new', startedAt: Date.now() });
11088
+ sessions = mergePlaceholders(sessions.filter((s) => !s.placeholder));
11089
+ setTerminalMode(id, true);
11090
+ closeNewSession();
11091
+ fetchTasks(id).then(() => sessionsList.querySelector('.session-item.active')?.scrollIntoView({ block: 'nearest' }));
11092
+ }
11093
+
11094
+ const SpeechRecognitionApi = window.SpeechRecognition || window.webkitSpeechRecognition;
11095
+ const promptVoice = { rec: null };
11096
+
11097
+ // Replaces the selection the field had when dictation began, so interim results rewrite
11098
+ // themselves in place instead of piling up.
11099
+ function togglePromptVoice() {
11100
+ if (promptVoice.rec) return promptVoice.rec.stop();
11101
+ const field = document.getElementById('ns-prompt');
11102
+ const btn = document.getElementById('ns-mic');
11103
+ const before = field.value.slice(0, field.selectionStart);
11104
+ const after = field.value.slice(field.selectionEnd);
11105
+ const lead = before && !/\s$/.test(before) ? ' ' : '';
11106
+ const rec = new SpeechRecognitionApi();
11107
+ rec.lang = navigator.language;
11108
+ rec.interimResults = true;
11109
+ rec.onresult = (e) => {
11110
+ const text = Array.from(e.results, (r) => r[0].transcript).join('');
11111
+ field.value = before + lead + text + after;
11112
+ const caret = before.length + lead.length + text.length;
11113
+ field.setSelectionRange(caret, caret);
11114
+ };
11115
+ rec.onerror = (e) => {
11116
+ if (e.error === 'not-allowed' || e.error === 'service-not-allowed')
11117
+ setNewSessionError('Microphone access is blocked for this page.');
11118
+ else if (e.error !== 'no-speech' && e.error !== 'aborted') setNewSessionError(`Dictation failed: ${e.error}`);
11119
+ };
11120
+ rec.onend = () => {
11121
+ promptVoice.rec = null;
11122
+ btn.setAttribute('aria-pressed', 'false');
11123
+ };
11124
+ promptVoice.rec = rec;
11125
+ btn.setAttribute('aria-pressed', 'true');
11126
+ field.focus();
11127
+ rec.start();
11128
+ }
11129
+
11130
+ function pickFolderOption(i) {
11131
+ const path = ns.matches[i];
11132
+ if (!path) return;
11133
+ setNewSessionFolder(path);
11134
+ document.getElementById(fieldAfterFolder()).focus();
11135
+ }
11136
+
11137
+ function initNewSession() {
11138
+ const modal = document.getElementById('new-session-modal');
11139
+ const input = document.getElementById('ns-folder');
11140
+ const list = document.getElementById('ns-folder-list');
11141
+ input.addEventListener('input', () => {
11142
+ setNewSessionFolder(input.value, true);
11143
+ if (ns.folder) return closeFolderList();
11144
+ ns.idx = 0;
11145
+ renderFolderList();
11146
+ });
11147
+ input.addEventListener('focus', () => {
11148
+ if (!ns.folder) renderFolderList();
11149
+ });
11150
+ input.addEventListener('click', () => {
11151
+ if (!folderListOpen()) renderFolderList();
11152
+ });
11153
+ input.addEventListener('blur', () =>
11154
+ setTimeout(() => {
11155
+ if (!list.contains(document.activeElement)) closeFolderList();
11156
+ }, 0),
11157
+ );
11158
+ // mousedown, not click: the input's blur would close the list before a click lands.
11159
+ list.addEventListener('mousedown', (e) => {
11160
+ e.preventDefault();
11161
+ if (e.target.closest('[data-browse]')) return browseNewSessionFolder();
11162
+ const row = e.target.closest('.ns-option');
11163
+ if (row) pickFolderOption(Number(row.dataset.idx));
11164
+ });
11165
+ document.getElementById('ns-wt').addEventListener('change', (e) => {
11166
+ renderNewSessionForm();
11167
+ if (e.target.checked) document.getElementById('ns-wt-name').focus();
11168
+ });
11169
+ for (const id of ['ns-name', 'ns-wt-name', 'ns-prompt', 'ns-model']) {
11170
+ document.getElementById(id).addEventListener('input', renderNewSessionForm);
11171
+ }
11172
+ if (SpeechRecognitionApi) {
11173
+ const mic = document.getElementById('ns-mic');
11174
+ mic.hidden = false;
11175
+ // mousedown would blur the textarea and lose the caret the dictation inserts at.
11176
+ mic.addEventListener('mousedown', (e) => e.preventDefault());
11177
+ mic.addEventListener('click', togglePromptVoice);
11178
+ }
11179
+ // Bound on the dialog: the global handler returns early on INPUT and TEXTAREA targets.
11180
+ modal.addEventListener('keydown', (e) => {
11181
+ const inFolder = e.target === input;
11182
+ if (e.key === 'Escape') {
11183
+ if (folderListOpen()) closeFolderList();
11184
+ else closeNewSession();
11185
+ } else if (inFolder && matchKey(e, 'ArrowDown')) {
11186
+ if (!folderListOpen()) renderFolderList();
11187
+ else moveFolderHighlight(1);
11188
+ } else if (inFolder && matchKey(e, 'ArrowUp') && folderListOpen()) {
11189
+ moveFolderHighlight(-1);
11190
+ } else if (inFolder && e.key === 'Enter' && folderListOpen() && ns.idx >= 0 && ns.matches[ns.idx] !== ns.folder) {
11191
+ pickFolderOption(ns.idx);
11192
+ } else if (e.key === 'Enter' && (e.target.tagName !== 'TEXTAREA' || e.ctrlKey || e.metaKey)) {
11193
+ if (e.target.tagName === 'BUTTON') return;
11194
+ startNewSession();
11195
+ } else return;
11196
+ e.preventDefault();
11197
+ e.stopPropagation();
11198
+ });
11199
+ }
11200
+
11201
+ initNewSession();
11202
+ //#endregion
11203
+
9186
11204
  //#region PWA
9187
11205
  if ('serviceWorker' in navigator) {
9188
11206
  navigator.serviceWorker.register('/sw.js');
@@ -9391,6 +11409,11 @@ if (urlState.search) {
9391
11409
  document.getElementById('search-clear-btn').classList.add('visible');
9392
11410
  }
9393
11411
 
11412
+ {
11413
+ const verbs = ['Clawding…', 'Shuffling cards…', 'Herding tasks…', 'Waking sessions…', 'Sorting the board…'];
11414
+ document.getElementById('boot-verb').textContent = pickRandom(verbs);
11415
+ }
11416
+
9394
11417
  Promise.all([
9395
11418
  fetch('/hub-config')
9396
11419
  .then((r) => r.json())
@@ -9406,6 +11429,7 @@ Promise.all([
9406
11429
  })
9407
11430
  .catch(() => {}),
9408
11431
  ])
11432
+ .then(restorePendingSessions)
9409
11433
  .then(() => fetchSessions())
9410
11434
  .then(async () => {
9411
11435
  if (urlState.projectView) {
@@ -9439,6 +11463,10 @@ Promise.all([
9439
11463
  if (currentMessages.length) renderMessages(currentMessages);
9440
11464
  });
9441
11465
  }
11466
+ })
11467
+ .finally(() => {
11468
+ document.body.classList.remove('booting');
11469
+ sessionsList.removeAttribute('aria-busy');
9442
11470
  });
9443
11471
 
9444
11472
  window.addEventListener('popstate', () => {
@@ -9465,23 +11493,31 @@ window.addEventListener('popstate', () => {
9465
11493
  //#endregion
9466
11494
 
9467
11495
  // #region HUB_INTEGRATION
9468
- document.addEventListener('keydown', (e) => {
9469
- if (!window.__HUB__?.enabled) return;
9470
- const fwd = (key) => {
9471
- e.preventDefault();
9472
- hubPost({ type: 'hub:keydown', key, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey });
9473
- };
9474
- if (e.ctrlKey && e.altKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
9475
- fwd(e.key);
9476
- }
11496
+ // e.code travels with e.key because macOS composes Option+<key> into a character (Option+P is
11497
+ // 'π'), so the key alone cannot identify the binding. The hub owns the keymap and normalizes;
11498
+ // these tests only decide whether a press is the hub's to handle.
11499
+ function isHubKey(e) {
11500
+ if (!window.__HUB__?.enabled) return false;
11501
+ if (e.ctrlKey && e.altKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) return true;
9477
11502
  // Own branch: the Alt+digit case below requires !ctrlKey. The hub owns the Ctrl+Alt+letter
9478
11503
  // 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);
9481
- }
9482
- if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey && /^[1-9]$/.test(e.key)) {
9483
- fwd(e.key);
11504
+ if (
11505
+ e.ctrlKey &&
11506
+ e.altKey &&
11507
+ !e.shiftKey &&
11508
+ !e.metaKey &&
11509
+ !CCK_CTRL_ALT_KEYS.has(e.code) &&
11510
+ (/^[a-z]$/i.test(e.key) || /^Key[A-Z]$/.test(e.code))
11511
+ ) {
11512
+ return true;
9484
11513
  }
11514
+ return e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey && (/^[1-9]$/.test(e.key) || /^Digit[1-9]$/.test(e.code));
11515
+ }
11516
+
11517
+ document.addEventListener('keydown', (e) => {
11518
+ if (!isHubKey(e)) return;
11519
+ e.preventDefault();
11520
+ hubPost({ type: 'hub:keydown', key: e.key, code: e.code, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey });
9485
11521
  });
9486
11522
 
9487
11523
  document.addEventListener('click', (e) => {