claude-code-kanban 5.1.0 → 5.2.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 (42) hide show
  1. package/README.md +3 -2
  2. package/lib/auto-compact.js +42 -0
  3. package/lib/claude-settings.js +21 -0
  4. package/lib/dispatch.js +11 -4
  5. package/lib/retention.js +137 -0
  6. package/lib/session-events.js +30 -7
  7. package/lib/terminal.js +23 -3
  8. package/package.json +1 -1
  9. package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
  10. package/plugin/plugins/claude-code-kanban/hooks/hooks.json +14 -7
  11. package/plugin/plugins/claude-code-kanban/monitors.json +3 -3
  12. package/plugin/plugins/claude-code-kanban/scripts/postman.js +2 -2
  13. package/plugin/plugins/claude-code-kanban/skills/{kanban-dispatch → dispatch}/SKILL.md +2 -2
  14. package/plugin/plugins/claude-code-kanban/skills/{kanban-follow → follow}/SKILL.md +17 -9
  15. package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +1 -1
  16. package/public/app.js +702 -141
  17. package/public/fonts/OFL-IBM-Plex.txt +93 -0
  18. package/public/fonts/OFL-Playfair-Display.txt +93 -0
  19. package/public/fonts/fonts.css +244 -0
  20. package/public/fonts/ibm-plex-mono-cyrillic-400.woff2 +0 -0
  21. package/public/fonts/ibm-plex-mono-cyrillic-500.woff2 +0 -0
  22. package/public/fonts/ibm-plex-mono-cyrillic-600.woff2 +0 -0
  23. package/public/fonts/ibm-plex-mono-cyrillic-ext-400.woff2 +0 -0
  24. package/public/fonts/ibm-plex-mono-cyrillic-ext-500.woff2 +0 -0
  25. package/public/fonts/ibm-plex-mono-cyrillic-ext-600.woff2 +0 -0
  26. package/public/fonts/ibm-plex-mono-latin-400.woff2 +0 -0
  27. package/public/fonts/ibm-plex-mono-latin-500.woff2 +0 -0
  28. package/public/fonts/ibm-plex-mono-latin-600.woff2 +0 -0
  29. package/public/fonts/ibm-plex-mono-latin-ext-400.woff2 +0 -0
  30. package/public/fonts/ibm-plex-mono-latin-ext-500.woff2 +0 -0
  31. package/public/fonts/ibm-plex-mono-latin-ext-600.woff2 +0 -0
  32. package/public/fonts/ibm-plex-mono-vietnamese-400.woff2 +0 -0
  33. package/public/fonts/ibm-plex-mono-vietnamese-500.woff2 +0 -0
  34. package/public/fonts/ibm-plex-mono-vietnamese-600.woff2 +0 -0
  35. package/public/fonts/playfair-display-cyrillic-400.woff2 +0 -0
  36. package/public/fonts/playfair-display-latin-400.woff2 +0 -0
  37. package/public/fonts/playfair-display-latin-ext-400.woff2 +0 -0
  38. package/public/fonts/playfair-display-vietnamese-400.woff2 +0 -0
  39. package/public/index.html +20 -14
  40. package/public/style.css +277 -44
  41. package/server.js +152 -15
  42. package/skill-guides/dispatch.md +1 -1
package/public/app.js CHANGED
@@ -260,7 +260,7 @@ let lastTasksHash = '';
260
260
  //#endregion
261
261
 
262
262
  //#region DATA_FETCHING
