claude-code-kanban 4.27.0 → 4.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/app.js CHANGED
@@ -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';
@@ -230,7 +247,7 @@ async function fetchSessions(includeTasks = true) {
230
247
  }
231
248
  lastSessionsHash = sessionsHash;
232
249
 
233
- sessions = newSessions;
250
+ sessions = mergePlaceholders(newSessions);
234
251
  renderSessions();
235
252
  renderActivityChip();
236
253
  } catch (error) {
@@ -448,6 +465,10 @@ async function fetchTasks(sessionId) {
448
465
  revealedStorageSessionId = null;
449
466
  }
450
467
  if (currentSessionId && currentSessionId !== sessionId) deferredPinPlacement.delete(currentSessionId);
468
+ if (lastSessionId !== sessionId) {
469
+ previousSessionId = lastSessionId;
470
+ lastSessionId = sessionId;
471
+ }
451
472
  currentSessionId = sessionId;
452
473
  currentPins = loadPins(sessionId);
453
474
  ownerFilter = '';
@@ -614,6 +635,7 @@ async function refreshProjectAgents() {
614
635
  function toggleMessagePanel() {
615
636
  const panel = document.getElementById('message-panel');
616
637
  messagePanelOpen = !messagePanelOpen;
638
+ termState.panelHidden = false;
617
639
  store.setItem('message-panel-open', messagePanelOpen);
618
640
  panel.classList.toggle('visible', messagePanelOpen);
619
641
  document.getElementById('message-toggle')?.classList.toggle('active', messagePanelOpen);
@@ -1503,6 +1525,8 @@ const ICON_AGENT_WAITING =
1503
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>';
1504
1526
  const ICON_AGENT_ACTIVE =
1505
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>';
1506
1530
  const ICON_CHAT =
1507
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>';
1508
1532
  const TOOL_ICONS = {
@@ -2558,6 +2582,17 @@ function sanitizeOutputHtml(text) {
2558
2582
  return typeof text === 'string' ? ansiToHtml(stripLineNumbers(text)) : '';
2559
2583
  }
2560
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
+
2561
2596
  function highlightBash(escaped) {
2562
2597
  return escaped
2563
2598
  .replace(/^(\s*)(#.*)$/gm, '$1<span style="color:#6a9955">$2</span>')
@@ -2623,11 +2658,11 @@ function autoSizeModal(modal, body) {
2623
2658
 
2624
2659
  function renderToolResultHtml(toolResult, isTruncated, fullResult, toolUseId) {
2625
2660
  if (!toolResult) return '';
2626
- const escaped = sanitizeOutputHtml(toolResult);
2661
+ const escaped = toolOutputHtml(toolResult);
2627
2662
  let truncLabel = '',
2628
2663
  fullBlock = '';
2629
2664
  if (isTruncated && fullResult) {
2630
- const toggle = makeExpandToggle(escaped, sanitizeOutputHtml(fullResult));
2665
+ const toggle = makeExpandToggle(escaped, toolOutputHtml(fullResult));
2631
2666
  truncLabel = toggle.btn;
2632
2667
  fullBlock = toggle.full;
2633
2668
  } else if (isTruncated && toolUseId) {
@@ -2669,7 +2704,7 @@ async function _toggleToolResultExpand(btn) {
2669
2704
  );
2670
2705
  if (!r.ok) throw new Error(`HTTP ${r.status}`);
2671
2706
  const { content } = await r.json();
2672
- f.innerHTML = sanitizeOutputHtml(content);
2707
+ f.innerHTML = toolOutputHtml(content);
2673
2708
  btn.dataset.loaded = '1';
2674
2709
  } catch (_e) {
2675
2710
  btn.textContent = 'Show more';
@@ -2860,9 +2895,10 @@ function renderAgentFooter() {
2860
2895
 
2861
2896
  clearInterval(agentDurationInterval);
2862
2897
  if (visible.some((a) => a.status === 'active' || a.status === 'idle')) {
2863
- agentDurationInterval = setInterval(() => renderAgentFooter(), 1000);
2898
+ agentDurationInterval = setInterval(() => isOnScreen() && renderAgentFooter(), 1000);
2864
2899
  if (!agentPollInterval) {
2865
2900
  agentPollInterval = setInterval(() => {
2901
+ if (skipOffScreen()) return;
2866
2902
  if (viewMode === 'project' && currentProjectPath) {
2867
2903
  refreshProjectAgents();
2868
2904
  } else if (currentSessionId) {
@@ -2871,7 +2907,7 @@ function renderAgentFooter() {
2871
2907
  }, 3000);
2872
2908
  }
2873
2909
  } else {
2874
- agentDurationInterval = setInterval(() => renderAgentFooter(), 10000);
2910
+ agentDurationInterval = setInterval(() => isOnScreen() && renderAgentFooter(), 10000);
2875
2911
  clearInterval(agentPollInterval);
2876
2912
  agentPollInterval = null;
2877
2913
  }
@@ -3130,11 +3166,12 @@ function getFilteredSessions() {
3130
3166
  filteredSessions = filteredSessions.filter((s) => {
3131
3167
  if (dismissedSessionIds.has(s.id)) return false;
3132
3168
  const isActive =
3133
- s.hasMessages &&
3134
- ((!s.sharedTaskList && (s.pending > 0 || s.inProgress > 0)) ||
3135
- s.hasActiveAgents ||
3136
- s.hasWaitingForUser ||
3137
- 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));
3138
3175
  if (isActive) activeSessionIds.add(s.id);
3139
3176
  return isActive;
3140
3177
  });
@@ -3262,6 +3299,7 @@ function renderSessions() {
3262
3299
 
3263
3300
  // Helper to render a single session card
3264
3301
  const renderSessionCard = (session) => {
3302
+ if (session.placeholder) return renderPlaceholderCard(session);
3265
3303
  const total = session.taskCount;
3266
3304
  const percent = total > 0 ? Math.round((session.completed / total) * 100) : 0;
3267
3305
  const isActive = session.id === currentSessionId && viewMode === 'session';
@@ -3290,11 +3328,11 @@ function renderSessions() {
3290
3328
  const showCtx = !!session.contextStatus && !zenMode;
3291
3329
  const linkedDocsCount = getSessionPreviewPaths(session.id).length;
3292
3330
  const bookmarksCount = loadPins(session.id).length;
3293
- const hasScratchpad = !!(store.getItem(_sessionScratchpadKey(session.id)) || '').trim();
3331
+ const hasScratchpad = _hasScratchpad(_sessionScratchpadKey(session.id));
3294
3332
  const tempClass = session.hasRecentLog || session.inProgress || session.hasWaitingForUser ? 'warm' : 'stale';
3295
3333
  const sid = escAttrJs(session.id);
3296
3334
  return `
3297
- <button onclick="fetchTasks('${sid}')" draggable="true" data-session-id="${escapeHtml(session.id)}" class="session-item ${isActive ? 'active' : ''} ${session.hasWaitingForUser ? 'permission-pending' : ''} ${tempClass} ${showCtx ? 'has-context' : ''}" title="${escapeHtml(tooltip)}">
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)}">
3298
3336
  <span class="session-pin-btn${pinClass}" onclick="event.stopPropagation();toggleSessionPin('${sid}')" title="${pinTitle} session">${pinState === 'sticky' ? SESSION_STAR_SVG : SESSION_PIN_SVG}</span>
3299
3337
  <div class="session-name">${escapeHtml(sessionName)}</div>
3300
3338
  ${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
@@ -3313,6 +3351,7 @@ function renderSessions() {
3313
3351
  ${session.hasPlan && !session.planSourceSessionId ? `<span class="plan-indicator" onclick="event.stopPropagation(); openPlanForSession('${sid}')" title="View plan">${ICON_PLAN}</span>` : ''}
3314
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>` : ''}
3315
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>` : ''}
3316
3355
  ${session.hasWaitingForUser ? `<span class="agent-badge agent-badge-waiting" title="Waiting for user">${ICON_AGENT_WAITING}</span>` : ''}
3317
3356
  ${session.hasRunningAgents && !session.hasWaitingForUser ? `<span class="agent-badge agent-badge-active" title="Agents running">${ICON_AGENT_ACTIVE}</span>` : ''}
3318
3357
  ${isLive || session.hasRunningAgents ? `<span class="pulse" title="${isLive ? 'Live' : 'Active agents'}"></span>` : ''}
@@ -3331,7 +3370,7 @@ function renderSessions() {
3331
3370
  if (zenMode) {
3332
3371
  sessionsList.innerHTML = `${renderSessionCard(zenSession)}
3333
3372
  <div class="zen-panel">
3334
- ${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>'}
3335
3374
  ${renderScratchpadRow(zenSession)}
3336
3375
  ${renderLinkedDocsHtml(zenSession.id)}
3337
3376
  ${renderArtifactsHtml(zenSession.id)}
@@ -3411,10 +3450,12 @@ function renderSessions() {
3411
3450
  <div class="project-group-header${isCollapsed ? ' collapsed' : ''}${nestedCls}" draggable="true" data-group-path="${escapedPath}" data-project-path="${escapedPath}">
3412
3451
  ${groupChevronSvg()}
3413
3452
  <span class="group-name">${escapeHtml(folderName)}</span>
3414
- ${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)}
3415
3455
  <span class="project-view-btn" data-project-path="${escapedPath}" title="Open project view — combined tasks from all sessions">
3416
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>
3417
3457
  </span>
3458
+ ${countHtml(projectSessions)}
3418
3459
  </div>
3419
3460
  <div class="project-group-breadcrumb${nestedCls}" data-full-path="${escapedPath}" title="Click to copy path">${breadcrumbHtml}</div>
3420
3461
  <div class="project-group-sessions${isCollapsed ? ' collapsed' : ''}${nestedCls}" data-project-path="${escapedPath}">
@@ -3426,28 +3467,27 @@ function renderSessions() {
3426
3467
  // Named groups are a pure partition of the already-filtered list: whatever they don't claim
3427
3468
  // falls through to the project blocks below, which look exactly as they did before groups.
3428
3469
  const sgBuckets = new Map();
3429
- let sgRest = filteredSessions;
3430
- if (sessionGroups.length > 0) {
3431
- sgRest = [];
3432
- for (const session of filteredSessions) {
3433
- const group = sgGroupForSession(session);
3434
- if (group) {
3435
- if (!sgBuckets.has(group.id)) sgBuckets.set(group.id, []);
3436
- sgBuckets.get(group.id).push(session);
3437
- } else {
3438
- sgRest.push(session);
3439
- }
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);
3440
3478
  }
3441
3479
  }
3480
+ for (const [name, g] of transientGroups) if (!sgBuckets.has(g.id)) transientGroups.delete(name);
3442
3481
  // A group emptied by a filter hides its header; a group the user just created (no filters)
3443
3482
  // shows its drop-here state instead of vanishing.
3444
3483
  const sgFiltering = !!searchQuery || activityFilter.size > 0 || (!!filterProject && filterProject !== '__recent__');
3445
3484
 
3446
3485
  // flat = the "All sessions" view, which has no project sub-blocks.
3447
3486
  const sgSectionHtml = (flat) => {
3448
- if (sessionGroups.length === 0) return '';
3487
+ const transient = [...transientGroups.values()];
3488
+ if (sessionGroups.length === 0 && transient.length === 0) return '';
3449
3489
  let out = '';
3450
- for (const group of sessionGroups) {
3490
+ for (const group of [...sessionGroups, ...transient]) {
3451
3491
  const bucket = sgBuckets.get(group.id) || [];
3452
3492
  if (bucket.length === 0 && sgFiltering) continue;
3453
3493
  if (!groupPinned) bucket.sort(pinSort);
@@ -3455,6 +3495,17 @@ function renderSessions() {
3455
3495
  let body = '';
3456
3496
  if (flat) {
3457
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
+ }
3458
3509
  } else {
3459
3510
  const loose = [];
3460
3511
  const byProject = new Map();
@@ -3486,8 +3537,9 @@ function renderSessions() {
3486
3537
  }
3487
3538
  }
3488
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)}"`;
3489
3541
  out += sgHeaderHtml(group, countHtml(bucket));
3490
- 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>`;
3491
3543
  }
3492
3544
  if (!out) return '';
3493
3545
  return sectionHtml(SECTION_GROUPS, 'Groups', false, out);
@@ -3541,7 +3593,7 @@ function renderSessions() {
3541
3593
  // The section header is rendered even when nothing is left under it: it is the drop target
3542
3594
  // for ungrouping. Its body is skipped while collapsed — nothing would be visible, and this is
3543
3595
  // the whole project list, re-parsed on every live refresh.
3544
- const sectioned = sessionGroups.length > 0;
3596
+ const sectioned = sessionGroups.length > 0 || transientGroups.size > 0;
3545
3597
  let projectsHtml = '';
3546
3598
  if (!sectioned || !collapsedProjectGroups.has(SECTION_PROJECTS)) {
3547
3599
  for (const [projectPath, projectSessions] of sortedGroups) {
@@ -3569,7 +3621,7 @@ function renderSessions() {
3569
3621
 
3570
3622
  sessionsList.innerHTML = html;
3571
3623
  } else {
3572
- const sectioned = sessionGroups.length > 0;
3624
+ const sectioned = sessionGroups.length > 0 || transientGroups.size > 0;
3573
3625
  const restHtml =
3574
3626
  sectioned && collapsedProjectGroups.has(SECTION_SESSIONS) ? '' : sgRest.map(renderSessionCard).join('');
3575
3627
  const tail = sectioned ? sectionHtml(SECTION_SESSIONS, 'Sessions', true, restHtml) : restHtml;
@@ -4000,7 +4052,7 @@ async function onColumnDrop(e) {
4000
4052
  // windows the list), so membership is never garbage-collected on load.
4001
4053
  const SESSION_GROUPS_KEY = 'sessionGroups';
4002
4054
  const SG_ACTION_SELECTOR =
4003
- '.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';
4004
4056
 
4005
4057
  // Collapse state shares the existing `collapsedGroups` key; this namespace keeps it from
4006
4058
  // colliding with a project path or the __ungrouped__ / __pinned_* sentinels.
@@ -4035,14 +4087,17 @@ function loadSessionGroups() {
4035
4087
  return out;
4036
4088
  }),
4037
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') : []);
4038
4092
  } catch (_) {
4039
4093
  sessionGroups = [];
4094
+ sgReleased = new Set();
4040
4095
  }
4041
4096
  }
4042
4097
 
4043
4098
  function persistSessionGroups() {
4044
4099
  try {
4045
- 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] }));
4046
4101
  } catch (_) {}
4047
4102
  }
4048
4103
 
@@ -4056,9 +4111,51 @@ function sgGroupOf(type, ref) {
4056
4111
 
4057
4112
  // An individually placed session wins over the placement of its project.
4058
4113
  function sgGroupForSession(session) {
4114
+ return sgUserGroupFor(session) || sgDispatchGroupFor(session);
4115
+ }
4116
+
4117
+ function sgUserGroupFor(session) {
4059
4118
  return sgGroupOf('session', session.id) || (session.project ? sgGroupOf('project', session.project) : null);
4060
4119
  }
4061
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
+
4062
4159
  function sgDetach(type, ref) {
4063
4160
  for (const g of sessionGroups) {
4064
4161
  g.members = g.members.filter((m) => !(m.type === type && m.ref === ref));
@@ -4155,19 +4252,35 @@ function sgSearchMatchIds(query) {
4155
4252
  return ids;
4156
4253
  }
4157
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
+
4158
4263
  function sgHeaderHtml(group, countHtml) {
4159
4264
  const collapsed = collapsedProjectGroups.has(sgKey(group.id));
4160
4265
  const editing = sgEditingId === group.id;
4161
4266
  const nameHtml = editing
4162
4267
  ? `<input class="sg-name-input" type="text" value="${escapeHtml(group.name)}" aria-label="Group name" spellcheck="false">`
4163
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>`;
4164
4278
  return `
4165
- <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))}">
4166
4280
  ${groupChevronSvg()}
4167
4281
  ${nameHtml}
4282
+ ${actions}
4168
4283
  ${countHtml}
4169
- <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>
4170
- <span class="sg-action sg-delete" title="Delete group (members return to Projects)">&times;</span>
4171
4284
  </div>`;
4172
4285
  }
4173
4286
 
@@ -4244,8 +4357,9 @@ function sgDropZone(target) {
4244
4357
  ? { zone, groupId: zone.dataset.groupId }
4245
4358
  : null;
4246
4359
  }
4247
- if (zone.classList.contains('sg-ungroup-zone')) return dragGroup ? { zone } : null;
4248
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;
4249
4363
  const fromGroup = dragSession ? sgGroupForSession(dragSession) : dragGroup;
4250
4364
  const fromGroupId = fromGroup?.id || null;
4251
4365
  // A drop that lands on the host the session already has changes nothing, and highlighting it
@@ -4264,7 +4378,8 @@ function sgDropZone(target) {
4264
4378
  const targetSession = sessions.find((s) => s.id === targetSessionId);
4265
4379
  const targetGroup = targetSession ? sgGroupForSession(targetSession) : null;
4266
4380
  const targetGroupId = targetGroup?.id || null;
4267
- if (targetGroup) {
4381
+ // A transient group holds no members to reorder or place under; a drop there pairs instead.
4382
+ if (targetGroup && !targetGroup.transient) {
4268
4383
  // Stacking onto a card adopts that card's placement, project block included.
4269
4384
  const targetHost = sgHostOf(targetGroup, targetSession);
4270
4385
  if (targetHost) {
@@ -4295,7 +4410,7 @@ function sgDropZone(target) {
4295
4410
  return fromHost === path && fromGroupId === pathGroup.id ? null : { zone, groupId: pathGroup.id, under: path };
4296
4411
  }
4297
4412
  // Stacking a session onto its own project block would only fold it back where it sits today.
4298
- if (dragSession?.project === path) return null;
4413
+ if (dragSession?.project === path) return releasable ? { zone } : null;
4299
4414
  return { zone, pairWith: path };
4300
4415
  }
4301
4416
 
@@ -4356,6 +4471,7 @@ function sgOnDrop(e) {
4356
4471
  else sgAssign(hit.groupId, drag.kind, drag.ref, { under: hit.under, loose: hit.loose });
4357
4472
  } else {
4358
4473
  sgDetach(drag.kind, drag.ref);
4474
+ if (drag.kind === 'session') sgReleased.add(drag.ref);
4359
4475
  persistSessionGroups();
4360
4476
  }
4361
4477
  sgFinishDrag();
@@ -5258,6 +5374,7 @@ const SHORTCUT_PAIRS = [
5258
5374
  { keys: ['Shift', 'L'], combo: true, label: 'Toggle session log' },
5259
5375
  { keys: ['Shift', 'M'], combo: true, label: 'Open last message' },
5260
5376
  { keys: ['J', 'K'], label: 'Previous / next message in detail' },
5377
+ { keys: ['Ctrl', 'Enter'], combo: true, label: 'Allow / approve the waiting prompt' },
5261
5378
  ],
5262
5379
  },
5263
5380
  ],
@@ -5267,7 +5384,7 @@ const SHORTCUT_PAIRS = [
5267
5384
  rows: [
5268
5385
  { keys: ['Enter'], label: 'Toggle task detail panel' },
5269
5386
  { keys: ['D'], label: 'Delete selected task' },
5270
- { keys: ['N'], label: 'Toggle scratchpad' },
5387
+ { keys: ['N'], label: 'Toggle scratchpad (in the sidebar: the item under the cursor)' },
5271
5388
  { keys: ['R'], label: 'Refresh data' },
5272
5389
  { keys: ['Esc'], label: 'Close panel / clear selection' },
5273
5390
  ],
@@ -5307,6 +5424,25 @@ const SHORTCUT_PAIRS = [
5307
5424
  ],
5308
5425
  },
5309
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
+ ],
5310
5446
  ];
5311
5447
 
5312
5448
  const EMPTY_GROUP = { title: '', rows: [] };
@@ -5375,18 +5511,52 @@ const _scratchpadCharcount = document.getElementById('scratchpad-charcount');
5375
5511
 
5376
5512
  let _scratchpadKeyOverride = null;
5377
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
+
5378
5518
  function _sessionScratchpadKey(sessionId) {
5379
- return `scratchpad-${sessionId}`;
5519
+ return SESSION_PAD_PREFIX + sessionId;
5520
+ }
5521
+
5522
+ function _projectScratchpadKey(projectPath) {
5523
+ return PROJECT_PAD_PREFIX + projectPath;
5380
5524
  }
5381
5525
 
5382
- function _isSessionScratchpadKey(key) {
5383
- return key.startsWith('scratchpad-') && !key.startsWith('scratchpad-project:');
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;
5540
+ }
5541
+
5542
+ function _hasScratchpad(key) {
5543
+ return !!(store.getItem(key) || '').trim();
5384
5544
  }
5385
5545
 
5386
5546
  function _scratchpadKey() {
5387
5547
  if (_scratchpadKeyOverride) return _scratchpadKeyOverride;
5388
5548
  if (currentSessionId) return _sessionScratchpadKey(currentSessionId);
5389
- 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
+ }
5390
5560
  return null;
5391
5561
  }
5392
5562
 
@@ -5427,14 +5597,14 @@ function saveScratchpad() {
5427
5597
  const key = _scratchpadKey();
5428
5598
  if (!key) return;
5429
5599
  const val = _scratchpadTextarea.value;
5430
- const had = !!(store.getItem(key) || '').trim();
5600
+ const had = _hasScratchpad(key);
5431
5601
  const has = !!val.trim();
5432
5602
  if (has) {
5433
5603
  store.setItem(key, val);
5434
5604
  } else {
5435
5605
  store.removeItem(key);
5436
5606
  }
5437
- if (had !== has && _isSessionScratchpadKey(key)) {
5607
+ if (had !== has) {
5438
5608
  renderSessions();
5439
5609
  }
5440
5610
  }
@@ -5448,6 +5618,30 @@ _scratchpadTextarea.addEventListener('input', () => {
5448
5618
  }, 500);
5449
5619
  });
5450
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
+
5451
5645
  //#endregion
5452
5646
 
5453
5647
  //#region STORAGE_MANAGER
@@ -5664,30 +5858,38 @@ function _storageUnpinMessage(sessionId, pinId) {
5664
5858
  function _renderStorageScratchpads() {
5665
5859
  const allItems = [];
5666
5860
  for (const key of store.keys()) {
5667
- if (!key.startsWith('scratchpad-')) continue;
5668
- const val = store.getItem(key) || '';
5669
- const isProject = key.startsWith('scratchpad-project:');
5670
- const id = isProject ? key.slice('scratchpad-project:'.length) : key.slice('scratchpad-'.length);
5671
- 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 });
5672
5864
  }
5673
5865
  if (!allItems.length) return '<div class="storage-empty">No scratchpads</div>';
5674
5866
 
5675
- const projectItems = allItems.filter((i) => i.isProject);
5676
- 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');
5677
5870
  const sessionIds = sessionItems.map((i) => i.id);
5678
5871
  const { groups: projectGroups, orphans } = _groupByProject(sessionIds);
5679
5872
  const scratchBySession = new Map(sessionItems.map((i) => [i.id, i]));
5680
5873
 
5681
5874
  function renderScratchItem(item) {
5682
- const session = !item.isProject ? sessions.find((s) => s.id === item.id) : null;
5683
- const typeBadge = item.isProject
5684
- ? '<span class="storage-item-badge">project</span>'
5685
- : '<span class="storage-item-badge">session</span>';
5686
5875
  const jsKey = escAttrJs(item.key);
5687
- 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
+ }
5688
5890
  return `<div class="storage-item">
5689
5891
  <span class="storage-item-id" title="${escapeHtml(item.id)}">${label}</span>
5690
- ${typeBadge}
5892
+ <span class="storage-item-badge">${item.kind}</span>
5691
5893
  <span class="storage-item-meta">${item.chars} chars</span>
5692
5894
  <div class="storage-item-actions">
5693
5895
  <button onclick="_storagePreviewScratchpad('${jsKey}')">View</button>
@@ -5698,6 +5900,14 @@ function _renderStorageScratchpads() {
5698
5900
 
5699
5901
  let html = '';
5700
5902
 
5903
+ if (groupItems.length) {
5904
+ html += _renderProjectGroup(
5905
+ 'Group Scratchpads',
5906
+ `${groupItems.length}`,
5907
+ groupItems.map(renderScratchItem).join(''),
5908
+ );
5909
+ }
5910
+
5701
5911
  if (projectItems.length) {
5702
5912
  html += _renderProjectGroup(
5703
5913
  'Project Scratchpads',
@@ -5831,10 +6041,13 @@ function _findOrphanedKeys() {
5831
6041
  for (const id of pinnedSessionIds) if (!known.has(id)) orphaned.push(`__pinned__${id}`);
5832
6042
  for (const id of stickySessionIds) if (!known.has(id)) orphaned.push(`__sticky__${id}`);
5833
6043
  for (const key of store.keys()) {
5834
- 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-')) {
5835
6048
  if (!known.has(key.slice('pinned-messages-'.length))) orphaned.push(key);
5836
- } else if (_isSessionScratchpadKey(key)) {
5837
- 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);
5838
6051
  } else if (key.startsWith(PREVIEW_STORAGE_PREFIX)) {
5839
6052
  if (!known.has(key.slice(PREVIEW_STORAGE_PREFIX.length))) orphaned.push(key);
5840
6053
  } else if (key.startsWith(PAD_LINKED_PREFIX)) {
@@ -5909,6 +6122,8 @@ const MODAL_CLOSERS = {
5909
6122
  'agent-modal': () => closeAgentModal(),
5910
6123
  'help-modal': () => closeHelpModal(),
5911
6124
  'session-picker-modal': () => closeSessionPicker(),
6125
+ 'terminal-manager-modal': () => closeTerminalManager(),
6126
+ 'new-session-modal': () => closeNewSession(),
5912
6127
  };
5913
6128
 
5914
6129
  document.addEventListener('keydown', (e) => {
@@ -5924,6 +6139,35 @@ document.addEventListener('keydown', (e) => {
5924
6139
  }
5925
6140
  }
5926
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
+
5927
6171
  if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') {
5928
6172
  return;
5929
6173
  }
@@ -5953,7 +6197,16 @@ document.addEventListener('keydown', (e) => {
5953
6197
  e.preventDefault();
5954
6198
  closeMsgDetailModal();
5955
6199
  } else if (document.getElementById('msg-detail-modal').classList.contains('visible')) {
5956
- 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')) {
5957
6210
  e.preventDefault();
5958
6211
  if (currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX) {
5959
6212
  msgDetailFollowLatest = true;
@@ -6037,6 +6290,12 @@ document.addEventListener('keydown', (e) => {
6037
6290
  // Tab toggles focus zone
6038
6291
  if (e.key === 'Tab') {
6039
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
+ }
6040
6299
  if (focusZone === 'sidebar') {
6041
6300
  const hasCards = document.querySelector('.task-card');
6042
6301
  if (!hasCards) return;
@@ -6135,10 +6394,9 @@ document.addEventListener('keydown', (e) => {
6135
6394
  }
6136
6395
 
6137
6396
  // Shared actions — work in both sidebar and board
6397
+ const cursorEl = focusZone === 'sidebar' ? sessionsList.querySelector('.kb-selected') : null;
6138
6398
  const contextSid =
6139
- focusZone === 'sidebar'
6140
- ? sessionsList.querySelector('.kb-selected')?.dataset.sessionId || currentSessionId
6141
- : selectedSessionId || currentSessionId;
6399
+ focusZone === 'sidebar' ? cursorEl?.dataset.sessionId || currentSessionId : selectedSessionId || currentSessionId;
6142
6400
  if (matchKey(e, 'KeyP') && !e.shiftKey) {
6143
6401
  e.preventDefault();
6144
6402
  if (contextSid) openPlanForSession(contextSid);
@@ -6151,7 +6409,9 @@ document.addEventListener('keydown', (e) => {
6151
6409
  }
6152
6410
  if (matchKey(e, 'KeyN') && !e.shiftKey) {
6153
6411
  e.preventDefault();
6154
- toggleScratchpad();
6412
+ const cursorKey = _scratchpadKeyOf(cursorEl);
6413
+ if (cursorKey) showScratchpad(cursorKey);
6414
+ else toggleScratchpad();
6155
6415
  return;
6156
6416
  }
6157
6417
  if (e.key === '$' && !e.ctrlKey && !e.altKey && !e.metaKey) {
@@ -7057,6 +7317,24 @@ function setupEventSource() {
7057
7317
  failCount = 0;
7058
7318
  }
7059
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
+
7060
7338
  function connect() {
7061
7339
  eventSource = new EventSource('/api/events');
7062
7340
 
@@ -7065,6 +7343,10 @@ function setupEventSource() {
7065
7343
  console.warn('[SSE] Reconnected after drop — forcing full refresh');
7066
7344
  fetchSessions().catch(() => {});
7067
7345
  if (currentSessionId) fetchTasks(currentSessionId);
7346
+ if (terminalAvailable())
7347
+ loadTerminals()
7348
+ .then(renderSessions)
7349
+ .catch(() => {});
7068
7350
  }
7069
7351
  wasConnected = true;
7070
7352
  retryDelay = 1000;
@@ -7080,31 +7362,29 @@ function setupEventSource() {
7080
7362
  retryDelay = Math.min(retryDelay * 2, 30000);
7081
7363
  };
7082
7364
 
7083
- let taskRefreshTimer = null;
7084
- let metadataRefreshTimer = null;
7085
- let agentRefreshTimer = null;
7086
- const pendingTaskSessionIds = new Set();
7087
- const pendingAgentSessionIds = new Set();
7088
-
7089
7365
  function debouncedRefresh(sessionId, isMetadata) {
7090
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;
7091
7370
  clearTimeout(metadataRefreshTimer);
7092
- metadataRefreshTimer = setTimeout(async () => {
7093
- fetchSessions(false).catch((err) => console.error('[SSE] fetchSessions failed:', err));
7094
- if (currentSessionId) {
7095
- await fetchAgents(currentSessionId);
7096
- if (!agentLogMode) fetchMessages(currentSessionId);
7097
- }
7098
- }, 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
+ );
7099
7380
  } else {
7100
7381
  pendingTaskSessionIds.add(sessionId);
7101
7382
  clearTimeout(taskRefreshTimer);
7102
7383
  taskRefreshTimer = setTimeout(async () => {
7384
+ if (skipOffScreen()) return;
7103
7385
  await fetchSessions().catch((err) => console.error('[SSE] fetchSessions failed:', err));
7104
7386
  if (viewMode === 'all') {
7105
- currentTasks = filterProject ? allTasksCache.filter((t) => matchesProjectFilter(t.project)) : allTasksCache;
7106
- renderAllTasks();
7107
- renderActivityChip();
7387
+ refreshAllView();
7108
7388
  } else if (viewMode === 'project' && currentProjectPath) {
7109
7389
  const hasUpdate = currentProjectSessionIds.some((id) => pendingTaskSessionIds.has(id));
7110
7390
  if (hasUpdate) fetchProjectView(currentProjectPath);
@@ -7124,23 +7404,27 @@ function setupEventSource() {
7124
7404
  }
7125
7405
 
7126
7406
  if (data.type === 'plan-update') {
7127
- refreshOpenPlan();
7407
+ if (!skipOffScreen()) refreshOpenPlan();
7128
7408
  }
7129
7409
 
7410
+ if (data.type === 'terminals-update' && Array.isArray(data.ids)) setRunningTerminals(data.ids);
7411
+
7412
+ if (data.type === 'dispatch-update') onDispatchUpdate();
7413
+
7130
7414
  if (data.type === 'agent-update') {
7131
7415
  pendingAgentSessionIds.add(data.sessionId);
7132
7416
  clearTimeout(agentRefreshTimer);
7133
7417
  agentRefreshTimer = setTimeout(() => {
7418
+ if (skipOffScreen()) return;
7134
7419
  fetchSessions(false).catch((err) => console.error('[SSE] fetchSessions failed:', err));
7135
7420
  if (viewMode === 'project' && currentProjectSessionIds.some((id) => pendingAgentSessionIds.has(id))) {
7136
7421
  refreshProjectAgents();
7137
7422
  } else if (currentSessionId && pendingAgentSessionIds.has(currentSessionId)) {
7138
- fetchAgents(currentSessionId);
7139
7423
  // An agent starting/stopping adds its Agent tool_use + completion rows to
7140
7424
  // the transcript. Refresh the message log on the same signal so they appear
7141
7425
  // at start, not only when an unrelated metadata/context refresh coincides
7142
7426
  // with completion.
7143
- if (!agentLogMode) fetchMessages(currentSessionId);
7427
+ refreshSessionDetail(currentSessionId);
7144
7428
  }
7145
7429
  pendingAgentSessionIds.clear();
7146
7430
  }, 500);
@@ -7148,7 +7432,7 @@ function setupEventSource() {
7148
7432
 
7149
7433
  if (data.type === 'context-update') {
7150
7434
  debouncedRefresh(data.sessionId, true);
7151
- refreshRateLimits();
7435
+ if (!skipOffScreen()) refreshRateLimits();
7152
7436
  }
7153
7437
 
7154
7438
  if (data.type === 'preview:open') {
@@ -7178,20 +7462,42 @@ function setupEventSource() {
7178
7462
  };
7179
7463
  }
7180
7464
 
7181
- // When the tab becomes visible after being hidden, catch up immediately
7182
- let _pollMissed = false;
7183
- document.addEventListener('visibilitychange', () => {
7184
- if (!document.hidden && _pollMissed) {
7185
- _pollMissed = false;
7186
- fetchSessions().catch(() => {});
7187
- if (currentSessionId) fetchTasks(currentSessionId).catch(() => {});
7188
- }
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();
7189
7495
  });
7190
7496
 
7191
- // 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
7192
7498
  setInterval(() => {
7193
- if (document.hidden) {
7194
- _pollMissed = true;
7499
+ if (!isOnScreen()) {
7500
+ if (eventSource?.readyState !== EventSource.OPEN) missedWhileHidden = true;
7195
7501
  return;
7196
7502
  }
7197
7503
  fetchSessions().catch(() => {});
@@ -7301,7 +7607,7 @@ function formatCost(usd) {
7301
7607
  return `$${usd.toFixed(2)}`;
7302
7608
  }
7303
7609
 
7304
- function renderContextDetail(raw) {
7610
+ function renderContextDetail(raw, { tokens = true } = {}) {
7305
7611
  const ctx = getCtx(raw);
7306
7612
  if (!ctx) return '';
7307
7613
  const totalK = ctx.size / 1000;
@@ -7310,6 +7616,16 @@ function renderContextDetail(raw) {
7310
7616
  const cw = raw.context_window || {};
7311
7617
  const usage = cw.current_usage || {};
7312
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
+ : '';
7313
7629
 
7314
7630
  return `
7315
7631
  <div class="detail-context">
@@ -7325,14 +7641,7 @@ function renderContextDetail(raw) {
7325
7641
  <span>${formatTokens((ctx.pct / 100) * totalK)} / ${formatTokens(totalK)}</span>
7326
7642
  </div>
7327
7643
  <div class="detail-context-stats">
7328
- <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>
7329
- <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>
7330
- <div class="stat-item"><span class="stat-label">Current input</span><span class="stat-value">${formatTokens((usage.input_tokens || 0) / 1000)}</span></div>
7331
- <div class="stat-item"><span class="stat-label">Current output</span><span class="stat-value">${formatTokens((usage.output_tokens || 0) / 1000)}</span></div>
7332
- <div class="stat-divider"></div>
7333
- <div class="stat-item"><span class="stat-label">Total input</span><span class="stat-value">${formatTokens(ctx.inputTokens / 1000)}</span></div>
7334
- <div class="stat-item"><span class="stat-label">Total output</span><span class="stat-value">${formatTokens(ctx.outputTokens / 1000)}</span></div>
7335
- <div class="stat-divider"></div>
7644
+ ${tokenRows}
7336
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>
7337
7646
  <div class="stat-item"><span class="stat-label">Duration</span><span class="stat-value">${formatDuration(cost.total_duration_ms)}</span></div>
7338
7647
  <div class="stat-item"><span class="stat-label">API time</span><span class="stat-value">${formatDuration(cost.total_api_duration_ms)}</span></div>
@@ -7365,6 +7674,8 @@ let waitingDetailAutoOpened = false;
7365
7674
  function maybeAutoOpenWaiting() {
7366
7675
  if (!isWaitingAnswerable() || !isWaitingFresh()) return;
7367
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;
7368
7679
  lastAutoOpenedWaitingId = currentWaiting.id;
7369
7680
  msgDetailFollowLatest = true;
7370
7681
  waitingDetailAutoOpened = true;
@@ -7762,6 +8073,10 @@ function stripTeammateWrapper(text) {
7762
8073
  // escaping is explicit here.
7763
8074
  const HTML_ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;', '`': '&#96;' };
7764
8075
 
8076
+ function pickRandom(items) {
8077
+ return items[Math.floor(Math.random() * items.length)];
8078
+ }
8079
+
7765
8080
  function escapeHtml(text) {
7766
8081
  if (text == null) return '';
7767
8082
  return String(text).replace(/[&<>"'`]/g, (c) => HTML_ESCAPES[c]);
@@ -8122,6 +8437,13 @@ document.addEventListener('click', (e) => {
8122
8437
  return;
8123
8438
  }
8124
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
+
8125
8447
  const projectBtn = e.target.closest('.project-view-btn');
8126
8448
  if (projectBtn) {
8127
8449
  e.stopPropagation();
@@ -8147,20 +8469,34 @@ document.addEventListener('click', (e) => {
8147
8469
  if (sgHeader) {
8148
8470
  if (e.target.closest('.sg-name-input')) return;
8149
8471
  e.stopPropagation();
8150
- const groupId = sgHeader.dataset.groupId;
8151
- const group = sgGroupById(groupId);
8152
- if (!group) return;
8153
- if (e.target.closest('.sg-rename')) {
8154
- sgBeginRename(groupId);
8472
+ if (e.target.closest('.sg-keep')) {
8473
+ sgKeepTransient(sgHeader.dataset.transientGroup);
8474
+ renderSessions();
8155
8475
  return;
8156
8476
  }
8157
- if (e.target.closest('.sg-delete')) {
8158
- if (confirm(`Delete group “${group.name}”? Its sessions and projects go back to Projects.`)) {
8159
- 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);
8160
8481
  renderSessions();
8161
8482
  }
8162
8483
  return;
8163
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;
8164
8500
  }
8165
8501
 
8166
8502
  const header = e.target.closest(COLLAPSIBLE_HEADER_SELECTOR);
@@ -8180,14 +8516,8 @@ function filterByProject(project) {
8180
8516
 
8181
8517
  let projectsCache = null;
8182
8518
 
8183
- async function updateProjectDropdown() {
8184
- const dropdown = document.getElementById('project-filter');
8185
-
8186
- if (!projectsCacheDirty && projectsCache) {
8187
- renderProjectDropdown(dropdown, projectsCache);
8188
- return;
8189
- }
8190
-
8519
+ async function loadProjects() {
8520
+ if (!projectsCacheDirty && projectsCache) return projectsCache;
8191
8521
  let projects;
8192
8522
  try {
8193
8523
  const res = await fetch('/api/projects');
@@ -8197,10 +8527,20 @@ async function updateProjectDropdown() {
8197
8527
  .sort()
8198
8528
  .map((p) => ({ path: p, modifiedAt: null }));
8199
8529
  }
8200
-
8201
8530
  projectsCache = projects;
8202
8531
  projectsCacheDirty = false;
8532
+ return projects;
8533
+ }
8534
+
8535
+ async function updateProjectDropdown() {
8536
+ const dropdown = document.getElementById('project-filter');
8203
8537
 
8538
+ if (!projectsCacheDirty && projectsCache) {
8539
+ renderProjectDropdown(dropdown, projectsCache);
8540
+ return;
8541
+ }
8542
+
8543
+ const projects = await loadProjects();
8204
8544
  const cutoff = Date.now() - 24 * 60 * 60 * 1000;
8205
8545
  const prevRecent = recentProjects;
8206
8546
  recentProjects = new Set(
@@ -9593,6 +9933,1274 @@ function filterByOwner(value) {
9593
9933
 
9594
9934
  //#endregion
9595
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
+
9596
11204
  //#region PWA
9597
11205
  if ('serviceWorker' in navigator) {
9598
11206
  navigator.serviceWorker.register('/sw.js');
@@ -9801,6 +11409,11 @@ if (urlState.search) {
9801
11409
  document.getElementById('search-clear-btn').classList.add('visible');
9802
11410
  }
9803
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
+
9804
11417
  Promise.all([
9805
11418
  fetch('/hub-config')
9806
11419
  .then((r) => r.json())
@@ -9816,6 +11429,7 @@ Promise.all([
9816
11429
  })
9817
11430
  .catch(() => {}),
9818
11431
  ])
11432
+ .then(restorePendingSessions)
9819
11433
  .then(() => fetchSessions())
9820
11434
  .then(async () => {
9821
11435
  if (urlState.projectView) {
@@ -9849,6 +11463,10 @@ Promise.all([
9849
11463
  if (currentMessages.length) renderMessages(currentMessages);
9850
11464
  });
9851
11465
  }
11466
+ })
11467
+ .finally(() => {
11468
+ document.body.classList.remove('booting');
11469
+ sessionsList.removeAttribute('aria-busy');
9852
11470
  });
9853
11471
 
9854
11472
  window.addEventListener('popstate', () => {
@@ -9878,23 +11496,28 @@ window.addEventListener('popstate', () => {
9878
11496
  // e.code travels with e.key because macOS composes Option+<key> into a character (Option+P is
9879
11497
  // 'π'), so the key alone cannot identify the binding. The hub owns the keymap and normalizes;
9880
11498
  // these tests only decide whether a press is the hub's to handle.
9881
- document.addEventListener('keydown', (e) => {
9882
- if (!window.__HUB__?.enabled) return;
9883
- const fwd = () => {
9884
- e.preventDefault();
9885
- hubPost({ type: 'hub:keydown', key: e.key, code: e.code, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey });
9886
- };
9887
- if (e.ctrlKey && e.altKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
9888
- fwd();
9889
- }
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;
9890
11502
  // Own branch: the Alt+digit case below requires !ctrlKey. The hub owns the Ctrl+Alt+letter
9891
11503
  // keymap and ignores unbound letters.
9892
- if (e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey && (/^[a-z]$/i.test(e.key) || /^Key[A-Z]$/.test(e.code))) {
9893
- fwd();
9894
- }
9895
- if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey && (/^[1-9]$/.test(e.key) || /^Digit[1-9]$/.test(e.code))) {
9896
- fwd();
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;
9897
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 });
9898
11521
  });
9899
11522
 
9900
11523
  document.addEventListener('click', (e) => {