claude-code-kanban 5.1.1 → 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.
Files changed (50) hide show
  1. package/README.md +3 -2
  2. package/lib/auto-compact.js +42 -0
  3. package/lib/claude-dir.js +7 -1
  4. package/lib/claude-settings.js +21 -0
  5. package/lib/dispatch.js +11 -4
  6. package/lib/net-guard.js +41 -14
  7. package/lib/proc-stats.js +82 -0
  8. package/lib/retention.js +140 -0
  9. package/lib/session-events.js +30 -7
  10. package/lib/terminal.js +39 -6
  11. package/lib/worktrees.js +105 -0
  12. package/package.json +1 -1
  13. package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
  14. package/plugin/plugins/claude-code-kanban/hooks/hooks.json +15 -8
  15. package/plugin/plugins/claude-code-kanban/monitors.json +3 -3
  16. package/plugin/plugins/claude-code-kanban/scripts/postman.js +2 -2
  17. package/plugin/plugins/claude-code-kanban/skills/{kanban-dispatch → dispatch}/SKILL.md +3 -3
  18. package/plugin/plugins/claude-code-kanban/skills/{kanban-follow → follow}/SKILL.md +17 -9
  19. package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +1 -2
  20. package/public/app.js +1332 -490
  21. package/public/fonts/OFL-IBM-Plex.txt +93 -0
  22. package/public/fonts/OFL-Playfair-Display.txt +93 -0
  23. package/public/fonts/fonts.css +244 -0
  24. package/public/fonts/ibm-plex-mono-cyrillic-400.woff2 +0 -0
  25. package/public/fonts/ibm-plex-mono-cyrillic-500.woff2 +0 -0
  26. package/public/fonts/ibm-plex-mono-cyrillic-600.woff2 +0 -0
  27. package/public/fonts/ibm-plex-mono-cyrillic-ext-400.woff2 +0 -0
  28. package/public/fonts/ibm-plex-mono-cyrillic-ext-500.woff2 +0 -0
  29. package/public/fonts/ibm-plex-mono-cyrillic-ext-600.woff2 +0 -0
  30. package/public/fonts/ibm-plex-mono-latin-400.woff2 +0 -0
  31. package/public/fonts/ibm-plex-mono-latin-500.woff2 +0 -0
  32. package/public/fonts/ibm-plex-mono-latin-600.woff2 +0 -0
  33. package/public/fonts/ibm-plex-mono-latin-ext-400.woff2 +0 -0
  34. package/public/fonts/ibm-plex-mono-latin-ext-500.woff2 +0 -0
  35. package/public/fonts/ibm-plex-mono-latin-ext-600.woff2 +0 -0
  36. package/public/fonts/ibm-plex-mono-vietnamese-400.woff2 +0 -0
  37. package/public/fonts/ibm-plex-mono-vietnamese-500.woff2 +0 -0
  38. package/public/fonts/ibm-plex-mono-vietnamese-600.woff2 +0 -0
  39. package/public/fonts/playfair-display-cyrillic-400.woff2 +0 -0
  40. package/public/fonts/playfair-display-latin-400.woff2 +0 -0
  41. package/public/fonts/playfair-display-latin-ext-400.woff2 +0 -0
  42. package/public/fonts/playfair-display-vietnamese-400.woff2 +0 -0
  43. package/public/index.html +27 -17
  44. package/public/project-match.js +6 -4
  45. package/public/style.css +528 -116
  46. package/public/terminal-frame.js +341 -0
  47. package/public/terminal.html +27 -0
  48. package/public/vendor/claude-hub-sdk.js +13 -3
  49. package/server.js +204 -60
  50. package/skill-guides/dispatch.md +5 -5
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();
@@ -260,7 +265,7 @@ let lastTasksHash = '';
260
265
  //#endregion
261
266
 
262
267
  //#region DATA_FETCHING
