claude-code-kanban 5.2.0 → 5.3.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
@@ -52,7 +52,12 @@ const collapsedProjectGroups = new Set();
52
52
  const SECTION_GROUPS = '__section_groups__';
53
53
  const SECTION_PROJECTS = '__section_projects__';
54
54
  const SECTION_SESSIONS = '__section_sessions__';
55
- let stableGroupOrder = []; // cached project path order to prevent jumping
55
+ // Project paths and session ids in Active-view sidebar order, kept across reloads so live
56
+ // activity does not reshuffle the list. Each holds only what the loaded list still has.
57
+ const PROJECT_ORDER_KEY = 'project-order';
58
+ const SESSION_ORDER_KEY = 'session-order';
59
+ let stableGroupOrder = readStoredList(PROJECT_ORDER_KEY);
60
+ let sessionOrder = readStoredList(SESSION_ORDER_KEY);
56
61
  let sessionGroups = []; // user-named groups: [{id, name, color, members:[{type,ref}]}]
57
62
  // Sessions the user pulled out of their dispatch placement; the server's group map is not theirs to edit.
58
63
  let sgReleased = new Set();
@@ -587,6 +592,9 @@ let lastCurrentTasksHash = '';
587
592
  async function fetchTasks(sessionId) {
588
593
  try {
589
594
  const refresh = viewMode === 'session' && sessionId === currentSessionId;
595
+ // The PTY already runs, so the attach need not wait for the session fetch and the render after it.
596
+ if (!refresh && termState.sessionId !== sessionId && runningTerminals.has(sessionId) && wantsTerminalFor(sessionId))
597
+ openTerminal(sessionId, terminalOpenMode(sessionId));
590
598
  viewMode = 'session';
591
599
  document.getElementById('message-toggle')?.style.removeProperty('display');
592
600
  const res = await fetch(`/api/sessions/${sessionId}`);
@@ -1694,6 +1702,8 @@ let pinnedCollapsed = false;
1694
1702
 
1695
1703
  const PIN_SVG =
1696
1704
  '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>';
1705
+ const DETAIL_EDIT_SVG =
1706
+ '<svg class="detail-edit-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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>';
1697
1707
  const MSG_ICON_USER =
1698
1708
  '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>';
1699
1709
  const MSG_ICON_ASSISTANT =
@@ -2043,6 +2053,7 @@ function isAnyPinned(sessionId) {
2043
2053
  }
2044
2054
 
2045
2055
  function _renderPinToDetail(pin) {
2056
+ unmountMsgReview();
2046
2057
  const body = document.getElementById('msg-detail-body');
2047
2058
  const agentBtn = document.getElementById('msg-detail-agent-btn');
2048
2059
  agentBtn.style.display = 'none';
@@ -2258,11 +2269,10 @@ function showMsgDetail(idx) {
2258
2269
  body.innerHTML = userExtras || '<em>No content</em>';
2259
2270
  }
2260
2271
  }
2261
- const modal = document.getElementById('msg-detail-modal').querySelector('.modal');
2262
- autoSizeModal(modal, body);
2263
- modal.classList.toggle('live', msgDetailFollowLatest);
2264
2272
  const overlay = document.getElementById('msg-detail-modal');
2265
- overlay.classList.toggle('live-overlay', msgDetailFollowLatest);
2273
+ const modal = overlay.querySelector('.modal');
2274
+ autoSizeModal(modal, body);
2275
+ setMsgDetailFollow(msgDetailFollowLatest);
2266
2276
 
2267
2277
  const meta = [formatDate(m.timestamp)];
2268
2278
  if (m.model) meta.unshift(m.model);
@@ -2271,9 +2281,34 @@ function showMsgDetail(idx) {
2271
2281
  currentPinDetailId = null;
2272
2282
  updateMsgDetailPinState();
2273
2283
  overlay.classList.add('visible');
2284
+ mountReplyReview(m, body, modal);
2285
+ }
2286
+
2287
+ function setMsgDetailFollow(on) {
2288
+ msgDetailFollowLatest = on;
2289
+ const overlay = document.getElementById('msg-detail-modal');
2290
+ overlay.classList.toggle('live-overlay', on);
2291
+ overlay.querySelector('.modal').classList.toggle('live', on);
2292
+ }
2293
+
2294
+ function unmountMsgReview() {
2295
+ unmountReview(document.getElementById('msg-review-panel'));
2296
+ }
2297
+
2298
+ // Only the main session's replies: the agent has them in context, so a quote is enough.
2299
+ function mountReplyReview(m, body, modal) {
2300
+ if (m.type !== 'assistant' || agentLogMode || !m.timestamp) return unmountMsgReview();
2301
+ mountReview({
2302
+ contentEl: body,
2303
+ panelEl: document.getElementById('msg-review-panel'),
2304
+ hostEl: modal,
2305
+ source: { kind: 'reply', id: m.timestamp, label: `your reply at ${new Date(m.timestamp).toLocaleTimeString()}` },
2306
+ onComment: () => setMsgDetailFollow(false),
2307
+ });
2274
2308
  }
2275
2309
 
2276
2310
  function closeMsgDetailModal() {
2311
+ unmountMsgReview();
2277
2312
  hideModalOverlay('msg-detail-modal');
2278
2313
  msgDetailFollowLatest = false;
2279
2314
  waitingDetailAutoOpened = false;
@@ -2890,8 +2925,9 @@ function autoSizeModal(modal, body) {
2890
2925
  // hasPre is already computed and short-circuits the textContent walk, which is
2891
2926
  // no longer cheap now that colourised output puts thousands of spans in there.
2892
2927
  const desired = hasTable ? 1100 : hasPre || body.textContent.length > 2000 ? 960 : 860;
2893
- const current = parseFloat(getComputedStyle(modal).maxWidth) || 0;
2894
- if (desired > current) modal.style.maxWidth = `${desired}px`;
2928
+ const style = getComputedStyle(modal);
2929
+ const current = (parseFloat(style.maxWidth) || 0) - (parseFloat(style.getPropertyValue('--review-extra')) || 0);
2930
+ if (desired > current) modal.style.maxWidth = `calc(${desired}px + var(--review-extra, 0px))`;
2895
2931
  }
2896
2932
 
2897
2933
  function renderToolResultHtml(toolResult, isTruncated, fullResult, toolUseId) {
@@ -3400,7 +3436,7 @@ function getFilteredSessions() {
3400
3436
  // above already makes, for the same reason.
3401
3437
  filteredSessions = filteredSessions.filter(
3402
3438
  (s) =>
3403
- matchesProjectFilter(s.project) ||
3439
+ matchesProjectFilter(s.project, s.worktree?.repo) ||
3404
3440
  s.id === currentSessionId ||
3405
3441
  (filterProject === '__recent__' && isAnyPinned(s.id)),
3406
3442
  );
@@ -3462,6 +3498,39 @@ function getFilteredSessions() {
3462
3498
  return filteredSessions;
3463
3499
  }
3464
3500
 
3501
+ function saveOrder(key, prev, next) {
3502
+ if (next.length === prev.length && next.every((id, i) => id === prev[i])) return prev;
3503
+ try {
3504
+ store.setItem(key, JSON.stringify(next));
3505
+ } catch (_) {}
3506
+ return next;
3507
+ }
3508
+
3509
+ // An entry keeps its slot while `loaded` has it; one not seen before enters at the top.
3510
+ function mergeOrder(key, prev, loaded, incoming) {
3511
+ const kept = prev.filter((id) => loaded.has(id));
3512
+ const known = new Set(kept);
3513
+ return saveOrder(key, prev, [...incoming.filter((id) => !known.has(id)), ...kept]);
3514
+ }
3515
+
3516
+ // Only the Active view groups into blocks; the other views stay in recency order.
3517
+ function applyStableSessionOrder(list) {
3518
+ if (sessionFilter !== 'active' || sessions.length === 0) return list;
3519
+ const loaded = new Set(sessions.map((s) => s.id));
3520
+ const ids = list.map((s) => s.id);
3521
+ sessionOrder = mergeOrder(SESSION_ORDER_KEY, sessionOrder, loaded, ids);
3522
+ const rank = new Map(sessionOrder.map((id, i) => [id, i]));
3523
+ return [...list].sort((a, b) => rank.get(a.id) - rank.get(b.id));
3524
+ }
3525
+
3526
+ function insertNear(arr, isRef, item, after) {
3527
+ const at = arr.findIndex(isRef);
3528
+ if (at < 0) return null;
3529
+ const out = [...arr];
3530
+ out.splice(at + (after ? 1 : 0), 0, item);
3531
+ return out;
3532
+ }
3533
+
3465
3534
  // A live refresh would replace the zen panel's link-a-file input mid-type, the same way it
3466
3535
  // would swallow a half-typed group name. Off zen there is no panel, so skip the DOM probe.
3467
3536
  function zenPanelIsEditing() {
@@ -3478,7 +3547,7 @@ function renderSessions() {
3478
3547
  // Zen narrows the rendered list only — the session picker keeps calling getFilteredSessions()
3479
3548
  // and still offers everything, or there would be no way to switch sessions without leaving zen.
3480
3549
  const zenSession = zenMode && currentSessionId ? sessions.find((s) => s.id === currentSessionId) : null;
3481
- const filteredSessions = zenMode ? (zenSession ? [zenSession] : []) : getFilteredSessions();
3550
+ const filteredSessions = zenMode ? (zenSession ? [zenSession] : []) : applyStableSessionOrder(getFilteredSessions());
3482
3551
 
3483
3552
  if (filteredSessions.length === 0) {
3484
3553
  let emptyMsg = 'No sessions found';
@@ -3787,7 +3856,7 @@ function renderSessions() {
3787
3856
  ungrouped.sort(pinSort);
3788
3857
  }
3789
3858
 
3790
- // Stable group order: preserve existing order, append new groups sorted by recency.
3859
+ // Stable group order: preserve existing order, put new groups first, sorted by recency.
3791
3860
  // Grouped projects stay in the array (they are just not rendered here), so dragging one
3792
3861
  // back out restores its old slot instead of jumping to the top.
3793
3862
  const latestByPath = new Map();
@@ -3796,11 +3865,9 @@ function renderSessions() {
3796
3865
  const t = new Date(s.modifiedAt).getTime();
3797
3866
  latestByPath.set(s.project, Math.max(latestByPath.get(s.project) ?? -Infinity, t));
3798
3867
  }
3799
- const knownPaths = new Set(stableGroupOrder);
3800
- const keptOrder = stableGroupOrder.filter((p) => latestByPath.has(p));
3801
- const newPaths = [...latestByPath.keys()].filter((p) => !knownPaths.has(p));
3802
- if (newPaths.length > 1) newPaths.sort((a, b) => latestByPath.get(b) - latestByPath.get(a));
3803
- stableGroupOrder = [...keptOrder, ...newPaths];
3868
+ const byRecency = [...latestByPath.keys()].sort((a, b) => latestByPath.get(b) - latestByPath.get(a));
3869
+ const loadedPaths = new Set(sessions.map((s) => s.project));
3870
+ stableGroupOrder = mergeOrder(PROJECT_ORDER_KEY, stableGroupOrder, loadedPaths, byRecency);
3804
3871
  const sortedGroups = stableGroupOrder.filter((p) => groups.has(p)).map((p) => [p, groups.get(p)]);
3805
3872
 
3806
3873
  // The section header is rendered even when nothing is left under it: it is the drop target
@@ -3941,11 +4008,13 @@ function renderProjectView() {
3941
4008
  }
3942
4009
 
3943
4010
  // Claude Code keeps a blocker's id in blockedBy after the blocker completes.
3944
- function openBlockers(task) {
4011
+ function findSiblingTask(task, id) {
3945
4012
  const list = (t) => t.sessionId || t._taskDir;
3946
- return (task.blockedBy || []).filter(
3947
- (id) => currentTasks.find((t) => t.id === id && list(t) === list(task))?.status !== 'completed',
3948
- );
4013
+ return currentTasks.find((t) => t.id === id && list(t) === list(task));
4014
+ }
4015
+
4016
+ function openBlockers(task) {
4017
+ return (task.blockedBy || []).filter((id) => findSiblingTask(task, id)?.status !== 'completed');
3949
4018
  }
3950
4019
 
3951
4020
  function renderTaskCard(task) {
@@ -3970,7 +4039,7 @@ function renderTaskCard(task) {
3970
4039
  aria-label="${escapeHtml(task.subject)} — ${task.status.replace('_', ' ')}">
3971
4040
  <div class="task-id">
3972
4041
  <span>#${taskId}</span>
3973
- ${isBlocked ? '<span class="task-badge blocked">Blocked</span>' : ''}
4042
+ ${isBlocked ? `<span class="task-blocked" title="Blocked">waiting on ${blockers.map((id) => `#${escapeHtml(id)}`).join(' ')}</span>` : ''}
3974
4043
  ${
3975
4044
  task.owner
3976
4045
  ? (
@@ -3984,7 +4053,6 @@ function renderTaskCard(task) {
3984
4053
  </div>
3985
4054
  <div class="task-title">${escapeHtml(task.subject)}</div>
3986
4055
  ${task.status === 'in_progress' && task.activeForm ? `<div class="task-active">${escapeHtml(task.activeForm)}</div>` : ''}
3987
- ${isBlocked ? `<div class="task-blocked">Waiting on ${blockers.map((id) => `#${id}`).join(', ')}</div>` : ''}
3988
4056
  ${task.description ? `<div class="task-desc">${escapeHtml(task.description.split('\n')[0])}</div>` : ''}
3989
4057
  </div>
3990
4058
  `;
@@ -4545,19 +4613,70 @@ function sgCommitRename(input, save) {
4545
4613
  //#region SESSION_GROUPS_DND
4546
4614
  // At most one zone is lit, so the reference is enough - dragover fires many times a second.
4547
4615
  let sgLitZone = null;
4616
+ let sgDragEl = null;
4617
+ const SG_LIT_CLASSES = ['sg-drop-over', 'sg-insert-before', 'sg-insert-after'];
4618
+ const SG_LIT_SELECTOR = SG_LIT_CLASSES.map((c) => `.${c}`).join(', ');
4548
4619
 
4549
4620
  function sgClearDropTargets() {
4550
- sgLitZone?.classList.remove('sg-drop-over');
4621
+ sgLitZone?.classList.remove(...SG_LIT_CLASSES);
4551
4622
  sgLitZone = null;
4552
4623
  }
4553
4624
 
4554
- function sgLightDropTarget(zone) {
4555
- if (sgLitZone === zone) return;
4625
+ function sgLightDropTarget(zone, cls = 'sg-drop-over') {
4626
+ if (sgLitZone === zone && zone.classList.contains(cls)) return;
4556
4627
  sgClearDropTargets();
4557
- zone.classList.add('sg-drop-over');
4628
+ zone.classList.add(cls);
4558
4629
  sgLitZone = zone;
4559
4630
  }
4560
4631
 
4632
+ // A session dropped on the edge of a card in its own list moves next to it. `share` is the part
4633
+ // of the card's height on each edge that means insert; the middle keeps its group gesture.
4634
+ function sgInsertHit(card, e, share) {
4635
+ const rect = card.getBoundingClientRect();
4636
+ const y = e.clientY - rect.top;
4637
+ const after = y > rect.height * (1 - share);
4638
+ if (!after && y >= rect.height * share) return null;
4639
+ return { zone: card, cls: after ? 'sg-insert-after' : 'sg-insert-before', insert: { card, after } };
4640
+ }
4641
+
4642
+ function sgSiblingCard(target) {
4643
+ if (sgDrag?.kind !== 'session' || sessionFilter !== 'active' || !sgDragEl) return null;
4644
+ const card = target.closest('.session-item');
4645
+ return card && card !== sgDragEl && card.parentElement === sgDragEl.parentElement ? card : null;
4646
+ }
4647
+
4648
+ function sgDropZone(target, e) {
4649
+ const sibling = sgSiblingCard(target);
4650
+ const hit = sgGroupDropZone(target);
4651
+ // Where the middle of a card does nothing, the whole card is an insert target.
4652
+ return (sibling && sgInsertHit(sibling, e, hit ? 0.25 : 0.5)) || hit;
4653
+ }
4654
+
4655
+ function sgInsertSession(dragId, { card, after }) {
4656
+ const ref = card.dataset.sessionId;
4657
+ const group = card.closest('.project-group-sessions') ? null : sgGroupOf('session', ref);
4658
+ if (group && sgGroupOf('session', dragId)?.id === group.id) {
4659
+ // Loose sessions in a named group follow the group's member order, not the sidebar order.
4660
+ const members = insertNear(
4661
+ group.members.filter((m) => !(m.type === 'session' && m.ref === dragId)),
4662
+ (m) => m.type === 'session' && m.ref === ref,
4663
+ { type: 'session', ref: dragId },
4664
+ after,
4665
+ );
4666
+ if (!members) return;
4667
+ group.members = members;
4668
+ persistSessionGroups();
4669
+ return;
4670
+ }
4671
+ const order = insertNear(
4672
+ sessionOrder.filter((id) => id !== dragId),
4673
+ (id) => id === ref,
4674
+ dragId,
4675
+ after,
4676
+ );
4677
+ if (order) sessionOrder = saveOrder(SESSION_ORDER_KEY, sessionOrder, order);
4678
+ }
4679
+
4561
4680
  function sgDragPayload(el) {
4562
4681
  if (el.classList.contains('session-group-header')) return { kind: 'group', ref: el.dataset.groupId };
4563
4682
  // data-project-path is on real project headers only, so the sentinels drop out by themselves.
@@ -4571,7 +4690,7 @@ function sgDragPayload(el) {
4571
4690
  // Resolve the element under the pointer to a legal drop zone for the in-flight drag.
4572
4691
  // Returns {zone} to ungroup, {zone, groupId} to assign, or {zone, pairWith}/{zone, pairSession}
4573
4692
  // to stack two ungrouped items into a brand-new group.
4574
- function sgDropZone(target) {
4693
+ function sgGroupDropZone(target) {
4575
4694
  if (!sgDrag) return null;
4576
4695
  const base =
4577
4696
  '.sg-ungroup-zone, .session-group-header, .session-group-sessions, .project-group-header, .project-group-sessions';
@@ -4655,6 +4774,7 @@ function sgOnDragStart(e) {
4655
4774
  return;
4656
4775
  }
4657
4776
  sgDrag = payload;
4777
+ sgDragEl = el;
4658
4778
  el.classList.add('sg-dragging');
4659
4779
  sessionsList.classList.add('sg-dragging-active');
4660
4780
  e.dataTransfer.effectAllowed = 'move';
@@ -4664,7 +4784,7 @@ function sgOnDragStart(e) {
4664
4784
  }
4665
4785
 
4666
4786
  function sgOnDragOver(e) {
4667
- const hit = sgDropZone(e.target);
4787
+ const hit = sgDropZone(e.target, e);
4668
4788
  // Moving onto a spot that refuses the drop has to put the previous highlight out, or two zones
4669
4789
  // stay lit and the one under the pointer is not the one that would receive the drop.
4670
4790
  if (!hit) {
@@ -4673,15 +4793,20 @@ function sgOnDragOver(e) {
4673
4793
  }
4674
4794
  e.preventDefault();
4675
4795
  e.dataTransfer.dropEffect = 'move';
4676
- sgLightDropTarget(hit.zone);
4796
+ sgLightDropTarget(hit.zone, hit.cls);
4677
4797
  }
4678
4798
 
4679
4799
  function sgOnDrop(e) {
4680
- const hit = sgDropZone(e.target);
4800
+ const hit = sgDropZone(e.target, e);
4681
4801
  if (!hit) return;
4682
4802
  e.preventDefault();
4683
4803
  e.stopPropagation();
4684
4804
  const drag = sgDrag;
4805
+ if (hit.insert) {
4806
+ sgInsertSession(drag.ref, hit.insert);
4807
+ sgFinishDrag();
4808
+ return;
4809
+ }
4685
4810
  if (hit.pairWith || hit.pairSession) {
4686
4811
  // Stacked onto an ungrouped item: seed a group, then let the user name it in place.
4687
4812
  const target = hit.pairWith
@@ -4712,6 +4837,7 @@ function sgOnDrop(e) {
4712
4837
 
4713
4838
  function sgFinishDrag() {
4714
4839
  sgDrag = null;
4840
+ sgDragEl = null;
4715
4841
  sgClearDropTargets();
4716
4842
  sessionsList.classList.remove('sg-dragging-active');
4717
4843
  // biome-ignore lint/suspicious/useIterableCallbackReturn: forEach side-effect
@@ -4732,7 +4858,7 @@ function initSessionGroupsDnd() {
4732
4858
  sessionsList.addEventListener('dragstart', sgOnDragStart);
4733
4859
  sessionsList.addEventListener('dragover', sgOnDragOver);
4734
4860
  sessionsList.addEventListener('dragleave', (e) => {
4735
- const zone = e.target.closest?.('.sg-drop-over');
4861
+ const zone = e.target.closest?.(SG_LIT_SELECTOR);
4736
4862
  if (zone && !zone.contains(e.relatedTarget)) sgClearDropTargets();
4737
4863
  });
4738
4864
  sessionsList.addEventListener('drop', sgOnDrop);
@@ -5349,22 +5475,33 @@ async function showTaskDetail(taskId, sessionId = null) {
5349
5475
  const blockers = openBlockers(task);
5350
5476
  const isBlocked = blockers.length > 0;
5351
5477
  const actualSessionId = task.sessionId || sessionId || currentSessionId;
5352
-
5478
+ const isProjectView = viewMode === 'project';
5479
+ const pencil = isProjectView ? '' : DETAIL_EDIT_SVG;
5480
+ const editable = isProjectView ? '' : ' editable';
5481
+ const depChip = (id, kind) => {
5482
+ const dep = findSiblingTask(task, id);
5483
+ const label = dep ? `#${id} ${dep.subject}` : `#${id}`;
5484
+ return `<button type="button" class="detail-dep ${escapeHtml(kind)}" data-dep-id="${escapeHtml(id)}" title="${escapeHtml(label)}"><span class="dot"></span><span class="detail-dep-text">${escapeHtml(label)}</span></button>`;
5485
+ };
5486
+ const deps = [
5487
+ isBlocked ? ['wait', 'Waiting on', blockers] : null,
5488
+ task.blocks?.length ? ['next', 'Unblocks', task.blocks] : null,
5489
+ ].filter(Boolean);
5490
+ detailContent.classList.toggle('blocked', isBlocked && task.status === 'pending');
5353
5491
  detailContent.innerHTML = `
5354
5492
  <div class="detail-section">
5355
- <div class="detail-label">Task #${task.id}</div>
5356
- <h2 class="detail-title">${escapeHtml(task.subject)}</h2>
5493
+ <div class="detail-label">Task #${task.id}${pencil}</div>
5494
+ <h2 class="detail-title${escapeHtml(editable)}">${escapeHtml(task.subject)}</h2>
5357
5495
  </div>
5358
5496
 
5359
5497
  <div class="detail-section" style="display: flex; gap: 12px; align-items: center;">
5360
5498
  <div>${statusLabels[task.status] || ''}</div>
5361
5499
  ${task.owner ? `<div style="font-size: 13px; color: ${getOwnerColor(task.owner).color}; font-weight: 500;">${escapeHtml(task.owner)}</div>` : ''}
5362
- ${isBlocked && task.status !== 'in_progress' ? '<div style="font-size: 10px; color: var(--warning);">Blocked</div>' : ''}
5363
5500
  </div>
5364
5501
 
5365
5502
  <div class="detail-section">
5366
- <div class="detail-label">Description</div>
5367
- <div class="detail-desc">${task.description ? renderMarkdown(task.description) : '<em style="color: var(--text-muted);">No description</em>'}</div>
5503
+ <div class="detail-label">Description${pencil}</div>
5504
+ <div class="detail-desc${escapeHtml(editable)}">${task.description ? renderMarkdown(task.description) : '<em style="color: var(--text-muted);">No description</em>'}</div>
5368
5505
  </div>
5369
5506
 
5370
5507
  ${
@@ -5380,33 +5517,24 @@ async function showTaskDetail(taskId, sessionId = null) {
5380
5517
  }
5381
5518
 
5382
5519
  ${
5383
- isBlocked
5520
+ deps.length
5384
5521
  ? `
5385
5522
  <div class="detail-section">
5386
- <div class="detail-label">Blocked By</div>
5523
+ <div class="detail-label">Dependencies</div>
5387
5524
  <div class="detail-deps">
5388
- <div class="detail-box blocked"><strong>Blocked by:</strong> ${blockers.map((id) => `#${id}`).join(', ')}</div>
5389
- </div>
5390
- </div>`
5391
- : ''
5392
- }
5393
-
5394
- ${
5395
- task.blocks && task.blocks.length > 0
5396
- ? `
5397
- <div class="detail-section">
5398
- <div class="detail-label">Blocks</div>
5399
- <div class="detail-deps">
5400
- <div class="detail-box blocks"><strong>Blocks:</strong> ${task.blocks.map((id) => `#${id}`).join(', ')}</div>
5525
+ ${deps.map(([kind, label, ids]) => `<span class="detail-deps-label ${escapeHtml(kind)}">${label}</span><div class="detail-deps-list">${ids.map((id) => depChip(id, kind)).join('')}</div>`).join('')}
5401
5526
  </div>
5402
5527
  </div>`
5403
5528
  : ''
5404
5529
  }
5405
5530
  `;
5406
5531
 
5532
+ for (const chip of detailContent.querySelectorAll('.detail-dep')) {
5533
+ chip.onclick = () => showTaskDetail(chip.dataset.depId, actualSessionId);
5534
+ }
5535
+
5407
5536
  // Setup button handlers (read-only in project view)
5408
5537
  const deleteBtn = document.getElementById('delete-task-btn');
5409
- const isProjectView = viewMode === 'project';
5410
5538
  deleteBtn.style.display = isProjectView ? 'none' : '';
5411
5539
  if (!isProjectView) deleteBtn.onclick = () => deleteTask(task.id, actualSessionId);
5412
5540
 
@@ -5439,7 +5567,7 @@ function editTitle(titleEl, task, sessionId) {
5439
5567
  if (val && val !== task.subject) {
5440
5568
  await saveTaskField(task.id, sessionId, 'subject', val);
5441
5569
  } else {
5442
- showTaskDetail(task.id, sessionId);
5570
+ await showTaskDetail(task.id, sessionId);
5443
5571
  }
5444
5572
  };
5445
5573
 
@@ -5449,6 +5577,18 @@ function editTitle(titleEl, task, sessionId) {
5449
5577
  save();
5450
5578
  }
5451
5579
  if (e.key === 'Escape') showTaskDetail(task.id, sessionId);
5580
+ // Saving re-renders the panel and drops the input, so native Tab would land on <body>.
5581
+ if (e.key === 'Tab') {
5582
+ e.preventDefault();
5583
+ input.onblur = null;
5584
+ const shift = e.shiftKey;
5585
+ save().then(() => {
5586
+ const target = shift
5587
+ ? document.getElementById('delete-task-btn')
5588
+ : detailContent.querySelector('.detail-dep') || document.getElementById('close-detail');
5589
+ target?.focus();
5590
+ });
5591
+ }
5452
5592
  };
5453
5593
  input.onblur = () => save();
5454
5594
  }
@@ -5488,8 +5628,10 @@ function editDescription(descEl, task, sessionId) {
5488
5628
 
5489
5629
  saveBtn.onclick = save;
5490
5630
  cancelBtn.onclick = () => showTaskDetail(task.id, sessionId);
5491
- textarea.onkeydown = (e) => {
5492
- if (e.key === 'Escape') showTaskDetail(task.id, sessionId);
5631
+ wrapper.onkeydown = (e) => {
5632
+ if (e.key !== 'Escape') return;
5633
+ e.stopPropagation();
5634
+ showTaskDetail(task.id, sessionId);
5493
5635
  };
5494
5636
  }
5495
5637
 
@@ -5504,7 +5646,7 @@ async function saveTaskField(taskId, sessionId, field, value) {
5504
5646
  if (res.ok) {
5505
5647
  lastCurrentTasksHash = null;
5506
5648
  await fetchTasks(sessionId);
5507
- showTaskDetail(taskId, sessionId);
5649
+ await showTaskDetail(taskId, sessionId);
5508
5650
  }
5509
5651
  } catch (error) {
5510
5652
  console.error('Failed to update task:', error);
@@ -6463,11 +6605,21 @@ function dismissSessionFromList(sid) {
6463
6605
  if (pinnedIdx >= 0) selectSessionByIndex(pinnedIdx, newItems);
6464
6606
  return;
6465
6607
  }
6608
+ // Ctrl+Alt+S would reveal the dismissed session, and revealing restores it.
6609
+ const forget = (x) => (x === sid ? null : x);
6610
+ const fallback = currentSessionId === sid && lastSessionId === sid ? previousSessionId : null;
6611
+ setSwapPair(forget(lastSessionId), forget(previousSessionId));
6466
6612
  const targetIdx = newItems.length > 0 ? Math.max(0, prevIdx - 1) : -1;
6467
6613
  // If the dismissed session is currently open, navigate to the previous one
6468
6614
  if (currentSessionId === sid || selectedSessionId === sid) {
6469
6615
  selectedSessionId = null;
6470
- if (targetIdx >= 0) {
6616
+ if (fallback && !dismissedSessionIds.has(fallback) && sessions.some((s) => s.id === fallback)) {
6617
+ revealSession(fallback).then(() => {
6618
+ const items = getNavigableItems();
6619
+ const idx = items.findIndex((el) => el.dataset?.sessionId === fallback);
6620
+ if (idx >= 0) selectSessionByIndex(idx, items);
6621
+ });
6622
+ } else if (targetIdx >= 0) {
6471
6623
  const targetSid = newItems[targetIdx]?.dataset?.sessionId;
6472
6624
  if (targetSid) {
6473
6625
  fetchTasks(targetSid).then(() => selectSessionByIndex(targetIdx, getNavigableItems()));
@@ -6483,16 +6635,28 @@ function dismissSessionFromList(sid) {
6483
6635
  }
6484
6636
  }
6485
6637
 
6486
- async function dismissStickySession(sid) {
6638
+ async function confirmDismissBlockers(sid) {
6639
+ const sticky = stickySessionIds.has(sid);
6640
+ const terminal = runningTerminals.has(sid);
6641
+ if (!sticky && !terminal) return true;
6487
6642
  const session = sessions.find((s) => s.id === sid);
6488
- const ok = await confirmModal({
6489
- title: 'Remove Sticky Pin',
6490
- message: `"${session ? sessionDisplayName(session) : sid.slice(0, 8)}" has a sticky pin. Remove the pin and dismiss the session?`,
6491
- okLabel: 'Unpin and dismiss',
6492
- });
6493
- if (!ok) return;
6494
- unpinSession(sid);
6495
- dismissSessionFromList(sid);
6643
+ const name = session ? sessionDisplayName(session) : sid.slice(0, 8);
6644
+ const has = [sticky && 'a sticky pin', terminal && 'a running terminal'].filter(Boolean).join(' and ');
6645
+ const [title, verb, okLabel] =
6646
+ sticky && terminal
6647
+ ? ['Unpin and Close Terminal', 'Unpin, close the terminal', 'Unpin, close and dismiss']
6648
+ : sticky
6649
+ ? ['Remove Sticky Pin', 'Remove the pin', 'Unpin and dismiss']
6650
+ : ['Close Terminal', 'Close the terminal', 'Close and dismiss'];
6651
+ const ok = await confirmModal({ title, message: `"${name}" has ${has}. ${verb} and dismiss the session?`, okLabel });
6652
+ if (!ok) return false;
6653
+ if (sticky) unpinSession(sid);
6654
+ if (terminal) closeTerminalSession(sid);
6655
+ return true;
6656
+ }
6657
+
6658
+ async function dismissSession(sid) {
6659
+ if (await confirmDismissBlockers(sid)) dismissSessionFromList(sid);
6496
6660
  }
6497
6661
 
6498
6662
  const MODAL_ESC_PRIORITY = [
@@ -6678,6 +6842,7 @@ document.addEventListener('keydown', (e) => {
6678
6842
  }
6679
6843
 
6680
6844
  // Tab toggles focus zone
6845
+ if (e.key === 'Tab' && detailPanel.contains(e.target)) return;
6681
6846
  if (e.key === 'Tab') {
6682
6847
  e.preventDefault();
6683
6848
  // The terminal replaces the board, so it takes the board's place in the Tab cycle.
@@ -6829,7 +6994,7 @@ document.addEventListener('keydown', (e) => {
6829
6994
  if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && e.key === 'd') {
6830
6995
  e.preventDefault();
6831
6996
  if (!contextSid) return;
6832
- (stickySessionIds.has(contextSid) ? dismissStickySession : dismissSessionFromList)(contextSid);
6997
+ dismissSession(contextSid);
6833
6998
  return;
6834
6999
  }
6835
7000
  if (e.code === 'KeyC' && e.shiftKey && !e.altKey && !e.metaKey) {
@@ -6894,9 +7059,11 @@ document.addEventListener('keydown', (e) => {
6894
7059
  const reviewDrafts = new Map();
6895
7060
  const reviewHighlight = typeof Highlight === 'function' && CSS.highlights ? new Highlight() : null;
6896
7061
  const reviewHotHighlight = reviewHighlight ? new Highlight() : null;
7062
+ const reviewPendingHighlight = reviewHighlight ? new Highlight() : null;
6897
7063
  if (reviewHighlight) {
6898
7064
  CSS.highlights.set('review', reviewHighlight);
6899
7065
  CSS.highlights.set('review-hot', reviewHotHighlight);
7066
+ CSS.highlights.set('review-pending', reviewPendingHighlight);
6900
7067
  }
6901
7068
  const REVIEW_MSG = 'cck-review:';
6902
7069
  let activeReview = null;
@@ -6906,7 +7073,7 @@ let reviewHoverAt = null;
6906
7073
  let reviewHoverFrame = 0;
6907
7074
 
6908
7075
  function reviewKey(sessionId, source) {
6909
- return `${sessionId}|${source.kind}|${source.path ? canonicalPath(source.path) : source.label}`;
7076
+ return `${sessionId}|${source.kind}|${source.path ? canonicalPath(source.path) : source.id || source.label}`;
6910
7077
  }
6911
7078
 
6912
7079
  function reviewItems() {
@@ -6914,8 +7081,18 @@ function reviewItems() {
6914
7081
  }
6915
7082
 
6916
7083
  // `context(range)` adds view-specific citation fields to a comment on the host DOM;
6917
- // `onSent()` runs after a batch is delivered.
6918
- function mountReview({ contentEl, panelEl, hostEl, source, frame, context, onSent, sessionId = currentSessionId }) {
7084
+ // `onComment()` runs after a comment is added; `onSent()` runs after a batch is delivered.
7085
+ function mountReview({
7086
+ contentEl,
7087
+ panelEl,
7088
+ hostEl,
7089
+ source,
7090
+ frame,
7091
+ context,
7092
+ onComment,
7093
+ onSent,
7094
+ sessionId = currentSessionId,
7095
+ }) {
6919
7096
  unmountReview();
6920
7097
  if (!sessionId) return;
6921
7098
  const ctl = new AbortController();
@@ -6928,6 +7105,7 @@ function mountReview({ contentEl, panelEl, hostEl, source, frame, context, onSen
6928
7105
  source,
6929
7106
  frame,
6930
7107
  context,
7108
+ onComment,
6931
7109
  onSent,
6932
7110
  sessionId,
6933
7111
  key,
@@ -6940,6 +7118,7 @@ function mountReview({ contentEl, panelEl, hostEl, source, frame, context, onSen
6940
7118
  }
6941
7119
  window.addEventListener('message', onReviewFrameMessage, { signal });
6942
7120
  document.addEventListener('mousedown', closeReviewPop, { signal });
7121
+ document.addEventListener('keydown', onReviewOfferKey, { capture: true, signal });
6943
7122
  document.addEventListener('scroll', closeReviewOffer, { capture: true, signal });
6944
7123
  panelEl.addEventListener('click', onReviewPanelClick, { signal });
6945
7124
  panelEl.addEventListener('mouseover', onReviewPanelHover, { signal });
@@ -7068,12 +7247,14 @@ function reviewBridge(textBefore, headingBefore, rangeAt) {
7068
7247
  let hoverFrame = 0;
7069
7248
  const hl = typeof Highlight === 'function' && CSS.highlights ? new Highlight() : null;
7070
7249
  const hot = hl ? new Highlight() : null;
7250
+ const pend = hl ? new Highlight() : null;
7071
7251
  if (hl) {
7072
7252
  CSS.highlights.set('cck-review', hl);
7073
7253
  CSS.highlights.set('cck-review-hot', hot);
7254
+ CSS.highlights.set('cck-review-pending', pend);
7074
7255
  const style = document.createElement('style');
7075
7256
  style.textContent =
7076
- '::highlight(cck-review){background-color:rgba(240,180,41,.35)}::highlight(cck-review-hot){background-color:rgba(240,180,41,.7)}';
7257
+ '::highlight(cck-review){background-color:rgba(240,180,41,.35)}::highlight(cck-review-hot),::highlight(cck-review-pending){background-color:rgba(240,180,41,.7)}';
7077
7258
  (document.head || document.documentElement).append(style);
7078
7259
  }
7079
7260
  const send = (msg) => parent.postMessage({ ...msg, type: P + msg.type }, '*');
@@ -7143,6 +7324,10 @@ function reviewBridge(textBefore, headingBefore, rangeAt) {
7143
7324
  hl?.add(pending);
7144
7325
  pending = null;
7145
7326
  getSelection().removeAllRanges();
7327
+ } else if (type === 'pend' && pending) {
7328
+ pend?.add(pending);
7329
+ } else if (type === 'unpend') {
7330
+ pend?.clear();
7146
7331
  } else if (type === 'hot') {
7147
7332
  hot?.clear();
7148
7333
  if (range) hot?.add(range);
@@ -7172,12 +7357,16 @@ function reviewPopEl() {
7172
7357
 
7173
7358
  function placeReviewPop(el, r) {
7174
7359
  el.style.left = `${Math.max(8, Math.min(r.left, window.innerWidth - el.offsetWidth - 8))}px`;
7175
- const below = r.bottom + 6;
7176
- el.style.top = `${below + el.offsetHeight > window.innerHeight ? Math.max(8, r.top - el.offsetHeight - 6) : below}px`;
7360
+ const above = r.top - el.offsetHeight - 6;
7361
+ el.style.top = `${above >= 8 ? above : r.bottom + 6}px`;
7177
7362
  }
7178
7363
 
7179
7364
  function closeReviewPop() {
7180
7365
  if (!reviewPopMode) return;
7366
+ if (reviewPopMode === 'editor') {
7367
+ reviewPendingHighlight?.clear();
7368
+ if (activeReview?.frame) postToReviewFrame('unpend');
7369
+ }
7181
7370
  reviewPopMode = null;
7182
7371
  const el = reviewPopEl();
7183
7372
  el.className = '';
@@ -7198,17 +7387,25 @@ function showReviewOffer(picked) {
7198
7387
  const el = reviewPopEl();
7199
7388
  reviewPopMode = 'offer';
7200
7389
  el.className = 'visible offer';
7201
- el.innerHTML = `<button type="button" class="review-offer-btn">Comment</button>`;
7390
+ el.innerHTML = `<button type="button" class="review-offer-btn" title="Comment (C)">${ICON_CHAT}Comment<kbd>C</kbd></button>`;
7202
7391
  el.firstElementChild.addEventListener('click', () => openReviewEditor(picked));
7203
7392
  placeReviewPop(el, picked.rect);
7204
7393
  }
7205
7394
 
7395
+ function onReviewOfferKey(e) {
7396
+ if (reviewPopMode !== 'offer' || !matchKey(e, 'c', 'C')) return;
7397
+ e.preventDefault();
7398
+ e.stopImmediatePropagation();
7399
+ reviewPopEl().querySelector('.review-offer-btn').click();
7400
+ }
7401
+
7206
7402
  function openReviewEditor(picked) {
7207
7403
  const el = reviewPopEl();
7208
7404
  reviewPopMode = 'editor';
7209
7405
  el.className = 'visible editor';
7210
- el.innerHTML = `<div class="review-pop-quote">${escapeHtml(picked.quote.slice(0, 200))}</div>
7211
- <textarea rows="3" placeholder="Comment (Ctrl+Enter to add)"></textarea>
7406
+ if (picked.range) reviewPendingHighlight?.add(picked.range);
7407
+ else if (picked.inFrame) postToReviewFrame('pend');
7408
+ el.innerHTML = `<textarea rows="3" placeholder="Comment (Ctrl+Enter to add)"></textarea>
7212
7409
  <div class="review-pop-actions"><button type="button" data-act="cancel">Cancel</button><button type="button" data-act="add" class="primary">Add</button></div>`;
7213
7410
  const ta = el.querySelector('textarea');
7214
7411
  const add = () => {
@@ -7258,6 +7455,7 @@ function addReviewComment(picked, comment) {
7258
7455
  items.sort((a, b) => a.pos - b.pos);
7259
7456
  closeReviewPop();
7260
7457
  renderReviewPanel();
7458
+ activeReview.onComment?.();
7261
7459
  }
7262
7460
 
7263
7461
  function unmarkReviewItem(c) {
@@ -8752,6 +8950,7 @@ function submitWaitingAnswers() {
8752
8950
  function showWaitingDetail() {
8753
8951
  if (!isWaitingFresh()) return;
8754
8952
  currentMsgDetailIdx = MSG_DETAIL_WAITING_IDX;
8953
+ unmountMsgReview();
8755
8954
  msgHighlightDimmed = false;
8756
8955
  highlightSelectedMsg();
8757
8956
  // Follow-mode poll ticks re-run this whole render — carry any typed reject
@@ -8807,9 +9006,8 @@ function showWaitingDetail() {
8807
9006
  document.getElementById('msg-detail-agent-btn').style.display = 'none';
8808
9007
  const modal = document.getElementById('msg-detail-modal').querySelector('.modal');
8809
9008
  autoSizeModal(modal, body);
8810
- modal.classList.toggle('live', msgDetailFollowLatest);
9009
+ setMsgDetailFollow(msgDetailFollowLatest);
8811
9010
  const overlay = document.getElementById('msg-detail-modal');
8812
- overlay.classList.toggle('live-overlay', msgDetailFollowLatest);
8813
9011
  const meta = [formatDate(currentWaiting.timestamp), 'waiting'];
8814
9012
  document.getElementById('msg-detail-meta').textContent = meta.join(' · ');
8815
9013
  currentPinDetailId = null;
@@ -9461,13 +9659,13 @@ function projectFilterText() {
9461
9659
  }
9462
9660
 
9463
9661
  let filterMatcher = { query: null, match: null };
9464
- function matchesProjectFilter(project) {
9662
+ function matchesProjectFilter(project, repo) {
9465
9663
  if (!filterProject) return true;
9466
9664
  if (filterProject === '__recent__') return recentProjects.has(project);
9467
9665
  if (filterMatcher.query !== filterProject) {
9468
9666
  filterMatcher = { query: filterProject, match: projectMatcher(filterProject) };
9469
9667
  }
9470
- return filterMatcher.match(project);
9668
+ return filterMatcher.match(project, repo);
9471
9669
  }
9472
9670
 
9473
9671
  //#endregion
@@ -9642,7 +9840,9 @@ async function refreshProjectList() {
9642
9840
  const cutoff = Date.now() - RECENT_PROJECT_HOURS * 60 * 60 * 1000;
9643
9841
  const prevRecent = recentProjects;
9644
9842
  recentProjects = new Set(
9645
- projects.filter((p) => p.modifiedAt && new Date(p.modifiedAt).getTime() > cutoff).map((p) => p.path),
9843
+ projects
9844
+ .filter((p) => p.modifiedAt && new Date(p.modifiedAt).getTime() > cutoff)
9845
+ .flatMap((p) => [p.path, ...(p.worktrees || [])]),
9646
9846
  );
9647
9847
 
9648
9848
  renderFilterState();
@@ -10198,8 +10398,11 @@ function openSessionFromInfo(sessionId) {
10198
10398
  }
10199
10399
 
10200
10400
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
10201
- function toggleDismissSession(sessionId) {
10202
- setSessionDismissed(sessionId, !dismissedSessionIds.has(sessionId));
10401
+ async function toggleDismissSession(sessionId) {
10402
+ const dismiss = !dismissedSessionIds.has(sessionId);
10403
+ if (dismiss && !(await confirmDismissBlockers(sessionId))) return;
10404
+ if (dismiss) dismissSessionFromList(sessionId);
10405
+ else setSessionDismissed(sessionId, false);
10203
10406
  }
10204
10407
 
10205
10408
  function updateDismissBtnState() {
@@ -11175,31 +11378,44 @@ const TERMINAL_MODES_KEY = 'terminal-sessions';
11175
11378
  const TERMINAL_FONT_KEY = 'terminal-font-size';
11176
11379
  const TERMINAL_FONT_MIN = 8;
11177
11380
  const TERMINAL_FONT_MAX = 32;
11178
- const ACK_BATCH_BYTES = 32 * 1024;
11381
+ const TERMINAL_FRAME_ISOLATED_MS = 5000;
11382
+ const TERMINAL_FRAME_LOAD_MS = 10000;
11383
+ const TERMINAL_FRAME_ALIVE_MS = 5000;
11384
+ const TERMINAL_MSG = 'cck-term:';
11179
11385
  const TERMINAL_RETRY_MS = [500, 1000, 2000, 4000, 8000];
11180
11386
  const TERMINAL_STABLE_MS = 5000;
11181
11387
  const STALE_TOKEN_MSG = 'The terminal token is out of date. Reload the hub window.';
11182
11388
  // Read at load, before the first updateUrl() rewrites the URL without the fragment.
11183
11389
  let terminalToken = readTerminalToken();
11184
11390
  const termState = {
11185
- loaded: null,
11186
- term: null,
11187
- fit: null,
11188
- webgl: null,
11189
- ws: null,
11391
+ socket: null,
11190
11392
  sessionId: null,
11393
+ fontSize: null,
11191
11394
  syncedView: null,
11192
11395
  panelHidden: false,
11193
11396
  shown: false,
11194
11397
  attached: false,
11195
11398
  closeGuard: false,
11196
- leaving: false,
11197
11399
  focusNext: false,
11198
11400
  openedByUser: null,
11199
- ackPending: 0,
11200
- ackTimer: null,
11201
11401
  retryTimer: null,
11202
11402
  };
11403
+ const termFrame = {
11404
+ el: null,
11405
+ origin: null,
11406
+ ready: null,
11407
+ resolve: null,
11408
+ inited: false,
11409
+ reload: false,
11410
+ claims: '',
11411
+ theme: '',
11412
+ sameOrigin: false,
11413
+ };
11414
+
11415
+ function resetTerminalFrame(patch) {
11416
+ Object.assign(termFrame, { inited: false, reload: false, claims: '', theme: '', ...patch });
11417
+ }
11418
+ let terminalSocketSeq = 0;
11203
11419
 
11204
11420
  // The hub passes the token in the fragment, which is never sent to a server or logged.
11205
11421
  function readTerminalToken() {
@@ -11259,7 +11475,11 @@ function setTerminalMode(sessionId, on) {
11259
11475
  }
11260
11476
 
11261
11477
  function wantsTerminal() {
11262
- return terminalAvailable() && viewMode === 'session' && !!currentSessionId && terminalModes().has(currentSessionId);
11478
+ return viewMode === 'session' && wantsTerminalFor(currentSessionId);
11479
+ }
11480
+
11481
+ function wantsTerminalFor(sessionId) {
11482
+ return terminalAvailable() && !!sessionId && terminalModes().has(sessionId);
11263
11483
  }
11264
11484
 
11265
11485
  function toggleTerminal() {
@@ -11280,12 +11500,13 @@ function inPageField(e) {
11280
11500
  return !!field && !document.getElementById('terminal-pane').contains(field);
11281
11501
  }
11282
11502
 
11283
- function terminalShortcut(e) {
11503
+ // `probe` asks which keys the terminal frame hands back, and the frame sees keys only while it has focus.
11504
+ function terminalShortcut(e, probe = false) {
11284
11505
  const ctrlAlt = e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey;
11285
11506
  const ctrlShift = e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey;
11286
11507
  // Ctrl+_ is Claude Code's undo, so the zoom keys leave it to the terminal.
11287
11508
  const zoom = e.key === '_' ? undefined : zoomDelta(e);
11288
- if (zoom !== undefined && termState.term && terminalPaneFocused()) {
11509
+ if (zoom !== undefined && termFrame.inited && (probe || terminalPaneFocused())) {
11289
11510
  return () => adjustTerminalFontSize(Math.sign(zoom));
11290
11511
  }
11291
11512
  if (ctrlAlt && e.code === 'KeyS') return swapToPreviousSession;
@@ -11364,7 +11585,13 @@ function syncTerminal() {
11364
11585
  btn.classList.toggle('active', on);
11365
11586
  }
11366
11587
  sessionView.classList.toggle('terminal-mode', on);
11588
+ pushTerminalClaims();
11367
11589
  if (!on) {
11590
+ // A hidden xterm textarea drops focus, but a display:none iframe keeps it and would take keys unseen.
11591
+ if (termState.shown && terminalPaneFocused()) {
11592
+ terminalHadFocus = false;
11593
+ document.activeElement.blur();
11594
+ }
11368
11595
  termState.shown = false;
11369
11596
  termState.focusNext = false;
11370
11597
  termState.openedByUser = null;
@@ -11409,8 +11636,7 @@ function promptAwaitsUser(sessionId) {
11409
11636
 
11410
11637
  function onTerminalShown(focus = true) {
11411
11638
  requestAnimationFrame(() => {
11412
- fitTerminal();
11413
- repaintTerminal();
11639
+ terminalFrameSend('refresh');
11414
11640
  if (focus) focusTerminalPane();
11415
11641
  });
11416
11642
  }
@@ -11418,37 +11644,210 @@ function onTerminalShown(focus = true) {
11418
11644
  function focusTerminalPane() {
11419
11645
  const prompt = document.getElementById('terminal-prompt');
11420
11646
  if (prompt.classList.contains('visible')) prompt.querySelector('button')?.focus();
11421
- else termState.term?.focus();
11647
+ else if (termFrame.inited) {
11648
+ termFrame.el.focus();
11649
+ terminalFrameSend('focus');
11650
+ }
11422
11651
  }
11423
11652
 
11424
11653
  function leaveTerminalPane() {
11425
- termState.leaving = true;
11654
+ terminalHadFocus = false;
11426
11655
  document.activeElement.blur();
11427
- termState.leaving = false;
11428
11656
  setFocusZone('sidebar');
11429
11657
  }
11430
11658
 
11431
- function loadXterm() {
11432
- if (termState.loaded) return termState.loaded;
11433
- const css = document.createElement('link');
11434
- css.rel = 'stylesheet';
11435
- css.href = '/vendor/xterm/xterm.css';
11436
- document.head.appendChild(css);
11437
- const script = (src) =>
11438
- new Promise((resolve, reject) => {
11439
- const s = document.createElement('script');
11440
- s.src = src;
11441
- s.onload = resolve;
11442
- s.onerror = () => reject(new Error(`failed to load ${src}`));
11443
- document.head.appendChild(s);
11659
+ // Chrome puts frames in one process per site, and a site ignores the port. Served from another
11660
+ // loopback name, the terminal runs in its own renderer, so board work never delays a key or a frame.
11661
+ // localhost maps to [::1], not 127.0.0.1: Chrome reaches localhost over IPv6, and some Windows
11662
+ // machines drop fresh IPv4 loopback connects in bursts, which stalls a socket for seconds.
11663
+ // A board on [::1] keeps the terminal in its process, because frame-ancestors cannot name an IPv6
11664
+ // literal; so does any other host, which has no second name.
11665
+ function terminalFrameOrigin(loc = location) {
11666
+ const other = { localhost: '[::1]', '127.0.0.1': 'localhost' }[loc.hostname];
11667
+ return other ? `${loc.protocol}//${other}${loc.port ? `:${loc.port}` : ''}` : loc.origin;
11668
+ }
11669
+
11670
+ function ensureTerminalFrame() {
11671
+ if (termFrame.ready) return termFrame.ready;
11672
+ const isolated = termFrame.sameOrigin ? location.origin : terminalFrameOrigin();
11673
+ // A host that does not answer on the other name (IPv6 turned off) still gets a terminal, in this
11674
+ // process, and later loads skip the wait.
11675
+ const ready = loadTerminalFrame(isolated, TERMINAL_FRAME_ISOLATED_MS).catch(async (err) => {
11676
+ if (isolated === location.origin) throw err;
11677
+ await loadTerminalFrame(location.origin, TERMINAL_FRAME_LOAD_MS);
11678
+ termFrame.sameOrigin = true;
11679
+ });
11680
+ termFrame.ready = ready;
11681
+ ready.catch(() => {
11682
+ if (termFrame.ready === ready) termFrame.ready = null;
11683
+ });
11684
+ return ready;
11685
+ }
11686
+
11687
+ function loadTerminalFrame(origin, timeoutMs) {
11688
+ termFrame.el?.remove();
11689
+ const el = document.createElement('iframe');
11690
+ el.className = 'terminal-frame';
11691
+ el.title = 'Terminal';
11692
+ el.allow = 'clipboard-read; clipboard-write';
11693
+ el.src = `${origin}/terminal.html#p=${encodeURIComponent(location.origin)}`;
11694
+ resetTerminalFrame({ el, origin });
11695
+ return new Promise((resolve, reject) => {
11696
+ const timer = setTimeout(() => {
11697
+ if (termFrame.el !== el || termFrame.inited) return;
11698
+ el.remove();
11699
+ termFrame.el = null;
11700
+ reject(new Error('The terminal did not load.'));
11701
+ }, timeoutMs);
11702
+ termFrame.resolve = () => {
11703
+ clearTimeout(timer);
11704
+ resolve();
11705
+ };
11706
+ document.getElementById('terminal-host').appendChild(el);
11707
+ });
11708
+ }
11709
+
11710
+ function sendTerminalTheme() {
11711
+ if (!termFrame.inited) return;
11712
+ const options = terminalThemeOptions();
11713
+ const key = JSON.stringify(options);
11714
+ if (key === termFrame.theme) return;
11715
+ termFrame.theme = key;
11716
+ postToTerminalFrame('theme', { options });
11717
+ }
11718
+
11719
+ // Keyed on the colors, and style is watched, because under the hub a theme change lands in two
11720
+ // steps: the class flips while the old pick's inline vars are still on body, then the SDK swaps them.
11721
+ new MutationObserver(sendTerminalTheme).observe(document.body, {
11722
+ attributes: true,
11723
+ attributeFilter: ['class', 'data-color-theme', 'style'],
11724
+ });
11725
+
11726
+ function postToTerminalFrame(t, data) {
11727
+ termFrame.el.contentWindow?.postMessage({ ...data, type: TERMINAL_MSG + t }, termFrame.origin);
11728
+ }
11729
+
11730
+ function terminalFrameSend(t, data) {
11731
+ if (termFrame.inited) postToTerminalFrame(t, data);
11732
+ }
11733
+
11734
+ window.addEventListener('message', (e) => {
11735
+ if (!termFrame.el || e.source !== termFrame.el.contentWindow || e.origin !== termFrame.origin) return;
11736
+ const m = e.data;
11737
+ if (typeof m?.type === 'string' && m.type.startsWith(TERMINAL_MSG))
11738
+ onTerminalFrameMessage(m.type.slice(TERMINAL_MSG.length), m);
11739
+ });
11740
+
11741
+ function onTerminalFrameMessage(t, m) {
11742
+ const socket = termState.socket;
11743
+ const current = socket && m.socketId === socket.id;
11744
+ if (t === 'loaded') {
11745
+ // A second load is the frame reloaded (its context menu), with no terminal and no socket.
11746
+ const themeOptions = terminalThemeOptions();
11747
+ resetTerminalFrame({ reload: termFrame.reload || termFrame.inited, theme: JSON.stringify(themeOptions) });
11748
+ postToTerminalFrame('init', {
11749
+ fontFamily:
11750
+ appConfig.terminal.fontFamily ||
11751
+ getComputedStyle(document.body).getPropertyValue('--font-mono').trim() ||
11752
+ 'monospace',
11753
+ fontSize: currentTerminalFontSize(),
11754
+ scrollback: appConfig.terminal.scrollback,
11755
+ themeOptions,
11444
11756
  });
11445
- termState.loaded = script('/vendor/xterm/xterm.js').then(() =>
11446
- Promise.all(['addon-fit', 'addon-webgl', 'addon-unicode11'].map((n) => script(`/vendor/xterm/${n}.js`))),
11757
+ } else if (t === 'term') {
11758
+ termFrame.inited = true;
11759
+ pushTerminalClaims();
11760
+ // A theme change between init and now was dropped.
11761
+ sendTerminalTheme();
11762
+ termFrame.resolve?.();
11763
+ if (termFrame.reload) {
11764
+ termFrame.reload = false;
11765
+ reconnectTerminal();
11766
+ }
11767
+ } else if (t === 'key') replayTerminalKey(m);
11768
+ // Chrome does not always fire focusin on the iframe element when its content takes focus.
11769
+ else if (t === 'focused') terminalHadFocus = terminalPaneFocused();
11770
+ else if (t === 'opening') {
11771
+ if (current) clearTimeout(socket.aliveTimer);
11772
+ } else if (t === 'output') {
11773
+ if (current && terminalBootTimer) hideTerminalBoot();
11774
+ } else if (t === 'ws') {
11775
+ if (!current) return;
11776
+ if (m.msg?.t === 'ready') socket.readyAt = Date.now();
11777
+ else if (m.msg?.t === 'error') socket.refused = true;
11778
+ onTerminalMessage(socket.sessionId, m.msg, m.tail);
11779
+ } else if (t === 'close') {
11780
+ if (current) onTerminalSocketClose(socket, m.code);
11781
+ }
11782
+ }
11783
+
11784
+ // The frame hands back the keys the board acts on. Replaying them on a textarea inside the pane gives
11785
+ // the document listeners the target they saw when xterm lived here: the shortcut runs, the SDK
11786
+ // forwards a hub key, and the text-field guard keeps the board's own keys out.
11787
+ function replayTerminalKey(m) {
11788
+ const target = document.getElementById('terminal-key-proxy');
11789
+ target.dispatchEvent(
11790
+ new KeyboardEvent('keydown', {
11791
+ key: m.key,
11792
+ code: m.code,
11793
+ ctrlKey: !!m.ctrl,
11794
+ altKey: !!m.alt,
11795
+ shiftKey: !!m.shift,
11796
+ metaKey: !!m.meta,
11797
+ bubbles: true,
11798
+ cancelable: true,
11799
+ }),
11447
11800
  );
11448
- termState.loaded.catch(() => {
11449
- termState.loaded = null;
11801
+ }
11802
+
11803
+ // [code, key, key with Shift] on a US layout. Each is probed by code alone and by key alone, so a claim
11804
+ // holds on any layout exactly when the shortcut would. Hub keys are not probed: the frame gets the
11805
+ // hub's combos and matches them with the SDK's comboOf.
11806
+ const TERMINAL_PROBE_KEYS = [
11807
+ ...[...'abcdefghijklmnopqrstuvwxyz'].map((k) => [`Key${k.toUpperCase()}`, k, k.toUpperCase()]),
11808
+ ...[...')!@#$%^&*('].map((s, n) => [`Digit${n}`, String(n), s]),
11809
+ ['Backquote', '`', '~'],
11810
+ ['Equal', '=', '+'],
11811
+ ['Minus', '-', '_'],
11812
+ ['NumpadAdd', '+', '+'],
11813
+ ['NumpadSubtract', '-', '-'],
11814
+ ['Numpad0', '0', '0'],
11815
+ ...['Left', 'Right', 'Up', 'Down'].map((d) => [`Arrow${d}`, `Arrow${d}`, `Arrow${d}`]),
11816
+ ];
11817
+
11818
+ function terminalClaims() {
11819
+ const keys = [];
11820
+ for (let bits = 0; bits < 16; bits++) {
11821
+ const mods = { ctrlKey: !!(bits & 1), altKey: !!(bits & 2), shiftKey: !!(bits & 4), metaKey: !!(bits & 8) };
11822
+ for (const [code, base, shifted] of TERMINAL_PROBE_KEYS) {
11823
+ const key = mods.shiftKey ? shifted : base;
11824
+ for (const probe of [
11825
+ { ...mods, key: 'Unidentified', code },
11826
+ { ...mods, key, code: '' },
11827
+ ]) {
11828
+ if (terminalShortcut(probe, true)) keys.push(probe);
11829
+ }
11830
+ }
11831
+ }
11832
+ return keys;
11833
+ }
11834
+
11835
+ let terminalClaimsQueued = false;
11836
+
11837
+ // The claims depend on state (the hub's welcome, a slow connect, the session), so every change re-sends
11838
+ // them. One state change calls this several times, and the probe is about 1,500 shortcut checks.
11839
+ function pushTerminalClaims() {
11840
+ if (!termFrame.inited || terminalClaimsQueued) return;
11841
+ terminalClaimsQueued = true;
11842
+ queueMicrotask(() => {
11843
+ terminalClaimsQueued = false;
11844
+ if (!termFrame.inited) return;
11845
+ const claims = { keys: terminalClaims(), forward: hub.forwardCombos() };
11846
+ const sig = JSON.stringify(claims);
11847
+ if (sig === termFrame.claims) return;
11848
+ termFrame.claims = sig;
11849
+ terminalFrameSend('claims', claims);
11450
11850
  });
11451
- return termState.loaded;
11452
11851
  }
11453
11852
 
11454
11853
  function terminalTheme() {
@@ -11477,141 +11876,6 @@ function terminalThemeOptions() {
11477
11876
  };
11478
11877
  }
11479
11878
 
11480
- // Returning false hands the key back to the page: xterm skips it and the document
11481
- // listeners (the toggle, hub forwarding, native paste) see it instead.
11482
- function terminalKeyFilter(e) {
11483
- if (e.type !== 'keydown') return true;
11484
- const ctrlOnly = e.ctrlKey && !e.altKey && !e.metaKey;
11485
- if (terminalShortcut(e)) return false;
11486
- if (ctrlOnly && e.code === 'KeyC' && (e.shiftKey || termState.term.hasSelection())) {
11487
- e.preventDefault();
11488
- navigator.clipboard?.writeText(termState.term.getSelection()).catch(() => {});
11489
- termState.term.clearSelection();
11490
- return false;
11491
- }
11492
- if (ctrlOnly && e.code === 'KeyV') return false;
11493
- return !hub.forwards(e);
11494
- }
11495
-
11496
- function oscColor(hex) {
11497
- const m = /^#([0-9a-f]{6})$/i.exec(hex || '');
11498
- if (!m) return null;
11499
- return `rgb:${[0, 2, 4].map((i) => m[1].slice(i, i + 2).repeat(2)).join('/')}`;
11500
- }
11501
-
11502
- function ensureTerm() {
11503
- if (termState.term) return termState.term;
11504
- const cfg = appConfig.terminal;
11505
- const host = document.getElementById('terminal-host');
11506
- const fontFamily =
11507
- cfg.fontFamily || getComputedStyle(document.body).getPropertyValue('--font-mono').trim() || 'monospace';
11508
- const fontSize = terminalFontSize();
11509
- const term = new window.Terminal({
11510
- fontFamily,
11511
- fontSize,
11512
- scrollback: cfg.scrollback,
11513
- cursorBlink: true,
11514
- allowProposedApi: true,
11515
- ...terminalThemeOptions(),
11516
- });
11517
- termState.fit = new window.FitAddon.FitAddon();
11518
- term.loadAddon(termState.fit);
11519
- term.loadAddon(new window.Unicode11Addon.Unicode11Addon());
11520
- term.unicode.activeVersion = '11';
11521
- term.open(host);
11522
- try {
11523
- const gl = new window.WebglAddon.WebglAddon();
11524
- gl.onContextLoss(() => {
11525
- gl.dispose();
11526
- termState.webgl = null;
11527
- repaintTerminal();
11528
- });
11529
- term.loadAddon(gl);
11530
- termState.webgl = gl;
11531
- } catch (_) {
11532
- // No WebGL: xterm falls back to its DOM renderer.
11533
- }
11534
- document.fonts
11535
- ?.load(`${fontSize}px ${fontFamily}`)
11536
- .then(repaintTerminal)
11537
- .catch(() => {});
11538
- term.attachCustomKeyEventHandler(terminalKeyFilter);
11539
- // xterm leaves OSC 10/11 color queries unanswered, so apps that pick a palette from the
11540
- // background (Claude Code's Auto theme) assume dark even on a light theme.
11541
- for (const [code, key] of [
11542
- [10, 'foreground'],
11543
- [11, 'background'],
11544
- ]) {
11545
- term.parser.registerOscHandler(code, (data) => {
11546
- if (data !== '?') return false;
11547
- const rgb = oscColor(term.options.theme[key]);
11548
- if (rgb) terminalSend({ t: 'in', d: `\x1b]${code};${rgb}\x1b\\` });
11549
- return true;
11550
- });
11551
- }
11552
- term.onData((d) => terminalSend({ t: 'in', d }));
11553
- term.onResize(({ cols, rows }) => terminalSend({ t: 'resize', cols, rows }));
11554
- // One fit per frame while a drag resizes the host. The atlas is rebuilt only when the host comes
11555
- // back from 0×0, which is also how a hidden hub iframe or pane shows again.
11556
- let frame = 0;
11557
- let visible = false;
11558
- new ResizeObserver(() => {
11559
- if (frame) return;
11560
- frame = requestAnimationFrame(() => {
11561
- frame = 0;
11562
- const nowVisible = host.offsetWidth > 0;
11563
- fitTerminal();
11564
- if (nowVisible && !visible) repaintTerminal();
11565
- visible = nowVisible;
11566
- });
11567
- }).observe(host);
11568
- const themeKey = () => `${isLightTheme()}|${document.body.dataset.colorTheme || ''}`;
11569
- let appliedTheme = themeKey();
11570
- new MutationObserver(() => {
11571
- if (themeKey() === appliedTheme) return;
11572
- appliedTheme = themeKey();
11573
- Object.assign(term.options, terminalThemeOptions());
11574
- repaintTerminal();
11575
- }).observe(document.body, { attributes: true, attributeFilter: ['class', 'data-color-theme'] });
11576
- // Vimium eats Escape inside a text field and only blurs it, so the key never reaches Claude.
11577
- // A blur no click caused, while the pane stays shown and the window keeps focus, is that Escape.
11578
- let pointerDown = false;
11579
- document.addEventListener('mousedown', () => (pointerDown = true), true);
11580
- document.addEventListener('mouseup', () => (pointerDown = false), true);
11581
- term.textarea.addEventListener('blur', () => {
11582
- if (pointerDown || termState.leaving) return;
11583
- requestAnimationFrame(() => {
11584
- if (!termState.attached || !host.offsetWidth) return;
11585
- if (!document.hasFocus() || document.activeElement !== document.body) return;
11586
- terminalSend({ t: 'in', d: '\x1b' });
11587
- term.focus();
11588
- });
11589
- });
11590
- termState.term = term;
11591
- return term;
11592
- }
11593
-
11594
- // The WebGL renderer keeps its last frame and glyph atlas while the pane (or the hub's iframe) is
11595
- // display:none, and comes back showing stale cells or backgrounds with no text. xterm does not
11596
- // repaint on its own when the canvas is shown again.
11597
- function repaintTerminal() {
11598
- const term = termState.term;
11599
- if (!term) return;
11600
- try {
11601
- termState.webgl?.clearTextureAtlas();
11602
- } catch (_) {}
11603
- term.refresh(0, term.rows - 1);
11604
- }
11605
-
11606
- // The fit addon measures 0×0 while the pane (or the hub's iframe) is display:none.
11607
- function fitTerminal() {
11608
- const host = document.getElementById('terminal-host');
11609
- if (!termState.fit || !host.offsetWidth || !host.offsetHeight) return;
11610
- try {
11611
- termState.fit.fit();
11612
- } catch (_) {}
11613
- }
11614
-
11615
11879
  function clampTerminalFont(v) {
11616
11880
  return Math.min(TERMINAL_FONT_MAX, Math.max(TERMINAL_FONT_MIN, v));
11617
11881
  }
@@ -11621,33 +11885,25 @@ function terminalFontSize() {
11621
11885
  return saved ? clampTerminalFont(saved) : appConfig.terminal.fontSize;
11622
11886
  }
11623
11887
 
11888
+ function currentTerminalFontSize() {
11889
+ termState.fontSize ??= terminalFontSize();
11890
+ return termState.fontSize;
11891
+ }
11892
+
11624
11893
  function adjustTerminalFontSize(step) {
11625
- const term = termState.term;
11626
- const size = step === 0 ? appConfig.terminal.fontSize : clampTerminalFont(term.options.fontSize + step);
11627
- if (size !== term.options.fontSize) {
11894
+ const current = currentTerminalFontSize();
11895
+ const size = step === 0 ? appConfig.terminal.fontSize : clampTerminalFont(current + step);
11896
+ if (size !== current) {
11628
11897
  if (step === 0) store.removeItem(TERMINAL_FONT_KEY);
11629
11898
  else store.setItem(TERMINAL_FONT_KEY, String(size));
11630
- term.options.fontSize = size;
11631
- fitTerminal();
11899
+ termState.fontSize = size;
11900
+ terminalFrameSend('font', { size });
11632
11901
  }
11633
11902
  showToast(`Terminal ${size}px`);
11634
11903
  }
11635
11904
 
11636
11905
  function terminalSend(msg) {
11637
- if (termState.ws?.readyState === WebSocket.OPEN) termState.ws.send(JSON.stringify(msg));
11638
- }
11639
-
11640
- function ackTerminal(ws, n) {
11641
- if (termState.ws !== ws) return;
11642
- termState.ackPending += n;
11643
- const flush = () => {
11644
- clearTimeout(termState.ackTimer);
11645
- termState.ackTimer = null;
11646
- if (termState.ws === ws && termState.ackPending) terminalSend({ t: 'ack', n: termState.ackPending });
11647
- termState.ackPending = 0;
11648
- };
11649
- if (termState.ackPending >= ACK_BATCH_BYTES) flush();
11650
- else if (!termState.ackTimer) termState.ackTimer = setTimeout(flush, 50);
11906
+ if (termState.socket) terminalFrameSend('send', { msg });
11651
11907
  }
11652
11908
 
11653
11909
  function setTerminalStatus(text) {
@@ -11687,6 +11943,7 @@ function clearTerminalSlow() {
11687
11943
  clearTimeout(terminalSlowTimer);
11688
11944
  terminalSlowTimer = null;
11689
11945
  document.getElementById('terminal-boot').classList.remove('slow');
11946
+ pushTerminalClaims();
11690
11947
  }
11691
11948
 
11692
11949
  function terminalConnectSlow() {
@@ -11715,22 +11972,12 @@ function showTerminalPrompt(sessionId, title, detail, choices, output = '') {
11715
11972
  el.querySelector('button')?.focus();
11716
11973
  }
11717
11974
 
11718
- function terminalTail(count) {
11719
- const buf = termState.term?.buffer.active;
11720
- if (!buf) return '';
11721
- const lines = [];
11722
- for (let i = buf.length - 1; i >= 0 && lines.length < count; i--) {
11723
- const text = buf.getLine(i)?.translateToString(true) ?? '';
11724
- if (text.trim() || lines.length) lines.unshift(text);
11725
- }
11726
- return lines.join('\n');
11727
- }
11728
-
11729
11975
  // Closing the page leaves the PTY running, but Ctrl+W meant for the prompt closes the tab, so ask first.
11730
11976
  // Under the hub the top frame asks: browsers do not reliably show the dialog for a frame.
11731
11977
  function setTerminalAttached(on) {
11732
11978
  termState.attached = on;
11733
11979
  syncCloseGuard();
11980
+ pushTerminalClaims();
11734
11981
  }
11735
11982
 
11736
11983
  // A hidden pane stays attached, but Ctrl+W can only be meant for a terminal on screen.
@@ -11758,6 +12005,26 @@ document.addEventListener('focusout', (e) => {
11758
12005
  if (document.hasFocus()) terminalHadFocus = terminalPaneFocused();
11759
12006
  });
11760
12007
  });
12008
+ // The hub hands focus back by focusing this frame after a palette closes, and focus does not
12009
+ // pass on into the nested terminal frame by itself. The hub makes this frame inert while the
12010
+ // palette is open, which can blur the terminal frame, so activeElement no longer says it had focus.
12011
+ // Without inert (the app launcher), activeElement stays the frame element while the frame has no focus.
12012
+ // Chrome fires this before focusin when a click or a focus() call in this page takes focus from the
12013
+ // frame, so it waits a task and gives way to whatever took focus.
12014
+ let boardPointerDown = false;
12015
+ document.addEventListener('pointerdown', () => (boardPointerDown = true), true);
12016
+ for (const type of ['pointerup', 'pointercancel']) {
12017
+ document.addEventListener(type, () => (boardPointerDown = false), true);
12018
+ }
12019
+ window.addEventListener('focus', () => {
12020
+ if (!terminalHadFocus || boardPointerDown) return;
12021
+ setTimeout(() => {
12022
+ const active = document.activeElement;
12023
+ if (terminalHadFocus && termState.shown && (active === document.body || active === termFrame.el)) {
12024
+ focusTerminalPane();
12025
+ }
12026
+ });
12027
+ });
11761
12028
  window.addEventListener('pagehide', () => {
11762
12029
  try {
11763
12030
  if (termState.attached && termState.shown && terminalHadFocus) {
@@ -11782,109 +12049,87 @@ document.getElementById('terminal-pane').addEventListener('focusin', () => {
11782
12049
  });
11783
12050
 
11784
12051
  function detachTerminal() {
11785
- const ws = termState.ws;
11786
- termState.ws = null;
12052
+ const had = termState.socket;
12053
+ termState.socket = null;
11787
12054
  termState.sessionId = null;
11788
- clearTimeout(termState.ackTimer);
11789
- termState.ackTimer = null;
11790
- termState.ackPending = 0;
11791
12055
  clearTimeout(termState.retryTimer);
11792
12056
  termState.retryTimer = null;
11793
12057
  setTerminalAttached(false);
11794
- if (ws) {
11795
- ws.onclose = null;
11796
- ws.close();
11797
- }
12058
+ if (had) terminalFrameSend('detach');
11798
12059
  hideTerminalPrompt();
11799
12060
  hideTerminalBoot();
11800
12061
  }
11801
12062
 
12063
+ // The frame owns the socket and xterm; the board picks the session, the mode and when to retry.
11802
12064
  async function openTerminal(sessionId, mode, attempt = 0) {
11803
12065
  detachTerminal();
11804
12066
  termState.sessionId = sessionId;
12067
+ const socket = { id: ++terminalSocketSeq, sessionId, mode, attempt, readyAt: 0, refused: false };
12068
+ termState.socket = socket;
11805
12069
  if (!attempt) setTerminalStatus('');
11806
- try {
11807
- await loadXterm();
11808
- } catch (e) {
11809
- setTerminalStatus(e.message);
11810
- return;
12070
+ // Even a settled await resumes only after the caller's render, so a loaded frame is used at once.
12071
+ if (!termFrame.inited) {
12072
+ try {
12073
+ await ensureTerminalFrame();
12074
+ } catch (e) {
12075
+ if (termState.socket === socket) setTerminalStatus(e.message);
12076
+ return;
12077
+ }
12078
+ if (termState.socket !== socket) return;
11811
12079
  }
11812
- if (termState.sessionId !== sessionId) return;
11813
- const term = ensureTerm();
11814
- if (!attempt) showTerminalBoot();
11815
- // RIS through the write queue, not term.reset(): reset() runs at once, and output the previous
11816
- // session had already queued would still be drawn after it. A retry keeps the last screen
11817
- // until the replay arrives.
11818
- const reset = () => term.write('\x1bc', repaintTerminal);
11819
- if (!attempt) reset();
11820
- fitTerminal();
11821
- const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/api/terminal/ws`);
11822
- ws.binaryType = 'arraybuffer';
11823
- termState.ws = ws;
11824
12080
  if (!attempt) {
12081
+ showTerminalBoot();
11825
12082
  terminalSlowTimer = setTimeout(() => {
11826
- if (termState.ws === ws) document.getElementById('terminal-boot').classList.add('slow');
12083
+ if (termState.socket !== socket) return;
12084
+ document.getElementById('terminal-boot').classList.add('slow');
12085
+ pushTerminalClaims();
11827
12086
  }, TERMINAL_SLOW_CONNECT_MS);
11828
12087
  }
11829
12088
  const spec = newSpecs.get(sessionId);
11830
- let readyAt = 0;
11831
- let refused = false;
11832
- ws.onopen = () =>
11833
- ws.send(
11834
- JSON.stringify({
11835
- t: 'hello',
11836
- token: terminalToken,
11837
- id: sessionId,
11838
- mode,
11839
- cols: term.cols,
11840
- rows: term.rows,
11841
- ...(spec?.mode === mode && {
11842
- cwd: spec.cwd,
11843
- name: spec.name,
11844
- worktree: spec.worktree,
11845
- model: spec.model,
11846
- prompt: spec.prompt,
11847
- }),
12089
+ terminalFrameSend('open', {
12090
+ socketId: socket.id,
12091
+ reset: !attempt,
12092
+ hello: {
12093
+ token: terminalToken,
12094
+ id: sessionId,
12095
+ mode,
12096
+ ...(spec?.mode === mode && {
12097
+ cwd: spec.cwd,
12098
+ name: spec.name,
12099
+ worktree: spec.worktree,
12100
+ model: spec.model,
12101
+ prompt: spec.prompt,
11848
12102
  }),
11849
- );
11850
- ws.onmessage = (ev) => {
11851
- if (termState.ws !== ws) return;
11852
- if (typeof ev.data !== 'string') {
11853
- const bytes = new Uint8Array(ev.data);
11854
- term.write(bytes, () => {
11855
- ackTerminal(ws, bytes.length);
11856
- if (terminalBootTimer && termState.ws === ws && terminalTail(1)) hideTerminalBoot();
11857
- });
11858
- return;
11859
- }
11860
- let msg;
11861
- try {
11862
- msg = JSON.parse(ev.data);
11863
- } catch (_) {
11864
- return;
11865
- }
11866
- if (msg.t === 'ready') {
11867
- readyAt = Date.now();
11868
- if (attempt) reset();
11869
- } else if (msg.t === 'error') refused = true;
11870
- onTerminalMessage(sessionId, msg);
11871
- };
11872
- ws.onclose = (ev) => {
11873
- if (termState.ws !== ws) return;
11874
- termState.ws = null;
11875
- setTerminalAttached(false);
11876
- // A refusal already shows the server's reason, and retrying would only repeat it, unless the
11877
- // reason was a token that the hub has since replaced (4001).
11878
- if (refused && ev.code === 4001) {
11879
- refreshTerminalToken().then((ok) => {
11880
- if (ok && termState.sessionId === sessionId) openTerminal(sessionId, mode, attempt + 1);
11881
- });
11882
- return;
11883
- }
11884
- if (refused || document.getElementById('terminal-prompt').classList.contains('visible')) return;
11885
- const stable = readyAt && Date.now() - readyAt > TERMINAL_STABLE_MS;
11886
- retryTerminal(sessionId, stable ? 0 : attempt);
11887
- };
12103
+ },
12104
+ });
12105
+ // Chrome shows a crashed frame as a sad face and does not reload it, so a dead frame only shows as silence.
12106
+ socket.aliveTimer = setTimeout(() => {
12107
+ if (termState.socket !== socket) return;
12108
+ dropTerminalFrame();
12109
+ openTerminal(sessionId, mode, attempt);
12110
+ }, TERMINAL_FRAME_ALIVE_MS);
12111
+ }
12112
+
12113
+ function dropTerminalFrame() {
12114
+ termFrame.el?.remove();
12115
+ resetTerminalFrame({ el: null, ready: null });
12116
+ }
12117
+
12118
+ function onTerminalSocketClose(socket, code) {
12119
+ termState.socket = null;
12120
+ setTerminalAttached(false);
12121
+ const { sessionId, mode, attempt } = socket;
12122
+ // A refusal already shows the server's reason, and retrying would only repeat it, unless the
12123
+ // reason was a token that the hub has since replaced (4001).
12124
+ if (socket.refused && code === 4001) {
12125
+ refreshTerminalToken().then((ok) => {
12126
+ if (ok && termState.sessionId === sessionId) openTerminal(sessionId, mode, attempt + 1);
12127
+ });
12128
+ return;
12129
+ }
12130
+ if (socket.refused || document.getElementById('terminal-prompt').classList.contains('visible')) return;
12131
+ const stable = socket.readyAt && Date.now() - socket.readyAt > TERMINAL_STABLE_MS;
12132
+ retryTerminal(sessionId, stable ? 0 : attempt);
11888
12133
  }
11889
12134
 
11890
12135
  function terminalOpenMode(sessionId) {
@@ -11923,7 +12168,7 @@ function retryTerminal(sessionId, attempt) {
11923
12168
  }, delay);
11924
12169
  }
11925
12170
 
11926
- function onTerminalMessage(sessionId, msg) {
12171
+ function onTerminalMessage(sessionId, msg, tail = '') {
11927
12172
  if (msg.t === 'ready') {
11928
12173
  // The server has the prompt now; a later start of the same placeholder must not send it again.
11929
12174
  const spec = newSpecs.get(sessionId);
@@ -11932,8 +12177,7 @@ function onTerminalMessage(sessionId, msg) {
11932
12177
  hideTerminalPrompt();
11933
12178
  setTerminalStatus('');
11934
12179
  setTerminalAttached(true);
11935
- terminalSend({ t: 'resize', cols: termState.term.cols, rows: termState.term.rows });
11936
- if (takeTerminalFocus(sessionId)) termState.term.focus();
12180
+ if (takeTerminalFocus(sessionId)) focusTerminalPane();
11937
12181
  } else if (msg.t === 'rekey' && typeof msg.id === 'string') {
11938
12182
  adoptPickedSession(sessionId, msg.id);
11939
12183
  if (msg.duplicate) showToast('That session is already open here, so this terminal switched to it', 'info');
@@ -11974,7 +12218,7 @@ function onTerminalMessage(sessionId, msg) {
11974
12218
  ['fork', 'Fork'],
11975
12219
  ['shell', 'Shell'],
11976
12220
  ],
11977
- clean ? '' : terminalTail(6),
12221
+ clean ? '' : tail,
11978
12222
  );
11979
12223
  } else if (msg.t === 'error') {
11980
12224
  hideTerminalBoot();
@@ -12004,12 +12248,42 @@ function openTerminalManager() {
12004
12248
  if (terminalPaneFocused()) leaveTerminalPane();
12005
12249
  document.getElementById('terminal-manager-modal').classList.add('visible');
12006
12250
  renderTerminalManager();
12251
+ clearInterval(terminalProcTimer);
12252
+ pollTerminalProcStats();
12253
+ terminalProcTimer = setInterval(pollTerminalProcStats, TERMINAL_PROC_POLL_MS);
12007
12254
  }
12008
12255
 
12009
12256
  function closeTerminalManager() {
12257
+ clearInterval(terminalProcTimer);
12010
12258
  hideModalOverlay('terminal-manager-modal');
12011
12259
  }
12012
12260
 
12261
+ // Each poll costs one OS process query on the server, so it runs only while the modal is on screen.
12262
+ const TERMINAL_PROC_POLL_MS = 5000;
12263
+ let terminalProcTimer = 0;
12264
+ let terminalProcStats = {};
12265
+
12266
+ function formatTerminalProc(s) {
12267
+ if (!s) return '';
12268
+ return `${Math.round(s.rss / 1048576)} MB${s.cpu === null ? '' : ` · ${s.cpu}% CPU`}`;
12269
+ }
12270
+
12271
+ async function pollTerminalProcStats() {
12272
+ if (!isOnScreen()) return;
12273
+ let cck;
12274
+ try {
12275
+ const res = await fetch('/api/terminals/stats', { cache: 'no-store' });
12276
+ if (!res.ok) return;
12277
+ ({ cck, terminals: terminalProcStats } = await res.json());
12278
+ } catch (_) {
12279
+ return;
12280
+ }
12281
+ document.getElementById('terminal-manager-stats').textContent = cck ? `cck ${formatTerminalProc(cck)}` : '';
12282
+ for (const el of document.querySelectorAll('#terminal-manager-body [data-proc]')) {
12283
+ el.textContent = formatTerminalProc(terminalProcStats[el.dataset.proc]);
12284
+ }
12285
+ }
12286
+
12013
12287
  async function renderTerminalManager() {
12014
12288
  const body = document.getElementById('terminal-manager-body');
12015
12289
  let list = [];
@@ -12043,6 +12317,7 @@ async function renderTerminalManager() {
12043
12317
  <span class="terminal-manager-mode">${escapeHtml(t.mode)}</span>
12044
12318
  <span title="${escapeHtml(`pid ${t.pid}`)}">up ${formatDuration(Date.now() - t.startedAt)}</span>
12045
12319
  <span>${attached}</span>
12320
+ <span class="terminal-manager-proc" data-proc="${escapeHtml(t.id)}" title="Memory and CPU of the claude process. Tools it runs are not counted. 100% CPU is one core.">${escapeHtml(formatTerminalProc(terminalProcStats[t.id]))}</span>
12046
12321
  <span class="terminal-manager-cwd" title="${escapeHtml(t.cwd)}">${escapeHtml(pathBasename(t.cwd))}</span>
12047
12322
  </div>
12048
12323
  </div>
@@ -12222,12 +12497,14 @@ async function loadTerminals() {
12222
12497
  const res = await fetch('/api/terminals', { cache: 'no-store' });
12223
12498
  const list = (await res.json()).sessions || [];
12224
12499
  runningTerminals = new Set(list.map((t) => t.id));
12500
+ pushTerminalClaims();
12225
12501
  renderActivityChip();
12226
12502
  return list;
12227
12503
  }
12228
12504
 
12229
12505
  function setRunningTerminals(ids) {
12230
12506
  runningTerminals = new Set(ids);
12507
+ pushTerminalClaims();
12231
12508
  if (ids.some((id) => !sessions.some((s) => s.id === id))) fetchSessions(false).catch(() => {});
12232
12509
  else renderSessionViews();
12233
12510
  renderActivityChip();
@@ -12876,7 +13153,11 @@ const hub = ClaudeHub.connect({
12876
13153
  }),
12877
13154
  });
12878
13155
 
12879
- hub.onActive((active) => onHubActive(active));
13156
+ hub.onActive((active) => {
13157
+ onHubActive(active);
13158
+ pushTerminalClaims();
13159
+ });
13160
+ hub.onStatus(pushTerminalClaims);
12880
13161
 
12881
13162
  // session.changed goes out once the switch settles, so arrowing through the list sends one event.
12882
13163
  let publishTimer = 0;