263
- async function fetchSessions(includeTasks = true) {
263
+ async function fetchSessions(includeTasks = true, focusId = currentSessionId) {
264
264
  try {
265
265
  const allPinnedIds = new Set([...pinnedSessionIds, ...stickySessionIds]);
266
266
  if (revealedPlanSessionId) allPinnedIds.add(revealedPlanSessionId);
@@ -269,7 +269,7 @@ async function fetchSessions(includeTasks = true) {
269
269
  // The focused session must come back whatever the filters say: renderSession and the
270
270
  // info modal both look it up in `sessions` and bail when it is absent, so a session
271
271
  // opened from outside the current filter would leave the view on the previous one.
272
- const includeParam = currentSessionId ? `&include=${encodeURIComponent(currentSessionId)}` : '';
272
+ const includeParam = focusId ? `&include=${encodeURIComponent(focusId)}` : '';
273
273
  const projectParam =
274
274
  filterProject === '__recent__'
275
275
  ? `&recentHours=${RECENT_PROJECT_HOURS}`
@@ -1727,6 +1727,8 @@ const ICON_AGENT_ACTIVE =
1727
1727
  const ICON_TERMINAL =
1728
1728
  '<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
1729
  const TERMINAL_TITLE = 'Running in a terminal here';
1730
+ const ICON_DISPATCHED =
1731
+ '<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
1732
  const ICON_CHAT =
1731
1733
  '<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
1734
  const TOOL_ICONS = {
@@ -1974,16 +1976,24 @@ function offloadSessionPin(sessionId) {
1974
1976
  }).catch(() => {});
1975
1977
  }
1976
1978
 
1979
+ function clearSessionPin(sessionId) {
1980
+ pinnedSessionIds.delete(sessionId);
1981
+ stickySessionIds.delete(sessionId);
1982
+ deferredPinPlacement.delete(sessionId);
1983
+ }
1984
+
1985
+ function unpinSession(sessionId) {
1986
+ clearSessionPin(sessionId);
1987
+ savePinnedSessions();
1988
+ offloadSessionPin(sessionId);
1989
+ renderSessions();
1990
+ }
1991
+
1977
1992
  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
- }
1993
+ if (pinnedSessionIds.has(sessionId)) return unpinSession(sessionId);
1994
+ pinnedSessionIds.add(sessionId);
1995
+ if (sessionId === currentSessionId) deferredPinPlacement.add(sessionId);
1996
+ expandPinnedFor(sessionId);
1987
1997
  savePinnedSessions();
1988
1998
  offloadSessionPin(sessionId);
1989
1999
  renderSessions();
@@ -1991,9 +2001,7 @@ function toggleSessionPin(sessionId) {
1991
2001
 
1992
2002
  function toggleSessionSticky(sessionId) {
1993
2003
  if (stickySessionIds.has(sessionId)) {
1994
- stickySessionIds.delete(sessionId);
1995
- pinnedSessionIds.delete(sessionId);
1996
- deferredPinPlacement.delete(sessionId);
2004
+ clearSessionPin(sessionId);
1997
2005
  } else {
1998
2006
  pinnedSessionIds.add(sessionId);
1999
2007
  stickySessionIds.add(sessionId);
@@ -2013,9 +2021,7 @@ function isPlacedSticky(id) {
2013
2021
 
2014
2022
  function handleSessionPinEvent({ id, state }) {
2015
2023
  if (!id) return;
2016
- pinnedSessionIds.delete(id);
2017
- stickySessionIds.delete(id);
2018
- deferredPinPlacement.delete(id);
2024
+ clearSessionPin(id);
2019
2025
  if (state === 'pinned') pinnedSessionIds.add(id);
2020
2026
  if (state === 'sticky') {
2021
2027
  pinnedSessionIds.add(id);
@@ -2456,14 +2462,21 @@ let _manualRefreshing = false;
2456
2462
 
2457
2463
  //#region TOAST
2458
2464
  // `action` ({label, onClick}) adds a button and keeps the toast up long enough to reach it.
2459
- function showToast(msg, type, action) {
2465
+ function showToast(msg, type, action, hint) {
2460
2466
  const el = document.getElementById('toast');
2461
2467
  clearTimeout(_toastTimer);
2462
2468
  el.style.transition = 'none';
2463
2469
  el.classList.remove('visible', 'toast-success', 'toast-error', 'toast-info', 'has-action');
2464
2470
  void el.offsetHeight;
2465
2471
  el.style.transition = '';
2466
- el.textContent = msg;
2472
+ if (hint) {
2473
+ const text = document.createElement('div');
2474
+ const hintEl = document.createElement('div');
2475
+ hintEl.className = 'toast-hint';
2476
+ hintEl.textContent = hint;
2477
+ text.append(msg, hintEl);
2478
+ el.replaceChildren(text);
2479
+ } else el.textContent = msg;
2467
2480
  const hide = () => el.classList.remove('visible', 'has-action');
2468
2481
  if (action) {
2469
2482
  const btn = document.createElement('button');
@@ -3316,7 +3329,7 @@ async function revealSession(id) {
3316
3329
  const uncollapsed = session ? uncollapseFor(session) : false;
3317
3330
  if (uncollapsed) persistCollapsedGroups();
3318
3331
  expandSidebar();
3319
- await fetchTasks(id);
3332
+ await openSession(id);
3320
3333
  // fetchTasks skips its render when the session and task hash are unchanged.
3321
3334
  if (uncollapsed) renderSessions();
3322
3335
  const el = document.querySelector(`.session-item[data-session-id="${escSel(id)}"]`);
@@ -3507,15 +3520,6 @@ function renderSessions() {
3507
3520
  const projectHtml = renderProjectIdentity(session);
3508
3521
 
3509
3522
  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
3523
  const isTeam = session.isTeam;
3520
3524
  const memberCount = session.memberCount || 0;
3521
3525
 
@@ -3539,14 +3543,20 @@ function renderSessions() {
3539
3543
  : '';
3540
3544
  const justFinishedClass = isJustFinished(session) ? 'just-finished' : '';
3541
3545
  const sid = escAttrJs(session.id);
3546
+ const progressHtml =
3547
+ total > 0
3548
+ ? `<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>`
3549
+ : '';
3550
+ const metricsHtml = progressHtml + (showCtx ? renderContextBar(session) : '');
3542
3551
  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)}">
3552
+ <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
3553
  <span class="session-pin-btn${pinClass}" onclick="event.stopPropagation();toggleSessionPin('${sid}')" title="${pinTitle} session">${pinState === 'sticky' ? SESSION_STAR_SVG : SESSION_PIN_SVG}</span>
3545
3554
  <div class="session-name">${escapeHtml(sessionName)}</div>
3546
3555
  ${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
3547
3556
  ${gitBranch ? `<div class="session-branch">${gitBranch}</div>` : ''}
3548
- ${session.planTitle ? `<div class="session-plan">${escapeHtml(session.planTitle)}</div>` : ''}
3549
- <div class="session-progress">
3557
+ ${metricsHtml ? `<div class="session-metrics">${metricsHtml}</div>` : ''}
3558
+ <div class="session-footer">
3559
+ <div class="session-time">${formatDate(session.modifiedAt)}</div>
3550
3560
  <span class="session-indicators">
3551
3561
  ${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
3562
  ${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 +3568,13 @@ function renderSessions() {
3558
3568
  ${session.hasPlan && !session.planSourceSessionId ? `<span class="plan-indicator" onclick="event.stopPropagation(); openPlanForSession('${sid}')" title="View plan">${ICON_PLAN}</span>` : ''}
3559
3569
  ${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
3570
  ${session.sharedTaskList ? `<span class="shared-tasklist-badge" title="Shared task list: ${escapeHtml(session.sharedTaskList)}">${linkSvg(12)}</span>` : ''}
3571
+ ${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
3572
  ${runningTerminals.has(session.id) ? `<span class="terminal-badge" onclick="event.stopPropagation(); showSessionTerminal('${escAttrJs(session.id)}')" title="${TERMINAL_TITLE}">${ICON_TERMINAL}</span>` : ''}
3562
3573
  ${session.hasWaitingForUser ? `<span class="agent-badge agent-badge-waiting" title="Waiting for user">${ICON_AGENT_WAITING}</span>` : ''}
3563
3574
  ${session.hasRunningAgents && !session.hasWaitingForUser ? `<span class="agent-badge agent-badge-active" title="Agents running">${ICON_AGENT_ACTIVE}</span>` : ''}
3564
3575
  ${isLive || session.hasRunningAgents ? `<span class="pulse" title="${isLive ? 'Live' : 'Active agents'}"></span>` : ''}
3565
3576
  </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
3577
  </div>
3569
- ${showCtx ? renderContextBar(session.contextStatus) : ''}
3570
- <div class="session-time">${formatDate(session.modifiedAt)}</div>
3571
3578
  </button>
3572
3579
  `;
3573
3580
  };
@@ -3577,7 +3584,7 @@ function renderSessions() {
3577
3584
  if (zenMode) {
3578
3585
  sessionsList.innerHTML = `${renderSessionCard(zenSession)}
3579
3586
  <div class="zen-panel">
3580
- ${renderContextDetail(zenSession.contextStatus, { tokens: false }) || '<div class="zen-panel-empty">No context data for this session</div>'}
3587
+ ${renderContextDetail(zenSession, { tokens: false }) || '<div class="zen-panel-empty">No context data for this session</div>'}
3581
3588
  ${renderScratchpadRow(zenSession)}
3582
3589
  ${renderLinkedDocsHtml(zenSession.id)}
3583
3590
  ${renderArtifactsHtml(zenSession.id)}
@@ -3591,7 +3598,7 @@ function renderSessions() {
3591
3598
  }
3592
3599
 
3593
3600
  const groupPinned = store.getItem('groupPinnedSessions') !== 'false';
3594
- const pinWeight = (s) => (isPlacedSticky(s.id) ? 2 : isPlacedPinned(s.id) && !isSessionActive(s) ? 1 : 0);
3601
+ const pinWeight = (s) => (isPlacedSticky(s.id) ? 2 : isInPinnedGroup(s) ? 1 : 0);
3595
3602
  const pinSort = (a, b) => pinWeight(b) - pinWeight(a);
3596
3603
  const countHtml = (arr) => {
3597
3604
  const active = arr.reduce((n, s) => n + (isSessionActive(s) ? 1 : 0), 0);
@@ -3609,12 +3616,10 @@ function renderSessions() {
3609
3616
 
3610
3617
  const renderGroupSessions = (sessions, pinKey) => {
3611
3618
  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));
3619
+ const gIdlePinned = sessions.filter(isInPinnedGroup);
3620
+ if (gIdlePinned.length === 0) return sessions.map(renderSessionCard).join('');
3621
+ const gUnpinned = sessions.filter((s) => !isInPinnedGroup(s));
3616
3622
  const pinCollapsed = collapsedProjectGroups.has(pinKey);
3617
- if (gIdlePinned.length === 0) return gUnpinned.map(renderSessionCard).join('');
3618
3623
  return (
3619
3624
  '<div class="pinned-sub-section">' +
3620
3625
  '<div class="pinned-sub-header' +
@@ -4351,6 +4356,12 @@ function sgTransientGroup(name) {
4351
4356
  return group;
4352
4357
  }
4353
4358
 
4359
+ function dispatchedTitle({ parent, outcome }) {
4360
+ const starter = parent && sessions.find((s) => s.id === parent);
4361
+ const by = starter ? sessionDisplayName(starter) : 'another session';
4362
+ return `Started by ${by}${outcome ? ` · ${outcome}` : ''}${parent ? ' — click to reveal it' : ''}`;
4363
+ }
4364
+
4354
4365
  // A user group with the same name takes the session in, so Keep does not split a group in two.
4355
4366
  // With no group of its own, a started session follows its starter into the starter's named group.
4356
4367
  function sgDispatchGroupFor(session) {
@@ -5131,7 +5142,7 @@ function uncollapseFor(session) {
5131
5142
  }
5132
5143
 
5133
5144
  function pinnedCollapseKeys(session) {
5134
- if (!isPlacedPinned(session.id) || isPlacedSticky(session.id) || isSessionActive(session)) return [];
5145
+ if (!isInPinnedGroup(session)) return [];
5135
5146
  const group = sgGroupForSession(session);
5136
5147
  const host = sgHostOf(group, session);
5137
5148
  return [pinKey(session.project || '__ungrouped__'), host && pinKey(host), group && `__pinned_group_${group.id}__`];
@@ -5507,10 +5518,6 @@ function closeDetailPanel() {
5507
5518
  taskHighlightDimmed = false;
5508
5519
  }
5509
5520
 
5510
- let deleteTaskId = null;
5511
- let deleteSessionId = null;
5512
- let deleteModalKeyHandler = null;
5513
-
5514
5521
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
5515
5522
  function showBlockedTaskModal(task) {
5516
5523
  const messageDiv = document.getElementById('blocked-task-message');
@@ -5555,62 +5562,47 @@ function closeBlockedTaskModal() {
5555
5562
  //#endregion
5556
5563
 
5557
5564
  //#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');
5565
+ // Set while #confirm-modal is open; settles the open confirmModal() promise.
5566
+ let closeConfirmModal = null;
5567
+
5568
+ function confirmModal({ title, message, okLabel }) {
5569
+ const modal = document.getElementById('confirm-modal');
5570
+ document.getElementById('confirm-modal-title').textContent = title;
5571
+ document.getElementById('confirm-modal-message').textContent = message;
5572
+ const buttons = [document.getElementById('confirm-cancel-btn'), document.getElementById('confirm-ok-btn')];
5573
+ buttons[1].textContent = okLabel;
5569
5574
  modal.classList.add('visible');
5575
+ buttons[1].focus();
5570
5576
 
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')) {
5577
+ const onKey = (e) => {
5578
+ if (matchKey(e, 'ArrowLeft', 'ArrowRight')) {
5580
5579
  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();
5580
+ buttons[e.key === 'ArrowLeft' ? 0 : 1].focus();
5587
5581
  } else if (e.key === 'Enter') {
5588
5582
  e.preventDefault();
5589
- buttons[focusIdx].click();
5583
+ (document.activeElement === buttons[0] ? buttons[0] : buttons[1]).click();
5590
5584
  }
5591
5585
  };
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
- }
5586
+ document.addEventListener('keydown', onKey);
5587
+ return new Promise((resolve) => {
5588
+ closeConfirmModal = (ok) => {
5589
+ modal.classList.remove('visible');
5590
+ document.removeEventListener('keydown', onKey);
5591
+ closeConfirmModal = null;
5592
+ resolve(ok);
5593
+ };
5594
+ });
5604
5595
  }
5605
5596
 
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();
5597
+ async function deleteTask(taskId, sessionId) {
5598
+ const task = currentTasks.find((t) => t.id === taskId);
5599
+ if (!task) return;
5600
+ const ok = await confirmModal({
5601
+ title: 'Delete Task',
5602
+ message: `Delete task "${task.subject}"? This cannot be undone.`,
5603
+ okLabel: 'Delete',
5604
+ });
5605
+ if (!ok) return;
5614
5606
 
5615
5607
  try {
5616
5608
  const res = await fetch(`/api/tasks/${sessionId}/${taskId}`, {
@@ -5696,7 +5688,7 @@ const SHORTCUT_TABS = [
5696
5688
  { keys: ['.'], label: 'Pin / unpin' },
5697
5689
  { keys: ['>'], label: 'Toggle sticky' },
5698
5690
  { keys: ['Shift', 'F10'], combo: true, label: 'Move to group (or Menu key)' },
5699
- { keys: ['Ctrl', 'D'], combo: true, label: 'Dismiss session' },
5691
+ { keys: ['Ctrl', 'D'], combo: true, label: 'Dismiss session (pinned: to Pinned group)' },
5700
5692
  { keys: ['Shift', 'L'], combo: true, label: 'Toggle session log' },
5701
5693
  { keys: ['Shift', 'M'], combo: true, label: 'Open last message' },
5702
5694
  { keys: ['↑', '↓'], label: 'Previous / next message in detail' },
@@ -6177,8 +6169,7 @@ async function _storageViewSession(id) {
6177
6169
  }
6178
6170
 
6179
6171
  function _storageUnpinSession(id) {
6180
- pinnedSessionIds.delete(id);
6181
- stickySessionIds.delete(id);
6172
+ clearSessionPin(id);
6182
6173
  savePinnedSessions();
6183
6174
  renderSessions();
6184
6175
  _renderStorageTab();
@@ -6463,6 +6454,47 @@ function matchKey(e, ...keys) {
6463
6454
  return keys.some((k) => e.key === k || e.code === k);
6464
6455
  }
6465
6456
 
6457
+ function dismissSessionFromList(sid) {
6458
+ const prevIdx = selectedSessionIdx;
6459
+ if (!setSessionDismissed(sid, true)) return;
6460
+ const newItems = getNavigableItems();
6461
+ if (isAnyPinned(sid)) {
6462
+ const pinnedIdx = newItems.findIndex((el) => el.dataset?.sessionId === sid);
6463
+ if (pinnedIdx >= 0) selectSessionByIndex(pinnedIdx, newItems);
6464
+ return;
6465
+ }
6466
+ const targetIdx = newItems.length > 0 ? Math.max(0, prevIdx - 1) : -1;
6467
+ // If the dismissed session is currently open, navigate to the previous one
6468
+ if (currentSessionId === sid || selectedSessionId === sid) {
6469
+ selectedSessionId = null;
6470
+ if (targetIdx >= 0) {
6471
+ const targetSid = newItems[targetIdx]?.dataset?.sessionId;
6472
+ if (targetSid) {
6473
+ fetchTasks(targetSid).then(() => selectSessionByIndex(targetIdx, getNavigableItems()));
6474
+ } else {
6475
+ showNoSession();
6476
+ selectSessionByIndex(targetIdx, getNavigableItems());
6477
+ }
6478
+ } else {
6479
+ showNoSession();
6480
+ }
6481
+ } else if (targetIdx >= 0) {
6482
+ selectSessionByIndex(targetIdx, newItems);
6483
+ }
6484
+ }
6485
+
6486
+ async function dismissStickySession(sid) {
6487
+ const session = sessions.find((s) => s.id === sid);
6488
+ const ok = await confirmModal({
6489
+ title: 'Remove Sticky Pin',
6490
+ message: `"${session ? sessionDisplayName(session) : sid.slice(0, 8)}" has a sticky pin. Remove the pin and dismiss the session?`,
6491
+ okLabel: 'Unpin and dismiss',
6492
+ });
6493
+ if (!ok) return;
6494
+ unpinSession(sid);
6495
+ dismissSessionFromList(sid);
6496
+ }
6497
+
6466
6498
  const MODAL_ESC_PRIORITY = [
6467
6499
  'preview-modal',
6468
6500
  'msg-detail-modal',
@@ -6488,6 +6520,7 @@ const MODAL_CLOSERS = {
6488
6520
  'project-picker-modal': () => closeProjectPicker(),
6489
6521
  'terminal-manager-modal': () => closeTerminalManager(),
6490
6522
  'new-session-modal': () => closeNewSession(),
6523
+ 'confirm-modal': () => closeConfirmModal?.(false),
6491
6524
  };
6492
6525
 
6493
6526
  document.addEventListener('keydown', (e) => {
@@ -6795,27 +6828,8 @@ document.addEventListener('keydown', (e) => {
6795
6828
  }
6796
6829
  if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && e.key === 'd') {
6797
6830
  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
- }
6831
+ if (!contextSid) return;
6832
+ (stickySessionIds.has(contextSid) ? dismissStickySession : dismissSessionFromList)(contextSid);
6819
6833
  return;
6820
6834
  }
6821
6835
  if (e.code === 'KeyC' && e.shiftKey && !e.altKey && !e.metaKey) {
@@ -6867,6 +6881,484 @@ document.addEventListener('keydown', (e) => {
6867
6881
 
6868
6882
  //#endregion
6869
6883
 
6884
+ //#region REVIEW
6885
+ // Comments on rendered content, sent to a session as one batch. Drafts outlive the view in
6886
+ // this tab, keyed by session and source, so closing a preview loses nothing. Highlights use
6887
+ // the CSS Custom Highlight API, which marks ranges without touching the DOM the renderer
6888
+ // built; a draft restored on reopen has no mark and shows in the panel only.
6889
+ //
6890
+ // An HTML preview is an iframe on an opaque origin, so its selection is out of reach from
6891
+ // here. `reviewBridge` runs inside the frame and reports each selection over postMessage;
6892
+ // the frame keeps its own ranges and marks them by id. A page whose CSP forbids inline
6893
+ // scripts gets no comments.
6894
+ const reviewDrafts = new Map();
6895
+ const reviewHighlight = typeof Highlight === 'function' && CSS.highlights ? new Highlight() : null;
6896
+ const reviewHotHighlight = reviewHighlight ? new Highlight() : null;
6897
+ if (reviewHighlight) {
6898
+ CSS.highlights.set('review', reviewHighlight);
6899
+ CSS.highlights.set('review-hot', reviewHotHighlight);
6900
+ }
6901
+ const REVIEW_MSG = 'cck-review:';
6902
+ let activeReview = null;
6903
+ let reviewMarkSeq = 0;
6904
+ let reviewPopMode = null;
6905
+ let reviewHoverAt = null;
6906
+ let reviewHoverFrame = 0;
6907
+
6908
+ function reviewKey(sessionId, source) {
6909
+ return `${sessionId}|${source.kind}|${source.path ? canonicalPath(source.path) : source.label}`;
6910
+ }
6911
+
6912
+ function reviewItems() {
6913
+ return (activeReview && reviewDrafts.get(activeReview.key)) || [];
6914
+ }
6915
+
6916
+ // `context(range)` adds view-specific citation fields to a comment on the host DOM;
6917
+ // `onSent()` runs after a batch is delivered.
6918
+ function mountReview({ contentEl, panelEl, hostEl, source, frame, context, onSent, sessionId = currentSessionId }) {
6919
+ unmountReview();
6920
+ if (!sessionId) return;
6921
+ const ctl = new AbortController();
6922
+ const { signal } = ctl;
6923
+ const key = reviewKey(sessionId, source);
6924
+ activeReview = {
6925
+ contentEl,
6926
+ panelEl,
6927
+ hostEl,
6928
+ source,
6929
+ frame,
6930
+ context,
6931
+ onSent,
6932
+ sessionId,
6933
+ key,
6934
+ ctl,
6935
+ hot: null,
6936
+ };
6937
+ if (!frame) {
6938
+ contentEl.addEventListener('mouseup', () => setTimeout(offerReviewComment, 0), { signal });
6939
+ contentEl.addEventListener('mousemove', (e) => queueReviewHover(e.clientX, e.clientY), { signal });
6940
+ }
6941
+ window.addEventListener('message', onReviewFrameMessage, { signal });
6942
+ document.addEventListener('mousedown', closeReviewPop, { signal });
6943
+ document.addEventListener('scroll', closeReviewOffer, { capture: true, signal });
6944
+ panelEl.addEventListener('click', onReviewPanelClick, { signal });
6945
+ panelEl.addEventListener('mouseover', onReviewPanelHover, { signal });
6946
+ panelEl.addEventListener('mouseleave', () => setReviewHot(null), { signal });
6947
+ renderReviewPanel();
6948
+ }
6949
+
6950
+ // With `ownPanel`, unmounts only the review that panel holds, so a modal under another one keeps it.
6951
+ function unmountReview(ownPanel) {
6952
+ if (ownPanel && activeReview?.panelEl !== ownPanel) return;
6953
+ closeReviewPop();
6954
+ if (!activeReview) return;
6955
+ const { ctl, panelEl, hostEl } = activeReview;
6956
+ ctl.abort();
6957
+ for (const c of reviewItems()) {
6958
+ c.range = null;
6959
+ c.frameId = 0;
6960
+ }
6961
+ panelEl.replaceChildren();
6962
+ panelEl.hidden = true;
6963
+ hostEl?.classList.remove('has-review');
6964
+ activeReview = null;
6965
+ reviewHighlight?.clear();
6966
+ reviewHotHighlight?.clear();
6967
+ }
6968
+
6969
+ // The three helpers below are also handed to `reviewBridge` as source text, so each must use
6970
+ // only its arguments and the globals of whichever document it runs in.
6971
+ function reviewTextBefore(root, container, offset) {
6972
+ const before = root.ownerDocument.createRange();
6973
+ before.setStart(root, 0);
6974
+ before.setEnd(container, offset);
6975
+ return before.toString();
6976
+ }
6977
+
6978
+ function reviewHeadingBefore(root, node) {
6979
+ let found = null;
6980
+ for (const h of root.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
6981
+ if (h.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING || h.contains(node)) found = h;
6982
+ else break;
6983
+ }
6984
+ return found?.textContent.trim().slice(0, 200) || undefined;
6985
+ }
6986
+
6987
+ function reviewRangeAt(x, y, entries) {
6988
+ const at = document.caretPositionFromPoint?.(x, y);
6989
+ if (!at?.offsetNode) return null;
6990
+ for (const [key, range] of entries) if (range?.isPointInRange(at.offsetNode, at.offset)) return key;
6991
+ return null;
6992
+ }
6993
+
6994
+ function queueReviewHover(x, y) {
6995
+ reviewHoverAt = [x, y];
6996
+ if (reviewHoverFrame) return;
6997
+ reviewHoverFrame = requestAnimationFrame(() => {
6998
+ reviewHoverFrame = 0;
6999
+ const marked = reviewItems().filter((c) => c.range);
7000
+ if (!marked.length) return;
7001
+ const [hx, hy] = reviewHoverAt;
7002
+ setReviewHot(
7003
+ reviewRangeAt(
7004
+ hx,
7005
+ hy,
7006
+ marked.map((c) => [c, c.range]),
7007
+ ),
7008
+ true,
7009
+ );
7010
+ });
7011
+ }
7012
+
7013
+ function setReviewHot(c, fromText) {
7014
+ if (!activeReview || activeReview.hot === c) return;
7015
+ activeReview.hot = c;
7016
+ reviewHotHighlight?.clear();
7017
+ if (c?.range) reviewHotHighlight?.add(c.range);
7018
+ if (activeReview.frame) postToReviewFrame('hot', c?.frameId || 0);
7019
+ const { panelEl } = activeReview;
7020
+ panelEl.querySelector('.review-card.hot')?.classList.remove('hot');
7021
+ const i = reviewItems().indexOf(c);
7022
+ const card = i >= 0 ? panelEl.querySelector(`.review-card[data-i="${i}"]`) : null;
7023
+ card?.classList.add('hot');
7024
+ if (fromText) card?.scrollIntoView({ block: 'nearest' });
7025
+ }
7026
+
7027
+ function reviewSelection() {
7028
+ const sel = window.getSelection();
7029
+ if (!activeReview || !sel || sel.isCollapsed || !sel.rangeCount) return null;
7030
+ const range = sel.getRangeAt(0);
7031
+ if (!activeReview.contentEl.contains(range.commonAncestorContainer)) return null;
7032
+ const quote = sel.toString().trim();
7033
+ if (!quote) return null;
7034
+ return { quote, range: range.cloneRange(), rect: range.getBoundingClientRect() };
7035
+ }
7036
+
7037
+ function onReviewFrameMessage(e) {
7038
+ const frameWin = activeReview?.frame?.contentWindow;
7039
+ const type =
7040
+ typeof e.data?.type === 'string' && e.data.type.startsWith(REVIEW_MSG) && e.data.type.slice(REVIEW_MSG.length);
7041
+ if (!frameWin || e.source !== frameWin || !type) return;
7042
+ if (type === 'clear') return closeReviewOffer();
7043
+ if (type === 'hover') {
7044
+ return setReviewHot((e.data.id && reviewItems().find((c) => c.frameId === e.data.id)) || null, true);
7045
+ }
7046
+ if (type !== 'select' || typeof e.data.quote !== 'string' || !e.data.quote.trim()) return;
7047
+ const box = activeReview.frame.getBoundingClientRect();
7048
+ const r = e.data.rect || {};
7049
+ const rect = { left: box.left + (r.left || 0), top: box.top + (r.top || 0), bottom: box.top + (r.bottom || 0) };
7050
+ const c = e.data.context || {};
7051
+ const context = { heading: c.heading, element: c.element, selector: c.selector };
7052
+ const pos = Number.isFinite(e.data.pos) ? e.data.pos : 0;
7053
+ showReviewOffer({ quote: e.data.quote.trim(), context, pos, rect, inFrame: true });
7054
+ }
7055
+
7056
+ function postToReviewFrame(type, id) {
7057
+ activeReview?.frame?.contentWindow?.postMessage({ type: REVIEW_MSG + type, id }, '*');
7058
+ }
7059
+
7060
+ // Runs inside the preview iframe as source text, so it must not close over anything outside
7061
+ // its own body and arguments.
7062
+ function reviewBridge(textBefore, headingBefore, rangeAt) {
7063
+ const P = 'cck-review:';
7064
+ const marks = new Map();
7065
+ let pending = null;
7066
+ let hotId = 0;
7067
+ let hoverAt = null;
7068
+ let hoverFrame = 0;
7069
+ const hl = typeof Highlight === 'function' && CSS.highlights ? new Highlight() : null;
7070
+ const hot = hl ? new Highlight() : null;
7071
+ if (hl) {
7072
+ CSS.highlights.set('cck-review', hl);
7073
+ CSS.highlights.set('cck-review-hot', hot);
7074
+ const style = document.createElement('style');
7075
+ style.textContent =
7076
+ '::highlight(cck-review){background-color:rgba(240,180,41,.35)}::highlight(cck-review-hot){background-color:rgba(240,180,41,.7)}';
7077
+ (document.head || document.documentElement).append(style);
7078
+ }
7079
+ const send = (msg) => parent.postMessage({ ...msg, type: P + msg.type }, '*');
7080
+ const describe = (node) => {
7081
+ const el = node.nodeType === 1 ? node : node.parentElement;
7082
+ if (!el) return {};
7083
+ const cls = [...el.classList].slice(0, 3);
7084
+ const attrs = [el.tagName.toLowerCase()];
7085
+ if (el.id) attrs.push(`id=${JSON.stringify(el.id)}`);
7086
+ if (cls.length) attrs.push(`class=${JSON.stringify(cls.join(' '))}`);
7087
+ const element = `<${attrs.join(' ')}>`;
7088
+ const parts = [];
7089
+ for (let n = el; n && n !== document.body && n.nodeType === 1; n = n.parentElement) {
7090
+ if (n.id) {
7091
+ parts.unshift(`#${n.id}`);
7092
+ break;
7093
+ }
7094
+ const tag = n.tagName.toLowerCase();
7095
+ const same = n.parentElement ? [...n.parentElement.children].filter((s) => s.tagName === n.tagName) : [];
7096
+ parts.unshift(same.length > 1 ? `${tag}:nth-of-type(${same.indexOf(n) + 1})` : tag);
7097
+ }
7098
+ return { element, selector: parts.join(' > '), heading: headingBefore(document, el) };
7099
+ };
7100
+ document.addEventListener('mouseup', () =>
7101
+ setTimeout(() => {
7102
+ const sel = getSelection();
7103
+ const quote = sel && !sel.isCollapsed && sel.rangeCount ? sel.toString().trim() : '';
7104
+ if (!quote) {
7105
+ pending = null;
7106
+ return;
7107
+ }
7108
+ pending = sel.getRangeAt(0).cloneRange();
7109
+ const r = pending.getBoundingClientRect();
7110
+ send({
7111
+ type: 'select',
7112
+ quote,
7113
+ pos: textBefore(document.body, pending.startContainer, pending.startOffset).length,
7114
+ rect: { left: r.left, top: r.top, bottom: r.bottom },
7115
+ context: describe(pending.commonAncestorContainer),
7116
+ });
7117
+ }, 0),
7118
+ );
7119
+ document.addEventListener('mousemove', (e) => {
7120
+ if (!marks.size && !hotId) return;
7121
+ hoverAt = [e.clientX, e.clientY];
7122
+ if (hoverFrame) return;
7123
+ hoverFrame = requestAnimationFrame(() => {
7124
+ hoverFrame = 0;
7125
+ const id = rangeAt(hoverAt[0], hoverAt[1], marks) || 0;
7126
+ if (id !== hotId) {
7127
+ hotId = id;
7128
+ send({ type: 'hover', id });
7129
+ }
7130
+ });
7131
+ });
7132
+ const clear = () => {
7133
+ if (pending) send({ type: 'clear' });
7134
+ };
7135
+ document.addEventListener('mousedown', clear);
7136
+ document.addEventListener('scroll', clear, true);
7137
+ addEventListener('message', (e) => {
7138
+ if (e.source !== parent || typeof e.data?.type !== 'string' || !e.data.type.startsWith(P)) return;
7139
+ const type = e.data.type.slice(P.length);
7140
+ const range = marks.get(e.data.id);
7141
+ if (type === 'mark' && pending) {
7142
+ marks.set(e.data.id, pending);
7143
+ hl?.add(pending);
7144
+ pending = null;
7145
+ getSelection().removeAllRanges();
7146
+ } else if (type === 'hot') {
7147
+ hot?.clear();
7148
+ if (range) hot?.add(range);
7149
+ } else if (type === 'unmark' && range) {
7150
+ hl?.delete(range);
7151
+ hot?.delete(range);
7152
+ marks.delete(e.data.id);
7153
+ } else if (type === 'reveal' && range) {
7154
+ range.startContainer.parentElement?.scrollIntoView({ block: 'center', behavior: 'smooth' });
7155
+ }
7156
+ });
7157
+ }
7158
+
7159
+ const REVIEW_BRIDGE_TAG = `<script>(${reviewBridge})(${reviewTextBefore}, ${reviewHeadingBefore}, ${reviewRangeAt});</script>`;
7160
+
7161
+ function reviewPopEl() {
7162
+ let el = document.getElementById('review-pop');
7163
+ if (!el) {
7164
+ el = document.createElement('div');
7165
+ el.id = 'review-pop';
7166
+ el.addEventListener('mousedown', (e) => e.stopPropagation());
7167
+ el.addEventListener('click', (e) => e.stopPropagation());
7168
+ document.body.append(el);
7169
+ }
7170
+ return el;
7171
+ }
7172
+
7173
+ function placeReviewPop(el, r) {
7174
+ el.style.left = `${Math.max(8, Math.min(r.left, window.innerWidth - el.offsetWidth - 8))}px`;
7175
+ const below = r.bottom + 6;
7176
+ el.style.top = `${below + el.offsetHeight > window.innerHeight ? Math.max(8, r.top - el.offsetHeight - 6) : below}px`;
7177
+ }
7178
+
7179
+ function closeReviewPop() {
7180
+ if (!reviewPopMode) return;
7181
+ reviewPopMode = null;
7182
+ const el = reviewPopEl();
7183
+ el.className = '';
7184
+ el.replaceChildren();
7185
+ }
7186
+
7187
+ function closeReviewOffer() {
7188
+ if (reviewPopMode === 'offer') closeReviewPop();
7189
+ }
7190
+
7191
+ function offerReviewComment() {
7192
+ const picked = reviewSelection();
7193
+ if (picked) showReviewOffer(picked);
7194
+ else if (reviewPopMode !== 'editor') closeReviewPop();
7195
+ }
7196
+
7197
+ function showReviewOffer(picked) {
7198
+ const el = reviewPopEl();
7199
+ reviewPopMode = 'offer';
7200
+ el.className = 'visible offer';
7201
+ el.innerHTML = `<button type="button" class="review-offer-btn">Comment</button>`;
7202
+ el.firstElementChild.addEventListener('click', () => openReviewEditor(picked));
7203
+ placeReviewPop(el, picked.rect);
7204
+ }
7205
+
7206
+ function openReviewEditor(picked) {
7207
+ const el = reviewPopEl();
7208
+ reviewPopMode = 'editor';
7209
+ el.className = 'visible editor';
7210
+ el.innerHTML = `<div class="review-pop-quote">${escapeHtml(picked.quote.slice(0, 200))}</div>
7211
+ <textarea rows="3" placeholder="Comment (Ctrl+Enter to add)"></textarea>
7212
+ <div class="review-pop-actions"><button type="button" data-act="cancel">Cancel</button><button type="button" data-act="add" class="primary">Add</button></div>`;
7213
+ const ta = el.querySelector('textarea');
7214
+ const add = () => {
7215
+ const comment = ta.value.trim();
7216
+ if (!comment) return ta.focus();
7217
+ addReviewComment(picked, comment);
7218
+ };
7219
+ ta.addEventListener('keydown', (e) => {
7220
+ e.stopPropagation();
7221
+ if (e.key === 'Escape') closeReviewPop();
7222
+ else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) add();
7223
+ });
7224
+ el.querySelector('[data-act="cancel"]').addEventListener('click', closeReviewPop);
7225
+ el.querySelector('[data-act="add"]').addEventListener('click', add);
7226
+ placeReviewPop(el, picked.rect);
7227
+ ta.focus();
7228
+ }
7229
+
7230
+ function addReviewComment(picked, comment) {
7231
+ if (!activeReview) return;
7232
+ const { key, contentEl } = activeReview;
7233
+ let item;
7234
+ if (picked.inFrame) {
7235
+ item = {
7236
+ quote: picked.quote,
7237
+ comment,
7238
+ context: picked.context,
7239
+ pos: picked.pos,
7240
+ range: null,
7241
+ frameId: ++reviewMarkSeq,
7242
+ };
7243
+ postToReviewFrame('mark', item.frameId);
7244
+ } else {
7245
+ const { range } = picked;
7246
+ const context = {
7247
+ heading: reviewHeadingBefore(contentEl, range.startContainer),
7248
+ ...activeReview.context?.(range),
7249
+ };
7250
+ const pos = reviewTextBefore(contentEl, range.startContainer, range.startOffset).length;
7251
+ item = { quote: picked.quote, comment, context, pos, range, frameId: 0 };
7252
+ reviewHighlight?.add(range);
7253
+ window.getSelection()?.removeAllRanges();
7254
+ }
7255
+ if (!reviewDrafts.has(key)) reviewDrafts.set(key, []);
7256
+ const items = reviewDrafts.get(key);
7257
+ items.push(item);
7258
+ items.sort((a, b) => a.pos - b.pos);
7259
+ closeReviewPop();
7260
+ renderReviewPanel();
7261
+ }
7262
+
7263
+ function unmarkReviewItem(c) {
7264
+ if (c.range) reviewHighlight?.delete(c.range);
7265
+ else if (c.frameId) postToReviewFrame('unmark', c.frameId);
7266
+ }
7267
+
7268
+ function revealReviewItem(c) {
7269
+ if (c.range) c.range.startContainer.parentElement?.scrollIntoView({ block: 'center', behavior: 'smooth' });
7270
+ else if (c.frameId) postToReviewFrame('reveal', c.frameId);
7271
+ }
7272
+
7273
+ function removeReviewComment(c) {
7274
+ const items = reviewItems();
7275
+ const i = items.indexOf(c);
7276
+ if (i < 0) return;
7277
+ items.splice(i, 1);
7278
+ unmarkReviewItem(c);
7279
+ if (activeReview.hot === c) setReviewHot(null);
7280
+ if (!items.length) reviewDrafts.delete(activeReview.key);
7281
+ renderReviewPanel();
7282
+ }
7283
+
7284
+ function clearReviewDrafts(key = activeReview?.key) {
7285
+ for (const c of reviewDrafts.get(key) || []) unmarkReviewItem(c);
7286
+ reviewDrafts.delete(key);
7287
+ setReviewHot(null);
7288
+ renderReviewPanel();
7289
+ }
7290
+
7291
+ function onReviewPanelClick(e) {
7292
+ const act = e.target.closest('[data-act]')?.dataset.act;
7293
+ if (act === 'send') return sendReview();
7294
+ if (act === 'discard') return clearReviewDrafts();
7295
+ const card = e.target.closest('.review-card');
7296
+ const c = card && reviewItems()[Number(card.dataset.i)];
7297
+ if (!c) return;
7298
+ if (e.target.closest('.review-remove')) removeReviewComment(c);
7299
+ else revealReviewItem(c);
7300
+ }
7301
+
7302
+ function onReviewPanelHover(e) {
7303
+ const card = e.target.closest('.review-card');
7304
+ setReviewHot((card && reviewItems()[Number(card.dataset.i)]) || null);
7305
+ }
7306
+
7307
+ function renderReviewPanel() {
7308
+ if (!activeReview) return;
7309
+ const { panelEl, hostEl } = activeReview;
7310
+ const items = reviewItems();
7311
+ panelEl.hidden = !items.length;
7312
+ hostEl?.classList.toggle('has-review', items.length > 0);
7313
+ if (!items.length) return panelEl.replaceChildren();
7314
+ panelEl.innerHTML = `<div class="review-panel-head">
7315
+ <span>${items.length} comment${items.length > 1 ? 's' : ''}</span>
7316
+ <button type="button" data-act="discard" class="review-discard">Discard</button>
7317
+ <button type="button" data-act="send" class="primary">Send</button>
7318
+ </div>
7319
+ <ol class="review-panel-list">${items
7320
+ .map((c, i) => {
7321
+ return `<li class="review-card${c === activeReview.hot ? ' hot' : ''}" data-i="${i}" title="Show in the document">
7322
+ <button type="button" class="review-remove" title="Remove" aria-label="Remove">×</button>
7323
+ <div class="review-card-quote">${escapeHtml(c.quote.slice(0, 300))}</div>
7324
+ <div class="review-card-comment">${escapeHtml(c.comment)}</div></li>`;
7325
+ })
7326
+ .join('')}</ol>`;
7327
+ }
7328
+
7329
+ async function sendReview() {
7330
+ if (!activeReview) return;
7331
+ const { sessionId, source, key, onSent } = activeReview;
7332
+ const items = reviewItems();
7333
+ if (!items.length) return;
7334
+ try {
7335
+ const res = await terminalFetch(`/api/sessions/${encodeURIComponent(sessionId)}/review`, 'POST', {
7336
+ source,
7337
+ comments: items.map(({ quote, comment, context }) => ({ quote, comment, ...context })),
7338
+ });
7339
+ const data = await res.json();
7340
+ if (!res.ok) return showToast(data.error || 'Review failed', 'error');
7341
+ if (data.delivered === 'doorbell') showToast('Sent to session', 'success');
7342
+ else if (data.delivered === 'terminal') showToast('Pasted into the terminal: press Enter there to send', 'info');
7343
+ else {
7344
+ const { markdown } = data;
7345
+ const copy = () => navigator.clipboard.writeText(markdown).catch(() => {});
7346
+ await copy();
7347
+ showToast(
7348
+ 'Review copied. Paste it into the session.',
7349
+ 'info',
7350
+ { label: 'Copy again', onClick: copy },
7351
+ 'Run /claude-code-kanban:follow there to send reviews directly.',
7352
+ );
7353
+ }
7354
+ clearReviewDrafts(key);
7355
+ if (activeReview?.key === key) onSent?.();
7356
+ } catch {
7357
+ showToast('Failed to send the review', 'error');
7358
+ }
7359
+ }
7360
+ //#endregion
7361
+
6870
7362
  //#region MARKDOWN_PREVIEW
6871
7363
  const PREVIEW_STORAGE_PREFIX = 'preview-paths-';
6872
7364
  let currentPreviewPath = null;
@@ -6984,7 +7476,9 @@ function renderHtmlPreview(bodyEl, content) {
6984
7476
  frame.className = 'preview-html-frame';
6985
7477
  frame.setAttribute('sandbox', 'allow-scripts allow-popups');
6986
7478
  frame.setAttribute('referrerpolicy', 'no-referrer');
6987
- frame.srcdoc = content;
7479
+ // After the document's end the parser still adds the script to <body>, and it runs after
7480
+ // the page's own scripts.
7481
+ frame.srcdoc = content + REVIEW_BRIDGE_TAG;
6988
7482
  bodyEl.appendChild(frame);
6989
7483
  }
6990
7484
 
@@ -7048,6 +7542,22 @@ function openPreviewModal(filePath, content, kind) {
7048
7542
  document.getElementById('preview-modal-meta').textContent = filePath;
7049
7543
  document.getElementById('preview-modal').classList.add('visible');
7050
7544
  updatePreviewLinkBtn();
7545
+ if (kind === 'image') return unmountReview();
7546
+ mountReview({
7547
+ contentEl: bodyEl,
7548
+ panelEl: document.getElementById('preview-review-panel'),
7549
+ hostEl: document.querySelector('#preview-modal .modal'),
7550
+ source: { kind: 'file', label: fileName, path: filePath },
7551
+ frame: isHtml ? bodyEl.querySelector('iframe') : null,
7552
+ context: kind === 'text' ? (range) => ({ line: sourceLineOf(bodyEl, range) }) : null,
7553
+ onSent: closePreviewModal,
7554
+ });
7555
+ }
7556
+
7557
+ function sourceLineOf(bodyEl, range) {
7558
+ const code = bodyEl.querySelector('.preview-source code');
7559
+ if (!code?.contains(range.startContainer)) return undefined;
7560
+ return reviewTextBefore(code, range.startContainer, range.startOffset).split('\n').length;
7051
7561
  }
7052
7562
 
7053
7563
  function isPreviewLinkedToCurrentSession() {
@@ -7098,6 +7608,7 @@ function refreshInfoModalLinkedDocs() {
7098
7608
  }
7099
7609
 
7100
7610
  function closePreviewModal() {
7611
+ unmountReview(document.getElementById('preview-review-panel'));
7101
7612
  hideModalOverlay('preview-modal');
7102
7613
  // Empty the body so an iframe preview is destroyed and its scripts/timers stop.
7103
7614
  document.getElementById('preview-modal-body').innerHTML = '';
@@ -7965,20 +8476,30 @@ function renderMarkers(markers) {
7965
8476
  }
7966
8477
 
7967
8478
  function formatTokens(k) {
7968
- if (k >= 1000) return `${(k / 1000).toFixed(1)}M`;
8479
+ if (k >= 1000) return `${(k / 1000).toFixed(1).replace(/\.0$/, '')}M`;
7969
8480
  if (k < 1) return (k * 1000).toFixed(0);
7970
8481
  return `${Math.round(k)}K`;
7971
8482
  }
7972
8483
 
7973
- function getCtx(raw) {
8484
+ // session.autoCompact ({window, pct} from the session's settings) makes the compaction point the
8485
+ // bar's full scale, because the status line reports the model's full window regardless.
8486
+ function getCtx(session) {
8487
+ const raw = session?.contextStatus;
8488
+ const autoCompact = session?.autoCompact;
7974
8489
  if (!raw) return null;
7975
8490
  const cw = raw.context_window || {};
7976
- const size = cw.context_window_size || 0;
7977
- const pct = cw.used_percentage || 0;
8491
+ const modelSize = cw.context_window_size || 0;
7978
8492
  const model = raw.model || {};
7979
8493
  const modelName = model.display_name || model.id || '';
7980
8494
  const thresholds = getModelThresholds(modelName);
7981
- const usedTokens = size > 0 ? (pct / 100) * size : 0;
8495
+ const usedTokens = modelSize > 0 ? ((cw.used_percentage || 0) / 100) * modelSize : 0;
8496
+ const compactAt =
8497
+ modelSize > 0 && autoCompact
8498
+ ? (Math.min(modelSize, autoCompact.window || modelSize) * (autoCompact.pct || 100)) / 100
8499
+ : 0;
8500
+ const compacts = compactAt > 0 && compactAt < modelSize;
8501
+ const size = compacts ? compactAt : modelSize;
8502
+ const pct = compacts ? Math.min(100, (usedTokens / size) * 100) : cw.used_percentage || 0;
7982
8503
  const markers =
7983
8504
  size > 0
7984
8505
  ? [
@@ -7989,8 +8510,10 @@ function getCtx(raw) {
7989
8510
  : [];
7990
8511
  return {
7991
8512
  pct,
7992
- remaining: cw.remaining_percentage || 100 - pct,
8513
+ remaining: 100 - pct,
7993
8514
  size,
8515
+ modelSize,
8516
+ compacts,
7994
8517
  usedTokens,
7995
8518
  modelName,
7996
8519
  inputTokens: cw.total_input_tokens || 0,
@@ -7999,18 +8522,23 @@ function getCtx(raw) {
7999
8522
  };
8000
8523
  }
8001
8524
 
8002
- function renderContextBar(raw) {
8003
- const ctx = getCtx(raw);
8525
+ function renderContextBar(session) {
8526
+ const ctx = getCtx(session);
8004
8527
  if (!ctx) return '';
8005
8528
  const color = getContextColor(ctx.usedTokens, ctx.modelName);
8529
+ const fmt = (n) => formatTokens(n / 1000);
8530
+ const used = fmt(ctx.usedTokens);
8531
+ const title = ctx.compacts
8532
+ ? `Context: ${used} of ${fmt(ctx.size)} tokens before auto-compact (model window ${fmt(ctx.modelSize)})`
8533
+ : `Context: ${used}${ctx.size ? ` of ${fmt(ctx.size)}` : ''} tokens`;
8006
8534
  return `
8007
- <div class="context-bar" style="display:block">
8535
+ <div class="context-bar" title="${escapeHtml(title)}">
8008
8536
  <div class="context-bar-track">
8009
8537
  <div class="context-bar-fill" style="width:${ctx.pct}%;background:${color}"></div>
8010
8538
  ${renderMarkers(ctx.markers)}
8011
8539
  </div>
8012
8540
  <div class="context-bar-labels">
8013
- <span style="color:${color}">${Math.round(ctx.pct)}% (${formatTokens(ctx.usedTokens / 1000)})</span>
8541
+ <span style="color:${color}">${Math.round(ctx.pct)}% (${used})</span>
8014
8542
  <span>${Math.round(ctx.remaining)}% free</span>
8015
8543
  </div>
8016
8544
  </div>`;
@@ -8021,9 +8549,10 @@ function formatCost(usd) {
8021
8549
  return `$${usd.toFixed(2)}`;
8022
8550
  }
8023
8551
 
8024
- function renderContextDetail(raw, { tokens = true } = {}) {
8025
- const ctx = getCtx(raw);
8552
+ function renderContextDetail(session, { tokens = true } = {}) {
8553
+ const ctx = getCtx(session);
8026
8554
  if (!ctx) return '';
8555
+ const raw = session.contextStatus;
8027
8556
  const totalK = ctx.size / 1000;
8028
8557
  const color = getContextColor(ctx.usedTokens, ctx.modelName);
8029
8558
 
@@ -8052,7 +8581,7 @@ function renderContextDetail(raw, { tokens = true } = {}) {
8052
8581
  </div>
8053
8582
  <div class="detail-context-summary">
8054
8583
  <span style="color:${color}">${Math.round(ctx.pct)}% used</span>
8055
- <span>${formatTokens((ctx.pct / 100) * totalK)} / ${formatTokens(totalK)}</span>
8584
+ <span${ctx.compacts ? ` title="Auto-compact window · model window ${escapeHtml(formatTokens(ctx.modelSize / 1000))}"` : ''}>${formatTokens(ctx.usedTokens / 1000)} / ${formatTokens(totalK)}</span>
8056
8585
  </div>
8057
8586
  <div class="detail-context-stats">
8058
8587
  ${tokenRows}
@@ -8292,6 +8821,11 @@ function isSessionActive(s) {
8292
8821
  return s.hasRecentLog || s.inProgress > 0 || s.hasActiveAgents || s.hasWaitingForUser;
8293
8822
  }
8294
8823
 
8824
+ // An active pinned session renders outside the Pinned group until the user dismisses it.
8825
+ function isInPinnedGroup(s) {
8826
+ return isPlacedPinned(s.id) && !isPlacedSticky(s.id) && (!isSessionActive(s) || dismissedSessionIds.has(s.id));
8827
+ }
8828
+
8295
8829
  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
8830
 
8297
8831
  function sessionDisplayName(session) {
@@ -9394,7 +9928,10 @@ async function showSessionInfoModal(sessionId) {
9394
9928
  const planPromise = fetch(`/api/sessions/${sessionId}/plan`)
9395
9929
  .then((r) => (r.ok ? r.json() : null))
9396
9930
  .catch(() => null)
9397
- .then((data) => data?.content || null);
9931
+ .then((data) => {
9932
+ if (data?.content) _pendingPlanPath = data.path || null;
9933
+ return data?.content || null;
9934
+ });
9398
9935
 
9399
9936
  const tasksPromise =
9400
9937
  cachedTasks.length > 0
@@ -9418,6 +9955,7 @@ async function showSessionInfoModal(sessionId) {
9418
9955
 
9419
9956
  let _infoModalSessionId = null;
9420
9957
  let _pendingPlanContent = null;
9958
+ let _pendingPlanPath = null;
9421
9959
 
9422
9960
  function updateStickyBtnState() {
9423
9961
  const stickyBtn = document.getElementById('session-info-sticky-btn');
@@ -9542,7 +10080,7 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
9542
10080
 
9543
10081
  if (session.contextStatus) {
9544
10082
  html += `<hr style="border: none; border-top: 1px solid var(--border); margin: 12px 0;">`;
9545
- html += renderContextDetail(session.contextStatus);
10083
+ html += renderContextDetail(session);
9546
10084
  }
9547
10085
 
9548
10086
  if (planContent) {
@@ -10061,10 +10599,11 @@ function refreshOpenPlan() {
10061
10599
  fetch(`/api/sessions/${_planSessionId}/plan`)
10062
10600
  .then((r) => (r.ok ? r.json() : null))
10063
10601
  .then((data) => {
10064
- if (data?.content) {
10602
+ if (data?.content && (data.content !== _pendingPlanContent || data.path !== _pendingPlanPath)) {
10065
10603
  _pendingPlanContent = data.content;
10066
- const body = document.getElementById('plan-modal-body');
10067
- body.innerHTML = renderMarkdown(_pendingPlanContent);
10604
+ _pendingPlanPath = data.path || null;
10605
+ document.getElementById('plan-modal-body').innerHTML = renderMarkdown(_pendingPlanContent);
10606
+ mountPlanReview();
10068
10607
  }
10069
10608
  })
10070
10609
  .catch(() => {});
@@ -10383,6 +10922,7 @@ function openPlanForSession(sid) {
10383
10922
  .then((data) => {
10384
10923
  if (data?.content) {
10385
10924
  _pendingPlanContent = data.content;
10925
+ _pendingPlanPath = data.path || null;
10386
10926
  _planSessionId = sid;
10387
10927
  openPlanModal();
10388
10928
  }
@@ -10403,10 +10943,12 @@ function openPlanModal() {
10403
10943
  document.querySelector('#plan-modal .modal-footer').prepend(controls);
10404
10944
  }
10405
10945
  document.getElementById('plan-modal').classList.add('visible');
10946
+ mountPlanReview();
10406
10947
  const keyHandler = (e) => {
10407
10948
  if (e.key === 'Escape') {
10408
10949
  e.preventDefault();
10409
10950
  e.stopPropagation();
10951
+ if (reviewPopMode) return closeReviewPop();
10410
10952
  closePlanModal();
10411
10953
  document.removeEventListener('keydown', keyHandler, true);
10412
10954
  }
@@ -10414,7 +10956,20 @@ function openPlanModal() {
10414
10956
  document.addEventListener('keydown', keyHandler, true);
10415
10957
  }
10416
10958
 
10959
+ function mountPlanReview() {
10960
+ if (!_pendingPlanPath || !_planSessionId) return;
10961
+ mountReview({
10962
+ contentEl: document.getElementById('plan-modal-body'),
10963
+ panelEl: document.getElementById('plan-review-panel'),
10964
+ hostEl: document.querySelector('#plan-modal .modal'),
10965
+ source: { kind: 'file', label: pathBasename(_pendingPlanPath), path: _pendingPlanPath },
10966
+ sessionId: _planSessionId,
10967
+ onSent: closePlanModal,
10968
+ });
10969
+ }
10970
+
10417
10971
  function closePlanModal() {
10972
+ unmountReview(document.getElementById('plan-review-panel'));
10418
10973
  hideModalOverlay('plan-modal');
10419
10974
  }
10420
10975
 
@@ -10769,11 +11324,11 @@ function setSwapPair(last, previous) {
10769
11324
  } catch {}
10770
11325
  }
10771
11326
 
10772
- function swapToPreviousSession() {
11327
+ async function swapToPreviousSession() {
10773
11328
  const target = currentSessionId === lastSessionId ? previousSessionId : lastSessionId;
10774
11329
  if (!target || target === currentSessionId) return;
10775
11330
  if (terminalPaneFocused()) termState.focusNext = true;
10776
- openSession(target);
11331
+ await revealSession(target);
10777
11332
  }
10778
11333
 
10779
11334
  // Esc belongs to Claude, so leaving the terminal without hiding it needs its own key.
@@ -11571,6 +12126,7 @@ function placeholderSession(id, spec) {
11571
12126
  name: spec.name || PLACEHOLDER_NAMES[spec.mode] || PLACEHOLDER_NAMES.new,
11572
12127
  dispatchGroup: spec.group || undefined,
11573
12128
  startedBy: spec.startedBy || undefined,
12129
+ dispatched: spec.mode === 'dispatch' ? { parent: spec.startedBy || null } : undefined,
11574
12130
  project: spec.cwd,
11575
12131
  worktree: spec.worktree ? { repo: spec.cwd, name: spec.worktree === true ? 'new' : spec.worktree } : null,
11576
12132
  modifiedAt: new Date(spec.startedAt).toISOString(),
@@ -12234,7 +12790,12 @@ fetch('/api/config')
12234
12790
  })
12235
12791
  .catch(() => {})
12236
12792
  .then(restorePendingSessions)
12237
- .then(() => fetchSessions())
12793
+ .then(() =>
12794
+ fetchSessions(
12795
+ true,
12796
+ urlState.projectView ? null : urlState.session || (lastView?.view === 'session' ? lastView.session : null),
12797
+ ),
12798
+ )
12238
12799
  .then(async () => {
12239
12800
  if (urlState.projectView) {
12240
12801
  try {