263
- async function fetchSessions(includeTasks = true) {
268
+ async function fetchSessions(includeTasks = true, focusId = currentSessionId) {
264
269
  try {
265
270
  const allPinnedIds = new Set([...pinnedSessionIds, ...stickySessionIds]);
266
271
  if (revealedPlanSessionId) allPinnedIds.add(revealedPlanSessionId);
@@ -269,7 +274,7 @@ async function fetchSessions(includeTasks = true) {
269
274
  // The focused session must come back whatever the filters say: renderSession and the
270
275
  // info modal both look it up in `sessions` and bail when it is absent, so a session
271
276
  // opened from outside the current filter would leave the view on the previous one.
272
- const includeParam = currentSessionId ? `&include=${encodeURIComponent(currentSessionId)}` : '';
277
+ const includeParam = focusId ? `&include=${encodeURIComponent(focusId)}` : '';
273
278
  const projectParam =
274
279
  filterProject === '__recent__'
275
280
  ? `&recentHours=${RECENT_PROJECT_HOURS}`
@@ -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 =
@@ -1727,6 +1737,8 @@ const ICON_AGENT_ACTIVE =
1727
1737
  const ICON_TERMINAL =
1728
1738
  '<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>';
1729
1739
  const TERMINAL_TITLE = 'Running in a terminal here';
1740
+ const ICON_DISPATCHED =
1741
+ '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"/><path d="m21.854 2.147-10.94 10.939"/></svg>';
1730
1742
  const ICON_CHAT =
1731
1743
  '<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>';
1732
1744
  const TOOL_ICONS = {
@@ -1974,16 +1986,24 @@ function offloadSessionPin(sessionId) {
1974
1986
  }).catch(() => {});
1975
1987
  }
1976
1988
 
1989
+ function clearSessionPin(sessionId) {
1990
+ pinnedSessionIds.delete(sessionId);
1991
+ stickySessionIds.delete(sessionId);
1992
+ deferredPinPlacement.delete(sessionId);
1993
+ }
1994
+
1995
+ function unpinSession(sessionId) {
1996
+ clearSessionPin(sessionId);
1997
+ savePinnedSessions();
1998
+ offloadSessionPin(sessionId);
1999
+ renderSessions();
2000
+ }
2001
+
1977
2002
  function toggleSessionPin(sessionId) {
1978
- if (pinnedSessionIds.has(sessionId)) {
1979
- pinnedSessionIds.delete(sessionId);
1980
- stickySessionIds.delete(sessionId);
1981
- deferredPinPlacement.delete(sessionId);
1982
- } else {
1983
- pinnedSessionIds.add(sessionId);
1984
- if (sessionId === currentSessionId) deferredPinPlacement.add(sessionId);
1985
- expandPinnedFor(sessionId);
1986
- }
2003
+ if (pinnedSessionIds.has(sessionId)) return unpinSession(sessionId);
2004
+ pinnedSessionIds.add(sessionId);
2005
+ if (sessionId === currentSessionId) deferredPinPlacement.add(sessionId);
2006
+ expandPinnedFor(sessionId);
1987
2007
  savePinnedSessions();
1988
2008
  offloadSessionPin(sessionId);
1989
2009
  renderSessions();
@@ -1991,9 +2011,7 @@ function toggleSessionPin(sessionId) {
1991
2011
 
1992
2012
  function toggleSessionSticky(sessionId) {
1993
2013
  if (stickySessionIds.has(sessionId)) {
1994
- stickySessionIds.delete(sessionId);
1995
- pinnedSessionIds.delete(sessionId);
1996
- deferredPinPlacement.delete(sessionId);
2014
+ clearSessionPin(sessionId);
1997
2015
  } else {
1998
2016
  pinnedSessionIds.add(sessionId);
1999
2017
  stickySessionIds.add(sessionId);
@@ -2013,9 +2031,7 @@ function isPlacedSticky(id) {
2013
2031
 
2014
2032
  function handleSessionPinEvent({ id, state }) {
2015
2033
  if (!id) return;
2016
- pinnedSessionIds.delete(id);
2017
- stickySessionIds.delete(id);
2018
- deferredPinPlacement.delete(id);
2034
+ clearSessionPin(id);
2019
2035
  if (state === 'pinned') pinnedSessionIds.add(id);
2020
2036
  if (state === 'sticky') {
2021
2037
  pinnedSessionIds.add(id);
@@ -2037,6 +2053,7 @@ function isAnyPinned(sessionId) {
2037
2053
  }
2038
2054
 
2039
2055
  function _renderPinToDetail(pin) {
2056
+ unmountMsgReview();
2040
2057
  const body = document.getElementById('msg-detail-body');
2041
2058
  const agentBtn = document.getElementById('msg-detail-agent-btn');
2042
2059
  agentBtn.style.display = 'none';
@@ -2252,11 +2269,10 @@ function showMsgDetail(idx) {
2252
2269
  body.innerHTML = userExtras || '<em>No content</em>';
2253
2270
  }
2254
2271
  }
2255
- const modal = document.getElementById('msg-detail-modal').querySelector('.modal');
2256
- autoSizeModal(modal, body);
2257
- modal.classList.toggle('live', msgDetailFollowLatest);
2258
2272
  const overlay = document.getElementById('msg-detail-modal');
2259
- overlay.classList.toggle('live-overlay', msgDetailFollowLatest);
2273
+ const modal = overlay.querySelector('.modal');
2274
+ autoSizeModal(modal, body);
2275
+ setMsgDetailFollow(msgDetailFollowLatest);
2260
2276
 
2261
2277
  const meta = [formatDate(m.timestamp)];
2262
2278
  if (m.model) meta.unshift(m.model);
@@ -2265,9 +2281,34 @@ function showMsgDetail(idx) {
2265
2281
  currentPinDetailId = null;
2266
2282
  updateMsgDetailPinState();
2267
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
+ });
2268
2308
  }
2269
2309
 
2270
2310
  function closeMsgDetailModal() {
2311
+ unmountMsgReview();
2271
2312
  hideModalOverlay('msg-detail-modal');
2272
2313
  msgDetailFollowLatest = false;
2273
2314
  waitingDetailAutoOpened = false;
@@ -2456,14 +2497,21 @@ let _manualRefreshing = false;
2456
2497
 
2457
2498
  //#region TOAST
2458
2499
  // `action` ({label, onClick}) adds a button and keeps the toast up long enough to reach it.
2459
- function showToast(msg, type, action) {
2500
+ function showToast(msg, type, action, hint) {
2460
2501
  const el = document.getElementById('toast');
2461
2502
  clearTimeout(_toastTimer);
2462
2503
  el.style.transition = 'none';
2463
2504
  el.classList.remove('visible', 'toast-success', 'toast-error', 'toast-info', 'has-action');
2464
2505
  void el.offsetHeight;
2465
2506
  el.style.transition = '';
2466
- el.textContent = msg;
2507
+ if (hint) {
2508
+ const text = document.createElement('div');
2509
+ const hintEl = document.createElement('div');
2510
+ hintEl.className = 'toast-hint';
2511
+ hintEl.textContent = hint;
2512
+ text.append(msg, hintEl);
2513
+ el.replaceChildren(text);
2514
+ } else el.textContent = msg;
2467
2515
  const hide = () => el.classList.remove('visible', 'has-action');
2468
2516
  if (action) {
2469
2517
  const btn = document.createElement('button');
@@ -2877,8 +2925,9 @@ function autoSizeModal(modal, body) {
2877
2925
  // hasPre is already computed and short-circuits the textContent walk, which is
2878
2926
  // no longer cheap now that colourised output puts thousands of spans in there.
2879
2927
  const desired = hasTable ? 1100 : hasPre || body.textContent.length > 2000 ? 960 : 860;
2880
- const current = parseFloat(getComputedStyle(modal).maxWidth) || 0;
2881
- 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))`;
2882
2931
  }
2883
2932
 
2884
2933
  function renderToolResultHtml(toolResult, isTruncated, fullResult, toolUseId) {
@@ -3387,7 +3436,7 @@ function getFilteredSessions() {
3387
3436
  // above already makes, for the same reason.
3388
3437
  filteredSessions = filteredSessions.filter(
3389
3438
  (s) =>
3390
- matchesProjectFilter(s.project) ||
3439
+ matchesProjectFilter(s.project, s.worktree?.repo) ||
3391
3440
  s.id === currentSessionId ||
3392
3441
  (filterProject === '__recent__' && isAnyPinned(s.id)),
3393
3442
  );
@@ -3449,6 +3498,39 @@ function getFilteredSessions() {
3449
3498
  return filteredSessions;
3450
3499
  }
3451
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
+
3452
3534
  // A live refresh would replace the zen panel's link-a-file input mid-type, the same way it
3453
3535
  // would swallow a half-typed group name. Off zen there is no panel, so skip the DOM probe.
3454
3536
  function zenPanelIsEditing() {
@@ -3465,7 +3547,7 @@ function renderSessions() {
3465
3547
  // Zen narrows the rendered list only — the session picker keeps calling getFilteredSessions()
3466
3548
  // and still offers everything, or there would be no way to switch sessions without leaving zen.
3467
3549
  const zenSession = zenMode && currentSessionId ? sessions.find((s) => s.id === currentSessionId) : null;
3468
- const filteredSessions = zenMode ? (zenSession ? [zenSession] : []) : getFilteredSessions();
3550
+ const filteredSessions = zenMode ? (zenSession ? [zenSession] : []) : applyStableSessionOrder(getFilteredSessions());
3469
3551
 
3470
3552
  if (filteredSessions.length === 0) {
3471
3553
  let emptyMsg = 'No sessions found';
@@ -3507,15 +3589,6 @@ function renderSessions() {
3507
3589
  const projectHtml = renderProjectIdentity(session);
3508
3590
 
3509
3591
  const gitBranch = session.gitBranch ? escapeHtml(session.gitBranch) : null;
3510
- const createdDisplay = session.createdAt ? formatDate(session.createdAt) : '';
3511
- const modifiedDisplay = formatDate(session.modifiedAt);
3512
- const timeDisplay =
3513
- session.createdAt && createdDisplay !== modifiedDisplay
3514
- ? `Created ${createdDisplay} · Modified ${modifiedDisplay}`
3515
- : modifiedDisplay;
3516
- const tooltip = [session.id, session.project, timeDisplay, gitBranch ? `Branch: ${gitBranch}` : '']
3517
- .filter(Boolean)
3518
- .join(' | ');
3519
3592
  const isTeam = session.isTeam;
3520
3593
  const memberCount = session.memberCount || 0;
3521
3594
 
@@ -3539,14 +3612,20 @@ function renderSessions() {
3539
3612
  : '';
3540
3613
  const justFinishedClass = isJustFinished(session) ? 'just-finished' : '';
3541
3614
  const sid = escAttrJs(session.id);
3615
+ const progressHtml =
3616
+ total > 0
3617
+ ? `<div class="session-progress" title="${escapeHtml(session.completed)} of ${total} tasks done"><div class="progress-bar"><div class="progress-fill" style="width: ${percent}%"></div></div><span class="progress-text">${session.completed}/${total}</span></div>`
3618
+ : '';
3619
+ const metricsHtml = progressHtml + (showCtx ? renderContextBar(session) : '');
3542
3620
  return `
3543
- <button onclick="openSession('${sid}')" draggable="true" data-session-id="${escapeHtml(session.id)}" class="session-item ${isActive ? 'active' : ''} ${session.hasWaitingForUser ? 'permission-pending' : ''} ${session.unread ? 'unread' : ''} ${tempClass} ${idleClass} ${justFinishedClass} ${showCtx ? 'has-context' : ''}" title="${escapeHtml(tooltip)}">
3621
+ <button onclick="openSession('${sid}')" draggable="true" data-session-id="${escapeHtml(session.id)}" class="session-item ${isActive ? 'active' : ''} ${session.hasWaitingForUser ? 'permission-pending' : ''} ${session.unread ? 'unread' : ''} ${tempClass} ${idleClass} ${justFinishedClass}">
3544
3622
  <span class="session-pin-btn${pinClass}" onclick="event.stopPropagation();toggleSessionPin('${sid}')" title="${pinTitle} session">${pinState === 'sticky' ? SESSION_STAR_SVG : SESSION_PIN_SVG}</span>
3545
3623
  <div class="session-name">${escapeHtml(sessionName)}</div>
3546
3624
  ${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
3547
3625
  ${gitBranch ? `<div class="session-branch">${gitBranch}</div>` : ''}
3548
- ${session.planTitle ? `<div class="session-plan">${escapeHtml(session.planTitle)}</div>` : ''}
3549
- <div class="session-progress">
3626
+ ${metricsHtml ? `<div class="session-metrics">${metricsHtml}</div>` : ''}
3627
+ <div class="session-footer">
3628
+ <div class="session-time">${formatDate(session.modifiedAt)}</div>
3550
3629
  <span class="session-indicators">
3551
3630
  ${isTeam ? `<span class="team-badge" title="${memberCount} team members"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>${memberCount}</span>` : ''}
3552
3631
  ${isTeam || session.project || showCtx ? `<span class="team-info-btn" onclick="event.stopPropagation(); showSessionInfoModal('${sid}')" title="View session info"><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"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg></span>` : ''}
@@ -3558,16 +3637,13 @@ function renderSessions() {
3558
3637
  ${session.hasPlan && !session.planSourceSessionId ? `<span class="plan-indicator" onclick="event.stopPropagation(); openPlanForSession('${sid}')" title="View plan">${ICON_PLAN}</span>` : ''}
3559
3638
  ${session.planSourceSessionId ? `<span class="plan-indicator" title="Implements plan — click to reveal plan session" onclick="event.stopPropagation(); revealPlanSession('${escAttrJs(session.planSourceSessionId)}')">${ICON_PLAN}</span>` : ''}
3560
3639
  ${session.sharedTaskList ? `<span class="shared-tasklist-badge" title="Shared task list: ${escapeHtml(session.sharedTaskList)}">${linkSvg(12)}</span>` : ''}
3640
+ ${session.dispatched ? `<span class="dispatched-badge"${session.dispatched.parent ? ` onclick="event.stopPropagation(); revealSession('${escAttrJs(session.dispatched.parent)}')"` : ''} title="${escapeHtml(dispatchedTitle(session.dispatched))}">${ICON_DISPATCHED}</span>` : ''}
3561
3641
  ${runningTerminals.has(session.id) ? `<span class="terminal-badge" onclick="event.stopPropagation(); showSessionTerminal('${escAttrJs(session.id)}')" title="${TERMINAL_TITLE}">${ICON_TERMINAL}</span>` : ''}
3562
3642
  ${session.hasWaitingForUser ? `<span class="agent-badge agent-badge-waiting" title="Waiting for user">${ICON_AGENT_WAITING}</span>` : ''}
3563
3643
  ${session.hasRunningAgents && !session.hasWaitingForUser ? `<span class="agent-badge agent-badge-active" title="Agents running">${ICON_AGENT_ACTIVE}</span>` : ''}
3564
3644
  ${isLive || session.hasRunningAgents ? `<span class="pulse" title="${isLive ? 'Live' : 'Active agents'}"></span>` : ''}
3565
3645
  </span>
3566
- <div class="progress-bar"><div class="progress-fill" style="width: ${percent}%"></div></div>
3567
- <span class="progress-text">${session.completed}/${total}</span>
3568
3646
  </div>
3569
- ${showCtx ? renderContextBar(session.contextStatus) : ''}
3570
- <div class="session-time">${formatDate(session.modifiedAt)}</div>
3571
3647
  </button>
3572
3648
  `;
3573
3649
  };
@@ -3577,7 +3653,7 @@ function renderSessions() {
3577
3653
  if (zenMode) {
3578
3654
  sessionsList.innerHTML = `${renderSessionCard(zenSession)}
3579
3655
  <div class="zen-panel">
3580
- ${renderContextDetail(zenSession.contextStatus, { tokens: false }) || '<div class="zen-panel-empty">No context data for this session</div>'}
3656
+ ${renderContextDetail(zenSession, { tokens: false }) || '<div class="zen-panel-empty">No context data for this session</div>'}
3581
3657
  ${renderScratchpadRow(zenSession)}
3582
3658
  ${renderLinkedDocsHtml(zenSession.id)}
3583
3659
  ${renderArtifactsHtml(zenSession.id)}
@@ -3591,7 +3667,7 @@ function renderSessions() {
3591
3667
  }
3592
3668
 
3593
3669
  const groupPinned = store.getItem('groupPinnedSessions') !== 'false';
3594
- const pinWeight = (s) => (isPlacedSticky(s.id) ? 2 : isPlacedPinned(s.id) && !isSessionActive(s) ? 1 : 0);
3670
+ const pinWeight = (s) => (isPlacedSticky(s.id) ? 2 : isInPinnedGroup(s) ? 1 : 0);
3595
3671
  const pinSort = (a, b) => pinWeight(b) - pinWeight(a);
3596
3672
  const countHtml = (arr) => {
3597
3673
  const active = arr.reduce((n, s) => n + (isSessionActive(s) ? 1 : 0), 0);
@@ -3609,12 +3685,10 @@ function renderSessions() {
3609
3685
 
3610
3686
  const renderGroupSessions = (sessions, pinKey) => {
3611
3687
  if (!groupPinned || pinnedSessionIds.size === 0) return sessions.map(renderSessionCard).join('');
3612
- const gPinned = sessions.filter((s) => isPlacedPinned(s.id) && !isPlacedSticky(s.id));
3613
- if (gPinned.length === 0) return sessions.map(renderSessionCard).join('');
3614
- const gIdlePinned = gPinned.filter((s) => !isSessionActive(s));
3615
- const gUnpinned = sessions.filter((s) => !isPlacedPinned(s.id) || isSessionActive(s) || isPlacedSticky(s.id));
3688
+ const gIdlePinned = sessions.filter(isInPinnedGroup);
3689
+ if (gIdlePinned.length === 0) return sessions.map(renderSessionCard).join('');
3690
+ const gUnpinned = sessions.filter((s) => !isInPinnedGroup(s));
3616
3691
  const pinCollapsed = collapsedProjectGroups.has(pinKey);
3617
- if (gIdlePinned.length === 0) return gUnpinned.map(renderSessionCard).join('');
3618
3692
  return (
3619
3693
  '<div class="pinned-sub-section">' +
3620
3694
  '<div class="pinned-sub-header' +
@@ -3782,7 +3856,7 @@ function renderSessions() {
3782
3856
  ungrouped.sort(pinSort);
3783
3857
  }
3784
3858
 
3785
- // 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.
3786
3860
  // Grouped projects stay in the array (they are just not rendered here), so dragging one
3787
3861
  // back out restores its old slot instead of jumping to the top.
3788
3862
  const latestByPath = new Map();
@@ -3791,11 +3865,9 @@ function renderSessions() {
3791
3865
  const t = new Date(s.modifiedAt).getTime();
3792
3866
  latestByPath.set(s.project, Math.max(latestByPath.get(s.project) ?? -Infinity, t));
3793
3867
  }
3794
- const knownPaths = new Set(stableGroupOrder);
3795
- const keptOrder = stableGroupOrder.filter((p) => latestByPath.has(p));
3796
- const newPaths = [...latestByPath.keys()].filter((p) => !knownPaths.has(p));
3797
- if (newPaths.length > 1) newPaths.sort((a, b) => latestByPath.get(b) - latestByPath.get(a));
3798
- 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);
3799
3871
  const sortedGroups = stableGroupOrder.filter((p) => groups.has(p)).map((p) => [p, groups.get(p)]);
3800
3872
 
3801
3873
  // The section header is rendered even when nothing is left under it: it is the drop target
@@ -3936,11 +4008,13 @@ function renderProjectView() {
3936
4008
  }
3937
4009
 
3938
4010
  // Claude Code keeps a blocker's id in blockedBy after the blocker completes.
3939
- function openBlockers(task) {
4011
+ function findSiblingTask(task, id) {
3940
4012
  const list = (t) => t.sessionId || t._taskDir;
3941
- return (task.blockedBy || []).filter(
3942
- (id) => currentTasks.find((t) => t.id === id && list(t) === list(task))?.status !== 'completed',
3943
- );
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');
3944
4018
  }
3945
4019
 
3946
4020
  function renderTaskCard(task) {
@@ -3965,7 +4039,7 @@ function renderTaskCard(task) {
3965
4039
  aria-label="${escapeHtml(task.subject)} — ${task.status.replace('_', ' ')}">
3966
4040
  <div class="task-id">
3967
4041
  <span>#${taskId}</span>
3968
- ${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>` : ''}
3969
4043
  ${
3970
4044
  task.owner
3971
4045
  ? (
@@ -3979,7 +4053,6 @@ function renderTaskCard(task) {
3979
4053
  </div>
3980
4054
  <div class="task-title">${escapeHtml(task.subject)}</div>
3981
4055
  ${task.status === 'in_progress' && task.activeForm ? `<div class="task-active">${escapeHtml(task.activeForm)}</div>` : ''}
3982
- ${isBlocked ? `<div class="task-blocked">Waiting on ${blockers.map((id) => `#${id}`).join(', ')}</div>` : ''}
3983
4056
  ${task.description ? `<div class="task-desc">${escapeHtml(task.description.split('\n')[0])}</div>` : ''}
3984
4057
  </div>
3985
4058
  `;
@@ -4351,6 +4424,12 @@ function sgTransientGroup(name) {
4351
4424
  return group;
4352
4425
  }
4353
4426
 
4427
+ function dispatchedTitle({ parent, outcome }) {
4428
+ const starter = parent && sessions.find((s) => s.id === parent);
4429
+ const by = starter ? sessionDisplayName(starter) : 'another session';
4430
+ return `Started by ${by}${outcome ? ` · ${outcome}` : ''}${parent ? ' — click to reveal it' : ''}`;
4431
+ }
4432
+
4354
4433
  // A user group with the same name takes the session in, so Keep does not split a group in two.
4355
4434
  // With no group of its own, a started session follows its starter into the starter's named group.
4356
4435
  function sgDispatchGroupFor(session) {
@@ -4534,19 +4613,70 @@ function sgCommitRename(input, save) {
4534
4613
  //#region SESSION_GROUPS_DND
4535
4614
  // At most one zone is lit, so the reference is enough - dragover fires many times a second.
4536
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(', ');
4537
4619
 
4538
4620
  function sgClearDropTargets() {
4539
- sgLitZone?.classList.remove('sg-drop-over');
4621
+ sgLitZone?.classList.remove(...SG_LIT_CLASSES);
4540
4622
  sgLitZone = null;
4541
4623
  }
4542
4624
 
4543
- function sgLightDropTarget(zone) {
4544
- if (sgLitZone === zone) return;
4625
+ function sgLightDropTarget(zone, cls = 'sg-drop-over') {
4626
+ if (sgLitZone === zone && zone.classList.contains(cls)) return;
4545
4627
  sgClearDropTargets();
4546
- zone.classList.add('sg-drop-over');
4628
+ zone.classList.add(cls);
4547
4629
  sgLitZone = zone;
4548
4630
  }
4549
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
+
4550
4680
  function sgDragPayload(el) {
4551
4681
  if (el.classList.contains('session-group-header')) return { kind: 'group', ref: el.dataset.groupId };
4552
4682
  // data-project-path is on real project headers only, so the sentinels drop out by themselves.
@@ -4560,7 +4690,7 @@ function sgDragPayload(el) {
4560
4690
  // Resolve the element under the pointer to a legal drop zone for the in-flight drag.
4561
4691
  // Returns {zone} to ungroup, {zone, groupId} to assign, or {zone, pairWith}/{zone, pairSession}
4562
4692
  // to stack two ungrouped items into a brand-new group.
4563
- function sgDropZone(target) {
4693
+ function sgGroupDropZone(target) {
4564
4694
  if (!sgDrag) return null;
4565
4695
  const base =
4566
4696
  '.sg-ungroup-zone, .session-group-header, .session-group-sessions, .project-group-header, .project-group-sessions';
@@ -4644,6 +4774,7 @@ function sgOnDragStart(e) {
4644
4774
  return;
4645
4775
  }
4646
4776
  sgDrag = payload;
4777
+ sgDragEl = el;
4647
4778
  el.classList.add('sg-dragging');
4648
4779
  sessionsList.classList.add('sg-dragging-active');
4649
4780
  e.dataTransfer.effectAllowed = 'move';
@@ -4653,7 +4784,7 @@ function sgOnDragStart(e) {
4653
4784
  }
4654
4785
 
4655
4786
  function sgOnDragOver(e) {
4656
- const hit = sgDropZone(e.target);
4787
+ const hit = sgDropZone(e.target, e);
4657
4788
  // Moving onto a spot that refuses the drop has to put the previous highlight out, or two zones
4658
4789
  // stay lit and the one under the pointer is not the one that would receive the drop.
4659
4790
  if (!hit) {
@@ -4662,15 +4793,20 @@ function sgOnDragOver(e) {
4662
4793
  }
4663
4794
  e.preventDefault();
4664
4795
  e.dataTransfer.dropEffect = 'move';
4665
- sgLightDropTarget(hit.zone);
4796
+ sgLightDropTarget(hit.zone, hit.cls);
4666
4797
  }
4667
4798
 
4668
4799
  function sgOnDrop(e) {
4669
- const hit = sgDropZone(e.target);
4800
+ const hit = sgDropZone(e.target, e);
4670
4801
  if (!hit) return;
4671
4802
  e.preventDefault();
4672
4803
  e.stopPropagation();
4673
4804
  const drag = sgDrag;
4805
+ if (hit.insert) {
4806
+ sgInsertSession(drag.ref, hit.insert);
4807
+ sgFinishDrag();
4808
+ return;
4809
+ }
4674
4810
  if (hit.pairWith || hit.pairSession) {
4675
4811
  // Stacked onto an ungrouped item: seed a group, then let the user name it in place.
4676
4812
  const target = hit.pairWith
@@ -4701,6 +4837,7 @@ function sgOnDrop(e) {
4701
4837
 
4702
4838
  function sgFinishDrag() {
4703
4839
  sgDrag = null;
4840
+ sgDragEl = null;
4704
4841
  sgClearDropTargets();
4705
4842
  sessionsList.classList.remove('sg-dragging-active');
4706
4843
  // biome-ignore lint/suspicious/useIterableCallbackReturn: forEach side-effect
@@ -4721,7 +4858,7 @@ function initSessionGroupsDnd() {
4721
4858
  sessionsList.addEventListener('dragstart', sgOnDragStart);
4722
4859
  sessionsList.addEventListener('dragover', sgOnDragOver);
4723
4860
  sessionsList.addEventListener('dragleave', (e) => {
4724
- const zone = e.target.closest?.('.sg-drop-over');
4861
+ const zone = e.target.closest?.(SG_LIT_SELECTOR);
4725
4862
  if (zone && !zone.contains(e.relatedTarget)) sgClearDropTargets();
4726
4863
  });
4727
4864
  sessionsList.addEventListener('drop', sgOnDrop);
@@ -5131,7 +5268,7 @@ function uncollapseFor(session) {
5131
5268
  }
5132
5269
 
5133
5270
  function pinnedCollapseKeys(session) {
5134
- if (!isPlacedPinned(session.id) || isPlacedSticky(session.id) || isSessionActive(session)) return [];
5271
+ if (!isInPinnedGroup(session)) return [];
5135
5272
  const group = sgGroupForSession(session);
5136
5273
  const host = sgHostOf(group, session);
5137
5274
  return [pinKey(session.project || '__ungrouped__'), host && pinKey(host), group && `__pinned_group_${group.id}__`];
@@ -5338,22 +5475,33 @@ async function showTaskDetail(taskId, sessionId = null) {
5338
5475
  const blockers = openBlockers(task);
5339
5476
  const isBlocked = blockers.length > 0;
5340
5477
  const actualSessionId = task.sessionId || sessionId || currentSessionId;
5341
-
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');
5342
5491
  detailContent.innerHTML = `
5343
5492
  <div class="detail-section">
5344
- <div class="detail-label">Task #${task.id}</div>
5345
- <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>
5346
5495
  </div>
5347
5496
 
5348
5497
  <div class="detail-section" style="display: flex; gap: 12px; align-items: center;">
5349
5498
  <div>${statusLabels[task.status] || ''}</div>
5350
5499
  ${task.owner ? `<div style="font-size: 13px; color: ${getOwnerColor(task.owner).color}; font-weight: 500;">${escapeHtml(task.owner)}</div>` : ''}
5351
- ${isBlocked && task.status !== 'in_progress' ? '<div style="font-size: 10px; color: var(--warning);">Blocked</div>' : ''}
5352
5500
  </div>
5353
5501
 
5354
5502
  <div class="detail-section">
5355
- <div class="detail-label">Description</div>
5356
- <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>
5357
5505
  </div>
5358
5506
 
5359
5507
  ${
@@ -5369,33 +5517,24 @@ async function showTaskDetail(taskId, sessionId = null) {
5369
5517
  }
5370
5518
 
5371
5519
  ${
5372
- isBlocked
5520
+ deps.length
5373
5521
  ? `
5374
5522
  <div class="detail-section">
5375
- <div class="detail-label">Blocked By</div>
5523
+ <div class="detail-label">Dependencies</div>
5376
5524
  <div class="detail-deps">
5377
- <div class="detail-box blocked"><strong>Blocked by:</strong> ${blockers.map((id) => `#${id}`).join(', ')}</div>
5378
- </div>
5379
- </div>`
5380
- : ''
5381
- }
5382
-
5383
- ${
5384
- task.blocks && task.blocks.length > 0
5385
- ? `
5386
- <div class="detail-section">
5387
- <div class="detail-label">Blocks</div>
5388
- <div class="detail-deps">
5389
- <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('')}
5390
5526
  </div>
5391
5527
  </div>`
5392
5528
  : ''
5393
5529
  }
5394
5530
  `;
5395
5531
 
5532
+ for (const chip of detailContent.querySelectorAll('.detail-dep')) {
5533
+ chip.onclick = () => showTaskDetail(chip.dataset.depId, actualSessionId);
5534
+ }
5535
+
5396
5536
  // Setup button handlers (read-only in project view)
5397
5537
  const deleteBtn = document.getElementById('delete-task-btn');
5398
- const isProjectView = viewMode === 'project';
5399
5538
  deleteBtn.style.display = isProjectView ? 'none' : '';
5400
5539
  if (!isProjectView) deleteBtn.onclick = () => deleteTask(task.id, actualSessionId);
5401
5540
 
@@ -5428,7 +5567,7 @@ function editTitle(titleEl, task, sessionId) {
5428
5567
  if (val && val !== task.subject) {
5429
5568
  await saveTaskField(task.id, sessionId, 'subject', val);
5430
5569
  } else {
5431
- showTaskDetail(task.id, sessionId);
5570
+ await showTaskDetail(task.id, sessionId);
5432
5571
  }
5433
5572
  };
5434
5573
 
@@ -5438,6 +5577,18 @@ function editTitle(titleEl, task, sessionId) {
5438
5577
  save();
5439
5578
  }
5440
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
+ }
5441
5592
  };
5442
5593
  input.onblur = () => save();
5443
5594
  }
@@ -5477,8 +5628,10 @@ function editDescription(descEl, task, sessionId) {
5477
5628
 
5478
5629
  saveBtn.onclick = save;
5479
5630
  cancelBtn.onclick = () => showTaskDetail(task.id, sessionId);
5480
- textarea.onkeydown = (e) => {
5481
- 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);
5482
5635
  };
5483
5636
  }
5484
5637
 
@@ -5493,7 +5646,7 @@ async function saveTaskField(taskId, sessionId, field, value) {
5493
5646
  if (res.ok) {
5494
5647
  lastCurrentTasksHash = null;
5495
5648
  await fetchTasks(sessionId);
5496
- showTaskDetail(taskId, sessionId);
5649
+ await showTaskDetail(taskId, sessionId);
5497
5650
  }
5498
5651
  } catch (error) {
5499
5652
  console.error('Failed to update task:', error);
@@ -5507,10 +5660,6 @@ function closeDetailPanel() {
5507
5660
  taskHighlightDimmed = false;
5508
5661
  }
5509
5662
 
5510
- let deleteTaskId = null;
5511
- let deleteSessionId = null;
5512
- let deleteModalKeyHandler = null;
5513
-
5514
5663
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
5515
5664
  function showBlockedTaskModal(task) {
5516
5665
  const messageDiv = document.getElementById('blocked-task-message');
@@ -5555,62 +5704,47 @@ function closeBlockedTaskModal() {
5555
5704
  //#endregion
5556
5705
 
5557
5706
  //#region DELETE_TASK
5558
- function deleteTask(taskId, sessionId) {
5559
- const task = currentTasks.find((t) => t.id === taskId);
5560
- if (!task) return;
5561
-
5562
- deleteTaskId = taskId;
5563
- deleteSessionId = sessionId;
5564
-
5565
- const message = document.getElementById('delete-confirm-message');
5566
- message.textContent = `Delete task "${task.subject}"? This cannot be undone.`;
5567
-
5568
- const modal = document.getElementById('delete-confirm-modal');
5707
+ // Set while #confirm-modal is open; settles the open confirmModal() promise.
5708
+ let closeConfirmModal = null;
5709
+
5710
+ function confirmModal({ title, message, okLabel }) {
5711
+ const modal = document.getElementById('confirm-modal');
5712
+ document.getElementById('confirm-modal-title').textContent = title;
5713
+ document.getElementById('confirm-modal-message').textContent = message;
5714
+ const buttons = [document.getElementById('confirm-cancel-btn'), document.getElementById('confirm-ok-btn')];
5715
+ buttons[1].textContent = okLabel;
5569
5716
  modal.classList.add('visible');
5717
+ buttons[1].focus();
5570
5718
 
5571
- const buttons = [document.getElementById('delete-cancel-btn'), document.getElementById('delete-confirm-btn')];
5572
- let focusIdx = 1;
5573
- buttons[focusIdx].focus();
5574
-
5575
- deleteModalKeyHandler = (e) => {
5576
- if (e.key === 'Escape') {
5577
- e.preventDefault();
5578
- closeDeleteConfirmModal();
5579
- } else if (matchKey(e, 'ArrowLeft')) {
5719
+ const onKey = (e) => {
5720
+ if (matchKey(e, 'ArrowLeft', 'ArrowRight')) {
5580
5721
  e.preventDefault();
5581
- focusIdx = 0;
5582
- buttons[focusIdx].focus();
5583
- } else if (matchKey(e, 'ArrowRight')) {
5584
- e.preventDefault();
5585
- focusIdx = 1;
5586
- buttons[focusIdx].focus();
5722
+ buttons[e.key === 'ArrowLeft' ? 0 : 1].focus();
5587
5723
  } else if (e.key === 'Enter') {
5588
5724
  e.preventDefault();
5589
- buttons[focusIdx].click();
5725
+ (document.activeElement === buttons[0] ? buttons[0] : buttons[1]).click();
5590
5726
  }
5591
5727
  };
5592
- document.addEventListener('keydown', deleteModalKeyHandler);
5593
- }
5594
-
5595
- function closeDeleteConfirmModal() {
5596
- const modal = document.getElementById('delete-confirm-modal');
5597
- modal.classList.remove('visible');
5598
- deleteTaskId = null;
5599
- deleteSessionId = null;
5600
- if (deleteModalKeyHandler) {
5601
- document.removeEventListener('keydown', deleteModalKeyHandler);
5602
- deleteModalKeyHandler = null;
5603
- }
5728
+ document.addEventListener('keydown', onKey);
5729
+ return new Promise((resolve) => {
5730
+ closeConfirmModal = (ok) => {
5731
+ modal.classList.remove('visible');
5732
+ document.removeEventListener('keydown', onKey);
5733
+ closeConfirmModal = null;
5734
+ resolve(ok);
5735
+ };
5736
+ });
5604
5737
  }
5605
5738
 
5606
- // biome-ignore lint/correctness/noUnusedVariables: used in HTML
5607
- async function confirmDelete() {
5608
- if (!deleteTaskId || !deleteSessionId) return;
5609
-
5610
- const taskId = deleteTaskId;
5611
- const sessionId = deleteSessionId;
5612
-
5613
- closeDeleteConfirmModal();
5739
+ async function deleteTask(taskId, sessionId) {
5740
+ const task = currentTasks.find((t) => t.id === taskId);
5741
+ if (!task) return;
5742
+ const ok = await confirmModal({
5743
+ title: 'Delete Task',
5744
+ message: `Delete task "${task.subject}"? This cannot be undone.`,
5745
+ okLabel: 'Delete',
5746
+ });
5747
+ if (!ok) return;
5614
5748
 
5615
5749
  try {
5616
5750
  const res = await fetch(`/api/tasks/${sessionId}/${taskId}`, {
@@ -5696,7 +5830,7 @@ const SHORTCUT_TABS = [
5696
5830
  { keys: ['.'], label: 'Pin / unpin' },
5697
5831
  { keys: ['>'], label: 'Toggle sticky' },
5698
5832
  { keys: ['Shift', 'F10'], combo: true, label: 'Move to group (or Menu key)' },
5699
- { keys: ['Ctrl', 'D'], combo: true, label: 'Dismiss session' },
5833
+ { keys: ['Ctrl', 'D'], combo: true, label: 'Dismiss session (pinned: to Pinned group)' },
5700
5834
  { keys: ['Shift', 'L'], combo: true, label: 'Toggle session log' },
5701
5835
  { keys: ['Shift', 'M'], combo: true, label: 'Open last message' },
5702
5836
  { keys: ['↑', '↓'], label: 'Previous / next message in detail' },
@@ -6177,8 +6311,7 @@ async function _storageViewSession(id) {
6177
6311
  }
6178
6312
 
6179
6313
  function _storageUnpinSession(id) {
6180
- pinnedSessionIds.delete(id);
6181
- stickySessionIds.delete(id);
6314
+ clearSessionPin(id);
6182
6315
  savePinnedSessions();
6183
6316
  renderSessions();
6184
6317
  _renderStorageTab();
@@ -6463,6 +6596,69 @@ function matchKey(e, ...keys) {
6463
6596
  return keys.some((k) => e.key === k || e.code === k);
6464
6597
  }
6465
6598
 
6599
+ function dismissSessionFromList(sid) {
6600
+ const prevIdx = selectedSessionIdx;
6601
+ if (!setSessionDismissed(sid, true)) return;
6602
+ const newItems = getNavigableItems();
6603
+ if (isAnyPinned(sid)) {
6604
+ const pinnedIdx = newItems.findIndex((el) => el.dataset?.sessionId === sid);
6605
+ if (pinnedIdx >= 0) selectSessionByIndex(pinnedIdx, newItems);
6606
+ return;
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));
6612
+ const targetIdx = newItems.length > 0 ? Math.max(0, prevIdx - 1) : -1;
6613
+ // If the dismissed session is currently open, navigate to the previous one
6614
+ if (currentSessionId === sid || selectedSessionId === sid) {
6615
+ selectedSessionId = null;
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) {
6623
+ const targetSid = newItems[targetIdx]?.dataset?.sessionId;
6624
+ if (targetSid) {
6625
+ fetchTasks(targetSid).then(() => selectSessionByIndex(targetIdx, getNavigableItems()));
6626
+ } else {
6627
+ showNoSession();
6628
+ selectSessionByIndex(targetIdx, getNavigableItems());
6629
+ }
6630
+ } else {
6631
+ showNoSession();
6632
+ }
6633
+ } else if (targetIdx >= 0) {
6634
+ selectSessionByIndex(targetIdx, newItems);
6635
+ }
6636
+ }
6637
+
6638
+ async function confirmDismissBlockers(sid) {
6639
+ const sticky = stickySessionIds.has(sid);
6640
+ const terminal = runningTerminals.has(sid);
6641
+ if (!sticky && !terminal) return true;
6642
+ const session = sessions.find((s) => s.id === 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);
6660
+ }
6661
+
6466
6662
  const MODAL_ESC_PRIORITY = [
6467
6663
  'preview-modal',
6468
6664
  'msg-detail-modal',
@@ -6488,6 +6684,7 @@ const MODAL_CLOSERS = {
6488
6684
  'project-picker-modal': () => closeProjectPicker(),
6489
6685
  'terminal-manager-modal': () => closeTerminalManager(),
6490
6686
  'new-session-modal': () => closeNewSession(),
6687
+ 'confirm-modal': () => closeConfirmModal?.(false),
6491
6688
  };
6492
6689
 
6493
6690
  document.addEventListener('keydown', (e) => {
@@ -6645,6 +6842,7 @@ document.addEventListener('keydown', (e) => {
6645
6842
  }
6646
6843
 
6647
6844
  // Tab toggles focus zone
6845
+ if (e.key === 'Tab' && detailPanel.contains(e.target)) return;
6648
6846
  if (e.key === 'Tab') {
6649
6847
  e.preventDefault();
6650
6848
  // The terminal replaces the board, so it takes the board's place in the Tab cycle.
@@ -6795,27 +6993,8 @@ document.addEventListener('keydown', (e) => {
6795
6993
  }
6796
6994
  if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && e.key === 'd') {
6797
6995
  e.preventDefault();
6798
- const prevIdx = selectedSessionIdx;
6799
- if (!contextSid || !setSessionDismissed(contextSid, true)) return;
6800
- const newItems = getNavigableItems();
6801
- const targetIdx = newItems.length > 0 ? Math.max(0, prevIdx - 1) : -1;
6802
- // If the dismissed session is currently open, navigate to the previous one
6803
- if (currentSessionId === contextSid || selectedSessionId === contextSid) {
6804
- selectedSessionId = null;
6805
- if (targetIdx >= 0) {
6806
- const targetSid = newItems[targetIdx]?.dataset?.sessionId;
6807
- if (targetSid) {
6808
- fetchTasks(targetSid).then(() => selectSessionByIndex(targetIdx, getNavigableItems()));
6809
- } else {
6810
- showNoSession();
6811
- selectSessionByIndex(targetIdx, getNavigableItems());
6812
- }
6813
- } else {
6814
- showNoSession();
6815
- }
6816
- } else if (targetIdx >= 0) {
6817
- selectSessionByIndex(targetIdx, newItems);
6818
- }
6996
+ if (!contextSid) return;
6997
+ dismissSession(contextSid);
6819
6998
  return;
6820
6999
  }
6821
7000
  if (e.code === 'KeyC' && e.shiftKey && !e.altKey && !e.metaKey) {
@@ -6867,6 +7046,517 @@ document.addEventListener('keydown', (e) => {
6867
7046
 
6868
7047
  //#endregion
6869
7048
 
7049
+ //#region REVIEW
7050
+ // Comments on rendered content, sent to a session as one batch. Drafts outlive the view in
7051
+ // this tab, keyed by session and source, so closing a preview loses nothing. Highlights use
7052
+ // the CSS Custom Highlight API, which marks ranges without touching the DOM the renderer
7053
+ // built; a draft restored on reopen has no mark and shows in the panel only.
7054
+ //
7055
+ // An HTML preview is an iframe on an opaque origin, so its selection is out of reach from
7056
+ // here. `reviewBridge` runs inside the frame and reports each selection over postMessage;
7057
+ // the frame keeps its own ranges and marks them by id. A page whose CSP forbids inline
7058
+ // scripts gets no comments.
7059
+ const reviewDrafts = new Map();
7060
+ const reviewHighlight = typeof Highlight === 'function' && CSS.highlights ? new Highlight() : null;
7061
+ const reviewHotHighlight = reviewHighlight ? new Highlight() : null;
7062
+ const reviewPendingHighlight = reviewHighlight ? new Highlight() : null;
7063
+ if (reviewHighlight) {
7064
+ CSS.highlights.set('review', reviewHighlight);
7065
+ CSS.highlights.set('review-hot', reviewHotHighlight);
7066
+ CSS.highlights.set('review-pending', reviewPendingHighlight);
7067
+ }
7068
+ const REVIEW_MSG = 'cck-review:';
7069
+ let activeReview = null;
7070
+ let reviewMarkSeq = 0;
7071
+ let reviewPopMode = null;
7072
+ let reviewHoverAt = null;
7073
+ let reviewHoverFrame = 0;
7074
+
7075
+ function reviewKey(sessionId, source) {
7076
+ return `${sessionId}|${source.kind}|${source.path ? canonicalPath(source.path) : source.id || source.label}`;
7077
+ }
7078
+
7079
+ function reviewItems() {
7080
+ return (activeReview && reviewDrafts.get(activeReview.key)) || [];
7081
+ }
7082
+
7083
+ // `context(range)` adds view-specific citation fields to a comment on the host DOM;
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
+ }) {
7096
+ unmountReview();
7097
+ if (!sessionId) return;
7098
+ const ctl = new AbortController();
7099
+ const { signal } = ctl;
7100
+ const key = reviewKey(sessionId, source);
7101
+ activeReview = {
7102
+ contentEl,
7103
+ panelEl,
7104
+ hostEl,
7105
+ source,
7106
+ frame,
7107
+ context,
7108
+ onComment,
7109
+ onSent,
7110
+ sessionId,
7111
+ key,
7112
+ ctl,
7113
+ hot: null,
7114
+ };
7115
+ if (!frame) {
7116
+ contentEl.addEventListener('mouseup', () => setTimeout(offerReviewComment, 0), { signal });
7117
+ contentEl.addEventListener('mousemove', (e) => queueReviewHover(e.clientX, e.clientY), { signal });
7118
+ }
7119
+ window.addEventListener('message', onReviewFrameMessage, { signal });
7120
+ document.addEventListener('mousedown', closeReviewPop, { signal });
7121
+ document.addEventListener('keydown', onReviewOfferKey, { capture: true, signal });
7122
+ document.addEventListener('scroll', closeReviewOffer, { capture: true, signal });
7123
+ panelEl.addEventListener('click', onReviewPanelClick, { signal });
7124
+ panelEl.addEventListener('mouseover', onReviewPanelHover, { signal });
7125
+ panelEl.addEventListener('mouseleave', () => setReviewHot(null), { signal });
7126
+ renderReviewPanel();
7127
+ }
7128
+
7129
+ // With `ownPanel`, unmounts only the review that panel holds, so a modal under another one keeps it.
7130
+ function unmountReview(ownPanel) {
7131
+ if (ownPanel && activeReview?.panelEl !== ownPanel) return;
7132
+ closeReviewPop();
7133
+ if (!activeReview) return;
7134
+ const { ctl, panelEl, hostEl } = activeReview;
7135
+ ctl.abort();
7136
+ for (const c of reviewItems()) {
7137
+ c.range = null;
7138
+ c.frameId = 0;
7139
+ }
7140
+ panelEl.replaceChildren();
7141
+ panelEl.hidden = true;
7142
+ hostEl?.classList.remove('has-review');
7143
+ activeReview = null;
7144
+ reviewHighlight?.clear();
7145
+ reviewHotHighlight?.clear();
7146
+ }
7147
+
7148
+ // The three helpers below are also handed to `reviewBridge` as source text, so each must use
7149
+ // only its arguments and the globals of whichever document it runs in.
7150
+ function reviewTextBefore(root, container, offset) {
7151
+ const before = root.ownerDocument.createRange();
7152
+ before.setStart(root, 0);
7153
+ before.setEnd(container, offset);
7154
+ return before.toString();
7155
+ }
7156
+
7157
+ function reviewHeadingBefore(root, node) {
7158
+ let found = null;
7159
+ for (const h of root.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
7160
+ if (h.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING || h.contains(node)) found = h;
7161
+ else break;
7162
+ }
7163
+ return found?.textContent.trim().slice(0, 200) || undefined;
7164
+ }
7165
+
7166
+ function reviewRangeAt(x, y, entries) {
7167
+ const at = document.caretPositionFromPoint?.(x, y);
7168
+ if (!at?.offsetNode) return null;
7169
+ for (const [key, range] of entries) if (range?.isPointInRange(at.offsetNode, at.offset)) return key;
7170
+ return null;
7171
+ }
7172
+
7173
+ function queueReviewHover(x, y) {
7174
+ reviewHoverAt = [x, y];
7175
+ if (reviewHoverFrame) return;
7176
+ reviewHoverFrame = requestAnimationFrame(() => {
7177
+ reviewHoverFrame = 0;
7178
+ const marked = reviewItems().filter((c) => c.range);
7179
+ if (!marked.length) return;
7180
+ const [hx, hy] = reviewHoverAt;
7181
+ setReviewHot(
7182
+ reviewRangeAt(
7183
+ hx,
7184
+ hy,
7185
+ marked.map((c) => [c, c.range]),
7186
+ ),
7187
+ true,
7188
+ );
7189
+ });
7190
+ }
7191
+
7192
+ function setReviewHot(c, fromText) {
7193
+ if (!activeReview || activeReview.hot === c) return;
7194
+ activeReview.hot = c;
7195
+ reviewHotHighlight?.clear();
7196
+ if (c?.range) reviewHotHighlight?.add(c.range);
7197
+ if (activeReview.frame) postToReviewFrame('hot', c?.frameId || 0);
7198
+ const { panelEl } = activeReview;
7199
+ panelEl.querySelector('.review-card.hot')?.classList.remove('hot');
7200
+ const i = reviewItems().indexOf(c);
7201
+ const card = i >= 0 ? panelEl.querySelector(`.review-card[data-i="${i}"]`) : null;
7202
+ card?.classList.add('hot');
7203
+ if (fromText) card?.scrollIntoView({ block: 'nearest' });
7204
+ }
7205
+
7206
+ function reviewSelection() {
7207
+ const sel = window.getSelection();
7208
+ if (!activeReview || !sel || sel.isCollapsed || !sel.rangeCount) return null;
7209
+ const range = sel.getRangeAt(0);
7210
+ if (!activeReview.contentEl.contains(range.commonAncestorContainer)) return null;
7211
+ const quote = sel.toString().trim();
7212
+ if (!quote) return null;
7213
+ return { quote, range: range.cloneRange(), rect: range.getBoundingClientRect() };
7214
+ }
7215
+
7216
+ function onReviewFrameMessage(e) {
7217
+ const frameWin = activeReview?.frame?.contentWindow;
7218
+ const type =
7219
+ typeof e.data?.type === 'string' && e.data.type.startsWith(REVIEW_MSG) && e.data.type.slice(REVIEW_MSG.length);
7220
+ if (!frameWin || e.source !== frameWin || !type) return;
7221
+ if (type === 'clear') return closeReviewOffer();
7222
+ if (type === 'hover') {
7223
+ return setReviewHot((e.data.id && reviewItems().find((c) => c.frameId === e.data.id)) || null, true);
7224
+ }
7225
+ if (type !== 'select' || typeof e.data.quote !== 'string' || !e.data.quote.trim()) return;
7226
+ const box = activeReview.frame.getBoundingClientRect();
7227
+ const r = e.data.rect || {};
7228
+ const rect = { left: box.left + (r.left || 0), top: box.top + (r.top || 0), bottom: box.top + (r.bottom || 0) };
7229
+ const c = e.data.context || {};
7230
+ const context = { heading: c.heading, element: c.element, selector: c.selector };
7231
+ const pos = Number.isFinite(e.data.pos) ? e.data.pos : 0;
7232
+ showReviewOffer({ quote: e.data.quote.trim(), context, pos, rect, inFrame: true });
7233
+ }
7234
+
7235
+ function postToReviewFrame(type, id) {
7236
+ activeReview?.frame?.contentWindow?.postMessage({ type: REVIEW_MSG + type, id }, '*');
7237
+ }
7238
+
7239
+ // Runs inside the preview iframe as source text, so it must not close over anything outside
7240
+ // its own body and arguments.
7241
+ function reviewBridge(textBefore, headingBefore, rangeAt) {
7242
+ const P = 'cck-review:';
7243
+ const marks = new Map();
7244
+ let pending = null;
7245
+ let hotId = 0;
7246
+ let hoverAt = null;
7247
+ let hoverFrame = 0;
7248
+ const hl = typeof Highlight === 'function' && CSS.highlights ? new Highlight() : null;
7249
+ const hot = hl ? new Highlight() : null;
7250
+ const pend = hl ? new Highlight() : null;
7251
+ if (hl) {
7252
+ CSS.highlights.set('cck-review', hl);
7253
+ CSS.highlights.set('cck-review-hot', hot);
7254
+ CSS.highlights.set('cck-review-pending', pend);
7255
+ const style = document.createElement('style');
7256
+ style.textContent =
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)}';
7258
+ (document.head || document.documentElement).append(style);
7259
+ }
7260
+ const send = (msg) => parent.postMessage({ ...msg, type: P + msg.type }, '*');
7261
+ const describe = (node) => {
7262
+ const el = node.nodeType === 1 ? node : node.parentElement;
7263
+ if (!el) return {};
7264
+ const cls = [...el.classList].slice(0, 3);
7265
+ const attrs = [el.tagName.toLowerCase()];
7266
+ if (el.id) attrs.push(`id=${JSON.stringify(el.id)}`);
7267
+ if (cls.length) attrs.push(`class=${JSON.stringify(cls.join(' '))}`);
7268
+ const element = `<${attrs.join(' ')}>`;
7269
+ const parts = [];
7270
+ for (let n = el; n && n !== document.body && n.nodeType === 1; n = n.parentElement) {
7271
+ if (n.id) {
7272
+ parts.unshift(`#${n.id}`);
7273
+ break;
7274
+ }
7275
+ const tag = n.tagName.toLowerCase();
7276
+ const same = n.parentElement ? [...n.parentElement.children].filter((s) => s.tagName === n.tagName) : [];
7277
+ parts.unshift(same.length > 1 ? `${tag}:nth-of-type(${same.indexOf(n) + 1})` : tag);
7278
+ }
7279
+ return { element, selector: parts.join(' > '), heading: headingBefore(document, el) };
7280
+ };
7281
+ document.addEventListener('mouseup', () =>
7282
+ setTimeout(() => {
7283
+ const sel = getSelection();
7284
+ const quote = sel && !sel.isCollapsed && sel.rangeCount ? sel.toString().trim() : '';
7285
+ if (!quote) {
7286
+ pending = null;
7287
+ return;
7288
+ }
7289
+ pending = sel.getRangeAt(0).cloneRange();
7290
+ const r = pending.getBoundingClientRect();
7291
+ send({
7292
+ type: 'select',
7293
+ quote,
7294
+ pos: textBefore(document.body, pending.startContainer, pending.startOffset).length,
7295
+ rect: { left: r.left, top: r.top, bottom: r.bottom },
7296
+ context: describe(pending.commonAncestorContainer),
7297
+ });
7298
+ }, 0),
7299
+ );
7300
+ document.addEventListener('mousemove', (e) => {
7301
+ if (!marks.size && !hotId) return;
7302
+ hoverAt = [e.clientX, e.clientY];
7303
+ if (hoverFrame) return;
7304
+ hoverFrame = requestAnimationFrame(() => {
7305
+ hoverFrame = 0;
7306
+ const id = rangeAt(hoverAt[0], hoverAt[1], marks) || 0;
7307
+ if (id !== hotId) {
7308
+ hotId = id;
7309
+ send({ type: 'hover', id });
7310
+ }
7311
+ });
7312
+ });
7313
+ const clear = () => {
7314
+ if (pending) send({ type: 'clear' });
7315
+ };
7316
+ document.addEventListener('mousedown', clear);
7317
+ document.addEventListener('scroll', clear, true);
7318
+ addEventListener('message', (e) => {
7319
+ if (e.source !== parent || typeof e.data?.type !== 'string' || !e.data.type.startsWith(P)) return;
7320
+ const type = e.data.type.slice(P.length);
7321
+ const range = marks.get(e.data.id);
7322
+ if (type === 'mark' && pending) {
7323
+ marks.set(e.data.id, pending);
7324
+ hl?.add(pending);
7325
+ pending = null;
7326
+ getSelection().removeAllRanges();
7327
+ } else if (type === 'pend' && pending) {
7328
+ pend?.add(pending);
7329
+ } else if (type === 'unpend') {
7330
+ pend?.clear();
7331
+ } else if (type === 'hot') {
7332
+ hot?.clear();
7333
+ if (range) hot?.add(range);
7334
+ } else if (type === 'unmark' && range) {
7335
+ hl?.delete(range);
7336
+ hot?.delete(range);
7337
+ marks.delete(e.data.id);
7338
+ } else if (type === 'reveal' && range) {
7339
+ range.startContainer.parentElement?.scrollIntoView({ block: 'center', behavior: 'smooth' });
7340
+ }
7341
+ });
7342
+ }
7343
+
7344
+ const REVIEW_BRIDGE_TAG = `<script>(${reviewBridge})(${reviewTextBefore}, ${reviewHeadingBefore}, ${reviewRangeAt});</script>`;
7345
+
7346
+ function reviewPopEl() {
7347
+ let el = document.getElementById('review-pop');
7348
+ if (!el) {
7349
+ el = document.createElement('div');
7350
+ el.id = 'review-pop';
7351
+ el.addEventListener('mousedown', (e) => e.stopPropagation());
7352
+ el.addEventListener('click', (e) => e.stopPropagation());
7353
+ document.body.append(el);
7354
+ }
7355
+ return el;
7356
+ }
7357
+
7358
+ function placeReviewPop(el, r) {
7359
+ el.style.left = `${Math.max(8, Math.min(r.left, window.innerWidth - el.offsetWidth - 8))}px`;
7360
+ const above = r.top - el.offsetHeight - 6;
7361
+ el.style.top = `${above >= 8 ? above : r.bottom + 6}px`;
7362
+ }
7363
+
7364
+ function closeReviewPop() {
7365
+ if (!reviewPopMode) return;
7366
+ if (reviewPopMode === 'editor') {
7367
+ reviewPendingHighlight?.clear();
7368
+ if (activeReview?.frame) postToReviewFrame('unpend');
7369
+ }
7370
+ reviewPopMode = null;
7371
+ const el = reviewPopEl();
7372
+ el.className = '';
7373
+ el.replaceChildren();
7374
+ }
7375
+
7376
+ function closeReviewOffer() {
7377
+ if (reviewPopMode === 'offer') closeReviewPop();
7378
+ }
7379
+
7380
+ function offerReviewComment() {
7381
+ const picked = reviewSelection();
7382
+ if (picked) showReviewOffer(picked);
7383
+ else if (reviewPopMode !== 'editor') closeReviewPop();
7384
+ }
7385
+
7386
+ function showReviewOffer(picked) {
7387
+ const el = reviewPopEl();
7388
+ reviewPopMode = 'offer';
7389
+ el.className = 'visible offer';
7390
+ el.innerHTML = `<button type="button" class="review-offer-btn" title="Comment (C)">${ICON_CHAT}Comment<kbd>C</kbd></button>`;
7391
+ el.firstElementChild.addEventListener('click', () => openReviewEditor(picked));
7392
+ placeReviewPop(el, picked.rect);
7393
+ }
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
+
7402
+ function openReviewEditor(picked) {
7403
+ const el = reviewPopEl();
7404
+ reviewPopMode = 'editor';
7405
+ el.className = 'visible editor';
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>
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>`;
7410
+ const ta = el.querySelector('textarea');
7411
+ const add = () => {
7412
+ const comment = ta.value.trim();
7413
+ if (!comment) return ta.focus();
7414
+ addReviewComment(picked, comment);
7415
+ };
7416
+ ta.addEventListener('keydown', (e) => {
7417
+ e.stopPropagation();
7418
+ if (e.key === 'Escape') closeReviewPop();
7419
+ else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) add();
7420
+ });
7421
+ el.querySelector('[data-act="cancel"]').addEventListener('click', closeReviewPop);
7422
+ el.querySelector('[data-act="add"]').addEventListener('click', add);
7423
+ placeReviewPop(el, picked.rect);
7424
+ ta.focus();
7425
+ }
7426
+
7427
+ function addReviewComment(picked, comment) {
7428
+ if (!activeReview) return;
7429
+ const { key, contentEl } = activeReview;
7430
+ let item;
7431
+ if (picked.inFrame) {
7432
+ item = {
7433
+ quote: picked.quote,
7434
+ comment,
7435
+ context: picked.context,
7436
+ pos: picked.pos,
7437
+ range: null,
7438
+ frameId: ++reviewMarkSeq,
7439
+ };
7440
+ postToReviewFrame('mark', item.frameId);
7441
+ } else {
7442
+ const { range } = picked;
7443
+ const context = {
7444
+ heading: reviewHeadingBefore(contentEl, range.startContainer),
7445
+ ...activeReview.context?.(range),
7446
+ };
7447
+ const pos = reviewTextBefore(contentEl, range.startContainer, range.startOffset).length;
7448
+ item = { quote: picked.quote, comment, context, pos, range, frameId: 0 };
7449
+ reviewHighlight?.add(range);
7450
+ window.getSelection()?.removeAllRanges();
7451
+ }
7452
+ if (!reviewDrafts.has(key)) reviewDrafts.set(key, []);
7453
+ const items = reviewDrafts.get(key);
7454
+ items.push(item);
7455
+ items.sort((a, b) => a.pos - b.pos);
7456
+ closeReviewPop();
7457
+ renderReviewPanel();
7458
+ activeReview.onComment?.();
7459
+ }
7460
+
7461
+ function unmarkReviewItem(c) {
7462
+ if (c.range) reviewHighlight?.delete(c.range);
7463
+ else if (c.frameId) postToReviewFrame('unmark', c.frameId);
7464
+ }
7465
+
7466
+ function revealReviewItem(c) {
7467
+ if (c.range) c.range.startContainer.parentElement?.scrollIntoView({ block: 'center', behavior: 'smooth' });
7468
+ else if (c.frameId) postToReviewFrame('reveal', c.frameId);
7469
+ }
7470
+
7471
+ function removeReviewComment(c) {
7472
+ const items = reviewItems();
7473
+ const i = items.indexOf(c);
7474
+ if (i < 0) return;
7475
+ items.splice(i, 1);
7476
+ unmarkReviewItem(c);
7477
+ if (activeReview.hot === c) setReviewHot(null);
7478
+ if (!items.length) reviewDrafts.delete(activeReview.key);
7479
+ renderReviewPanel();
7480
+ }
7481
+
7482
+ function clearReviewDrafts(key = activeReview?.key) {
7483
+ for (const c of reviewDrafts.get(key) || []) unmarkReviewItem(c);
7484
+ reviewDrafts.delete(key);
7485
+ setReviewHot(null);
7486
+ renderReviewPanel();
7487
+ }
7488
+
7489
+ function onReviewPanelClick(e) {
7490
+ const act = e.target.closest('[data-act]')?.dataset.act;
7491
+ if (act === 'send') return sendReview();
7492
+ if (act === 'discard') return clearReviewDrafts();
7493
+ const card = e.target.closest('.review-card');
7494
+ const c = card && reviewItems()[Number(card.dataset.i)];
7495
+ if (!c) return;
7496
+ if (e.target.closest('.review-remove')) removeReviewComment(c);
7497
+ else revealReviewItem(c);
7498
+ }
7499
+
7500
+ function onReviewPanelHover(e) {
7501
+ const card = e.target.closest('.review-card');
7502
+ setReviewHot((card && reviewItems()[Number(card.dataset.i)]) || null);
7503
+ }
7504
+
7505
+ function renderReviewPanel() {
7506
+ if (!activeReview) return;
7507
+ const { panelEl, hostEl } = activeReview;
7508
+ const items = reviewItems();
7509
+ panelEl.hidden = !items.length;
7510
+ hostEl?.classList.toggle('has-review', items.length > 0);
7511
+ if (!items.length) return panelEl.replaceChildren();
7512
+ panelEl.innerHTML = `<div class="review-panel-head">
7513
+ <span>${items.length} comment${items.length > 1 ? 's' : ''}</span>
7514
+ <button type="button" data-act="discard" class="review-discard">Discard</button>
7515
+ <button type="button" data-act="send" class="primary">Send</button>
7516
+ </div>
7517
+ <ol class="review-panel-list">${items
7518
+ .map((c, i) => {
7519
+ return `<li class="review-card${c === activeReview.hot ? ' hot' : ''}" data-i="${i}" title="Show in the document">
7520
+ <button type="button" class="review-remove" title="Remove" aria-label="Remove">×</button>
7521
+ <div class="review-card-quote">${escapeHtml(c.quote.slice(0, 300))}</div>
7522
+ <div class="review-card-comment">${escapeHtml(c.comment)}</div></li>`;
7523
+ })
7524
+ .join('')}</ol>`;
7525
+ }
7526
+
7527
+ async function sendReview() {
7528
+ if (!activeReview) return;
7529
+ const { sessionId, source, key, onSent } = activeReview;
7530
+ const items = reviewItems();
7531
+ if (!items.length) return;
7532
+ try {
7533
+ const res = await terminalFetch(`/api/sessions/${encodeURIComponent(sessionId)}/review`, 'POST', {
7534
+ source,
7535
+ comments: items.map(({ quote, comment, context }) => ({ quote, comment, ...context })),
7536
+ });
7537
+ const data = await res.json();
7538
+ if (!res.ok) return showToast(data.error || 'Review failed', 'error');
7539
+ if (data.delivered === 'doorbell') showToast('Sent to session', 'success');
7540
+ else if (data.delivered === 'terminal') showToast('Pasted into the terminal: press Enter there to send', 'info');
7541
+ else {
7542
+ const { markdown } = data;
7543
+ const copy = () => navigator.clipboard.writeText(markdown).catch(() => {});
7544
+ await copy();
7545
+ showToast(
7546
+ 'Review copied. Paste it into the session.',
7547
+ 'info',
7548
+ { label: 'Copy again', onClick: copy },
7549
+ 'Run /claude-code-kanban:follow there to send reviews directly.',
7550
+ );
7551
+ }
7552
+ clearReviewDrafts(key);
7553
+ if (activeReview?.key === key) onSent?.();
7554
+ } catch {
7555
+ showToast('Failed to send the review', 'error');
7556
+ }
7557
+ }
7558
+ //#endregion
7559
+
6870
7560
  //#region MARKDOWN_PREVIEW
6871
7561
  const PREVIEW_STORAGE_PREFIX = 'preview-paths-';
6872
7562
  let currentPreviewPath = null;
@@ -6984,7 +7674,9 @@ function renderHtmlPreview(bodyEl, content) {
6984
7674
  frame.className = 'preview-html-frame';
6985
7675
  frame.setAttribute('sandbox', 'allow-scripts allow-popups');
6986
7676
  frame.setAttribute('referrerpolicy', 'no-referrer');
6987
- frame.srcdoc = content;
7677
+ // After the document's end the parser still adds the script to <body>, and it runs after
7678
+ // the page's own scripts.
7679
+ frame.srcdoc = content + REVIEW_BRIDGE_TAG;
6988
7680
  bodyEl.appendChild(frame);
6989
7681
  }
6990
7682
 
@@ -7048,6 +7740,22 @@ function openPreviewModal(filePath, content, kind) {
7048
7740
  document.getElementById('preview-modal-meta').textContent = filePath;
7049
7741
  document.getElementById('preview-modal').classList.add('visible');
7050
7742
  updatePreviewLinkBtn();
7743
+ if (kind === 'image') return unmountReview();
7744
+ mountReview({
7745
+ contentEl: bodyEl,
7746
+ panelEl: document.getElementById('preview-review-panel'),
7747
+ hostEl: document.querySelector('#preview-modal .modal'),
7748
+ source: { kind: 'file', label: fileName, path: filePath },
7749
+ frame: isHtml ? bodyEl.querySelector('iframe') : null,
7750
+ context: kind === 'text' ? (range) => ({ line: sourceLineOf(bodyEl, range) }) : null,
7751
+ onSent: closePreviewModal,
7752
+ });
7753
+ }
7754
+
7755
+ function sourceLineOf(bodyEl, range) {
7756
+ const code = bodyEl.querySelector('.preview-source code');
7757
+ if (!code?.contains(range.startContainer)) return undefined;
7758
+ return reviewTextBefore(code, range.startContainer, range.startOffset).split('\n').length;
7051
7759
  }
7052
7760
 
7053
7761
  function isPreviewLinkedToCurrentSession() {
@@ -7098,6 +7806,7 @@ function refreshInfoModalLinkedDocs() {
7098
7806
  }
7099
7807
 
7100
7808
  function closePreviewModal() {
7809
+ unmountReview(document.getElementById('preview-review-panel'));
7101
7810
  hideModalOverlay('preview-modal');
7102
7811
  // Empty the body so an iframe preview is destroyed and its scripts/timers stop.
7103
7812
  document.getElementById('preview-modal-body').innerHTML = '';
@@ -7965,20 +8674,30 @@ function renderMarkers(markers) {
7965
8674
  }
7966
8675
 
7967
8676
  function formatTokens(k) {
7968
- if (k >= 1000) return `${(k / 1000).toFixed(1)}M`;
8677
+ if (k >= 1000) return `${(k / 1000).toFixed(1).replace(/\.0$/, '')}M`;
7969
8678
  if (k < 1) return (k * 1000).toFixed(0);
7970
8679
  return `${Math.round(k)}K`;
7971
8680
  }
7972
8681
 
7973
- function getCtx(raw) {
8682
+ // session.autoCompact ({window, pct} from the session's settings) makes the compaction point the
8683
+ // bar's full scale, because the status line reports the model's full window regardless.
8684
+ function getCtx(session) {
8685
+ const raw = session?.contextStatus;
8686
+ const autoCompact = session?.autoCompact;
7974
8687
  if (!raw) return null;
7975
8688
  const cw = raw.context_window || {};
7976
- const size = cw.context_window_size || 0;
7977
- const pct = cw.used_percentage || 0;
8689
+ const modelSize = cw.context_window_size || 0;
7978
8690
  const model = raw.model || {};
7979
8691
  const modelName = model.display_name || model.id || '';
7980
8692
  const thresholds = getModelThresholds(modelName);
7981
- const usedTokens = size > 0 ? (pct / 100) * size : 0;
8693
+ const usedTokens = modelSize > 0 ? ((cw.used_percentage || 0) / 100) * modelSize : 0;
8694
+ const compactAt =
8695
+ modelSize > 0 && autoCompact
8696
+ ? (Math.min(modelSize, autoCompact.window || modelSize) * (autoCompact.pct || 100)) / 100
8697
+ : 0;
8698
+ const compacts = compactAt > 0 && compactAt < modelSize;
8699
+ const size = compacts ? compactAt : modelSize;
8700
+ const pct = compacts ? Math.min(100, (usedTokens / size) * 100) : cw.used_percentage || 0;
7982
8701
  const markers =
7983
8702
  size > 0
7984
8703
  ? [
@@ -7989,8 +8708,10 @@ function getCtx(raw) {
7989
8708
  : [];
7990
8709
  return {
7991
8710
  pct,
7992
- remaining: cw.remaining_percentage || 100 - pct,
8711
+ remaining: 100 - pct,
7993
8712
  size,
8713
+ modelSize,
8714
+ compacts,
7994
8715
  usedTokens,
7995
8716
  modelName,
7996
8717
  inputTokens: cw.total_input_tokens || 0,
@@ -7999,18 +8720,23 @@ function getCtx(raw) {
7999
8720
  };
8000
8721
  }
8001
8722
 
8002
- function renderContextBar(raw) {
8003
- const ctx = getCtx(raw);
8723
+ function renderContextBar(session) {
8724
+ const ctx = getCtx(session);
8004
8725
  if (!ctx) return '';
8005
8726
  const color = getContextColor(ctx.usedTokens, ctx.modelName);
8727
+ const fmt = (n) => formatTokens(n / 1000);
8728
+ const used = fmt(ctx.usedTokens);
8729
+ const title = ctx.compacts
8730
+ ? `Context: ${used} of ${fmt(ctx.size)} tokens before auto-compact (model window ${fmt(ctx.modelSize)})`
8731
+ : `Context: ${used}${ctx.size ? ` of ${fmt(ctx.size)}` : ''} tokens`;
8006
8732
  return `
8007
- <div class="context-bar" style="display:block">
8733
+ <div class="context-bar" title="${escapeHtml(title)}">
8008
8734
  <div class="context-bar-track">
8009
8735
  <div class="context-bar-fill" style="width:${ctx.pct}%;background:${color}"></div>
8010
8736
  ${renderMarkers(ctx.markers)}
8011
8737
  </div>
8012
8738
  <div class="context-bar-labels">
8013
- <span style="color:${color}">${Math.round(ctx.pct)}% (${formatTokens(ctx.usedTokens / 1000)})</span>
8739
+ <span style="color:${color}">${Math.round(ctx.pct)}% (${used})</span>
8014
8740
  <span>${Math.round(ctx.remaining)}% free</span>
8015
8741
  </div>
8016
8742
  </div>`;
@@ -8021,9 +8747,10 @@ function formatCost(usd) {
8021
8747
  return `$${usd.toFixed(2)}`;
8022
8748
  }
8023
8749
 
8024
- function renderContextDetail(raw, { tokens = true } = {}) {
8025
- const ctx = getCtx(raw);
8750
+ function renderContextDetail(session, { tokens = true } = {}) {
8751
+ const ctx = getCtx(session);
8026
8752
  if (!ctx) return '';
8753
+ const raw = session.contextStatus;
8027
8754
  const totalK = ctx.size / 1000;
8028
8755
  const color = getContextColor(ctx.usedTokens, ctx.modelName);
8029
8756
 
@@ -8052,7 +8779,7 @@ function renderContextDetail(raw, { tokens = true } = {}) {
8052
8779
  </div>
8053
8780
  <div class="detail-context-summary">
8054
8781
  <span style="color:${color}">${Math.round(ctx.pct)}% used</span>
8055
- <span>${formatTokens((ctx.pct / 100) * totalK)} / ${formatTokens(totalK)}</span>
8782
+ <span${ctx.compacts ? ` title="Auto-compact window · model window ${escapeHtml(formatTokens(ctx.modelSize / 1000))}"` : ''}>${formatTokens(ctx.usedTokens / 1000)} / ${formatTokens(totalK)}</span>
8056
8783
  </div>
8057
8784
  <div class="detail-context-stats">
8058
8785
  ${tokenRows}
@@ -8223,6 +8950,7 @@ function submitWaitingAnswers() {
8223
8950
  function showWaitingDetail() {
8224
8951
  if (!isWaitingFresh()) return;
8225
8952
  currentMsgDetailIdx = MSG_DETAIL_WAITING_IDX;
8953
+ unmountMsgReview();
8226
8954
  msgHighlightDimmed = false;
8227
8955
  highlightSelectedMsg();
8228
8956
  // Follow-mode poll ticks re-run this whole render — carry any typed reject
@@ -8278,9 +9006,8 @@ function showWaitingDetail() {
8278
9006
  document.getElementById('msg-detail-agent-btn').style.display = 'none';
8279
9007
  const modal = document.getElementById('msg-detail-modal').querySelector('.modal');
8280
9008
  autoSizeModal(modal, body);
8281
- modal.classList.toggle('live', msgDetailFollowLatest);
9009
+ setMsgDetailFollow(msgDetailFollowLatest);
8282
9010
  const overlay = document.getElementById('msg-detail-modal');
8283
- overlay.classList.toggle('live-overlay', msgDetailFollowLatest);
8284
9011
  const meta = [formatDate(currentWaiting.timestamp), 'waiting'];
8285
9012
  document.getElementById('msg-detail-meta').textContent = meta.join(' · ');
8286
9013
  currentPinDetailId = null;
@@ -8292,6 +9019,11 @@ function isSessionActive(s) {
8292
9019
  return s.hasRecentLog || s.inProgress > 0 || s.hasActiveAgents || s.hasWaitingForUser;
8293
9020
  }
8294
9021
 
9022
+ // An active pinned session renders outside the Pinned group until the user dismisses it.
9023
+ function isInPinnedGroup(s) {
9024
+ return isPlacedPinned(s.id) && !isPlacedSticky(s.id) && (!isSessionActive(s) || dismissedSessionIds.has(s.id));
9025
+ }
9026
+
8295
9027
  const SESSION_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
8296
9028
 
8297
9029
  function sessionDisplayName(session) {
@@ -8927,13 +9659,13 @@ function projectFilterText() {
8927
9659
  }
8928
9660
 
8929
9661
  let filterMatcher = { query: null, match: null };
8930
- function matchesProjectFilter(project) {
9662
+ function matchesProjectFilter(project, repo) {
8931
9663
  if (!filterProject) return true;
8932
9664
  if (filterProject === '__recent__') return recentProjects.has(project);
8933
9665
  if (filterMatcher.query !== filterProject) {
8934
9666
  filterMatcher = { query: filterProject, match: projectMatcher(filterProject) };
8935
9667
  }
8936
- return filterMatcher.match(project);
9668
+ return filterMatcher.match(project, repo);
8937
9669
  }
8938
9670
 
8939
9671
  //#endregion
@@ -9108,7 +9840,9 @@ async function refreshProjectList() {
9108
9840
  const cutoff = Date.now() - RECENT_PROJECT_HOURS * 60 * 60 * 1000;
9109
9841
  const prevRecent = recentProjects;
9110
9842
  recentProjects = new Set(
9111
- 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 || [])]),
9112
9846
  );
9113
9847
 
9114
9848
  renderFilterState();
@@ -9394,7 +10128,10 @@ async function showSessionInfoModal(sessionId) {
9394
10128
  const planPromise = fetch(`/api/sessions/${sessionId}/plan`)
9395
10129
  .then((r) => (r.ok ? r.json() : null))
9396
10130
  .catch(() => null)
9397
- .then((data) => data?.content || null);
10131
+ .then((data) => {
10132
+ if (data?.content) _pendingPlanPath = data.path || null;
10133
+ return data?.content || null;
10134
+ });
9398
10135
 
9399
10136
  const tasksPromise =
9400
10137
  cachedTasks.length > 0
@@ -9418,6 +10155,7 @@ async function showSessionInfoModal(sessionId) {
9418
10155
 
9419
10156
  let _infoModalSessionId = null;
9420
10157
  let _pendingPlanContent = null;
10158
+ let _pendingPlanPath = null;
9421
10159
 
9422
10160
  function updateStickyBtnState() {
9423
10161
  const stickyBtn = document.getElementById('session-info-sticky-btn');
@@ -9542,7 +10280,7 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
9542
10280
 
9543
10281
  if (session.contextStatus) {
9544
10282
  html += `<hr style="border: none; border-top: 1px solid var(--border); margin: 12px 0;">`;
9545
- html += renderContextDetail(session.contextStatus);
10283
+ html += renderContextDetail(session);
9546
10284
  }
9547
10285
 
9548
10286
  if (planContent) {
@@ -9660,8 +10398,11 @@ function openSessionFromInfo(sessionId) {
9660
10398
  }
9661
10399
 
9662
10400
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
9663
- function toggleDismissSession(sessionId) {
9664
- 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);
9665
10406
  }
9666
10407
 
9667
10408
  function updateDismissBtnState() {
@@ -10061,10 +10802,11 @@ function refreshOpenPlan() {
10061
10802
  fetch(`/api/sessions/${_planSessionId}/plan`)
10062
10803
  .then((r) => (r.ok ? r.json() : null))
10063
10804
  .then((data) => {
10064
- if (data?.content) {
10805
+ if (data?.content && (data.content !== _pendingPlanContent || data.path !== _pendingPlanPath)) {
10065
10806
  _pendingPlanContent = data.content;
10066
- const body = document.getElementById('plan-modal-body');
10067
- body.innerHTML = renderMarkdown(_pendingPlanContent);
10807
+ _pendingPlanPath = data.path || null;
10808
+ document.getElementById('plan-modal-body').innerHTML = renderMarkdown(_pendingPlanContent);
10809
+ mountPlanReview();
10068
10810
  }
10069
10811
  })
10070
10812
  .catch(() => {});
@@ -10383,6 +11125,7 @@ function openPlanForSession(sid) {
10383
11125
  .then((data) => {
10384
11126
  if (data?.content) {
10385
11127
  _pendingPlanContent = data.content;
11128
+ _pendingPlanPath = data.path || null;
10386
11129
  _planSessionId = sid;
10387
11130
  openPlanModal();
10388
11131
  }
@@ -10403,10 +11146,12 @@ function openPlanModal() {
10403
11146
  document.querySelector('#plan-modal .modal-footer').prepend(controls);
10404
11147
  }
10405
11148
  document.getElementById('plan-modal').classList.add('visible');
11149
+ mountPlanReview();
10406
11150
  const keyHandler = (e) => {
10407
11151
  if (e.key === 'Escape') {
10408
11152
  e.preventDefault();
10409
11153
  e.stopPropagation();
11154
+ if (reviewPopMode) return closeReviewPop();
10410
11155
  closePlanModal();
10411
11156
  document.removeEventListener('keydown', keyHandler, true);
10412
11157
  }
@@ -10414,7 +11159,20 @@ function openPlanModal() {
10414
11159
  document.addEventListener('keydown', keyHandler, true);
10415
11160
  }
10416
11161
 
11162
+ function mountPlanReview() {
11163
+ if (!_pendingPlanPath || !_planSessionId) return;
11164
+ mountReview({
11165
+ contentEl: document.getElementById('plan-modal-body'),
11166
+ panelEl: document.getElementById('plan-review-panel'),
11167
+ hostEl: document.querySelector('#plan-modal .modal'),
11168
+ source: { kind: 'file', label: pathBasename(_pendingPlanPath), path: _pendingPlanPath },
11169
+ sessionId: _planSessionId,
11170
+ onSent: closePlanModal,
11171
+ });
11172
+ }
11173
+
10417
11174
  function closePlanModal() {
11175
+ unmountReview(document.getElementById('plan-review-panel'));
10418
11176
  hideModalOverlay('plan-modal');
10419
11177
  }
10420
11178
 
@@ -10620,31 +11378,44 @@ const TERMINAL_MODES_KEY = 'terminal-sessions';
10620
11378
  const TERMINAL_FONT_KEY = 'terminal-font-size';
10621
11379
  const TERMINAL_FONT_MIN = 8;
10622
11380
  const TERMINAL_FONT_MAX = 32;
10623
- 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:';
10624
11385
  const TERMINAL_RETRY_MS = [500, 1000, 2000, 4000, 8000];
10625
11386
  const TERMINAL_STABLE_MS = 5000;
10626
11387
  const STALE_TOKEN_MSG = 'The terminal token is out of date. Reload the hub window.';
10627
11388
  // Read at load, before the first updateUrl() rewrites the URL without the fragment.
10628
11389
  let terminalToken = readTerminalToken();
10629
11390
  const termState = {
10630
- loaded: null,
10631
- term: null,
10632
- fit: null,
10633
- webgl: null,
10634
- ws: null,
11391
+ socket: null,
10635
11392
  sessionId: null,
11393
+ fontSize: null,
10636
11394
  syncedView: null,
10637
11395
  panelHidden: false,
10638
11396
  shown: false,
10639
11397
  attached: false,
10640
11398
  closeGuard: false,
10641
- leaving: false,
10642
11399
  focusNext: false,
10643
11400
  openedByUser: null,
10644
- ackPending: 0,
10645
- ackTimer: null,
10646
11401
  retryTimer: null,
10647
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;
10648
11419
 
10649
11420
  // The hub passes the token in the fragment, which is never sent to a server or logged.
10650
11421
  function readTerminalToken() {
@@ -10704,7 +11475,11 @@ function setTerminalMode(sessionId, on) {
10704
11475
  }
10705
11476
 
10706
11477
  function wantsTerminal() {
10707
- 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);
10708
11483
  }
10709
11484
 
10710
11485
  function toggleTerminal() {
@@ -10725,12 +11500,13 @@ function inPageField(e) {
10725
11500
  return !!field && !document.getElementById('terminal-pane').contains(field);
10726
11501
  }
10727
11502
 
10728
- 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) {
10729
11505
  const ctrlAlt = e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey;
10730
11506
  const ctrlShift = e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey;
10731
11507
  // Ctrl+_ is Claude Code's undo, so the zoom keys leave it to the terminal.
10732
11508
  const zoom = e.key === '_' ? undefined : zoomDelta(e);
10733
- if (zoom !== undefined && termState.term && terminalPaneFocused()) {
11509
+ if (zoom !== undefined && termFrame.inited && (probe || terminalPaneFocused())) {
10734
11510
  return () => adjustTerminalFontSize(Math.sign(zoom));
10735
11511
  }
10736
11512
  if (ctrlAlt && e.code === 'KeyS') return swapToPreviousSession;
@@ -10809,7 +11585,13 @@ function syncTerminal() {
10809
11585
  btn.classList.toggle('active', on);
10810
11586
  }
10811
11587
  sessionView.classList.toggle('terminal-mode', on);
11588
+ pushTerminalClaims();
10812
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
+ }
10813
11595
  termState.shown = false;
10814
11596
  termState.focusNext = false;
10815
11597
  termState.openedByUser = null;
@@ -10854,8 +11636,7 @@ function promptAwaitsUser(sessionId) {
10854
11636
 
10855
11637
  function onTerminalShown(focus = true) {
10856
11638
  requestAnimationFrame(() => {
10857
- fitTerminal();
10858
- repaintTerminal();
11639
+ terminalFrameSend('refresh');
10859
11640
  if (focus) focusTerminalPane();
10860
11641
  });
10861
11642
  }
@@ -10863,37 +11644,210 @@ function onTerminalShown(focus = true) {
10863
11644
  function focusTerminalPane() {
10864
11645
  const prompt = document.getElementById('terminal-prompt');
10865
11646
  if (prompt.classList.contains('visible')) prompt.querySelector('button')?.focus();
10866
- else termState.term?.focus();
11647
+ else if (termFrame.inited) {
11648
+ termFrame.el.focus();
11649
+ terminalFrameSend('focus');
11650
+ }
10867
11651
  }
10868
11652
 
10869
11653
  function leaveTerminalPane() {
10870
- termState.leaving = true;
11654
+ terminalHadFocus = false;
10871
11655
  document.activeElement.blur();
10872
- termState.leaving = false;
10873
11656
  setFocusZone('sidebar');
10874
11657
  }
10875
11658
 
10876
- function loadXterm() {
10877
- if (termState.loaded) return termState.loaded;
10878
- const css = document.createElement('link');
10879
- css.rel = 'stylesheet';
10880
- css.href = '/vendor/xterm/xterm.css';
10881
- document.head.appendChild(css);
10882
- const script = (src) =>
10883
- new Promise((resolve, reject) => {
10884
- const s = document.createElement('script');
10885
- s.src = src;
10886
- s.onload = resolve;
10887
- s.onerror = () => reject(new Error(`failed to load ${src}`));
10888
- 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,
10889
11756
  });
10890
- termState.loaded = script('/vendor/xterm/xterm.js').then(() =>
10891
- 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
+ }),
10892
11800
  );
10893
- termState.loaded.catch(() => {
10894
- 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);
10895
11850
  });
10896
- return termState.loaded;
10897
11851
  }
10898
11852
 
10899
11853
  function terminalTheme() {
@@ -10922,141 +11876,6 @@ function terminalThemeOptions() {
10922
11876
  };
10923
11877
  }
10924
11878
 
10925
- // Returning false hands the key back to the page: xterm skips it and the document
10926
- // listeners (the toggle, hub forwarding, native paste) see it instead.
10927
- function terminalKeyFilter(e) {
10928
- if (e.type !== 'keydown') return true;
10929
- const ctrlOnly = e.ctrlKey && !e.altKey && !e.metaKey;
10930
- if (terminalShortcut(e)) return false;
10931
- if (ctrlOnly && e.code === 'KeyC' && (e.shiftKey || termState.term.hasSelection())) {
10932
- e.preventDefault();
10933
- navigator.clipboard?.writeText(termState.term.getSelection()).catch(() => {});
10934
- termState.term.clearSelection();
10935
- return false;
10936
- }
10937
- if (ctrlOnly && e.code === 'KeyV') return false;
10938
- return !hub.forwards(e);
10939
- }
10940
-
10941
- function oscColor(hex) {
10942
- const m = /^#([0-9a-f]{6})$/i.exec(hex || '');
10943
- if (!m) return null;
10944
- return `rgb:${[0, 2, 4].map((i) => m[1].slice(i, i + 2).repeat(2)).join('/')}`;
10945
- }
10946
-
10947
- function ensureTerm() {
10948
- if (termState.term) return termState.term;
10949
- const cfg = appConfig.terminal;
10950
- const host = document.getElementById('terminal-host');
10951
- const fontFamily =
10952
- cfg.fontFamily || getComputedStyle(document.body).getPropertyValue('--font-mono').trim() || 'monospace';
10953
- const fontSize = terminalFontSize();
10954
- const term = new window.Terminal({
10955
- fontFamily,
10956
- fontSize,
10957
- scrollback: cfg.scrollback,
10958
- cursorBlink: true,
10959
- allowProposedApi: true,
10960
- ...terminalThemeOptions(),
10961
- });
10962
- termState.fit = new window.FitAddon.FitAddon();
10963
- term.loadAddon(termState.fit);
10964
- term.loadAddon(new window.Unicode11Addon.Unicode11Addon());
10965
- term.unicode.activeVersion = '11';
10966
- term.open(host);
10967
- try {
10968
- const gl = new window.WebglAddon.WebglAddon();
10969
- gl.onContextLoss(() => {
10970
- gl.dispose();
10971
- termState.webgl = null;
10972
- repaintTerminal();
10973
- });
10974
- term.loadAddon(gl);
10975
- termState.webgl = gl;
10976
- } catch (_) {
10977
- // No WebGL: xterm falls back to its DOM renderer.
10978
- }
10979
- document.fonts
10980
- ?.load(`${fontSize}px ${fontFamily}`)
10981
- .then(repaintTerminal)
10982
- .catch(() => {});
10983
- term.attachCustomKeyEventHandler(terminalKeyFilter);
10984
- // xterm leaves OSC 10/11 color queries unanswered, so apps that pick a palette from the
10985
- // background (Claude Code's Auto theme) assume dark even on a light theme.
10986
- for (const [code, key] of [
10987
- [10, 'foreground'],
10988
- [11, 'background'],
10989
- ]) {
10990
- term.parser.registerOscHandler(code, (data) => {
10991
- if (data !== '?') return false;
10992
- const rgb = oscColor(term.options.theme[key]);
10993
- if (rgb) terminalSend({ t: 'in', d: `\x1b]${code};${rgb}\x1b\\` });
10994
- return true;
10995
- });
10996
- }
10997
- term.onData((d) => terminalSend({ t: 'in', d }));
10998
- term.onResize(({ cols, rows }) => terminalSend({ t: 'resize', cols, rows }));
10999
- // One fit per frame while a drag resizes the host. The atlas is rebuilt only when the host comes
11000
- // back from 0×0, which is also how a hidden hub iframe or pane shows again.
11001
- let frame = 0;
11002
- let visible = false;
11003
- new ResizeObserver(() => {
11004
- if (frame) return;
11005
- frame = requestAnimationFrame(() => {
11006
- frame = 0;
11007
- const nowVisible = host.offsetWidth > 0;
11008
- fitTerminal();
11009
- if (nowVisible && !visible) repaintTerminal();
11010
- visible = nowVisible;
11011
- });
11012
- }).observe(host);
11013
- const themeKey = () => `${isLightTheme()}|${document.body.dataset.colorTheme || ''}`;
11014
- let appliedTheme = themeKey();
11015
- new MutationObserver(() => {
11016
- if (themeKey() === appliedTheme) return;
11017
- appliedTheme = themeKey();
11018
- Object.assign(term.options, terminalThemeOptions());
11019
- repaintTerminal();
11020
- }).observe(document.body, { attributes: true, attributeFilter: ['class', 'data-color-theme'] });
11021
- // Vimium eats Escape inside a text field and only blurs it, so the key never reaches Claude.
11022
- // A blur no click caused, while the pane stays shown and the window keeps focus, is that Escape.
11023
- let pointerDown = false;
11024
- document.addEventListener('mousedown', () => (pointerDown = true), true);
11025
- document.addEventListener('mouseup', () => (pointerDown = false), true);
11026
- term.textarea.addEventListener('blur', () => {
11027
- if (pointerDown || termState.leaving) return;
11028
- requestAnimationFrame(() => {
11029
- if (!termState.attached || !host.offsetWidth) return;
11030
- if (!document.hasFocus() || document.activeElement !== document.body) return;
11031
- terminalSend({ t: 'in', d: '\x1b' });
11032
- term.focus();
11033
- });
11034
- });
11035
- termState.term = term;
11036
- return term;
11037
- }
11038
-
11039
- // The WebGL renderer keeps its last frame and glyph atlas while the pane (or the hub's iframe) is
11040
- // display:none, and comes back showing stale cells or backgrounds with no text. xterm does not
11041
- // repaint on its own when the canvas is shown again.
11042
- function repaintTerminal() {
11043
- const term = termState.term;
11044
- if (!term) return;
11045
- try {
11046
- termState.webgl?.clearTextureAtlas();
11047
- } catch (_) {}
11048
- term.refresh(0, term.rows - 1);
11049
- }
11050
-
11051
- // The fit addon measures 0×0 while the pane (or the hub's iframe) is display:none.
11052
- function fitTerminal() {
11053
- const host = document.getElementById('terminal-host');
11054
- if (!termState.fit || !host.offsetWidth || !host.offsetHeight) return;
11055
- try {
11056
- termState.fit.fit();
11057
- } catch (_) {}
11058
- }
11059
-
11060
11879
  function clampTerminalFont(v) {
11061
11880
  return Math.min(TERMINAL_FONT_MAX, Math.max(TERMINAL_FONT_MIN, v));
11062
11881
  }
@@ -11066,33 +11885,25 @@ function terminalFontSize() {
11066
11885
  return saved ? clampTerminalFont(saved) : appConfig.terminal.fontSize;
11067
11886
  }
11068
11887
 
11888
+ function currentTerminalFontSize() {
11889
+ termState.fontSize ??= terminalFontSize();
11890
+ return termState.fontSize;
11891
+ }
11892
+
11069
11893
  function adjustTerminalFontSize(step) {
11070
- const term = termState.term;
11071
- const size = step === 0 ? appConfig.terminal.fontSize : clampTerminalFont(term.options.fontSize + step);
11072
- if (size !== term.options.fontSize) {
11894
+ const current = currentTerminalFontSize();
11895
+ const size = step === 0 ? appConfig.terminal.fontSize : clampTerminalFont(current + step);
11896
+ if (size !== current) {
11073
11897
  if (step === 0) store.removeItem(TERMINAL_FONT_KEY);
11074
11898
  else store.setItem(TERMINAL_FONT_KEY, String(size));
11075
- term.options.fontSize = size;
11076
- fitTerminal();
11899
+ termState.fontSize = size;
11900
+ terminalFrameSend('font', { size });
11077
11901
  }
11078
11902
  showToast(`Terminal ${size}px`);
11079
11903
  }
11080
11904
 
11081
11905
  function terminalSend(msg) {
11082
- if (termState.ws?.readyState === WebSocket.OPEN) termState.ws.send(JSON.stringify(msg));
11083
- }
11084
-
11085
- function ackTerminal(ws, n) {
11086
- if (termState.ws !== ws) return;
11087
- termState.ackPending += n;
11088
- const flush = () => {
11089
- clearTimeout(termState.ackTimer);
11090
- termState.ackTimer = null;
11091
- if (termState.ws === ws && termState.ackPending) terminalSend({ t: 'ack', n: termState.ackPending });
11092
- termState.ackPending = 0;
11093
- };
11094
- if (termState.ackPending >= ACK_BATCH_BYTES) flush();
11095
- else if (!termState.ackTimer) termState.ackTimer = setTimeout(flush, 50);
11906
+ if (termState.socket) terminalFrameSend('send', { msg });
11096
11907
  }
11097
11908
 
11098
11909
  function setTerminalStatus(text) {
@@ -11132,6 +11943,7 @@ function clearTerminalSlow() {
11132
11943
  clearTimeout(terminalSlowTimer);
11133
11944
  terminalSlowTimer = null;
11134
11945
  document.getElementById('terminal-boot').classList.remove('slow');
11946
+ pushTerminalClaims();
11135
11947
  }
11136
11948
 
11137
11949
  function terminalConnectSlow() {
@@ -11160,22 +11972,12 @@ function showTerminalPrompt(sessionId, title, detail, choices, output = '') {
11160
11972
  el.querySelector('button')?.focus();
11161
11973
  }
11162
11974
 
11163
- function terminalTail(count) {
11164
- const buf = termState.term?.buffer.active;
11165
- if (!buf) return '';
11166
- const lines = [];
11167
- for (let i = buf.length - 1; i >= 0 && lines.length < count; i--) {
11168
- const text = buf.getLine(i)?.translateToString(true) ?? '';
11169
- if (text.trim() || lines.length) lines.unshift(text);
11170
- }
11171
- return lines.join('\n');
11172
- }
11173
-
11174
11975
  // Closing the page leaves the PTY running, but Ctrl+W meant for the prompt closes the tab, so ask first.
11175
11976
  // Under the hub the top frame asks: browsers do not reliably show the dialog for a frame.
11176
11977
  function setTerminalAttached(on) {
11177
11978
  termState.attached = on;
11178
11979
  syncCloseGuard();
11980
+ pushTerminalClaims();
11179
11981
  }
11180
11982
 
11181
11983
  // A hidden pane stays attached, but Ctrl+W can only be meant for a terminal on screen.
@@ -11203,6 +12005,26 @@ document.addEventListener('focusout', (e) => {
11203
12005
  if (document.hasFocus()) terminalHadFocus = terminalPaneFocused();
11204
12006
  });
11205
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
+ });
11206
12028
  window.addEventListener('pagehide', () => {
11207
12029
  try {
11208
12030
  if (termState.attached && termState.shown && terminalHadFocus) {
@@ -11227,109 +12049,87 @@ document.getElementById('terminal-pane').addEventListener('focusin', () => {
11227
12049
  });
11228
12050
 
11229
12051
  function detachTerminal() {
11230
- const ws = termState.ws;
11231
- termState.ws = null;
12052
+ const had = termState.socket;
12053
+ termState.socket = null;
11232
12054
  termState.sessionId = null;
11233
- clearTimeout(termState.ackTimer);
11234
- termState.ackTimer = null;
11235
- termState.ackPending = 0;
11236
12055
  clearTimeout(termState.retryTimer);
11237
12056
  termState.retryTimer = null;
11238
12057
  setTerminalAttached(false);
11239
- if (ws) {
11240
- ws.onclose = null;
11241
- ws.close();
11242
- }
12058
+ if (had) terminalFrameSend('detach');
11243
12059
  hideTerminalPrompt();
11244
12060
  hideTerminalBoot();
11245
12061
  }
11246
12062
 
12063
+ // The frame owns the socket and xterm; the board picks the session, the mode and when to retry.
11247
12064
  async function openTerminal(sessionId, mode, attempt = 0) {
11248
12065
  detachTerminal();
11249
12066
  termState.sessionId = sessionId;
12067
+ const socket = { id: ++terminalSocketSeq, sessionId, mode, attempt, readyAt: 0, refused: false };
12068
+ termState.socket = socket;
11250
12069
  if (!attempt) setTerminalStatus('');
11251
- try {
11252
- await loadXterm();
11253
- } catch (e) {
11254
- setTerminalStatus(e.message);
11255
- 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;
11256
12079
  }
11257
- if (termState.sessionId !== sessionId) return;
11258
- const term = ensureTerm();
11259
- if (!attempt) showTerminalBoot();
11260
- // RIS through the write queue, not term.reset(): reset() runs at once, and output the previous
11261
- // session had already queued would still be drawn after it. A retry keeps the last screen
11262
- // until the replay arrives.
11263
- const reset = () => term.write('\x1bc', repaintTerminal);
11264
- if (!attempt) reset();
11265
- fitTerminal();
11266
- const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/api/terminal/ws`);
11267
- ws.binaryType = 'arraybuffer';
11268
- termState.ws = ws;
11269
12080
  if (!attempt) {
12081
+ showTerminalBoot();
11270
12082
  terminalSlowTimer = setTimeout(() => {
11271
- 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();
11272
12086
  }, TERMINAL_SLOW_CONNECT_MS);
11273
12087
  }
11274
12088
  const spec = newSpecs.get(sessionId);
11275
- let readyAt = 0;
11276
- let refused = false;
11277
- ws.onopen = () =>
11278
- ws.send(
11279
- JSON.stringify({
11280
- t: 'hello',
11281
- token: terminalToken,
11282
- id: sessionId,
11283
- mode,
11284
- cols: term.cols,
11285
- rows: term.rows,
11286
- ...(spec?.mode === mode && {
11287
- cwd: spec.cwd,
11288
- name: spec.name,
11289
- worktree: spec.worktree,
11290
- model: spec.model,
11291
- prompt: spec.prompt,
11292
- }),
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,
11293
12102
  }),
11294
- );
11295
- ws.onmessage = (ev) => {
11296
- if (termState.ws !== ws) return;
11297
- if (typeof ev.data !== 'string') {
11298
- const bytes = new Uint8Array(ev.data);
11299
- term.write(bytes, () => {
11300
- ackTerminal(ws, bytes.length);
11301
- if (terminalBootTimer && termState.ws === ws && terminalTail(1)) hideTerminalBoot();
11302
- });
11303
- return;
11304
- }
11305
- let msg;
11306
- try {
11307
- msg = JSON.parse(ev.data);
11308
- } catch (_) {
11309
- return;
11310
- }
11311
- if (msg.t === 'ready') {
11312
- readyAt = Date.now();
11313
- if (attempt) reset();
11314
- } else if (msg.t === 'error') refused = true;
11315
- onTerminalMessage(sessionId, msg);
11316
- };
11317
- ws.onclose = (ev) => {
11318
- if (termState.ws !== ws) return;
11319
- termState.ws = null;
11320
- setTerminalAttached(false);
11321
- // A refusal already shows the server's reason, and retrying would only repeat it, unless the
11322
- // reason was a token that the hub has since replaced (4001).
11323
- if (refused && ev.code === 4001) {
11324
- refreshTerminalToken().then((ok) => {
11325
- if (ok && termState.sessionId === sessionId) openTerminal(sessionId, mode, attempt + 1);
11326
- });
11327
- return;
11328
- }
11329
- if (refused || document.getElementById('terminal-prompt').classList.contains('visible')) return;
11330
- const stable = readyAt && Date.now() - readyAt > TERMINAL_STABLE_MS;
11331
- retryTerminal(sessionId, stable ? 0 : attempt);
11332
- };
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);
11333
12133
  }
11334
12134
 
11335
12135
  function terminalOpenMode(sessionId) {
@@ -11368,7 +12168,7 @@ function retryTerminal(sessionId, attempt) {
11368
12168
  }, delay);
11369
12169
  }
11370
12170
 
11371
- function onTerminalMessage(sessionId, msg) {
12171
+ function onTerminalMessage(sessionId, msg, tail = '') {
11372
12172
  if (msg.t === 'ready') {
11373
12173
  // The server has the prompt now; a later start of the same placeholder must not send it again.
11374
12174
  const spec = newSpecs.get(sessionId);
@@ -11377,8 +12177,7 @@ function onTerminalMessage(sessionId, msg) {
11377
12177
  hideTerminalPrompt();
11378
12178
  setTerminalStatus('');
11379
12179
  setTerminalAttached(true);
11380
- terminalSend({ t: 'resize', cols: termState.term.cols, rows: termState.term.rows });
11381
- if (takeTerminalFocus(sessionId)) termState.term.focus();
12180
+ if (takeTerminalFocus(sessionId)) focusTerminalPane();
11382
12181
  } else if (msg.t === 'rekey' && typeof msg.id === 'string') {
11383
12182
  adoptPickedSession(sessionId, msg.id);
11384
12183
  if (msg.duplicate) showToast('That session is already open here, so this terminal switched to it', 'info');
@@ -11419,7 +12218,7 @@ function onTerminalMessage(sessionId, msg) {
11419
12218
  ['fork', 'Fork'],
11420
12219
  ['shell', 'Shell'],
11421
12220
  ],
11422
- clean ? '' : terminalTail(6),
12221
+ clean ? '' : tail,
11423
12222
  );
11424
12223
  } else if (msg.t === 'error') {
11425
12224
  hideTerminalBoot();
@@ -11449,12 +12248,42 @@ function openTerminalManager() {
11449
12248
  if (terminalPaneFocused()) leaveTerminalPane();
11450
12249
  document.getElementById('terminal-manager-modal').classList.add('visible');
11451
12250
  renderTerminalManager();
12251
+ clearInterval(terminalProcTimer);
12252
+ pollTerminalProcStats();
12253
+ terminalProcTimer = setInterval(pollTerminalProcStats, TERMINAL_PROC_POLL_MS);
11452
12254
  }
11453
12255
 
11454
12256
  function closeTerminalManager() {
12257
+ clearInterval(terminalProcTimer);
11455
12258
  hideModalOverlay('terminal-manager-modal');
11456
12259
  }
11457
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
+
11458
12287
  async function renderTerminalManager() {
11459
12288
  const body = document.getElementById('terminal-manager-body');
11460
12289
  let list = [];
@@ -11488,6 +12317,7 @@ async function renderTerminalManager() {
11488
12317
  <span class="terminal-manager-mode">${escapeHtml(t.mode)}</span>
11489
12318
  <span title="${escapeHtml(`pid ${t.pid}`)}">up ${formatDuration(Date.now() - t.startedAt)}</span>
11490
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>
11491
12321
  <span class="terminal-manager-cwd" title="${escapeHtml(t.cwd)}">${escapeHtml(pathBasename(t.cwd))}</span>
11492
12322
  </div>
11493
12323
  </div>
@@ -11571,6 +12401,7 @@ function placeholderSession(id, spec) {
11571
12401
  name: spec.name || PLACEHOLDER_NAMES[spec.mode] || PLACEHOLDER_NAMES.new,
11572
12402
  dispatchGroup: spec.group || undefined,
11573
12403
  startedBy: spec.startedBy || undefined,
12404
+ dispatched: spec.mode === 'dispatch' ? { parent: spec.startedBy || null } : undefined,
11574
12405
  project: spec.cwd,
11575
12406
  worktree: spec.worktree ? { repo: spec.cwd, name: spec.worktree === true ? 'new' : spec.worktree } : null,
11576
12407
  modifiedAt: new Date(spec.startedAt).toISOString(),
@@ -11666,12 +12497,14 @@ async function loadTerminals() {
11666
12497
  const res = await fetch('/api/terminals', { cache: 'no-store' });
11667
12498
  const list = (await res.json()).sessions || [];
11668
12499
  runningTerminals = new Set(list.map((t) => t.id));
12500
+ pushTerminalClaims();
11669
12501
  renderActivityChip();
11670
12502
  return list;
11671
12503
  }
11672
12504
 
11673
12505
  function setRunningTerminals(ids) {
11674
12506
  runningTerminals = new Set(ids);
12507
+ pushTerminalClaims();
11675
12508
  if (ids.some((id) => !sessions.some((s) => s.id === id))) fetchSessions(false).catch(() => {});
11676
12509
  else renderSessionViews();
11677
12510
  renderActivityChip();
@@ -12234,7 +13067,12 @@ fetch('/api/config')
12234
13067
  })
12235
13068
  .catch(() => {})
12236
13069
  .then(restorePendingSessions)
12237
- .then(() => fetchSessions())
13070
+ .then(() =>
13071
+ fetchSessions(
13072
+ true,
13073
+ urlState.projectView ? null : urlState.session || (lastView?.view === 'session' ? lastView.session : null),
13074
+ ),
13075
+ )
12238
13076
  .then(async () => {
12239
13077
  if (urlState.projectView) {
12240
13078
  try {
@@ -12315,7 +13153,11 @@ const hub = ClaudeHub.connect({
12315
13153
  }),
12316
13154
  });
12317
13155
 
12318
- hub.onActive((active) => onHubActive(active));
13156
+ hub.onActive((active) => {
13157
+ onHubActive(active);
13158
+ pushTerminalClaims();
13159
+ });
13160
+ hub.onStatus(pushTerminalClaims);
12319
13161
 
12320
13162
  // session.changed goes out once the switch settles, so arrowing through the list sends one event.
12321
13163
  let publishTimer = 0;